@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,116 @@
1
+ const assert = require('node:assert/strict');
2
+ const test = require('node:test');
3
+ const { applyEffect, applyEffects, SUPPORTED_SCHEMA_EFFECT_KINDS } = require('../src/model/index.cjs');
4
+
5
+ function ctx(overrides = {}) {
6
+ return {
7
+ operation: { id: 'op_1', createdAt: 1000, type: 'card.move', ...(overrides.operation || {}) },
8
+ actor: { memberId: 'alice', deviceId: 'dev_alice', role: 'member', ...(overrides.actor || {}) },
9
+ payload: { ...(overrides.payload || {}) }
10
+ };
11
+ }
12
+
13
+ function legacyMoveCard(state, payload, createdAt = 1000) {
14
+ const cards = state.cards || {};
15
+ const card = cards[payload.cardId];
16
+ if (!card) throw new Error(`Card ${payload.cardId} not found`);
17
+ const lists = Array.isArray(state.lists) ? state.lists : [];
18
+ const toList = lists.find((list) => list.id === payload.toListId);
19
+ if (!toList) throw new Error(`List ${payload.toListId} not found`);
20
+ const nextLists = lists.map((list) => {
21
+ const without = (list.cardIds || []).filter((id) => id !== payload.cardId);
22
+ if (list.id !== toList.id) return { ...list, cardIds: without };
23
+ const cardIds = without.slice();
24
+ cardIds.splice(Math.min(payload.position ?? cardIds.length, cardIds.length), 0, payload.cardId);
25
+ return { ...list, cardIds };
26
+ });
27
+ return {
28
+ ...state,
29
+ lists: nextLists,
30
+ cards: { ...cards, [card.id]: { ...card, listId: toList.id, updatedAt: createdAt } }
31
+ };
32
+ }
33
+
34
+ const moveEffects = [
35
+ { kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'fromListId', arrayField: 'cardIds', value: '$payload.cardId', recordLabel: 'List' },
36
+ { kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'toListId', arrayField: 'cardIds', value: '$payload.cardId', position: '$payload.position', recordLabel: 'List' },
37
+ { kind: 'updateRecord', collection: 'cards', storage: 'map', idField: 'cardId', fields: { listId: '$payload.toListId', updatedAt: '$createdAt' }, recordLabel: 'Card' }
38
+ ];
39
+
40
+ test('schema array primitives expose a domain-neutral allowlist', () => {
41
+ assert.ok(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('insertIdIntoRecordArray'));
42
+ assert.ok(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('removeIdFromRecordArray'));
43
+ assert.equal(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('moveCard'), false);
44
+ assert.equal(SUPPORTED_SCHEMA_EFFECT_KINDS.includes('appendIdToArrayInRecord'), false);
45
+ });
46
+
47
+ test('insertIdIntoRecordArray appends with de-dupe and repositions when a finite position is supplied', () => {
48
+ const state = { lists: [{ id: 'todo', cardIds: ['a', 'b'] }] };
49
+ const appendDuplicate = applyEffect(state, {
50
+ kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
51
+ }, ctx({ payload: { listId: 'todo', cardId: 'b' } }));
52
+ assert.deepEqual(appendDuplicate.lists[0].cardIds, ['a', 'b']);
53
+
54
+ const appendNew = applyEffect(state, {
55
+ kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
56
+ }, ctx({ payload: { listId: 'todo', cardId: 'c' } }));
57
+ assert.deepEqual(appendNew.lists[0].cardIds, ['a', 'b', 'c']);
58
+
59
+ const repositioned = applyEffect(state, {
60
+ kind: 'insertIdIntoRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId', position: '$payload.position'
61
+ }, ctx({ payload: { listId: 'todo', cardId: 'b', position: 0 } }));
62
+ assert.deepEqual(repositioned.lists[0].cardIds, ['b', 'a']);
63
+ });
64
+
65
+ test('insertIdIntoRecordArray clamps positioned inserts and works with map storage', () => {
66
+ const state = { columns: { todo: { id: 'todo', itemIds: ['a'] } } };
67
+ const next = applyEffect(state, {
68
+ kind: 'insertIdIntoRecordArray', collection: 'columns', storage: 'map', idField: 'columnId', arrayField: 'itemIds', value: '$payload.itemId', position: '$payload.position'
69
+ }, ctx({ payload: { columnId: 'todo', itemId: 'b', position: 99 } }));
70
+ assert.deepEqual(next.columns.todo.itemIds, ['a', 'b']);
71
+ });
72
+
73
+ test('removeIdFromRecordArray removes all matching ids and throws on missing records', () => {
74
+ const state = { lists: [{ id: 'todo', cardIds: ['a', 'b', 'a'] }] };
75
+ const next = applyEffect(state, {
76
+ kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
77
+ }, ctx({ payload: { listId: 'todo', cardId: 'a' } }));
78
+ assert.deepEqual(next.lists[0].cardIds, ['b']);
79
+
80
+ assert.throws(() => applyEffect(state, {
81
+ kind: 'removeIdFromRecordArray', collection: 'lists', storage: 'array', idField: 'listId', arrayField: 'cardIds', value: '$payload.cardId'
82
+ }, ctx({ payload: { listId: 'missing', cardId: 'a' } })), /Record missing not found/);
83
+ });
84
+
85
+ test('composed card.move effects match the legacy moveCard behavior under the single-list invariant', () => {
86
+ const fixtures = [
87
+ {
88
+ name: 'move to empty list',
89
+ state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: [] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' } } },
90
+ payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done' }
91
+ },
92
+ {
93
+ name: 'move with explicit position',
94
+ state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: ['card_2'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'done', title: 'Two' } } },
95
+ payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done', position: 0 }
96
+ },
97
+ {
98
+ name: 'position clamps past end',
99
+ state: { lists: [{ id: 'todo', cardIds: ['card_1'] }, { id: 'done', cardIds: ['card_2'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'done', title: 'Two' } } },
100
+ payload: { cardId: 'card_1', fromListId: 'todo', toListId: 'done', position: 99 }
101
+ },
102
+ {
103
+ name: 'same-list reorder',
104
+ state: { lists: [{ id: 'todo', cardIds: ['card_1', 'card_2', 'card_3'] }], cards: { card_1: { id: 'card_1', listId: 'todo', title: 'One' }, card_2: { id: 'card_2', listId: 'todo', title: 'Two' }, card_3: { id: 'card_3', listId: 'todo', title: 'Three' } } },
105
+ payload: { cardId: 'card_3', fromListId: 'todo', toListId: 'todo', position: 0 }
106
+ }
107
+ ];
108
+
109
+ for (const fixture of fixtures) {
110
+ const context = ctx({ payload: fixture.payload, operation: { createdAt: 2000 } });
111
+ const rewritten = applyEffects(fixture.state, moveEffects, context);
112
+ const legacy = legacyMoveCard(fixture.state, fixture.payload, 2000);
113
+ assert.deepEqual(rewritten, legacy, fixture.name);
114
+ assert.deepEqual(JSON.parse(JSON.stringify(rewritten)), JSON.parse(JSON.stringify(legacy)), `${fixture.name} JSON shape`);
115
+ }
116
+ });
@@ -0,0 +1,383 @@
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 {
7
+ manifestHash,
8
+ playerSupportsApp,
9
+ validateAppPackManifest,
10
+ validateHostPackManifest,
11
+ validatePlayerPackManifest
12
+ } = require('@mh-gg/base');
13
+ const { HostPluginRuntime, createMemoryOperationLog, createMemoryRoomStore } = require('@mh-gg/host-runtime');
14
+ const { ensureOperationIdentity } = require('@mh-gg/protocol');
15
+ const { createSignedPartyEvent, nostrEventToPartyEvent, partyEventToNostrEvent } = require('@mh-gg/event');
16
+ const {
17
+ defineApp,
18
+ defineModel,
19
+ fx,
20
+ guard,
21
+ op,
22
+ p,
23
+ q,
24
+ ref,
25
+ createSchemaDefinedHostPlugin,
26
+ genericSummary
27
+ } = require('../src/index.cjs');
28
+
29
+ function inertPlugin(id) {
30
+ return {
31
+ id,
32
+ version: '1.0.0',
33
+ operationSchemaDescriptor: { operations: {} }
34
+ };
35
+ }
36
+
37
+ function dynamicBoardApp() {
38
+ const model = defineModel({
39
+ roles: {
40
+ member: { name: 'Member', rank: 1, system: true },
41
+ admin: { name: 'Admin', rank: 3, system: true }
42
+ },
43
+ state: { lists: {}, cards: {}, settings: {}, activity: [] }
44
+ });
45
+ const { lists, cards } = model.collections;
46
+ const board = model.withOperations({
47
+ 'list.create': op({
48
+ authorize: { roles: ['member'] },
49
+ payload: {
50
+ listId: p.string({ max: 80 }),
51
+ title: p.string({ max: 80 }),
52
+ wipLimit: p.number({ min: 0 })
53
+ }
54
+ }, ({ ref, fx }) => ({
55
+ effects: [fx.create(lists, {
56
+ id: ref.payload('listId'),
57
+ fields: { title: ref.payload('title'), cardIds: [], wipLimit: ref.payload('wipLimit'), createdAt: ref.now() }
58
+ })]
59
+ })),
60
+ 'card.create': op({
61
+ authorize: { roles: ['member'] },
62
+ payload: { cardId: p.string({ max: 80 }), listId: p.string({ max: 80 }), title: p.string({ max: 160 }) }
63
+ }, ({ ref, fx, guard }) => ({
64
+ guards: [
65
+ guard.lt(
66
+ guard.length('lists[$payload.listId].cardIds'),
67
+ guard.path('lists[$payload.listId].wipLimit'),
68
+ 'List is at its WIP limit'
69
+ )
70
+ ],
71
+ effects: [
72
+ fx.create(cards, { id: ref.payload('cardId'), fields: { listId: ref.payload('listId'), title: ref.payload('title'), createdAt: ref.now() } }),
73
+ fx.insertIntoArray(lists, { matchId: ref.payload('listId'), field: 'cardIds', value: ref.payload('cardId') })
74
+ ]
75
+ })),
76
+ 'settings.update': op({
77
+ authorize: { roles: ['member'] },
78
+ payload: {
79
+ media: p.object({
80
+ audio: p.boolean().default(true),
81
+ video: p.boolean().optional(),
82
+ screen: p.boolean().optional()
83
+ }),
84
+ roleAccess: p.record(p.enum(['editor', 'readonly'])).optional()
85
+ }
86
+ }, () => ({ effects: [fx.merge({ path: 'settings', fields: '$payload' })] }))
87
+ });
88
+
89
+ return defineApp({
90
+ id: 'com.matterhorn.tests.dynamic-board',
91
+ name: 'Dynamic Board',
92
+ version: '1.0.0',
93
+ model: board,
94
+ views: { board: q.state() },
95
+ routes: [{ path: '/', component: 'BoardPage' }]
96
+ });
97
+ }
98
+
99
+ function operation(app, type, payload, overrides = {}) {
100
+ const composition = app.toJSON();
101
+ const actor = overrides.actor || { memberId: 'ada', deviceId: 'dev_ada', role: 'member', displayName: 'Ada' };
102
+ const createdAt = overrides.createdAt || 1000;
103
+ return ensureOperationIdentity({
104
+ clientOperationId: overrides.id || `op_${type.replace(/[^a-z0-9]+/gi, '_')}`,
105
+ roomId: 'room_dynamic_board',
106
+ appPackId: composition.app.id,
107
+ appPackHash: manifestHash({ id: composition.app.id, version: composition.app.version }),
108
+ pluginId: composition.primaryPlugin.id,
109
+ type,
110
+ actor,
111
+ seq: overrides.seq || 1,
112
+ createdAt,
113
+ payload,
114
+ auth: { credentialId: 'cred', signature: 'sig' }
115
+ }, { now: createdAt, nodeId: actor.deviceId || actor.memberId });
116
+ }
117
+
118
+ test('defineApp emits JSON fragments and a concrete frontend type namespace', () => {
119
+ const app = dynamicBoardApp();
120
+ const composition = app.toJSON();
121
+ assert.equal(composition.primaryPlugin.model.operations['settings.update'].payload.required.media.type, 'object');
122
+ assert.equal(composition.primaryPlugin.model.operations['settings.update'].payload.optional.roleAccess.type, 'record');
123
+ assert.doesNotThrow(() => JSON.stringify(composition));
124
+
125
+ const types = app.toTypes({ namespace: 'DynamicBoard' });
126
+ assert.match(types, /export namespace DynamicBoard/);
127
+ assert.match(types, /cardCreate: \{ cardId: string; listId: string; title: string \}/);
128
+ assert.match(types, /cardIds: string\[\]/);
129
+ assert.match(types, /settingsUpdate: \{ media: \{ audio: boolean; video\?: boolean; screen\?: boolean \}; roleAccess\?: Record<string, "editor" \| "readonly"> \}/);
130
+ assert.match(types, /export type ActionName = keyof Actions/);
131
+ assert.match(types, /Requires `member` role\./);
132
+ assert.match(types, /title: string, <= 80/);
133
+ assert.match(types, /wipLimit: number, >= 0/);
134
+ assert.doesNotMatch(types, /ActionHelpers|DispatchResult|ConnectionStatus|export interface Client/);
135
+ assert.doesNotMatch(types, /dispatch<K extends ActionName>|query<K extends QueryName>/);
136
+
137
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'matterhorn-builder-'));
138
+ const emitted = app.emit({ outDir, namespace: 'DynamicBoard' });
139
+ assert.equal(fs.existsSync(emitted.schemaPath), true);
140
+ assert.equal(fs.existsSync(emitted.typesPath), true);
141
+ assert.equal(path.basename(emitted.schemaPath), 'matterhorn.schema.json');
142
+ assert.equal(path.basename(emitted.typesPath), 'matterhorn.types.d.ts');
143
+ assert.equal("modelPath" in emitted, false);
144
+ assert.equal("actionsPath" in emitted, false);
145
+ assert.equal("compositionPath" in emitted, false);
146
+ assert.equal("clientPath" in emitted, false);
147
+ assert.equal(fs.existsSync(path.join(outDir, "client.ts")), false);
148
+ assert.equal(fs.existsSync(path.join(outDir, "actions.json")), false);
149
+ assert.equal(fs.existsSync(path.join(outDir, "model.json")), false);
150
+ assert.equal(fs.existsSync(path.join(outDir, "composition.json")), false);
151
+ const schema = JSON.parse(fs.readFileSync(emitted.schemaPath, 'utf8'));
152
+ assert.equal(schema.primaryPlugin.model.operations['card.create'].guards[0].kind, 'lt');
153
+ });
154
+
155
+ test('defineApp builds self-contained Matterhorn app exports without JSON sidecars', () => {
156
+ const app = dynamicBoardApp();
157
+ const matterhornOptions = {
158
+ slug: 'dynamic-board',
159
+ packageName: '@mh-gg/test-dynamic-board',
160
+ exportPrefix: 'dynamicBoard',
161
+ constantPrefix: 'DYNAMIC_BOARD',
162
+ frontend: false,
163
+ matterhorn: { bridgeGlobalName: 'MATTERHORN_DYNAMIC_BOARD_HOST', frontendProjection: 'board' },
164
+ demo: {
165
+ roomId: 'demo_board',
166
+ operations: [{
167
+ id: 'demo_list',
168
+ plugin: '$primary',
169
+ type: 'list.create',
170
+ actor: { memberId: 'ada', deviceId: 'dev_ada', role: 'member', displayName: 'Ada' },
171
+ payload: { listId: 'todo', title: 'Todo', wipLimit: 2 }
172
+ }]
173
+ }
174
+ };
175
+ const exported = app.toMatterhornExports(matterhornOptions);
176
+ const bundle = app.toMatterhornBundle(matterhornOptions);
177
+
178
+ assert.equal(validateAppPackManifest(exported.appPack).id, 'com.matterhorn.tests.dynamic-board');
179
+ assert.equal(validateHostPackManifest(exported.hostPack).appPackId, exported.appPack.id);
180
+ assert.equal(validatePlayerPackManifest(exported.playerPack).supports[0].appPackId, exported.appPack.id);
181
+ assert.equal(playerSupportsApp(exported.playerPack, exported.appPack), true);
182
+ assert.equal(exported.matterhornApp.matterhorn.bridgeGlobalName, 'MATTERHORN_DYNAMIC_BOARD_HOST');
183
+ assert.equal(exported.matterhornApp.frontend, undefined);
184
+ assert.match(exported.generatedTypes, /export namespace DynamicBoard/);
185
+ assert.equal(exported.DYNAMIC_BOARD_APP_ID, 'com.matterhorn.tests.dynamic-board');
186
+ assert.equal(exported.DYNAMIC_BOARD_PLUGIN_ID, 'com.matterhorn.tests.dynamic-board.plugin');
187
+ assert.equal(exported.dynamicBoardAppPack, exported.appPack);
188
+ assert.equal(exported.dynamicBoardHostPlugin, exported.hostPlugin);
189
+ assert.equal(exported.schemaArtifacts.types, exported.generatedTypes);
190
+ assert.equal(exported.schemaArtifacts.schema.app.id, exported.appPack.id);
191
+ assert.deepEqual(exported.schemaArtifacts.files, { schema: 'matterhorn.schema.json', types: 'matterhorn.types.d.ts' });
192
+
193
+ const operations = exported.createDemoOperations('room_override');
194
+ assert.equal(operations.length, 1);
195
+ assert.equal(operations[0].roomId, 'room_override');
196
+ assert.equal(operations[0].pluginId, exported.hostPlugin.id);
197
+ assert.equal(operations[0].type, 'list.create');
198
+
199
+ assert.equal(bundle.kind, 'matterhorn.app-bundle');
200
+ assert.equal(bundle.id, exported.appPack.id);
201
+ assert.equal(bundle.appPack.id, exported.appPack.id);
202
+ assert.equal(bundle.hostPack.appPackId, exported.appPack.id);
203
+ assert.equal(bundle.hostPlugins[0].kind, 'matterhorn.schema-model-host-plugin');
204
+ assert.equal(bundle.hostPlugins[0].composition.primaryPlugin.id, exported.hostPlugin.id);
205
+ assert.equal('types' in bundle, false);
206
+ assert.equal('types' in bundle.artifacts, false);
207
+ assert.equal(bundle.artifacts.composition.app.id, exported.appPack.id);
208
+ assert.doesNotThrow(() => JSON.stringify(bundle));
209
+
210
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'matterhorn-bundle-'));
211
+ const emitted = app.emitMatterhornBundle({ ...matterhornOptions, outDir });
212
+ assert.equal(emitted.bundlePath, path.join(outDir, 'com.matterhorn.tests.dynamic-board.json'));
213
+ const emittedBundle = JSON.parse(fs.readFileSync(emitted.bundlePath, 'utf8'));
214
+ assert.equal(emittedBundle.kind, 'matterhorn.app-bundle');
215
+ assert.equal('types' in emittedBundle, false);
216
+ assert.equal('types' in emittedBundle.artifacts, false);
217
+ });
218
+
219
+ test('defineApp matterhorn exports honor app-stack options and host plugin helpers', () => {
220
+ const app = dynamicBoardApp();
221
+ const explicitHostPlugin = createSchemaDefinedHostPlugin(app.toJSON());
222
+ const sidecarPlugin = inertPlugin('com.matterhorn.tests.sidecar');
223
+ const nestedPlugin = inertPlugin('com.matterhorn.tests.nested');
224
+ const suitePlugin = inertPlugin('com.matterhorn.tests.suite');
225
+ const exported = app.toMatterhornExports({
226
+ exportPrefix: 'dynamicBoard',
227
+ constantPrefix: 'DYNAMIC_BOARD',
228
+ packageName: '@mh-gg/test-dynamic-board',
229
+ hostPlugin: explicitHostPlugin,
230
+ hostPluginExport: 'explicitHostPlugin',
231
+ hostPluginSource: 'workspace:test#explicitHostPlugin',
232
+ hostPluginDependsOn: [{ id: 'matterhorn.core', optional: true }],
233
+ hostPluginConflictsWith: ['com.matterhorn.tests.conflict'],
234
+ hostPlugins: [
235
+ null,
236
+ [{ plugin: sidecarPlugin, config: { enabled: true }, packageName: '@mh-gg/sidecar', exportName: 'sidecarPlugin' }],
237
+ { entries: [nestedPlugin] },
238
+ { kind: 'matterhorn.example-plugin-suite', entries: [{ plugin: suitePlugin, source: 'workspace:test#suitePlugin' }] }
239
+ ],
240
+ frontend: {
241
+ root: __dirname,
242
+ port: 4555,
243
+ label: 'Dynamic Board test frontend',
244
+ backgroundColor: ' #102030 ',
245
+ icon: ' assets/board.svg ',
246
+ devEntry: 'src/main.ts',
247
+ bundle: {
248
+ builtEntry: 'dynamic-board.js',
249
+ prebuilt: true
250
+ }
251
+ },
252
+ matterhornApp: { matterhorn: { bridgeGlobalName: 'MATTERHORN_DYNAMIC_BOARD_HOST', frontendProjection: 'board' } },
253
+ deployment: { relay: { mode: 'embedded', autoStart: false } },
254
+ publisher: { id: 'com.matterhorn.tests', name: 'Matterhorn Tests', publicKey: 'rk_pub_tests' },
255
+ trust: { signatures: [{ publicKey: 'rk_pub_tests', signature: 'sig_tests' }] },
256
+ appCapabilities: { required: ['room.state'], optional: [] },
257
+ hostCapabilities: { required: ['room.state'], optional: [] },
258
+ entrypoints: { default: 'https://example.test/player/' },
259
+ recommendedFor: { devices: ['desktop'], roles: ['member'] },
260
+ playerRoutes: [{ path: '/', component: 'BoardShell', requiredPlugins: [explicitHostPlugin.id] }],
261
+ playerActions: { custom: async () => {} },
262
+ optimisticReducers: { custom: (state) => state },
263
+ navigation: { adminRoute: '/settings' },
264
+ deviceHints: { phone: true },
265
+ exportAliases: { primaryPack: 'dynamicBoardAppPack' },
266
+ createdAt: '2026-06-04T00:00:00Z'
267
+ });
268
+
269
+ const explicitRef = exported.hostPack.plugins.find((plugin) => plugin.id === explicitHostPlugin.id);
270
+ assert.equal(explicitRef.source, 'workspace:test#explicitHostPlugin');
271
+ assert.deepEqual(explicitRef.dependsOn, [{ id: 'matterhorn.core', optional: true }]);
272
+ assert.deepEqual(explicitRef.conflictsWith, ['com.matterhorn.tests.conflict']);
273
+ assert.equal(exported.hostPlugins.find((plugin) => plugin.id === sidecarPlugin.id).config.enabled, true);
274
+ assert.equal(exported.hostPack.plugins.some((plugin) => plugin.id === nestedPlugin.id), true);
275
+ assert.equal(exported.hostPack.plugins.some((plugin) => plugin.id === suitePlugin.id), true);
276
+ assert.equal(exported.matterhornApp.frontend.bundle.dev.command, 'npm');
277
+ assert.equal(exported.matterhornApp.frontend.bundle.build.command, 'npm');
278
+ assert.equal(exported.matterhornApp.frontend.bundle.prebuilt, true);
279
+ assert.equal(exported.matterhornApp.frontend.backgroundColor, '#102030');
280
+ assert.deepEqual(exported.matterhornApp.frontend.icon, { path: 'assets/board.svg' });
281
+ assert.equal(exported.matterhornApp.deployment.relay.autoStart, false);
282
+ assert.equal(exported.appPack.publisher.id, 'com.matterhorn.tests');
283
+ assert.equal(exported.appPack.trust.createdAt, '2026-06-04T00:00:00Z');
284
+ assert.equal(exported.playerPack.entrypoints.default, 'https://example.test/player/');
285
+ assert.deepEqual(exported.playerPack.recommendedFor.roles, ['member']);
286
+ assert.equal(exported.playerPlugin.routes[0].component, 'BoardShell');
287
+ assert.equal(typeof exported.playerPlugin.actions.custom, 'function');
288
+ assert.equal(exported.playerPlugin.navigation.adminRoute, '/settings');
289
+ assert.equal(exported.playerPlugin.deviceHints.phone, true);
290
+ assert.equal(exported.primaryPack, exported.dynamicBoardAppPack);
291
+
292
+ assert.throws(() => app.toMatterhornExports({ hostPlugins: [inertPlugin('com.matterhorn.tests.dup'), inertPlugin('com.matterhorn.tests.dup')] }), /Duplicate host plugin/);
293
+ assert.throws(() => app.toMatterhornExports({ exportAliases: { missingAlias: 'missingExport' } }), /missingExport/);
294
+ assert.throws(() => app.toMatterhornExports({ hostPlugin: { id: 'bad.plugin', version: '1.0.0', operationSchemaDescriptor: {} } }), /canonical operationSchemaDescriptor/);
295
+ });
296
+
297
+ test('defineApp demo helpers replay schema operations', async () => {
298
+ const app = dynamicBoardApp();
299
+ const exported = app.toMatterhornExports({
300
+ slug: 'dynamic-board',
301
+ packageName: '@mh-gg/test-dynamic-board',
302
+ exportPrefix: 'dynamicBoard',
303
+ demo: {
304
+ roomId: 'demo_board',
305
+ startTime: 1700000000000,
306
+ operations: [{
307
+ id: 'demo_list',
308
+ type: 'list.create',
309
+ payload: { listId: 'todo', title: 'Todo', wipLimit: 2 }
310
+ }]
311
+ },
312
+ summary(state) {
313
+ return { version: state.version, lists: Object.keys(state.plugins[exported.hostPlugin.id].lists).length };
314
+ }
315
+ });
316
+
317
+ const operations = exported.createDemoOperations();
318
+ assert.equal(operations[0].roomId, 'demo_board');
319
+ assert.equal(operations[0].actor.memberId, 'admin');
320
+ assert.equal(operations[0].pluginId, exported.hostPlugin.id);
321
+
322
+ const replay = await exported.createDemo({ roomId: 'demo_override' });
323
+ assert.equal(replay.operations[0].roomId, 'demo_override');
324
+ assert.equal(replay.state.version, 1);
325
+ assert.equal(replay.summary.lists, 1);
326
+ assert.equal(replay.guestView.version, 1);
327
+
328
+ const fileEvent = partyEventToNostrEvent(createSignedPartyEvent({
329
+ kind: 'file.upload',
330
+ partyId: 'demo_board',
331
+ identity: {
332
+ privateKey: '0000000000000000000000000000000000000000000000000000000000000002',
333
+ pubkey: 'c6047f9441ed7d6d3045406e95c07cd85a5c778e4b8cef3ca7abac09b95c709e'
334
+ },
335
+ payload: { fileName: 'demo.txt', mimeType: 'text/plain', sizeBytes: 4, sha256: '0'.repeat(64), dataBase64: 'ZGVtbw==' },
336
+ createdAt: 1700000000000
337
+ }));
338
+ const fileDemo = app.toMatterhornExports({
339
+ slug: 'dynamic-board',
340
+ packageName: '@mh-gg/test-dynamic-board',
341
+ exportPrefix: 'dynamicBoard',
342
+ demo: {
343
+ roomId: 'demo_board',
344
+ operations: [{ id: 'demo_file', type: 'file.upload', pluginId: 'com.matterhorn.examples.plugins.files', payload: { scopeType: 'list', scopeId: 'todo', event: fileEvent } }]
345
+ }
346
+ });
347
+ const fileOperation = fileDemo.createDemoOperations('demo_override')[0];
348
+ assert.equal(nostrEventToPartyEvent(fileOperation.payload.event).partyId, 'demo_override');
349
+ assert.notEqual(fileOperation.payload.event.id, fileEvent.id);
350
+
351
+ const summary = genericSummary({
352
+ version: 2,
353
+ plugins: {
354
+ [exported.hostPlugin.id]: {
355
+ lists: { todo: { id: 'todo' }, done: { id: 'done', archivedAt: 1 } },
356
+ cards: [{ id: 'card_1' }, { id: 'card_2', deletedAt: 1 }],
357
+ settings: { theme: 'dark' },
358
+ details: { title: 'Board' }
359
+ }
360
+ }
361
+ }, exported.hostPlugin.id);
362
+ assert.deepEqual(summary, { version: 2, lists: 1, cards: 1, title: 'Board' });
363
+ });
364
+
365
+ test('declarative comparison guards run in the schema interpreter', async () => {
366
+ const app = dynamicBoardApp();
367
+ const composition = app.toJSON();
368
+ const plugin = createSchemaDefinedHostPlugin(composition);
369
+ const runtime = new HostPluginRuntime({
370
+ room: { id: 'room_dynamic_board', appPack: { id: composition.app.id, version: composition.app.version, hash: manifestHash({ id: composition.app.id, version: composition.app.version }), protocolHash: composition.app.id } },
371
+ plugins: [plugin],
372
+ store: createMemoryRoomStore(),
373
+ operationLog: createMemoryOperationLog(),
374
+ authenticateActor: async (_auth, actor) => actor
375
+ });
376
+ await runtime.start();
377
+
378
+ assert.equal((await runtime.handleOperation(operation(app, 'list.create', { listId: 'todo', title: 'Todo', wipLimit: 1 }, { id: 'op_1', seq: 1 }))).ok, true);
379
+ assert.equal((await runtime.handleOperation(operation(app, 'card.create', { cardId: 'card_1', listId: 'todo', title: 'First' }, { id: 'op_2', seq: 2 }))).ok, true);
380
+ const denied = await runtime.handleOperation(operation(app, 'card.create', { cardId: 'card_2', listId: 'todo', title: 'Second' }, { id: 'op_3', seq: 3 }));
381
+ assert.equal(denied.ok, false);
382
+ assert.match(denied.reason, /WIP limit/);
383
+ });
@@ -0,0 +1,144 @@
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
+ APP_COMPOSITION_SCHEMA_KIND,
8
+ actionDescriptorsForPlugin,
9
+ builtinMicroPluginRegistry,
10
+ createActionDispatchersFromComposition,
11
+ createAppCompositionSchema,
12
+ createMicroPluginSchema,
13
+ listMicroPluginSchemas,
14
+ resolveCompositionHostPluginEntries,
15
+ validateAppCompositionSchema,
16
+ loadAppCompositionSchemaFile,
17
+ assertValidJsonSchema
18
+ } = createConfiguredSchemaApi(schemaModule, registryRecords());
19
+ const { COMMENTS_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID, PRESENCE_PLUGIN_ID } = require("@mh-gg/base-plugins");
20
+
21
+ test("micro plugin schemas are JSON-only and describe operations, views, and actions", () => {
22
+ const comments = builtinMicroPluginRegistry.get("comments");
23
+ assert.equal(comments.schema.kind, "matterhorn.micro-plugin.schema");
24
+ assert.equal(comments.schema.id, COMMENTS_PLUGIN_ID);
25
+ assert.equal(typeof comments.schema.schemaHash, "string");
26
+ assert.equal([...comments.schema.schemas.operations["comments.add"].required, ...comments.schema.schemas.operations["comments.add"].optional].includes("scopeType"), true);
27
+ assert.equal(comments.schema.views.some((view) => view.name === "commentsForScope"), true);
28
+ assert.equal(comments.schema.actions.some((action) => action.type === "comments.add"), true);
29
+ assert.doesNotThrow(() => JSON.stringify(comments.schema));
30
+ });
31
+
32
+ test("micro plugin schemas reject function-bearing glue", () => {
33
+ assert.throws(() => createMicroPluginSchema({
34
+ key: "bad",
35
+ id: "bad.plugin",
36
+ version: "1.0.0",
37
+ stateSchema: { type: "object" },
38
+ operationSchemas: { "bad.run": { parse() {} } }
39
+ }), /JSON serializable/);
40
+ });
41
+
42
+ test("app composition schemas resolve reusable micro plugins without passing plugin JS through app code", () => {
43
+ const schema = createAppCompositionSchema({
44
+ app: { id: "app.test", version: "1.0.0", name: "Schema App" },
45
+ primaryPlugin: { id: "app.test.primary", version: "1.0.0" },
46
+ plugins: ["comments", "presence", { key: "mediaRooms", config: { defaultRooms: [{ id: "event_room", name: "Event room" }] } }, "comments"],
47
+ sharedScopes: {
48
+ directMessages: {
49
+ scopeType: "chat.direct",
50
+ scopeId: "$app.id",
51
+ identity: "$profile.id",
52
+ participants: "userIds",
53
+ collections: ["directThreads", "directMessages"]
54
+ }
55
+ },
56
+ views: [{ name: "onlineMembers", plugin: "presence", kind: "query", query: "onlineMembers" }],
57
+ actions: {
58
+ comment: { plugin: "comments", type: "comments.add", payloadDefaults: { scopeType: "task" } },
59
+ updatePresence: { plugin: "presence", type: "presence.update" },
60
+ createRoom: { plugin: "mediaRooms", type: "media.room.create" }
61
+ }
62
+ });
63
+
64
+ assert.equal(schema.kind, APP_COMPOSITION_SCHEMA_KIND);
65
+ assert.deepEqual(schema.plugins.map((plugin) => plugin.id), [COMMENTS_PLUGIN_ID, PRESENCE_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID]);
66
+ assert.equal(validateAppCompositionSchema(schema).schemaHash, schema.schemaHash);
67
+ assert.deepEqual(schema.sharedScopes.directMessages.collections, ["directThreads", "directMessages"]);
68
+
69
+ const entries = resolveCompositionHostPluginEntries(schema);
70
+ assert.deepEqual(entries.map((entry) => entry.plugin.id), [COMMENTS_PLUGIN_ID, PRESENCE_PLUGIN_ID, MEDIA_ROOMS_PLUGIN_ID]);
71
+ assert.deepEqual(entries[2].config.defaultRooms[0], { id: "event_room", name: "Event room" });
72
+
73
+ const dispatched = [];
74
+ const actions = createActionDispatchersFromComposition(schema);
75
+ actions.comment({ dispatch: (op) => dispatched.push(op) }, { scopeId: "card_1", body: "Ship it" });
76
+ actions.createRoom({ dispatch: (op) => dispatched.push(op) }, { name: "Standup", allowsVideo: true });
77
+ assert.deepEqual(dispatched, [
78
+ { schemaAction: "comment", pluginId: COMMENTS_PLUGIN_ID, type: "comments.add", payload: { scopeType: "task", scopeId: "card_1", body: "Ship it" } },
79
+ { schemaAction: "createRoom", pluginId: MEDIA_ROOMS_PLUGIN_ID, type: "media.room.create", payload: { name: "Standup", allowsVideo: true } }
80
+ ]);
81
+ assert.deepEqual(actionDescriptorsForPlugin(schema, "comments").map((action) => action.name), ["comment"]);
82
+ assert.deepEqual(actionDescriptorsForPlugin(schema, { key: "mediaRooms" }).map((action) => action.name), ["createRoom"]);
83
+ assert.deepEqual(actionDescriptorsForPlugin({
84
+ ...schema,
85
+ actions: [...schema.actions, { name: "idBasedPresence", plugin: PRESENCE_PLUGIN_ID, type: "presence.update" }]
86
+ }, { id: PRESENCE_PLUGIN_ID }).map((action) => action.name), ["idBasedPresence"]);
87
+ });
88
+
89
+ test("raw JSON composition files are normalized and validated", () => {
90
+ const eventsSchema = loadAppCompositionSchemaFile(require("node:path").join(__dirname, "../../../examples/events/src/composition.json"));
91
+ const normalized = validateAppCompositionSchema(eventsSchema);
92
+ assert.equal(normalized.kind, APP_COMPOSITION_SCHEMA_KIND);
93
+ assert.equal(normalized.actions.find((action) => action.name === "updateEvent").plugin, "primary");
94
+ assert.equal(normalized.plugins.some((plugin) => plugin.id === MEDIA_ROOMS_PLUGIN_ID), true);
95
+ assert.equal(JSON.parse(JSON.stringify(normalized)).schemaHash, normalized.schemaHash);
96
+ });
97
+
98
+ test("first-party reusable registry exposes every micro plugin schema", () => {
99
+ const schemas = listMicroPluginSchemas();
100
+ assert.equal(schemas.length >= 12, true);
101
+ assert.equal(new Set(schemas.map((schema) => schema.id)).size, schemas.length);
102
+ assert.equal(schemas.every((schema) => schema.kind === "matterhorn.micro-plugin.schema"), true);
103
+ });
104
+
105
+
106
+ test("composition JSON Schema rejects arbitrary app glue", () => {
107
+ assert.throws(() => assertValidJsonSchema("app-composition.schema.json", {
108
+ kind: "matterhorn.app-composition.schema",
109
+ schemaVersion: 1,
110
+ app: { id: "bad", version: "1", name: "Bad" },
111
+ primaryPlugin: { id: "bad.plugin", version: "1" },
112
+ plugins: [],
113
+ views: [],
114
+ actions: [],
115
+ routes: [],
116
+ run() {}
117
+ }, { label: "composition" }), /failed: .*not allowed/);
118
+ });
119
+
120
+ test("composition shared scopes require an explicit scope id", () => {
121
+ assert.throws(() => assertValidJsonSchema("app-composition.schema.json", {
122
+ kind: "matterhorn.app-composition.schema",
123
+ schemaVersion: 1,
124
+ app: { id: "app.test", version: "1", name: "App" },
125
+ primaryPlugin: { id: "app.test.primary", version: "1" },
126
+ plugins: [],
127
+ sharedScopes: {
128
+ directMessages: {
129
+ scopeType: "chat.direct",
130
+ collections: ["directThreads", "directMessages"]
131
+ }
132
+ },
133
+ views: [],
134
+ actions: [],
135
+ routes: []
136
+ }, { label: "composition" }), /scopeId/);
137
+ });
138
+
139
+ test("composition imports let actions and models live in separate files", () => {
140
+ const schema = loadAppCompositionSchemaFile(require("node:path").join(__dirname, "../../../examples/events/src/composition.json"));
141
+ assert.equal(schema.primaryPlugin.model.kind, "matterhorn.primary-model.schema");
142
+ assert.equal(schema.actions.some((action) => action.name === "updateEvent"), true);
143
+ assert.equal(schema.schemaHash, validateAppCompositionSchema(schema).schemaHash);
144
+ });