@mh-gg/schema 0.1.1-alpha.20260613T085325975Z

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.
Files changed (68) hide show
  1. package/package.json +39 -0
  2. package/schemas/app-composition.schema.json +684 -0
  3. package/schemas/example-app.schema.json +64 -0
  4. package/schemas/example-demo.schema.json +45 -0
  5. package/schemas/micro-plugin.schema.json +20 -0
  6. package/src/actions.cjs +31 -0
  7. package/src/app.d.ts +119 -0
  8. package/src/builder/app.cjs +157 -0
  9. package/src/builder/effects.cjs +116 -0
  10. package/src/builder/guards.cjs +35 -0
  11. package/src/builder/index.cjs +11 -0
  12. package/src/builder/matterhornApp/build.cjs +137 -0
  13. package/src/builder/matterhornApp/bundle.cjs +89 -0
  14. package/src/builder/matterhornApp/demo.cjs +161 -0
  15. package/src/builder/matterhornApp/demoAliases.cjs +50 -0
  16. package/src/builder/matterhornApp/descriptors.cjs +59 -0
  17. package/src/builder/matterhornApp/exports.cjs +95 -0
  18. package/src/builder/matterhornApp/frontend.cjs +80 -0
  19. package/src/builder/matterhornApp/plugins.cjs +95 -0
  20. package/src/builder/matterhornApp/shared.cjs +105 -0
  21. package/src/builder/matterhornApp.cjs +7 -0
  22. package/src/builder/model.cjs +172 -0
  23. package/src/builder/notifications.cjs +51 -0
  24. package/src/builder/refs.cjs +33 -0
  25. package/src/builder/schema.cjs +101 -0
  26. package/src/builder/streamKey.cjs +27 -0
  27. package/src/composition.cjs +157 -0
  28. package/src/configured.cjs +41 -0
  29. package/src/configured.d.ts +1 -0
  30. package/src/imports/loader.cjs +86 -0
  31. package/src/index.cjs +12 -0
  32. package/src/index.d.ts +168 -0
  33. package/src/json.cjs +70 -0
  34. package/src/jsonSchema/validator.cjs +131 -0
  35. package/src/microPlugin.cjs +112 -0
  36. package/src/model/collections.cjs +57 -0
  37. package/src/model/effects.cjs +149 -0
  38. package/src/model/expressions.cjs +85 -0
  39. package/src/model/guards.cjs +139 -0
  40. package/src/model/index.cjs +9 -0
  41. package/src/model/partitionOperands.cjs +89 -0
  42. package/src/model/partitionValidator.cjs +95 -0
  43. package/src/model/payload.cjs +93 -0
  44. package/src/model/plugin.cjs +146 -0
  45. package/src/notifications.cjs +41 -0
  46. package/src/notifications.d.ts +63 -0
  47. package/src/registry.cjs +63 -0
  48. package/src/runtime-exports.d.ts +65 -0
  49. package/src/streamKey.d.ts +15 -0
  50. package/src/types/actionTypes.cjs +164 -0
  51. package/src/types/coreActionPayloadSchemas.cjs +35 -0
  52. package/src/types/coreFeatures.cjs +29 -0
  53. package/src/types/entities.cjs +131 -0
  54. package/src/types/entityAliases.cjs +84 -0
  55. package/src/types/generator.cjs +145 -0
  56. package/src/types/index.cjs +5 -0
  57. package/src/types/interfaceBlock.cjs +41 -0
  58. package/src/types/pluginEntities.cjs +83 -0
  59. package/src/types/schema.cjs +148 -0
  60. package/src/types/standardPluginEntityTypes.cjs +135 -0
  61. package/test/content-lww-contract.test.cjs +104 -0
  62. package/test/partitionValidator.test.cjs +92 -0
  63. package/test/schema-array-effects.test.cjs +116 -0
  64. package/test/schema-builder-types.test.cjs +383 -0
  65. package/test/schema-composition.test.cjs +144 -0
  66. package/test/schema-configured-builders.test.cjs +286 -0
  67. package/test/schema-imports.test.cjs +66 -0
  68. package/test/schema-model-interpreter.test.cjs +273 -0
