@mh-gg/reducer-runtime 0.1.1-alpha.20260813T095547173Z
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +6 -0
- package/package.json +28 -0
- package/src/errors.cjs +21 -0
- package/src/index.cjs +6 -0
- package/src/plugin.cjs +38 -0
- package/src/runtime.cjs +181 -0
- package/src/schema.cjs +13 -0
- package/src/scopedRoles/access.cjs +19 -0
- package/src/shared.cjs +5 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matterhorn contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mh-gg/reducer-runtime",
|
|
3
|
+
"version": "0.1.1-alpha.20260813T095547173Z",
|
|
4
|
+
"description": "Deterministic client-side and test-only reducer engine for Matterhorn apps.",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"main": "src/index.cjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.cjs"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@mh-gg/authority": "^0.1.1-alpha.20260813T095547173Z",
|
|
12
|
+
"@mh-gg/base": "^0.1.1-alpha.20260813T095547173Z",
|
|
13
|
+
"@mh-gg/protocol": "^0.1.1-alpha.20260813T095547173Z"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=22.12"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"package.json"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.cjs",
|
|
26
|
+
"coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-include=src/**/*.cjs test/*.test.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/errors.cjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class ReducerRuntimeError extends Error {
|
|
2
|
+
constructor(code, message) {
|
|
3
|
+
super(message || code);
|
|
4
|
+
this.name = "ReducerRuntimeError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function reducerError(code, message) {
|
|
10
|
+
return new ReducerRuntimeError(code, message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function allow(details = {}) {
|
|
14
|
+
return { ok: true, ...details };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function deny(reason = "Forbidden") {
|
|
18
|
+
return { ok: false, reason };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { ReducerRuntimeError, allow, deny, reducerError };
|
package/src/index.cjs
ADDED
package/src/plugin.cjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const { reducerError } = require("./errors.cjs");
|
|
2
|
+
const { clone } = require("./shared.cjs");
|
|
3
|
+
|
|
4
|
+
const OPERATION_SCHEMA_DESCRIPTOR_KIND = "matterhorn.operation-schema-descriptor";
|
|
5
|
+
|
|
6
|
+
function operationMap(value) {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw reducerError("INVALID_PLUGIN_OPERATION_DESCRIPTOR", "operations must be an object");
|
|
8
|
+
return Object.fromEntries(Object.entries(value).map(([type, descriptor]) => [type, clone(descriptor)]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validatePlugin(plugin) {
|
|
12
|
+
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) throw reducerError("INVALID_PLUGIN", "Plugin must be an object");
|
|
13
|
+
if (!plugin.id || !plugin.version) throw reducerError("INVALID_PLUGIN", "Plugin id and version are required");
|
|
14
|
+
if (typeof plugin.createInitialState !== "function") throw reducerError("INVALID_PLUGIN", `${plugin.id} createInitialState is required`);
|
|
15
|
+
if (typeof plugin.authorize !== "function") throw reducerError("INVALID_PLUGIN", `${plugin.id} authorize is required`);
|
|
16
|
+
if (typeof plugin.reduce !== "function") throw reducerError("INVALID_PLUGIN", `${plugin.id} reduce is required`);
|
|
17
|
+
if (!plugin.schemas?.state || !plugin.schemas?.operations) throw reducerError("INVALID_PLUGIN", `${plugin.id} schemas are required`);
|
|
18
|
+
for (const hook of ["afterCommit", "onInstall", "onRoomStart", "afterStart", "onRoomStop", "onUninstall", "migrations"]) {
|
|
19
|
+
if (plugin[hook] !== undefined) throw reducerError("NONDETERMINISTIC_PLUGIN", `${plugin.id} ${hook} is not available in the reducer runtime`);
|
|
20
|
+
}
|
|
21
|
+
return plugin;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function definePlugin(plugin) {
|
|
25
|
+
return validatePlugin(plugin);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createOperationSchemaDescriptor(input, version, operations) {
|
|
29
|
+
if (input && typeof input === "object" && !Array.isArray(input) && arguments.length === 1) {
|
|
30
|
+
const plugin = input.plugin || input.pluginId;
|
|
31
|
+
if (!plugin || !input.version) throw reducerError("INVALID_PLUGIN_OPERATION_DESCRIPTOR", "plugin and version are required");
|
|
32
|
+
return { kind: input.kind || OPERATION_SCHEMA_DESCRIPTOR_KIND, plugin, version: input.version, ...(input.source ? { source: input.source } : {}), operations: operationMap(input.operations) };
|
|
33
|
+
}
|
|
34
|
+
if (typeof input !== "string" || !input || typeof version !== "string" || !version) throw reducerError("INVALID_PLUGIN_OPERATION_DESCRIPTOR", "plugin and version are required");
|
|
35
|
+
return { kind: OPERATION_SCHEMA_DESCRIPTOR_KIND, plugin: input, version, operations: operationMap(operations) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { OPERATION_SCHEMA_DESCRIPTOR_KIND, createOperationSchemaDescriptor, definePlugin, validatePlugin };
|
package/src/runtime.cjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
const { hashCanonical } = require("@mh-gg/base");
|
|
2
|
+
const { sortOperations } = require("@mh-gg/authority");
|
|
3
|
+
const { snowflakeIdForOperation } = require("@mh-gg/protocol");
|
|
4
|
+
const { reducerError } = require("./errors.cjs");
|
|
5
|
+
const { validatePlugin } = require("./plugin.cjs");
|
|
6
|
+
const { parseWithSchema } = require("./schema.cjs");
|
|
7
|
+
const { clone } = require("./shared.cjs");
|
|
8
|
+
const { canEditScope, canViewScope, evaluateScopedAction, scopedRoleForActor } = require("./scopedRoles/access.cjs");
|
|
9
|
+
|
|
10
|
+
function requireRoom(room) {
|
|
11
|
+
if (!room?.id) throw reducerError("INVALID_ROOM", "room.id is required");
|
|
12
|
+
if (!room.appPack?.id || !room.appPack?.hash) throw reducerError("INVALID_ROOM", "room.appPack id and hash are required");
|
|
13
|
+
return clone(room);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function operationTime(operation) {
|
|
17
|
+
if (Number.isFinite(operation.createdAt)) return operation.createdAt;
|
|
18
|
+
throw reducerError("INVALID_OPERATION", "operation.createdAt is required for deterministic folding");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ReducerRuntime {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
this.room = requireRoom(options.room);
|
|
24
|
+
this.plugins = new Map();
|
|
25
|
+
this.capabilities = new Set(options.capabilities || []);
|
|
26
|
+
this.initialState = clone(options.initialState);
|
|
27
|
+
this.state = undefined;
|
|
28
|
+
for (const plugin of options.plugins || []) {
|
|
29
|
+
validatePlugin(plugin);
|
|
30
|
+
if (this.plugins.has(plugin.id)) throw reducerError("DUPLICATE_PLUGIN", `Duplicate plugin ${plugin.id}`);
|
|
31
|
+
this.plugins.set(plugin.id, plugin);
|
|
32
|
+
}
|
|
33
|
+
if (this.plugins.size === 0) throw reducerError("NO_PLUGINS", "At least one plugin is required");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
context(plugin, roomState, pluginState, actor, operation) {
|
|
37
|
+
const runtime = this;
|
|
38
|
+
let randomIndex = 0;
|
|
39
|
+
return {
|
|
40
|
+
room: { id: this.room.id, appPackId: this.room.appPack.id, appPackHash: this.room.appPack.hash },
|
|
41
|
+
plugin: { id: plugin.id, version: plugin.version, ...(plugin.config === undefined ? {} : { config: clone(plugin.config) }) },
|
|
42
|
+
actor: clone(actor),
|
|
43
|
+
roomState: clone(roomState),
|
|
44
|
+
pluginState: clone(pluginState),
|
|
45
|
+
now: operation ? operationTime(operation) : roomState.createdAt,
|
|
46
|
+
crypto: {
|
|
47
|
+
hash: hashCanonical,
|
|
48
|
+
verifySignature() { return false; },
|
|
49
|
+
randomId(prefix = "id") {
|
|
50
|
+
randomIndex += 1;
|
|
51
|
+
const digest = hashCanonical({ operationId: operation?.id || "initial", pluginId: plugin.id, prefix, randomIndex }).slice(7, 23);
|
|
52
|
+
return `${prefix}_${digest}`;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
access: {
|
|
56
|
+
roleForScope(scopeType, scopeId, inputActor = actor) {
|
|
57
|
+
return scopedRoleForActor(roomState, inputActor, scopeType, scopeId);
|
|
58
|
+
},
|
|
59
|
+
canView(scopeType, scopeId, inputActor = actor) {
|
|
60
|
+
return canViewScope(roomState, inputActor, scopeType, scopeId);
|
|
61
|
+
},
|
|
62
|
+
canEdit(scopeType, scopeId, inputActor = actor) {
|
|
63
|
+
return canEditScope(roomState, inputActor, scopeType, scopeId);
|
|
64
|
+
},
|
|
65
|
+
canPerform(operationType, scopeType, scopeId, options = {}, inputActor = actor) {
|
|
66
|
+
return evaluateScopedAction({ state: roomState, actor: inputActor, operationType, scopeType, scopeId, at: operation?.createdAt, ...options }).allowed;
|
|
67
|
+
},
|
|
68
|
+
explain(operationType, scopeType, scopeId, options = {}, inputActor = actor) {
|
|
69
|
+
return evaluateScopedAction({ state: roomState, actor: inputActor, operationType, scopeType, scopeId, at: operation?.createdAt, ...options });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
plugins: {
|
|
73
|
+
has(pluginId) { return runtime.plugins.has(pluginId); },
|
|
74
|
+
async call(pluginId, method, input) {
|
|
75
|
+
const target = runtime.plugins.get(pluginId);
|
|
76
|
+
const fn = target?.methods?.[method];
|
|
77
|
+
if (typeof fn !== "function") throw reducerError("PLUGIN_METHOD_NOT_FOUND", `Plugin method ${pluginId}.${method} is not available`);
|
|
78
|
+
return clone(await fn({ actor: clone(actor), roomState: clone(roomState), state: clone(roomState.plugins[pluginId]), capabilities: runtime.capabilities }, clone(input)));
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
capabilities: {
|
|
82
|
+
has: (capability) => runtime.capabilities.has(capability),
|
|
83
|
+
require: (capability) => {
|
|
84
|
+
if (!runtime.capabilities.has(capability)) throw reducerError("CAPABILITY_MISSING", `Missing capability ${capability}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async initialize() {
|
|
91
|
+
if (this.state) return this.getState();
|
|
92
|
+
const createdAt = Number.isFinite(this.initialState?.createdAt) ? this.initialState.createdAt : 0;
|
|
93
|
+
const state = this.initialState || {
|
|
94
|
+
roomId: this.room.id,
|
|
95
|
+
appPack: clone(this.room.appPack),
|
|
96
|
+
version: 0,
|
|
97
|
+
createdAt,
|
|
98
|
+
updatedAt: createdAt,
|
|
99
|
+
plugins: {},
|
|
100
|
+
pluginVersions: {}
|
|
101
|
+
};
|
|
102
|
+
state.plugins ||= {};
|
|
103
|
+
state.pluginVersions ||= {};
|
|
104
|
+
for (const plugin of this.plugins.values()) {
|
|
105
|
+
if (state.plugins[plugin.id] === undefined) {
|
|
106
|
+
const initial = await plugin.createInitialState(this.context(plugin, state, undefined, undefined, undefined));
|
|
107
|
+
state.plugins[plugin.id] = parseWithSchema(plugin.schemas.state, initial, `${plugin.id} state`);
|
|
108
|
+
} else {
|
|
109
|
+
state.plugins[plugin.id] = parseWithSchema(plugin.schemas.state, state.plugins[plugin.id], `${plugin.id} state`);
|
|
110
|
+
}
|
|
111
|
+
state.pluginVersions[plugin.id] = plugin.version;
|
|
112
|
+
}
|
|
113
|
+
this.state = clone(state);
|
|
114
|
+
return this.getState();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async fold(operation) {
|
|
118
|
+
await this.initialize();
|
|
119
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) throw reducerError("INVALID_OPERATION", "Operation must be an object");
|
|
120
|
+
if (operation.roomId !== this.room.id) throw reducerError("ROOM_MISMATCH", "Operation room does not match reducer room");
|
|
121
|
+
if (operation.appPackId !== this.room.appPack.id || operation.appPackHash !== this.room.appPack.hash) throw reducerError("APP_PACK_MISMATCH", "Operation app pack does not match reducer app");
|
|
122
|
+
const plugin = this.plugins.get(operation.pluginId);
|
|
123
|
+
if (!plugin) throw reducerError("PLUGIN_NOT_INSTALLED", `Plugin ${operation.pluginId} is not installed`);
|
|
124
|
+
const schema = plugin.schemas.operations[operation.type];
|
|
125
|
+
if (!schema) throw reducerError("UNKNOWN_OPERATION_TYPE", `Unknown operation type ${operation.type}`);
|
|
126
|
+
const ledgerId = snowflakeIdForOperation(operation);
|
|
127
|
+
const parsed = {
|
|
128
|
+
...clone(operation),
|
|
129
|
+
ledgerId,
|
|
130
|
+
snowflakeId: ledgerId,
|
|
131
|
+
payload: parseWithSchema(schema, operation.payload, `${plugin.id}.${operation.type}`)
|
|
132
|
+
};
|
|
133
|
+
const pluginState = clone(this.state.plugins[plugin.id]);
|
|
134
|
+
const context = this.context(plugin, this.state, pluginState, parsed.actor, parsed);
|
|
135
|
+
const authorized = await plugin.authorize(context, parsed);
|
|
136
|
+
if (!authorized?.ok) throw reducerError("FORBIDDEN", authorized?.reason || "Forbidden");
|
|
137
|
+
const next = await plugin.reduce(context, pluginState, parsed);
|
|
138
|
+
this.state.plugins[plugin.id] = parseWithSchema(plugin.schemas.state, next, `${plugin.id} state`);
|
|
139
|
+
this.state.pluginVersions[plugin.id] = plugin.version;
|
|
140
|
+
this.state.version += 1;
|
|
141
|
+
this.state.updatedAt = operationTime(parsed);
|
|
142
|
+
return this.getState();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async foldBatch(operations) {
|
|
146
|
+
if (!Array.isArray(operations)) throw reducerError("INVALID_OPERATION_BATCH", "Operations must be an array");
|
|
147
|
+
for (const operation of sortOperations(operations)) await this.fold(operation);
|
|
148
|
+
return this.getState();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async query(pluginId, queryName, input, actor) {
|
|
152
|
+
await this.initialize();
|
|
153
|
+
const plugin = this.plugins.get(pluginId);
|
|
154
|
+
const query = plugin?.queries?.[queryName];
|
|
155
|
+
if (typeof query !== "function") throw reducerError("PLUGIN_QUERY_NOT_FOUND", `Plugin query ${pluginId}.${queryName} is not available`);
|
|
156
|
+
const pluginState = clone(this.state.plugins[pluginId]);
|
|
157
|
+
const result = await query(this.context(plugin, this.state, pluginState, actor), pluginState, clone(input), clone(actor));
|
|
158
|
+
const schema = plugin.schemas.queries?.[queryName];
|
|
159
|
+
return clone(schema ? parseWithSchema(schema, result, `${pluginId}.${queryName} query`) : result);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async project(actor) {
|
|
163
|
+
await this.initialize();
|
|
164
|
+
const plugins = {};
|
|
165
|
+
for (const [pluginId, plugin] of this.plugins) {
|
|
166
|
+
const pluginState = clone(this.state.plugins[pluginId]);
|
|
167
|
+
const value = typeof plugin.getPublicView === "function"
|
|
168
|
+
? await plugin.getPublicView(this.context(plugin, this.state, pluginState, actor), pluginState, clone(actor))
|
|
169
|
+
: pluginState;
|
|
170
|
+
plugins[pluginId] = clone(plugin.schemas.publicView ? parseWithSchema(plugin.schemas.publicView, value, `${pluginId} public view`) : value);
|
|
171
|
+
}
|
|
172
|
+
return { roomId: this.state.roomId, appPack: clone(this.state.appPack), version: this.state.version, updatedAt: this.state.updatedAt, plugins };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
getState() {
|
|
176
|
+
if (!this.state) throw reducerError("NOT_INITIALIZED", "Reducer runtime is not initialized");
|
|
177
|
+
return clone(this.state);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { ReducerRuntime };
|
package/src/schema.cjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const { ReducerRuntimeError, reducerError } = require("./errors.cjs");
|
|
2
|
+
|
|
3
|
+
function parseWithSchema(schema, value, name) {
|
|
4
|
+
if (!schema || typeof schema.parse !== "function") throw reducerError("SCHEMA_MISSING", `${name} schema is missing`);
|
|
5
|
+
try {
|
|
6
|
+
return schema.parse(value);
|
|
7
|
+
} catch (error) {
|
|
8
|
+
if (error instanceof ReducerRuntimeError) throw error;
|
|
9
|
+
throw reducerError("SCHEMA_INVALID", `${name} did not validate: ${error?.message || error}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { parseWithSchema };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { canEditScoped, canViewScoped, evaluateScopedAction, resolveScopedRole } = require("@mh-gg/authority");
|
|
2
|
+
|
|
3
|
+
function scopedRoleForActor(roomState, actor, scopeType, scopeId) {
|
|
4
|
+
return resolveScopedRole(roomState, actor, scopeType, scopeId);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function canAccessScope(roomState, actor, scopeType, scopeId, requiredRole) {
|
|
8
|
+
return evaluateScopedAction({ state: roomState, actor, scopeType, scopeId, operationType: "matterhorn.scope.access", minimumLevel: requiredRole }).allowed;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function canViewScope(roomState, actor, scopeType, scopeId) {
|
|
12
|
+
return canViewScoped(roomState, actor, scopeType, scopeId);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function canEditScope(roomState, actor, scopeType, scopeId) {
|
|
16
|
+
return canEditScoped(roomState, actor, scopeType, scopeId);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { canAccessScope, canEditScope, canViewScope, evaluateScopedAction, scopedRoleForActor };
|