@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.
- package/package.json +39 -0
- package/schemas/app-composition.schema.json +684 -0
- package/schemas/example-app.schema.json +64 -0
- package/schemas/example-demo.schema.json +45 -0
- package/schemas/micro-plugin.schema.json +20 -0
- package/src/actions.cjs +31 -0
- package/src/app.d.ts +119 -0
- package/src/builder/app.cjs +157 -0
- package/src/builder/effects.cjs +116 -0
- package/src/builder/guards.cjs +35 -0
- package/src/builder/index.cjs +11 -0
- package/src/builder/matterhornApp/build.cjs +137 -0
- package/src/builder/matterhornApp/bundle.cjs +89 -0
- package/src/builder/matterhornApp/demo.cjs +161 -0
- package/src/builder/matterhornApp/demoAliases.cjs +50 -0
- package/src/builder/matterhornApp/descriptors.cjs +59 -0
- package/src/builder/matterhornApp/exports.cjs +95 -0
- package/src/builder/matterhornApp/frontend.cjs +80 -0
- package/src/builder/matterhornApp/plugins.cjs +95 -0
- package/src/builder/matterhornApp/shared.cjs +105 -0
- package/src/builder/matterhornApp.cjs +7 -0
- package/src/builder/model.cjs +172 -0
- package/src/builder/notifications.cjs +51 -0
- package/src/builder/refs.cjs +33 -0
- package/src/builder/schema.cjs +101 -0
- package/src/builder/streamKey.cjs +27 -0
- package/src/composition.cjs +157 -0
- package/src/configured.cjs +41 -0
- package/src/configured.d.ts +1 -0
- package/src/imports/loader.cjs +86 -0
- package/src/index.cjs +12 -0
- package/src/index.d.ts +168 -0
- package/src/json.cjs +70 -0
- package/src/jsonSchema/validator.cjs +131 -0
- package/src/microPlugin.cjs +112 -0
- package/src/model/collections.cjs +57 -0
- package/src/model/effects.cjs +149 -0
- package/src/model/expressions.cjs +85 -0
- package/src/model/guards.cjs +139 -0
- package/src/model/index.cjs +9 -0
- package/src/model/partitionOperands.cjs +89 -0
- package/src/model/partitionValidator.cjs +95 -0
- package/src/model/payload.cjs +93 -0
- package/src/model/plugin.cjs +146 -0
- package/src/notifications.cjs +41 -0
- package/src/notifications.d.ts +63 -0
- package/src/registry.cjs +63 -0
- package/src/runtime-exports.d.ts +65 -0
- package/src/streamKey.d.ts +15 -0
- package/src/types/actionTypes.cjs +164 -0
- package/src/types/coreActionPayloadSchemas.cjs +35 -0
- package/src/types/coreFeatures.cjs +29 -0
- package/src/types/entities.cjs +131 -0
- package/src/types/entityAliases.cjs +84 -0
- package/src/types/generator.cjs +145 -0
- package/src/types/index.cjs +5 -0
- package/src/types/interfaceBlock.cjs +41 -0
- package/src/types/pluginEntities.cjs +83 -0
- package/src/types/schema.cjs +148 -0
- package/src/types/standardPluginEntityTypes.cjs +135 -0
- package/test/content-lww-contract.test.cjs +104 -0
- package/test/partitionValidator.test.cjs +92 -0
- package/test/schema-array-effects.test.cjs +116 -0
- package/test/schema-builder-types.test.cjs +383 -0
- package/test/schema-composition.test.cjs +144 -0
- package/test/schema-configured-builders.test.cjs +286 -0
- package/test/schema-imports.test.cjs +66 -0
- 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
|
+
});
|