@@ -0,0 +1,286 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const schemaModule = require("../src/index.cjs");
4
+ const { createConfiguredSchemaApi } = require("../src/configured.cjs");
5
+ const { registryRecords } = require("@mh-gg/base-plugins/composer/registry");
6
+ const {
7
+ COMMENTS_PLUGIN_ID,
8
+ MEDIA_ROOMS_PLUGIN_ID,
9
+ PRESENCE_PLUGIN_ID
10
+ } = require("@mh-gg/base-plugins");
11
+
12
+ const schema = createConfiguredSchemaApi(schemaModule, registryRecords());
13
+
14
+ function boardModel() {
15
+ const model = schema.defineModel({
16
+ roles: {
17
+ member: { name: "Member", rank: "1", systemRole: false },
18
+ admin: { name: "Admin", rank: 10, color: "red", archivedAt: null }
19
+ },
20
+ state: {
21
+ initial: {
22
+ cards: {
23
+ seed: { id: "seed", title: "Seed", ownerId: "ada", reactions: {} }
24
+ },
25
+ lists: {},
26
+ participants: [],
27
+ settings: {},
28
+ activity: []
29
+ },
30
+ roomIdPath: "room.id"
31
+ },
32
+ views: {
33
+ cards: schema.q.collection("cards", { includeArchived: true }),
34
+ cardCount: schema.q.count("cards")
35
+ },
36
+ publicView: { kind: "state" },
37
+ capabilities: { required: ["room.state"] }
38
+ });
39
+ const { cards, lists, participants } = model.collections;
40
+ return model.withOperations({
41
+ "card.create": schema.op({
42
+ authorize: { roles: ["member"] },
43
+ payload: {
44
+ cardId: schema.p.string(),
45
+ title: schema.p.string().nullable(),
46
+ listId: schema.p.string().optional()
47
+ }
48
+ }, ({ ref, fx, guard }) => ({
49
+ guard: guard.ne(guard.path("cards[$payload.cardId]"), null, "Card already exists"),
50
+ effects: [
51
+ fx.create(cards, {
52
+ id: ref.payload("cardId"),
53
+ fields: {
54
+ id: ref.payload("cardId"),
55
+ title: ref.payload("title"),
56
+ ownerId: ref.actor("memberId"),
57
+ createdAt: ref.now()
58
+ },
59
+ activity: "created",
60
+ recordLabel: "card"
61
+ }),
62
+ fx.insertIntoArray(lists, { matchId: ref.payload("listId"), field: "cardIds", value: ref.payload("cardId"), at: 0 }),
63
+ fx.noop()
64
+ ]
65
+ })),
66
+ "card.update": schema.op({
67
+ payload: {
68
+ cardId: schema.p.string(),
69
+ title: schema.p.string().optional(),
70
+ archivedAt: schema.p.number().optional()
71
+ }
72
+ }, ({ ref, fx, guard }) => ({
73
+ guards: [
74
+ guard.ownerOrRole(cards, { id: ref.payload("cardId"), ownerField: "ownerId", roles: ["admin"] }),
75
+ guard.flagClear(cards, { id: ref.payload("cardId"), flag: "archivedAt" })
76
+ ],
77
+ effects: [
78
+ fx.update(cards, { id: ref.payload("cardId"), set: { title: ref.payload("title") } }),
79
+ fx.mark(cards, { id: ref.payload("cardId"), set: { archivedAt: ref.payload("archivedAt") } })
80
+ ]
81
+ })),
82
+ "reaction.toggle": schema.op({
83
+ payload: {
84
+ cardId: schema.p.string(),
85
+ emoji: schema.p.string()
86
+ }
87
+ }, ({ ref, fx }) => ({
88
+ effects: [fx.toggleReaction(cards, { id: ref.payload("cardId"), emoji: ref.payload("emoji") })]
89
+ })),
90
+ "list.removeCard": schema.op({
91
+ payload: {
92
+ listId: schema.p.string(),
93
+ cardId: schema.p.string()
94
+ }
95
+ }, ({ ref, fx }) => ({
96
+ effects: [fx.removeFromArray(lists, { matchId: ref.payload("listId"), field: "cardIds", value: ref.payload("cardId") })]
97
+ })),
98
+ "settings.merge": schema.op({
99
+ payload: { theme: schema.p.enum(["light", "dark"]).optional() }
100
+ }, {
101
+ effects: [schema.fx.merge({ path: "settings" })]
102
+ }),
103
+ "participant.upsert": schema.op({
104
+ payload: { memberId: schema.p.string(), name: schema.p.string().optional() }
105
+ }, ({ fx }) => ({
106
+ effects: [fx.upsertActor(participants, { fields: { memberId: "$actor.memberId", name: "$actor.displayName" } })]
107
+ }))
108
+ });
109
+ }
110
+
111
+ test("configured schema API builds app compositions with base plugin registry defaults", () => {
112
+ assert.equal(schema.getMicroPluginSchema("comments").id, COMMENTS_PLUGIN_ID);
113
+ assert.equal(schema.listMicroPluginSchemas().some((item) => item.id === MEDIA_ROOMS_PLUGIN_ID), true);
114
+
115
+ const model = boardModel();
116
+ const cardScope = schema.defineScope({
117
+ key: "cardDetail",
118
+ type: "card",
119
+ id: "$payload.cardId",
120
+ identity: "$payload.cardId",
121
+ collections: [model.collections.cards, "activity"]
122
+ });
123
+ const app = schema.defineApp({
124
+ id: "com.matterhorn.tests.configured-board",
125
+ name: "Configured Board",
126
+ version: "1.2.3",
127
+ namespace: "ConfiguredBoard",
128
+ model,
129
+ plugins: ["comments", "presence", { id: MEDIA_ROOMS_PLUGIN_ID, config: { defaultRooms: [{ id: "standup", name: "Standup" }] } }],
130
+ scopes: [cardScope, schema.defineScope({ key: "workspace", type: "workspace", id: "$app.id", collections: ["cards"] })],
131
+ views: {
132
+ cards: schema.q.collection(model.collections.cards),
133
+ online: { plugin: "presence", kind: "query", query: "onlineMembers" }
134
+ },
135
+ actions: {
136
+ commentOnCard: { plugin: "comments", type: "comments.add", payloadDefaults: { scopeType: "card" } },
137
+ directMessageSend: { plugin: "core", type: "dm.message", requiredRole: "member" }
138
+ },
139
+ routes: [
140
+ { path: "/", component: "BoardPage", requiredPlugins: ["comments", model, { id: MEDIA_ROOMS_PLUGIN_ID }] }
141
+ ]
142
+ });
143
+
144
+ const composition = app.toJSON();
145
+ assert.deepEqual(composition.plugins.map((plugin) => plugin.id), [COMMENTS_PLUGIN_ID, PRESENCE_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID]);
146
+ assert.equal(composition.sharedScopes.cardDetail.scopeId, "$payload.cardId");
147
+ assert.deepEqual(composition.routes[0].requiredPlugins, ["comments", MEDIA_ROOMS_PLUGIN_ID]);
148
+ assert.equal(schema.validateAppCompositionSchema(composition).schemaHash, composition.schemaHash);
149
+ assert.equal(schema.resolveCompositionHostPluginEntries(composition)[2].config.defaultRooms[0].id, "standup");
150
+ assert.deepEqual(
151
+ composition.actions.find((action) => action.name === "directMessageSend"),
152
+ { name: "directMessageSend", plugin: "core", type: "dm.message", requiredRole: "member" }
153
+ );
154
+ assert.equal(composition.actions.some((action) => action.plugin === "comments" && action.type === "comments.add" && action.name !== "commentOnCard"), true);
155
+
156
+ const descriptor = composition.actions.find((action) => action.plugin === "comments" && action.type === "comments.add");
157
+ const dispatched = [];
158
+ schema.createActionDispatchersFromComposition(composition)[descriptor.name]({ dispatch: (op) => dispatched.push(op) }, {
159
+ scopeType: "card",
160
+ scopeId: "seed",
161
+ body: "Looks good"
162
+ });
163
+ assert.deepEqual(dispatched, [{
164
+ schemaAction: descriptor.name,
165
+ pluginId: COMMENTS_PLUGIN_ID,
166
+ type: "comments.add",
167
+ payload: { scopeType: "card", scopeId: "seed", body: "Looks good" }
168
+ }]);
169
+ schema.createActionDispatchersFromComposition(composition).commentOnCard({ dispatch: (op) => dispatched.push(op) }, {
170
+ scopeId: "card_1",
171
+ body: "Explicit alias"
172
+ });
173
+ assert.deepEqual(dispatched[1], {
174
+ schemaAction: "commentOnCard",
175
+ pluginId: COMMENTS_PLUGIN_ID,
176
+ type: "comments.add",
177
+ payload: { scopeType: "card", scopeId: "card_1", body: "Explicit alias" }
178
+ });
179
+ assert.equal(schema.pluginIdForAction({ plugin: "primary" }, composition), composition.primaryPlugin.id);
180
+ assert.equal(schema.pluginIdForAction({ plugin: "matterhorn.core" }, composition), "matterhorn.core");
181
+ assert.equal(schema.pluginIdForAction(descriptor, composition), COMMENTS_PLUGIN_ID);
182
+
183
+ const fragments = app.toJSONFragments({ modelPath: "./board-model.json", actionsPath: "./board-actions.json" });
184
+ assert.deepEqual(fragments.composition.$imports.map((item) => item.path), ["./board-model.json", "./board-actions.json"]);
185
+ const types = schema.generateAppTypeDeclaration(composition, { namespace: "ConfiguredBoard" });
186
+ assert.doesNotMatch(types, /ScopedRole/);
187
+ assert.doesNotMatch(types, /PluginState/);
188
+ assert.doesNotMatch(types, /comments: CommentsPluginState/);
189
+ assert.match(types, /export type ThreadId = string & \{ readonly __brand: "ThreadId" \};/);
190
+ assert.match(types, /export type ScopeType = "card" \| "channel" \| "workspace" \| \(string & \{\}\);/);
191
+ assert.match(types, /From the comments plugin\./);
192
+ assert.match(types, /Requires `member` role\./);
193
+ assert.match(types, /commentThreads: Record<ThreadId, CommentThread>;/);
194
+ assert.match(types, /comments: Record<CommentId, Comment>;/);
195
+ assert.match(types, /commentOnCard: \{ body: string; threadId\?: ThreadId; parentId\?: CommentId \} & Partial<ScopeRef>/);
196
+ assert.match(types, /directMessageSend: \{ userIds: MemberId\[\]; body: string; topicKey\?: string \}/);
197
+ assert.match(types, /directMessageSend: "dm\.message"/);
198
+ assert.doesNotMatch(types, /getDMs\(topicPattern\?: RegExp\): DirectMessageThread\[\]/);
199
+ assert.doesNotMatch(types, /subscribeDMs/);
200
+ assert.doesNotMatch(types, /sendDirectMessage\(input:/);
201
+ assert.doesNotMatch(types, /ActionHelpers|DispatchResult|ConnectionStatus|export interface Client/);
202
+ assert.doesNotMatch(types, /dispatch<K extends ActionName>/);
203
+ assert.doesNotMatch(types, /plugins: Plugins/);
204
+ assert.doesNotMatch(types, /"comments\.add":/);
205
+ assert.doesNotMatch(types, /MarkdownDocument|GitRepo|ApprovalRequest/);
206
+
207
+ assert.equal(typeof app.toClientHelpers, "undefined");
208
+ });
209
+
210
+ test("schema builder primitives reject malformed input and keep JSON output explicit", () => {
211
+ const arrayCollection = schema.collectionHandle("items", []);
212
+ assert.equal(arrayCollection.storage, "array");
213
+ assert.deepEqual(schema.fx.create("cards", { id: "$id:card", fields: { title: "New" } }).idPrefix, "card");
214
+ assert.deepEqual(schema.fx.update("cards", { id: "seed", fields: { title: "Done" } }).id, "seed");
215
+ assert.deepEqual(schema.fx.append(arrayCollection, { id: "item_1" }).path, "items");
216
+ assert.deepEqual(schema.guard.eq("$payload.status", schema.guard.value("done"), "Done only").left, { kind: "value.expr", expr: "$payload.status" });
217
+ assert.deepEqual(schema.guard.and([schema.guard.noop()], schema.guard.not(schema.guard.exists("cards.seed"))).guards[0], { kind: "noop" });
218
+ assert.throws(() => schema.normalizeCollection({}), /collection handle or collection name/);
219
+ assert.throws(() => schema.op({}, () => ({ effects: {} })), /effects must be an array/);
220
+ assert.throws(() => schema.op({}, () => ({ guards: {} })), /guards must be an array/);
221
+ assert.throws(() => schema.queryToJson("bad"), /view must be a query definition object/);
222
+ assert.throws(() => schema.modelToJson(null), /defineApp requires a model/);
223
+ assert.throws(() => schema.emitAppArtifacts({}, {}), /requires a defineApp/);
224
+ });
225
+
226
+ test("generated app types use stored create shapes and selected plugin entities", () => {
227
+ const model = schema.defineModel({
228
+ roles: { member: { name: "Member", rank: 1 } },
229
+ state: { initial: { items: [] } }
230
+ });
231
+ const { items } = model.collections;
232
+ const app = schema.defineApp({
233
+ id: "com.matterhorn.tests.typed-items",
234
+ name: "Typed Items",
235
+ namespace: "TypedItems",
236
+ model: model.withOperations({
237
+ "item.create": schema.op({
238
+ payload: {
239
+ name: schema.p.string(),
240
+ note: schema.p.string().nullable().optional()
241
+ }
242
+ }, ({ ref, fx }) => ({
243
+ effects: [fx.create(items, {
244
+ id: ref.newId("item"),
245
+ fields: { name: ref.payload("name"), note: ref.payload("note", null), createdAt: ref.now() }
246
+ })]
247
+ }))
248
+ }),
249
+ plugins: ["presence"]
250
+ });
251
+
252
+ const types = app.toTypes({ namespace: "TypedItems" });
253
+ assert.match(types, /export interface Item \{\n createdAt: number;\n id: string;\n name: string;\n note: null \| string;\n \}/);
254
+ assert.match(types, /export interface PresenceMember/);
255
+ assert.doesNotMatch(types, /PluginState|ScopedRole|MarkdownDocument|GitRepo|ApprovalRequest|MediaRoom \{/);
256
+ });
257
+
258
+ test("generated app types emit ScopedRole only for scoped role entities", () => {
259
+ const model = schema.defineModel({ state: { initial: {} } });
260
+ const approvalTypes = schema.defineApp({
261
+ id: "com.matterhorn.tests.approval-types",
262
+ name: "Approval Types",
263
+ namespace: "ApprovalTypes",
264
+ model,
265
+ plugins: ["approvals"]
266
+ }).toTypes({ namespace: "ApprovalTypes" });
267
+
268
+ assert.match(approvalTypes, /export type ScopedRole = "none" \| "viewer" \| "editor" \| "moderator" \| "admin" \| "owner";/);
269
+ assert.match(approvalTypes, /export interface ApprovalRequest/);
270
+ assert.match(approvalTypes, /approvals: Record<string, ApprovalRequest>;/);
271
+
272
+ const coreAccessTypes = schema.defineApp({
273
+ id: "com.matterhorn.tests.core-access-types",
274
+ name: "Core Access Types",
275
+ namespace: "CoreAccessTypes",
276
+ model,
277
+ actions: {
278
+ grantScopeRole: { plugin: "core", type: "scope.role.set" }
279
+ }
280
+ }).toTypes({ namespace: "CoreAccessTypes" });
281
+
282
+ assert.match(coreAccessTypes, /export type ScopedRole = "none" \| "viewer" \| "editor" \| "moderator" \| "admin" \| "owner";/);
283
+ assert.match(coreAccessTypes, /export interface CoreScopeAccess/);
284
+ assert.match(coreAccessTypes, /access: CoreAccessState;/);
285
+ assert.doesNotMatch(coreAccessTypes, /ApprovalRequest|PluginState/);
286
+ });
@@ -0,0 +1,66 @@
1
+ const assert = require('node:assert/strict');
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const test = require('node:test');
6
+ const { loadJsonWithImports, assertValidJsonSchema, readSchema } = require('../src/index.cjs');
7
+
8
+ function tmpDir() {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'matterhorn-schema-imports-'));
10
+ }
11
+
12
+ test('JSON schema files are published as draft 2020-12 contracts', () => {
13
+ for (const name of [
14
+ 'app-composition.schema.json',
15
+ 'example-app.schema.json',
16
+ 'example-demo.schema.json',
17
+ 'micro-plugin.schema.json'
18
+ ]) {
19
+ const schema = readSchema(name);
20
+ assert.equal(schema.$schema, 'https://json-schema.org/draft/2020-12/schema', `${name} should declare JSON Schema draft 2020-12`);
21
+ assert.equal(schema.type, 'object', `${name} should validate an object`);
22
+ }
23
+ });
24
+
25
+ test('schema imports merge decomposed action and model JSON into composition documents', () => {
26
+ const root = tmpDir();
27
+ fs.writeFileSync(path.join(root, 'model.json'), JSON.stringify({ kind: 'matterhorn.primary-model.schema', schemaVersion: 1, state: { initial: {} }, operations: {}, views: {} }));
28
+ fs.writeFileSync(path.join(root, 'actions.json'), JSON.stringify({ createThing: { plugin: 'primary', type: 'thing.create' } }));
29
+ fs.writeFileSync(path.join(root, 'composition.json'), JSON.stringify({
30
+ $schema: 'https://matterhorn.gg/schemas/app-composition.schema.json',
31
+ $imports: [
32
+ { path: './model.json', into: 'primaryPlugin.model' },
33
+ { path: './actions.json', into: 'actions' }
34
+ ],
35
+ kind: 'matterhorn.app-composition.schema',
36
+ schemaVersion: 1,
37
+ app: { id: 'app.import-test', version: '1.0.0', name: 'Import Test' },
38
+ primaryPlugin: { id: 'app.import-test.primary', version: '1.0.0' },
39
+ plugins: ['comments'],
40
+ views: [],
41
+ routes: []
42
+ }));
43
+
44
+ const resolved = loadJsonWithImports(path.join(root, 'composition.json'), { baseDir: root });
45
+ assert.equal(resolved.primaryPlugin.model.kind, 'matterhorn.primary-model.schema');
46
+ assert.equal(resolved.actions.createThing.type, 'thing.create');
47
+ assert.doesNotThrow(() => assertValidJsonSchema('app-composition.schema.json', resolved));
48
+ });
49
+
50
+ test('schema imports reject remote, non-json, path escape, and circular references', () => {
51
+ const root = tmpDir();
52
+ fs.writeFileSync(path.join(root, 'a.json'), JSON.stringify({ $imports: [{ path: './b.json' }] }));
53
+ fs.writeFileSync(path.join(root, 'b.json'), JSON.stringify({ $imports: [{ path: './a.json' }] }));
54
+ fs.writeFileSync(path.join(root, 'remote.json'), JSON.stringify({ $imports: [{ path: 'https://example.com/model.json' }] }));
55
+ fs.writeFileSync(path.join(root, 'bad-ext.json'), JSON.stringify({ $imports: [{ path: './model.js' }] }));
56
+ fs.writeFileSync(path.join(root, 'escape.json'), JSON.stringify({ $imports: [{ path: '../outside.json' }] }));
57
+ fs.writeFileSync(path.join(root, 'missing-select.json'), JSON.stringify({ $imports: [{ path: './fragment.json', select: 'missing.path' }] }));
58
+ fs.writeFileSync(path.join(root, 'fragment.json'), JSON.stringify({ ok: true }));
59
+ fs.writeFileSync(path.join(path.dirname(root), 'outside.json'), JSON.stringify({ escaped: true }));
60
+
61
+ assert.throws(() => loadJsonWithImports(path.join(root, 'a.json'), { baseDir: root }), /Circular schema import/);
62
+ assert.throws(() => loadJsonWithImports(path.join(root, 'remote.json'), { baseDir: root }), /must be a relative JSON file/);
63
+ assert.throws(() => loadJsonWithImports(path.join(root, 'bad-ext.json'), { baseDir: root }), /must point to a \.json file/);
64
+ assert.throws(() => loadJsonWithImports(path.join(root, 'escape.json'), { baseDir: root }), /escapes/);
65
+ assert.throws(() => loadJsonWithImports(path.join(root, 'missing-select.json'), { baseDir: root }), /did not contain/);
66
+ });
@@ -0,0 +1,273 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const { manifestHash } = require('@mh-gg/base');
4
+ const { HostPluginRuntime, createMemoryOperationLog, createMemoryRoomStore } = require('@mh-gg/host-runtime');
5
+ const { ensureOperationIdentity } = require('@mh-gg/protocol');
6
+ const { createSchemaDefinedHostPlugin, schemaDefinedHostPluginEntry, SCHEMA_DEFINED_PLUGIN_SOURCE } = require('../src/index.cjs');
7
+
8
+ const APP_PACK = { id: 'com.matterhorn.schema-model-test', version: '1.0.0' };
9
+ const APP_HASH = manifestHash(APP_PACK);
10
+ const PLUGIN_ID = 'com.matterhorn.schema-model-test.plugin';
11
+
12
+ function composition(overrides = {}) {
13
+ return {
14
+ kind: 'matterhorn.app-composition.schema',
15
+ schemaVersion: 1,
16
+ app: { id: APP_PACK.id, version: APP_PACK.version, name: 'Schema Model Test' },
17
+ primaryPlugin: {
18
+ id: PLUGIN_ID,
19
+ version: '1.0.0',
20
+ model: {
21
+ kind: 'matterhorn.primary-model.schema',
22
+ schemaVersion: 1,
23
+ state: { initial: { lists: [], cards: {}, guests: {}, details: {}, notes: [], posts: {}, activity: [] }, roomIdPath: 'roomId' },
24
+ operations: {
25
+ 'list.create': {
26
+ authorize: { roles: ['admin'] },
27
+ payload: { required: { title: { type: 'string', max: 80 } }, optional: { wipLimit: { type: 'number', min: 0 } } },
28
+ effects: [{ kind: 'createRecord', collection: 'lists', storage: 'array', idPrefix: 'list', fields: { title: '$payload.title', wipLimit: '$payload.wipLimit', cardIds: [], createdAt: '$createdAt' }, activity: 'created list' }]
29
+ },
30
+ 'card.create': {
31
+ authorize: { roles: ['member'] },
32
+ payload: { required: { listId: { type: 'string' }, title: { type: 'string' } }, optional: { body: { type: 'string', nonEmpty: false, nullable: true } } },
33
+ effects: [
34
+ { kind: 'createRecord', collection: 'cards', storage: 'map', idPrefix: 'card', fields: { listId: '$payload.listId', title: '$payload.title', body: '$payload.body', createdBy: '$actor.memberId', createdAt: '$createdAt', reactions: {}, archivedAt: null } },
35
+ { kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$id:card' }
36
+ ]
37
+ },
38
+ 'card.move': {
39
+ authorize: { roles: ['member'] },
40
+ payload: { required: { cardId: { type: 'string' }, fromListId: { type: 'string' }, toListId: { type: 'string' } }, optional: { position: { type: 'number', min: 0 } } },
41
+ effects: [
42
+ { kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'fromListId', arrayField: 'cardIds', value: '$payload.cardId', recordLabel: 'List' },
43
+ { kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'toListId', arrayField: 'cardIds', value: '$payload.cardId', position: '$payload.position', recordLabel: 'List' },
44
+ { kind: 'updateRecord', collection: 'cards', storage: 'map', idField: 'cardId', fields: { listId: '$payload.toListId', updatedAt: '$createdAt' }, recordLabel: 'Card' }
45
+ ]
46
+ },
47
+ 'card.react': {
48
+ authorize: { roles: ['member'] },
49
+ payload: { required: { cardId: { type: 'string' }, emoji: { type: 'string' } } },
50
+ effects: [{ kind: 'toggleReaction', collection: 'cards', storage: 'map', idField: 'cardId', emojiField: 'emoji' }]
51
+ },
52
+ 'card.archive': {
53
+ authorize: { roles: ['admin'] },
54
+ payload: { required: { cardId: { type: 'string' } } },
55
+ effects: [{ kind: 'markRecord', collection: 'cards', storage: 'map', idField: 'cardId', fields: { archivedAt: '$createdAt' } }]
56
+ },
57
+ 'guest.upsert': {
58
+ authorize: { roles: ['guest'] },
59
+ payload: { additional: true },
60
+ effects: [{ kind: 'upsertActorRecord', collection: 'guests', fields: { displayName: '$actor.displayName', rsvp: '$payload.rsvp', lastSeenAt: '$createdAt' } }]
61
+ },
62
+ 'details.merge': {
63
+ authorize: { roles: ['admin'] },
64
+ payload: { additional: true },
65
+ effects: [{ kind: 'mergePath', path: 'details', fields: '$payload' }]
66
+ },
67
+ 'note.add': {
68
+ authorize: { roles: ['member'] },
69
+ payload: { required: { body: { type: 'string' } } },
70
+ effects: [{ kind: 'appendToArray', path: 'notes', item: { id: '$operation.id', body: '$payload.body', by: '$actor.memberId', createdAt: '$createdAt' } }]
71
+ }
72
+ },
73
+ views: { board: { kind: 'state' }, activeCards: { kind: 'collection', collection: 'cards' }, activeCardCount: { kind: 'count', collection: 'cards' }, rsvpCounts: { kind: 'rsvpCounts' } }
74
+ }
75
+ },
76
+ plugins: [],
77
+ actions: { createList: { plugin: 'primary', type: 'list.create' } },
78
+ ...overrides
79
+ };
80
+ }
81
+
82
+ function op(type, payload = {}, overrides = {}) {
83
+ const operation = {
84
+ clientOperationId: overrides.id || `op_${type.replace(/[^a-z0-9]+/gi, '_')}`,
85
+ roomId: 'room_schema',
86
+ appPackId: APP_PACK.id,
87
+ appPackHash: APP_HASH,
88
+ pluginId: PLUGIN_ID,
89
+ type,
90
+ actor: overrides.actor || { memberId: 'admin', deviceId: 'dev_admin', role: 'admin', displayName: 'Ada' },
91
+ seq: overrides.seq || 1,
92
+ createdAt: overrides.createdAt || 1000,
93
+ payload,
94
+ auth: { credentialId: 'cred', signature: 'sig' }
95
+ };
96
+ return ensureOperationIdentity(operation, { now: operation.createdAt, nodeId: operation.actor?.deviceId || 'dev_admin' });
97
+ }
98
+
99
+ async function runtimeFor(schema = composition()) {
100
+ const plugin = createSchemaDefinedHostPlugin(schema);
101
+ const runtime = new HostPluginRuntime({
102
+ room: { id: 'room_schema', appPack: { id: APP_PACK.id, version: APP_PACK.version, hash: APP_HASH, protocolHash: schema.app?.id || APP_PACK.id } },
103
+ plugins: [plugin],
104
+ store: createMemoryRoomStore(),
105
+ operationLog: createMemoryOperationLog(),
106
+ authenticateActor: async (_auth, actor) => actor
107
+ });
108
+ await runtime.start();
109
+ return { runtime, plugin };
110
+ }
111
+
112
+ test('schema-defined host plugin exposes deterministic descriptors without app backend JS', () => {
113
+ const schema = composition();
114
+ const plugin = createSchemaDefinedHostPlugin(schema);
115
+ const entry = schemaDefinedHostPluginEntry(schema, { packageName: '@mh-gg/base-plugins' });
116
+
117
+ assert.equal(plugin.schemaDefined, true);
118
+ assert.equal(plugin.schemaSource, SCHEMA_DEFINED_PLUGIN_SOURCE);
119
+ assert.equal(plugin.stateSchemaDescriptor.source, SCHEMA_DEFINED_PLUGIN_SOURCE);
120
+ assert.equal(plugin.operationSchemaDescriptor.operations['card.create'].required.title.type, 'string');
121
+ assert.equal(entry.source, SCHEMA_DEFINED_PLUGIN_SOURCE);
122
+ assert.equal(entry.plugin.id, PLUGIN_ID);
123
+ });
124
+
125
+ test('schema-defined model applies common reducer effects through the trusted interpreter', async () => {
126
+ const { runtime, plugin } = await runtimeFor();
127
+ await runtime.handleOperation(op('details.merge', { title: 'Schema Room' }, { id: 'op_1', seq: 1 }));
128
+ await runtime.handleOperation(op('list.create', { title: 'Todo' }, { id: 'op_2', seq: 2 }));
129
+ let state = await runtime.getState();
130
+ const todoId = state.plugins[PLUGIN_ID].lists[0].id;
131
+ await runtime.handleOperation(op('list.create', { title: 'Done' }, { id: 'op_3', seq: 3 }));
132
+ state = await runtime.getState();
133
+ const doneId = state.plugins[PLUGIN_ID].lists[1].id;
134
+ await runtime.handleOperation(op('card.create', { listId: todoId, title: 'Ship schema backends', body: null }, { id: 'op_4', seq: 4, actor: { memberId: 'mira', deviceId: 'dev_mira', role: 'member', displayName: 'Mira' } }));
135
+ state = await runtime.getState();
136
+ const cardId = state.plugins[PLUGIN_ID].lists[0].cardIds[0];
137
+ await runtime.handleOperation(op('card.react', { cardId, emoji: '🚀' }, { id: 'op_5', seq: 5, actor: { memberId: 'mira', deviceId: 'dev_mira', role: 'member', displayName: 'Mira' } }));
138
+ await runtime.handleOperation(op('card.move', { cardId, fromListId: todoId, toListId: doneId, position: 0 }, { id: 'op_6', seq: 6, actor: { memberId: 'mira', deviceId: 'dev_mira', role: 'member', displayName: 'Mira' } }));
139
+ await runtime.handleOperation(op('guest.upsert', { rsvp: 'yes' }, { id: 'op_7', seq: 7, actor: { memberId: 'guest', deviceId: 'dev_guest', role: 'guest', displayName: 'Gus' } }));
140
+ await runtime.handleOperation(op('note.add', { body: 'No app reducer ran here' }, { id: 'op_8', seq: 8, actor: { memberId: 'mira', deviceId: 'dev_mira', role: 'member', displayName: 'Mira' } }));
141
+ await runtime.handleOperation(op('card.archive', { cardId }, { id: 'op_9', seq: 9 }));
142
+
143
+ state = await runtime.getState();
144
+ const primary = state.plugins[PLUGIN_ID];
145
+ assert.equal(primary.roomId, 'room_schema');
146
+ assert.equal(primary.details.title, 'Schema Room');
147
+ assert.equal(primary.cards[cardId].listId, doneId);
148
+ assert.deepEqual(primary.cards[cardId].reactions['🚀'], ['mira']);
149
+ assert.equal(primary.guests.guest.rsvp, 'yes');
150
+ assert.equal(primary.notes[0].body, 'No app reducer ran here');
151
+ assert.equal(primary.activity.some((item) => item.message === 'created list'), true);
152
+
153
+ const activeCards = await runtime.handleQuery(plugin.id, 'activeCards', {}, { role: 'member' });
154
+ const activeCardCount = await runtime.handleQuery(plugin.id, 'activeCardCount', {}, { role: 'member' });
155
+ const rsvpCounts = await runtime.handleQuery(plugin.id, 'rsvpCounts', {}, { role: 'member' });
156
+ assert.deepEqual(activeCards, []);
157
+ assert.equal(activeCardCount, 0);
158
+ assert.deepEqual(rsvpCounts, { yes: 1, no: 0, maybe: 0, unset: 0 });
159
+ });
160
+
161
+ test('schema-defined model enforces owner guard rules and enum payload fallbacks', async () => {
162
+ const schema = composition();
163
+ Object.assign(schema.primaryPlugin.model.operations, {
164
+ 'post.add': {
165
+ authorize: { roles: ['member'] },
166
+ payload: { required: { body: { type: 'string' } } },
167
+ effects: [{ kind: 'createRecord', collection: 'posts', storage: 'map', idPrefix: 'post', fields: { body: '$payload.body', authorId: '$actor.memberId' } }]
168
+ },
169
+ 'post.delete': {
170
+ authorize: { roles: ['member'] },
171
+ payload: { required: { id: { type: 'string' } } },
172
+ guards: [{ kind: 'recordOwnerOrRole', collection: 'posts', storage: 'map', idField: 'id', ownerField: 'authorId', roles: ['admin'], message: 'Only owners or admins.' }],
173
+ effects: [{ kind: 'updateRecord', collection: 'posts', storage: 'map', idField: 'id', fields: { deletedAt: '$createdAt' } }]
174
+ },
175
+ 'details.theme': {
176
+ authorize: { roles: ['admin'] },
177
+ payload: { optional: { theme: { type: 'enum', values: ['sunset', 'mint'], fallback: 'sunset' } }, additional: false },
178
+ effects: [{ kind: 'mergePath', path: 'details', fields: '$payload', deleteNullFields: true }]
179
+ }
180
+ });
181
+
182
+ const { runtime } = await runtimeFor(schema);
183
+ const member = { memberId: 'mira', deviceId: 'dev_mira', role: 'member', displayName: 'Mira' };
184
+ assert.equal((await runtime.handleOperation(op('post.add', { body: 'Owned' }, { id: 'guard_post', seq: 1, actor: member }))).ok, true);
185
+
186
+ let state = await runtime.getState();
187
+ const postId = Object.values(state.plugins[PLUGIN_ID].posts)[0].id;
188
+ const deniedDelete = await runtime.handleOperation(op('post.delete', { id: postId }, { id: 'guard_other_delete', seq: 2, actor: { memberId: 'omar', deviceId: 'dev_omar', role: 'member' } }));
189
+ assert.equal(deniedDelete.ok, false);
190
+ assert.match(deniedDelete.reason, /Only owners or admins/);
191
+ assert.equal((await runtime.handleOperation(op('post.delete', { id: postId }, { id: 'guard_admin_delete', seq: 3 }))).ok, true);
192
+
193
+ await runtime.handleOperation(op('details.theme', { theme: 'invalid' }, { id: 'guard_theme', seq: 4 }));
194
+ state = await runtime.getState();
195
+ assert.equal(state.plugins[PLUGIN_ID].details.theme, 'sunset');
196
+ });
197
+
198
+ test('schema-defined model validates payloads, roles, bad schemas, and unsupported effects', async () => {
199
+ const { runtime } = await runtimeFor();
200
+
201
+ const guestAdminOp = await runtime.handleOperation(op('list.create', { title: 'Nope' }, { actor: { memberId: 'g', deviceId: 'd', role: 'guest' } }));
202
+ assert.equal(guestAdminOp.ok, false);
203
+ assert.match(guestAdminOp.reason, /Operation is not allowed|requires admin|requires member/);
204
+
205
+ const badPayload = await runtime.handleOperation(op('list.create', { title: '' }, { id: 'bad_payload' }));
206
+ assert.equal(badPayload.ok, false);
207
+ assert.match(badPayload.reason, /title is required/);
208
+
209
+ const badOptionalPayload = await runtime.handleOperation(op('list.create', { title: 'Todo', wipLimit: -1 }, { id: 'bad_optional_payload' }));
210
+ assert.equal(badOptionalPayload.ok, false);
211
+ assert.match(badOptionalPayload.reason, /wipLimit must be >= 0/);
212
+
213
+ const badEffectSchema = composition();
214
+ badEffectSchema.primaryPlugin.model.operations['bad.effect'] = {
215
+ authorize: { roles: ['admin'] },
216
+ payload: { additional: true },
217
+ effects: [{ kind: 'doesNotExist' }]
218
+ };
219
+ assert.throws(() => createSchemaDefinedHostPlugin(badEffectSchema), /JSON schema validation|kind/);
220
+
221
+ assert.throws(() => createSchemaDefinedHostPlugin({ ...composition(), primaryPlugin: { id: PLUGIN_ID, version: '1.0.0', model: { state: { initial: { forbidden: BigInt(1) } } } } }), /JSON|serialize a BigInt/);
222
+ });
223
+
224
+ const { evaluate, collectionValue, getPath, sanitizeId, setPath } = require('../src/model/expressions.cjs');
225
+ const { createPayloadParser, parseValue } = require('../src/model/payload.cjs');
226
+
227
+ test('schema model primitive parsers and expression helpers cover edge cases', () => {
228
+ assert.equal(sanitizeId('a/b c'), 'a_b_c');
229
+ assert.deepEqual(setPath({ a: { b: 1 } }, 'a.c', 2), { a: { b: 1, c: 2 } });
230
+ assert.deepEqual(setPath(null, '', { root: true }), { root: true });
231
+ assert.equal(getPath({ a: { b: 2 } }, 'a.b'), 2);
232
+ assert.deepEqual(collectionValue({ things: [] }, 'things'), []);
233
+ assert.deepEqual(collectionValue({}, 'metadata'), {});
234
+
235
+ const ctx = { payload: { nested: { value: 'payload' } }, actor: { memberId: 'alice' }, operation: { id: 'op/a b', type: 'thing.create', createdAt: 123 } };
236
+ assert.equal(evaluate('$payload.nested.value', ctx), 'payload');
237
+ assert.deepEqual(evaluate('$payload', ctx), ctx.payload);
238
+ assert.equal(evaluate('$actor.memberId', ctx), 'alice');
239
+ assert.deepEqual(evaluate('$actor', ctx), ctx.actor);
240
+ assert.equal(evaluate('$createdAt', ctx), 123);
241
+ assert.equal(evaluate('$operation.id', ctx), 'op/a b');
242
+ assert.equal(evaluate('$operation.type', ctx), 'thing.create');
243
+ assert.equal(evaluate('$id:item', ctx), 'item_op_a_b');
244
+ assert.equal(evaluate('$const:fixed', ctx), 'fixed');
245
+ assert.deepEqual(evaluate(['$const:a', '$const:b'], ctx), ['a', 'b']);
246
+ assert.deepEqual(evaluate({ keep: { $literal: { raw: '$payload.nested.value' } }, expr: { $expr: '$payload.nested.value' }, fallback: { $expr: '$payload.nope', fallback: null }, missing: '$payload.nope' }, ctx), { keep: { raw: '$payload.nested.value' }, expr: 'payload', fallback: null });
247
+
248
+ assert.equal(parseValue(undefined, { type: 'string', default: 'fallback' }, 'name'), 'fallback');
249
+ assert.equal(parseValue('', { type: 'string', nullable: true }, 'name'), null);
250
+ assert.equal(parseValue(' hi ', { type: 'string' }, 'name'), 'hi');
251
+ assert.equal(parseValue(' hi ', { type: 'string', trim: false }, 'name'), ' hi ');
252
+ assert.equal(parseValue('abcd', { type: 'string', max: 3, truncate: true }, 'name'), 'abc');
253
+ assert.equal(parseValue('2', { type: 'number', min: 1, max: 3 }, 'count'), 2);
254
+ assert.equal(parseValue(1, { type: 'boolean' }, 'flag'), true);
255
+ assert.equal(parseValue('yes', { type: 'enum', values: ['yes', 'no'] }, 'choice'), 'yes');
256
+ assert.equal(parseValue('bad', { type: 'enum', values: ['ok'], fallback: 'ok' }, 'choice'), 'ok');
257
+ assert.deepEqual(parseValue(['1', '2'], { type: 'array', items: { type: 'number' } }, 'items'), [1, 2]);
258
+ assert.deepEqual(parseValue({ a: 1 }, { type: 'object' }, 'obj'), { a: 1 });
259
+ assert.deepEqual(parseValue({ media: { video: true }, access: { team: 'editor' } }, {
260
+ type: 'object',
261
+ required: { media: { type: 'object', optional: { audio: { type: 'boolean', default: true }, video: { type: 'boolean' } }, additional: false } },
262
+ optional: { access: { type: 'record', values: { type: 'enum', values: ['editor', 'readonly'] } } },
263
+ additional: false
264
+ }, 'settings'), { media: { audio: true, video: true }, access: { team: 'editor' } });
265
+ assert.throws(() => parseValue('bad', { type: 'enum', values: ['ok'] }, 'choice'), /must be one of/);
266
+ assert.throws(() => parseValue('x', { type: 'array' }, 'items'), /must be an array/);
267
+ assert.throws(() => parseValue([], { type: 'object' }, 'obj'), /must be an object/);
268
+ assert.throws(() => parseValue('x', { type: 'mystery' }, 'field'), /not supported/);
269
+
270
+ const parse = createPayloadParser({ required: ['title'], optional: { count: { type: 'number', default: 1 }, hidden: { type: 'string' } }, additional: false });
271
+ assert.deepEqual(parse({ title: 'Task', extra: 'ignored' }), { title: 'Task', count: 1 });
272
+ assert.throws(() => parse(null), /payload must be an object/);
273
+ });