From 7649e82a75d9f5e660962ad827e8b6757119092b Mon Sep 17 00:00:00 2001 From: b-ma Date: Mon, 7 Oct 2024 09:40:27 +0200 Subject: [PATCH] feat: implement `BaseStateManager#registerCreateHook` --- src/server/ServerStateManager.js | 99 +++++++++++++++----- src/server/SharedStatePrivate.js | 4 +- tests/states/StateManager.spec.js | 148 ++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 24 deletions(-) diff --git a/src/server/ServerStateManager.js b/src/server/ServerStateManager.js index 08d46ef2..eeed2054 100644 --- a/src/server/ServerStateManager.js +++ b/src/server/ServerStateManager.js @@ -41,24 +41,37 @@ export const kServerStateManagerAddClient = Symbol('soundworks:server-state-mana export const kServerStateManagerRemoveClient = Symbol('soundworks:server-state-manager-remove-client'); export const kServerStateManagerHasClient = Symbol('soundworks:server-state-manager-has-client'); export const kServerStateManagerDeletePrivateState = Symbol('soundworks:server-state-manager-delete-private-state'); -export const kServerStateManagerGetHooks = Symbol('soundworks:server-state-manager-get-hooks'); +export const kServerStateManagerGetUpdateHooks = Symbol('soundworks:server-state-manager-get-update-hooks'); // for testing purposes export const kStateManagerClientsByNodeId = Symbol('soundworks:server-state-clients-by-node-id'); +/** + * @callback serverStateManagerCreateHook + * @async + * + * @param {object} initValues - Initialization values object as given when the + * shared state is created + */ + /** * @callback serverStateManagerUpdateHook * @async * - * @param {object} updates - Update object as given on a set callback, or - * result of the previous hook + * @param {object} updates - Update object as given on a `set` callback, or + * result of the previous hook. * @param {object} currentValues - Current values of the state. - * @param {object} [context=null] - Optionnal context passed by the creator - * of the update. - * * @returns {object} The "real" updates to be applied on the state. */ +/** + * @callback serverStateManagerDeleteHook + * @async + * + * @param {object} currentValues - Update object as given on a `set` callback, or + * result of the previous hook. + */ + /** * The `StateManager` allows to create new {@link SharedState}s, or attach * to {@link SharedState}s created by other nodes (clients or server). It @@ -120,7 +133,9 @@ class ServerStateManager extends BaseStateManager { #sharedStatePrivateById = new Map(); #classes = new Map(); #observers = new Set(); - #hooksByClassName = new Map(); // protected + #createHooksByClassName = new Map(); + #updateHooksByClassName = new Map(); + #deleteHooksByClassName = new Map(); constructor() { super(); @@ -141,10 +156,11 @@ class ServerStateManager extends BaseStateManager { } /** @private */ - [kServerStateManagerGetHooks](className) { - return this.#hooksByClassName.get(className); + [kServerStateManagerGetUpdateHooks](className) { + return this.#updateHooksByClassName.get(className); } + /** @private */ [kServerStateManagerHasClient](nodeId) { return this[kStateManagerClientsByNodeId].has(nodeId); } @@ -175,14 +191,35 @@ class ServerStateManager extends BaseStateManager { // --------------------------------------------- client.transport.addListener( CREATE_REQUEST, - (reqId, className, requireDescription, initValues = {}) => { + async (reqId, className, requireDescription, initValues = {}) => { if (this.#classes.has(className)) { try { const classDescription = this.#classes.get(className); const stateId = generateStateId(); const instanceId = instanceIdGenerator(); - const state = new SharedStatePrivate(this, className, classDescription, stateId, initValues); + // apply create hooks on init values + const hooks = this.#createHooksByClassName.get(className); + let hookAborted = false; + + for (let hook of hooks.values()) { + const result = await hook(initValues); + + if (result === null) { // explicit abort + hookAborted = true; + break; + } else if (result === undefined) { // implicit continue + continue; + } else { + initValues = result; + } + } + + if (hookAborted) { + throw new Error(`A create hook function explicitly aborted state creation by returninig 'null'`); + } + + const state = new SharedStatePrivate(this, className, classDescription, stateId, initValues); // attach client to the state as owner const isOwner = true; const filter = null; @@ -211,11 +248,11 @@ class ServerStateManager extends BaseStateManager { }); } } catch (err) { - const msg = `[stateManager] Cannot create state "${className}", ${err.message}`; + const msg = `Cannot execute 'create' on 'BaseStateManager' for class '${className}': ${err.message}`; client.transport.emit(CREATE_ERROR, reqId, msg); } } else { - const msg = `[stateManager] Cannot create state "${className}", class is not defined`; + const msg = `Cannot execute 'create' on 'BaseStateManager' for class '${className}': class is not defined`; client.transport.emit(CREATE_ERROR, reqId, msg); } }, @@ -434,7 +471,9 @@ class ServerStateManager extends BaseStateManager { this.#classes.set(className, clonedeep(classDescription)); // create hooks list - this.#hooksByClassName.set(className, new Set()); + this.#createHooksByClassName.set(className, new Set()); + this.#updateHooksByClassName.set(className, new Set()); + this.#deleteHooksByClassName.set(className, new Set()); } /** @@ -474,7 +513,9 @@ class ServerStateManager extends BaseStateManager { this.#classes.delete(className); // delete registered hooks - this.#hooksByClassName.delete(className); + this.#createHooksByClassName.delete(className); + this.#updateHooksByClassName.delete(className); + this.#deleteHooksByClassName.delete(className); } /** @@ -485,6 +526,21 @@ class ServerStateManager extends BaseStateManager { this.deleteClass(className); } + registerCreateHook(className, createHook) { + if (!this.#classes.has(className)) { + throw new TypeError(`Cannot execute 'registerCreateHook' on 'BaseStateManager': SharedState class '${className}' does not exists`); + } + + if (!isFunction(createHook)) { + throw new TypeError(`Cannot execute 'registerCreateHook' on 'BaseStateManager': argument 2 must be a function`); + } + + const hooks = this.#createHooksByClassName.get(className); + hooks.add(createHook); + + return () => hooks.delete(createHook); + } + /** * Register a function for a given shared state class the be executed between * `set` instructions and `onUpdate` callback(s). @@ -499,10 +555,10 @@ class ServerStateManager extends BaseStateManager { * before the call of the `onUpdate` callback of the shared state. * * @param {string} className - Kind of states on which applying the hook. - * @param {serverStateManagerUpdateHook} updateHook - Function called between - * the `set` call and the actual update. + * @param {serverStateManagerUpdateHook} updateHook - Function called on each update, + * to eventually modify the updates before they are actually applied. * - * @returns {Fuction} deleteHook - Handler that deletes the hook when executed. + * @returns {function} deleteHook - Handler that deletes the hook when executed. * * @example * server.stateManager.defineClass('hooked', { @@ -523,16 +579,15 @@ class ServerStateManager extends BaseStateManager { * assert.deepEqual(result, { value: 'test', numUpdates: 1 }); */ registerUpdateHook(className, updateHook) { - // throw error if className has not been registered if (!this.#classes.has(className)) { - throw new TypeError(`[stateManager.registerUpdateHook] Cannot register update hook for class "${className}", class does not exists`); + throw new TypeError(`Cannot execute 'registerUpdateHook' on 'BaseStateManager': SharedState class '${className}' does not exists`); } if (!isFunction(updateHook)) { - throw new TypeError(`[stateManager.registerUpdateHook] Cannot register update hook for class "${className}", argument 2 must be a function`); + throw new TypeError(`Cannot execute 'registerUpdateHook' on 'BaseStateManager': argument 2 must be a function`); } - const hooks = this.#hooksByClassName.get(className); + const hooks = this.#updateHooksByClassName.get(className); hooks.add(updateHook); return () => hooks.delete(updateHook); diff --git a/src/server/SharedStatePrivate.js b/src/server/SharedStatePrivate.js index 643ec7c2..b8d2ef2e 100644 --- a/src/server/SharedStatePrivate.js +++ b/src/server/SharedStatePrivate.js @@ -12,7 +12,7 @@ import { } from '../common/constants.js'; import { kServerStateManagerDeletePrivateState, - kServerStateManagerGetHooks, + kServerStateManagerGetUpdateHooks, } from '../server/ServerStateManager.js'; /** @@ -96,7 +96,7 @@ class SharedStatePrivate { // attach client listeners client.transport.addListener(`${UPDATE_REQUEST}-${this.id}-${instanceId}`, async (reqId, updates) => { // apply registered hooks - const hooks = this.#manager[kServerStateManagerGetHooks](this.className); + const hooks = this.#manager[kServerStateManagerGetUpdateHooks](this.className); const values = this.#parameters.getValues(); let hookAborted = false; diff --git a/tests/states/StateManager.spec.js b/tests/states/StateManager.spec.js index be6fcf03..c0ee7279 100644 --- a/tests/states/StateManager.spec.js +++ b/tests/states/StateManager.spec.js @@ -711,6 +711,154 @@ describe(`# StateManager`, () => { }); }); + describe(`## [server] registerCreateHook(className, createHook)`, () => { + const hookSchema = { + name: { + type: 'string', + default: null, + nullable: true, + }, + // value will be updated according to name from hook + value: { + type: 'string', + default: null, + nullable: true, + }, + }; + + it(`should throw if invalid className`, () => { + let errored = false; + + try { + server.stateManager.registerCreateHook('hooked', () => {}); + } catch (err) { + console.log(err.message); + errored = true; + } + + assert.isTrue(errored); + }); + + it(`should throw if updateHook is not a function`, () => { + server.stateManager.defineClass('hooked', hookSchema); + let errored = false; + + assert.throws(() => server.stateManager.registerCreateHook('hooked', null)); + assert.throws(() => server.stateManager.registerCreateHook('hooked', {})); + assert.doesNotThrow(() => server.stateManager.registerCreateHook('hooked', () => {})); + assert.doesNotThrow(() => server.stateManager.registerCreateHook('hooked', async () => {})); + }); + + it(`should execute hook on creation of state of given class`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.defineClass('other', {}); + + let hookedCalled = false; + let otherCalled = false; + + server.stateManager.registerCreateHook('hooked', () => hookedCalled = true);; + server.stateManager.registerCreateHook('other', () => otherCalled = true);; + + const _ = await server.stateManager.create('hooked'); + + assert.isTrue(hookedCalled); + assert.isFalse(otherCalled); + }); + + it(`should allow to modify init values`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.registerCreateHook('hooked', (initValues) => { + if (initValues.name === 'test') { + return { + value: 'ok', + ...initValues + }; + } + }); + + const hookedState = await server.stateManager.create('hooked', { name: 'test' }); + const expected = { name: 'test', value: 'ok' }; + assert.deepEqual(hookedState.getValues(), expected); + }); + + it(`should throw if create hook explicitly return null`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.registerCreateHook('hooked', (initValues) => { + return null; + }); + + let errored = false; + + try { + const hookedState = await server.stateManager.create('hooked', { name: 'test' }); + } catch (err) { + console.log(err.message); + errored = true; + } + + assert.isTrue(errored); + }); + + it(`should implicitly continue if hook returns undefined`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.registerCreateHook('hooked', initValues => { + if (initValues.name === 'test') { + return undefined; + } + }); + + const hookedState = await server.stateManager.create('hooked', { name: 'test' }); + const expected = { name: 'test', value: null }; + assert.deepEqual(hookedState.getValues(), expected); + }); + + it(`should support async hooks`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.registerCreateHook('hooked', async (initValues) => { + await delay(20); + if (initValues.name === 'test') { + return { + value: 'ok', + ...initValues + }; + } + }); + + const hookedState = await server.stateManager.create('hooked', { name: 'test' }); + const expected = { name: 'test', value: 'ok' }; + assert.deepEqual(hookedState.getValues(), expected); + }); + + it(`should allow to chain hooks`, async () => { + server.stateManager.defineClass('hooked', hookSchema); + server.stateManager.registerCreateHook('hooked', async (initValues) => { + await delay(20); + if (initValues.name === 'test') { + return { + ...initValues, + value: 'ok1', + }; + } + }); + + server.stateManager.registerCreateHook('hooked', (initValues) => { + // first callback has been properly executed + const expected = { name: 'test', value: 'ok1' }; + assert.deepEqual(initValues, expected) + if (initValues.name === 'test') { + return { + ...initValues, + value: 'ok2', + }; + } + }); + + const hookedState = await server.stateManager.create('hooked', { name: 'test' }); + const expected = { name: 'test', value: 'ok2' }; + assert.deepEqual(hookedState.getValues(), expected); + }); + }); + describe('## [server] registerUpdateHook(className, updateHook)', () => { const hookSchema = { name: {