Skip to content

Commit

Permalink
feat: implement BaseStateManager#registerCreateHook
Browse files Browse the repository at this point in the history
  • Loading branch information
b-ma committed Oct 7, 2024
1 parent bc6d00f commit 7649e82
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 24 deletions.
99 changes: 77 additions & 22 deletions src/server/ServerStateManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
},
Expand Down Expand Up @@ -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());
}

/**
Expand Down Expand Up @@ -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);
}

/**
Expand All @@ -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).
Expand All @@ -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', {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/server/SharedStatePrivate.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from '../common/constants.js';
import {
kServerStateManagerDeletePrivateState,
kServerStateManagerGetHooks,
kServerStateManagerGetUpdateHooks,
} from '../server/ServerStateManager.js';

/**
Expand Down Expand Up @@ -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;

Expand Down
148 changes: 148 additions & 0 deletions tests/states/StateManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down

0 comments on commit 7649e82

Please sign in to comment.