@gonvex/module-sdk 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +185 -0
- package/dist/identity-contract.test.d.ts +1 -0
- package/dist/identity-contract.test.js +18 -0
- package/dist/identity-contract.test.js.map +1 -0
- package/dist/index.d.ts +641 -0
- package/dist/index.js +805 -0
- package/dist/index.js.map +1 -0
- package/package.json +28 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
const freeze = (value) => Object.freeze(value);
|
|
2
|
+
/** Constructors for the language-neutral schema subset. */
|
|
3
|
+
export const schema = {
|
|
4
|
+
string(options = {}) {
|
|
5
|
+
return freeze({ kind: "string", ...options });
|
|
6
|
+
},
|
|
7
|
+
email() {
|
|
8
|
+
return freeze({ kind: "string", format: "email" });
|
|
9
|
+
},
|
|
10
|
+
uri() {
|
|
11
|
+
return freeze({ kind: "string", format: "uri" });
|
|
12
|
+
},
|
|
13
|
+
uuid() {
|
|
14
|
+
return freeze({ kind: "string", format: "uuid" });
|
|
15
|
+
},
|
|
16
|
+
datetime() {
|
|
17
|
+
return freeze({ kind: "string", format: "datetime" });
|
|
18
|
+
},
|
|
19
|
+
number(options = {}) {
|
|
20
|
+
return freeze({ kind: "number", ...options });
|
|
21
|
+
},
|
|
22
|
+
integer(options = {}) {
|
|
23
|
+
return freeze({ kind: "number", integer: true, ...options });
|
|
24
|
+
},
|
|
25
|
+
boolean() {
|
|
26
|
+
return freeze({ kind: "boolean" });
|
|
27
|
+
},
|
|
28
|
+
null() {
|
|
29
|
+
return freeze({ kind: "null" });
|
|
30
|
+
},
|
|
31
|
+
any() {
|
|
32
|
+
return freeze({ kind: "any" });
|
|
33
|
+
},
|
|
34
|
+
id(entity) {
|
|
35
|
+
if (!entity.trim())
|
|
36
|
+
throw new Error("schema.id requires an entity name");
|
|
37
|
+
return freeze({ kind: "id", entity });
|
|
38
|
+
},
|
|
39
|
+
literal(value) {
|
|
40
|
+
return freeze({ kind: "literal", value });
|
|
41
|
+
},
|
|
42
|
+
array(items) {
|
|
43
|
+
return freeze({ kind: "array", items });
|
|
44
|
+
},
|
|
45
|
+
object(fields, options = {}) {
|
|
46
|
+
return freeze({ kind: "object", fields: freeze({ ...fields }), ...options });
|
|
47
|
+
},
|
|
48
|
+
record(values) {
|
|
49
|
+
return freeze({ kind: "record", values });
|
|
50
|
+
},
|
|
51
|
+
optional(value) {
|
|
52
|
+
return freeze({ kind: "optional", value });
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/** Declare the host-invoked internal Reducer that applies invitation payloads. */
|
|
56
|
+
export function invitationAcceptance(reducerPath) {
|
|
57
|
+
const reducer = normalizePath(reducerPath);
|
|
58
|
+
return freeze({ reducer });
|
|
59
|
+
}
|
|
60
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
const normalizePath = (path) => {
|
|
62
|
+
const normalized = path.trim();
|
|
63
|
+
if (!normalized)
|
|
64
|
+
throw new Error("module function path is required");
|
|
65
|
+
if (normalized === "control" || normalized.startsWith("control.")) {
|
|
66
|
+
throw new Error(`module function path ${JSON.stringify(normalized)} uses the host-reserved Control Plane namespace`);
|
|
67
|
+
}
|
|
68
|
+
return normalized;
|
|
69
|
+
};
|
|
70
|
+
const validateOfflinePolicy = (value, path) => {
|
|
71
|
+
if (!isRecord(value) || (value.mode !== "forbidden" && value.mode !== "allowed" && value.mode !== "onlineOnly")) {
|
|
72
|
+
throw new Error(`reducer ${path} must declare a valid offline policy`);
|
|
73
|
+
}
|
|
74
|
+
if (value.mode === "onlineOnly" && (typeof value.reason !== "string" || !value.reason.trim())) {
|
|
75
|
+
throw new Error(`reducer ${path} onlineOnly policy requires a reason`);
|
|
76
|
+
}
|
|
77
|
+
if (value.mode === "allowed" && value.conflict !== undefined &&
|
|
78
|
+
value.conflict !== "reject" && value.conflict !== "expectedVersion" && value.conflict !== "merge") {
|
|
79
|
+
throw new Error(`reducer ${path} has an invalid offline conflict policy`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const validateOptimisticTransaction = (value, path) => {
|
|
83
|
+
if (!isRecord(value) || !Array.isArray(value.effects) || value.effects.length === 0) {
|
|
84
|
+
throw new Error(`reducer ${path} optimistic metadata must contain a non-empty effects array`);
|
|
85
|
+
}
|
|
86
|
+
if (value.expectedRevision !== undefined &&
|
|
87
|
+
(typeof value.expectedRevision !== "number" || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0)) {
|
|
88
|
+
throw new Error(`reducer ${path} optimistic expectedRevision must be a non-negative integer`);
|
|
89
|
+
}
|
|
90
|
+
for (const effect of value.effects) {
|
|
91
|
+
if (!isRecord(effect) || (effect.operation !== "patch" && effect.operation !== "upsert" && effect.operation !== "delete")) {
|
|
92
|
+
throw new Error(`reducer ${path} has an invalid optimistic effect`);
|
|
93
|
+
}
|
|
94
|
+
if (typeof effect.entity !== "string" || !effect.entity.trim()) {
|
|
95
|
+
throw new Error(`reducer ${path} optimistic effects require an entity`);
|
|
96
|
+
}
|
|
97
|
+
if ((typeof effect.id === "string" && !effect.id.trim()) ||
|
|
98
|
+
(typeof effect.id !== "string" &&
|
|
99
|
+
(!Array.isArray(effect.id) || effect.id.length === 0 || effect.id.some((part) => typeof part !== "string" || !part.trim())))) {
|
|
100
|
+
throw new Error(`reducer ${path} optimistic effects require a string id or id references`);
|
|
101
|
+
}
|
|
102
|
+
if ((effect.operation === "patch" || effect.operation === "upsert") && !isRecord(effect.operation === "patch" ? effect.fields : effect.value)) {
|
|
103
|
+
throw new Error(`reducer ${path} optimistic ${effect.operation} effects require an object value`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const validateReplicaCollection = (value, path) => {
|
|
108
|
+
if (!isRecord(value) || typeof value.table !== "string" || !value.table.trim() ||
|
|
109
|
+
typeof value.key !== "string" || !value.key.trim() || !Array.isArray(value.columns) ||
|
|
110
|
+
value.columns.some((column) => typeof column !== "string" || !column.trim())) {
|
|
111
|
+
throw new Error(`replica collection ${path} requires a table, key, and columns`);
|
|
112
|
+
}
|
|
113
|
+
for (const field of ["maxRows", "maxBytes", "retentionMs"]) {
|
|
114
|
+
const budget = value[field];
|
|
115
|
+
if (budget !== undefined && (typeof budget !== "number" || !Number.isSafeInteger(budget) || budget <= 0)) {
|
|
116
|
+
throw new Error(`replica collection ${path} ${field} must be a positive integer`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (value.mode !== undefined && value.mode !== "eager" && value.mode !== "progressive") {
|
|
120
|
+
throw new Error(`replica collection ${path} has an invalid completeness mode`);
|
|
121
|
+
}
|
|
122
|
+
if (value.orderDirection !== undefined && value.orderDirection !== "asc" && value.orderDirection !== "desc") {
|
|
123
|
+
throw new Error(`replica collection ${path} has an invalid order direction`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const validateActionCapabilities = (profile, value, path) => {
|
|
127
|
+
if (value === undefined)
|
|
128
|
+
return;
|
|
129
|
+
if (!isRecord(value))
|
|
130
|
+
throw new Error(`action ${path} capabilities must be an object`);
|
|
131
|
+
const allowed = new Set(["networkOrigins", "secrets", "tools", "scheduler", "storage", "sandbox"]);
|
|
132
|
+
for (const field of Object.keys(value)) {
|
|
133
|
+
if (!allowed.has(field))
|
|
134
|
+
throw new Error(`action ${path} capabilities has unsupported field ${field}`);
|
|
135
|
+
}
|
|
136
|
+
if (value.networkOrigins !== undefined) {
|
|
137
|
+
if (!Array.isArray(value.networkOrigins) || value.networkOrigins.length === 0) {
|
|
138
|
+
throw new Error(`action ${path} networkOrigins must be a non-empty array`);
|
|
139
|
+
}
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
for (const origin of value.networkOrigins) {
|
|
142
|
+
if (typeof origin !== "string")
|
|
143
|
+
throw new Error(`action ${path} networkOrigins must contain strings`);
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = new URL(origin);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new Error(`action ${path} network origin ${JSON.stringify(origin)} is invalid`);
|
|
150
|
+
}
|
|
151
|
+
if ((parsed.protocol !== "https:" && parsed.protocol !== "http:") || parsed.origin !== origin || parsed.username || parsed.password) {
|
|
152
|
+
throw new Error(`action ${path} network origin ${JSON.stringify(origin)} must be an exact HTTP(S) origin`);
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(origin))
|
|
155
|
+
throw new Error(`action ${path} declares duplicate network origin ${origin}`);
|
|
156
|
+
seen.add(origin);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (value.secrets !== undefined) {
|
|
160
|
+
if (!Array.isArray(value.secrets) || value.secrets.some((name) => typeof name !== "string" || !/^[A-Z][A-Z0-9_]*$/.test(name))) {
|
|
161
|
+
throw new Error(`action ${path} secrets must be uppercase environment names`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (value.tools !== undefined) {
|
|
165
|
+
if (profile !== "agent")
|
|
166
|
+
throw new Error(`action ${path} tools require profile "agent"`);
|
|
167
|
+
if (!isRecord(value.tools) || Object.keys(value.tools).length === 0) {
|
|
168
|
+
throw new Error(`agent action ${path} tools must be a non-empty object`);
|
|
169
|
+
}
|
|
170
|
+
for (const [name, binding] of Object.entries(value.tools)) {
|
|
171
|
+
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || !isRecord(binding) ||
|
|
172
|
+
(binding.kind !== "query" && binding.kind !== "reducer") || typeof binding.function !== "string" || !binding.function.trim()) {
|
|
173
|
+
throw new Error(`agent action ${path} has an invalid tool binding ${JSON.stringify(name)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (value.scheduler !== undefined && value.scheduler !== true)
|
|
178
|
+
throw new Error(`action ${path} scheduler must be true when declared`);
|
|
179
|
+
if (value.storage !== undefined && value.storage !== true)
|
|
180
|
+
throw new Error(`action ${path} storage must be true when declared`);
|
|
181
|
+
if (value.sandbox !== undefined) {
|
|
182
|
+
if (profile !== "agent")
|
|
183
|
+
throw new Error(`action ${path} sandbox requires profile "agent"`);
|
|
184
|
+
if (!isRecord(value.sandbox))
|
|
185
|
+
throw new Error(`action ${path} sandbox must be an object`);
|
|
186
|
+
for (const field of Object.keys(value.sandbox)) {
|
|
187
|
+
if (field !== "duckdb")
|
|
188
|
+
throw new Error(`action ${path} sandbox has unsupported field ${field}`);
|
|
189
|
+
}
|
|
190
|
+
if (value.sandbox.duckdb !== undefined && value.sandbox.duckdb !== true) {
|
|
191
|
+
throw new Error(`action ${path} sandbox.duckdb must be true when declared`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
const validateStructuredQueryPlan = (value, path) => {
|
|
196
|
+
if (!isRecord(value) || typeof value.table !== "string" || !value.table.trim() ||
|
|
197
|
+
typeof value.key !== "string" || !value.key.trim() || !Array.isArray(value.columns) ||
|
|
198
|
+
value.columns.length === 0 || value.columns.some((column) => typeof column !== "string" || !column.trim())) {
|
|
199
|
+
throw new Error(`one-shot query ${path} requires a structured live query plan with a table, key, and columns`);
|
|
200
|
+
}
|
|
201
|
+
if (!value.columns.includes(value.key)) {
|
|
202
|
+
throw new Error(`one-shot query ${path} live query plan columns must include its key`);
|
|
203
|
+
}
|
|
204
|
+
if (value.filters !== undefined) {
|
|
205
|
+
if (!isRecord(value.filters) || typeof value.filters.argument !== "string" || !value.filters.argument.trim() ||
|
|
206
|
+
!Array.isArray(value.filters.allowedColumns) || value.filters.allowedColumns.length === 0 ||
|
|
207
|
+
value.filters.allowedColumns.some((column) => typeof column !== "string" || !column.trim()) ||
|
|
208
|
+
!Array.isArray(value.filters.allowedOperators) || value.filters.allowedOperators.length === 0) {
|
|
209
|
+
throw new Error(`structured query plan ${path} filters must declare an argument, allowed columns, and allowed operators`);
|
|
210
|
+
}
|
|
211
|
+
const operators = new Set(["contains", "notContains", "equals", "notEquals", "startsWith", "endsWith", "empty", "notEmpty", "oneOf", "lessThan", "lessThanOrEqual", "greaterThan", "greaterThanOrEqual", "inRange"]);
|
|
212
|
+
if (value.filters.allowedOperators.some((operator) => typeof operator !== "string" || !operators.has(operator))) {
|
|
213
|
+
throw new Error(`structured query plan ${path} filters contains an unsupported operator`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const visibilityOperators = new Set(["public", "permission", "role", "eqContext", "inSet", "and", "or", "not"]);
|
|
218
|
+
const visibilityContexts = new Set(["account.id", "member.id", "tenant.id"]);
|
|
219
|
+
const validateExactObject = (value, path, fields) => {
|
|
220
|
+
if (!isRecord(value))
|
|
221
|
+
throw new Error(`${path} must be an object`);
|
|
222
|
+
const allowed = new Set(fields);
|
|
223
|
+
const unexpected = Object.keys(value).find((field) => !allowed.has(field));
|
|
224
|
+
if (unexpected !== undefined)
|
|
225
|
+
throw new Error(`${path} has unsupported field ${unexpected}`);
|
|
226
|
+
};
|
|
227
|
+
const requireVisibilityString = (value, path) => {
|
|
228
|
+
if (typeof value !== "string" || !value.trim())
|
|
229
|
+
throw new Error(`${path} must be a non-empty string`);
|
|
230
|
+
return value;
|
|
231
|
+
};
|
|
232
|
+
const validateVisibilityContext = (value, path) => {
|
|
233
|
+
if (typeof value !== "string" || !visibilityContexts.has(value)) {
|
|
234
|
+
throw new Error(`${path} must be account.id, member.id, or tenant.id`);
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
};
|
|
238
|
+
const validateVisibilityExpression = (value, path, sets, ancestors = new Set()) => {
|
|
239
|
+
validateExactObject(value, path, ["operator", "column", "context", "set", "value", "children"]);
|
|
240
|
+
if (ancestors.has(value))
|
|
241
|
+
throw new Error(`${path} contains a cycle`);
|
|
242
|
+
const operator = value.operator;
|
|
243
|
+
if (typeof operator !== "string" || !visibilityOperators.has(operator)) {
|
|
244
|
+
throw new Error(`${path}.operator is unsupported`);
|
|
245
|
+
}
|
|
246
|
+
const nested = new Set(ancestors);
|
|
247
|
+
nested.add(value);
|
|
248
|
+
switch (operator) {
|
|
249
|
+
case "public":
|
|
250
|
+
validateExactObject(value, path, ["operator"]);
|
|
251
|
+
return;
|
|
252
|
+
case "permission":
|
|
253
|
+
case "role":
|
|
254
|
+
validateExactObject(value, path, ["operator", "value"]);
|
|
255
|
+
requireVisibilityString(value.value, `${path}.value`);
|
|
256
|
+
return;
|
|
257
|
+
case "eqContext":
|
|
258
|
+
validateExactObject(value, path, ["operator", "column", "context"]);
|
|
259
|
+
requireVisibilityString(value.column, `${path}.column`);
|
|
260
|
+
validateVisibilityContext(value.context, `${path}.context`);
|
|
261
|
+
return;
|
|
262
|
+
case "inSet": {
|
|
263
|
+
validateExactObject(value, path, ["operator", "column", "set"]);
|
|
264
|
+
requireVisibilityString(value.column, `${path}.column`);
|
|
265
|
+
const set = requireVisibilityString(value.set, `${path}.set`);
|
|
266
|
+
if (!Object.prototype.hasOwnProperty.call(sets, set))
|
|
267
|
+
throw new Error(`${path}.set references unknown visibility set ${set}`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case "and":
|
|
271
|
+
case "or":
|
|
272
|
+
validateExactObject(value, path, ["operator", "children"]);
|
|
273
|
+
if (!Array.isArray(value.children) || value.children.length === 0) {
|
|
274
|
+
throw new Error(`${path}.${operator} requires children`);
|
|
275
|
+
}
|
|
276
|
+
value.children.forEach((child, index) => validateVisibilityExpression(child, `${path}.children[${index}]`, sets, nested));
|
|
277
|
+
return;
|
|
278
|
+
case "not":
|
|
279
|
+
validateExactObject(value, path, ["operator", "children"]);
|
|
280
|
+
if (!Array.isArray(value.children) || value.children.length !== 1) {
|
|
281
|
+
throw new Error(`${path}.not requires exactly one child`);
|
|
282
|
+
}
|
|
283
|
+
validateVisibilityExpression(value.children[0], `${path}.children[0]`, sets, nested);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const validateVisibilityPlan = (value, path) => {
|
|
288
|
+
validateExactObject(value, path, ["table", "key", "sets", "where"]);
|
|
289
|
+
requireVisibilityString(value.table, `${path}.table`);
|
|
290
|
+
requireVisibilityString(value.key, `${path}.key`);
|
|
291
|
+
if (!isRecord(value.sets))
|
|
292
|
+
throw new Error(`${path}.sets must be an object`);
|
|
293
|
+
for (const [name, candidate] of Object.entries(value.sets)) {
|
|
294
|
+
requireVisibilityString(name, `${path}.sets key`);
|
|
295
|
+
const setPath = `${path}.sets.${name}`;
|
|
296
|
+
validateExactObject(candidate, setPath, ["table", "select", "joins", "where"]);
|
|
297
|
+
requireVisibilityString(candidate.table, `${setPath}.table`);
|
|
298
|
+
requireVisibilityString(candidate.select, `${setPath}.select`);
|
|
299
|
+
if (!Array.isArray(candidate.joins))
|
|
300
|
+
throw new Error(`${setPath}.joins must be an array`);
|
|
301
|
+
candidate.joins.forEach((join, index) => {
|
|
302
|
+
const joinPath = `${setPath}.joins[${index}]`;
|
|
303
|
+
validateExactObject(join, joinPath, ["table", "leftColumn", "rightColumn"]);
|
|
304
|
+
requireVisibilityString(join.table, `${joinPath}.table`);
|
|
305
|
+
requireVisibilityString(join.leftColumn, `${joinPath}.leftColumn`);
|
|
306
|
+
requireVisibilityString(join.rightColumn, `${joinPath}.rightColumn`);
|
|
307
|
+
});
|
|
308
|
+
if (!Array.isArray(candidate.where))
|
|
309
|
+
throw new Error(`${setPath}.where must be an array`);
|
|
310
|
+
candidate.where.forEach((constraint, index) => {
|
|
311
|
+
const constraintPath = `${setPath}.where[${index}]`;
|
|
312
|
+
validateExactObject(constraint, constraintPath, ["table", "column", "context"]);
|
|
313
|
+
requireVisibilityString(constraint.table, `${constraintPath}.table`);
|
|
314
|
+
requireVisibilityString(constraint.column, `${constraintPath}.column`);
|
|
315
|
+
validateVisibilityContext(constraint.context, `${constraintPath}.context`);
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
validateVisibilityExpression(value.where, `${path}.where`, value.sets);
|
|
319
|
+
};
|
|
320
|
+
const freezeVisibilityExpression = (expression) => freeze({
|
|
321
|
+
...expression,
|
|
322
|
+
children: expression.children === undefined
|
|
323
|
+
? undefined
|
|
324
|
+
: freeze(expression.children.map(freezeVisibilityExpression)),
|
|
325
|
+
});
|
|
326
|
+
const freezeVisibilityPlan = (plan) => {
|
|
327
|
+
const sets = {};
|
|
328
|
+
for (const name of Object.keys(plan.sets).sort()) {
|
|
329
|
+
const set = plan.sets[name];
|
|
330
|
+
sets[name] = freeze({
|
|
331
|
+
...set,
|
|
332
|
+
joins: freeze(set.joins.map((join) => freeze({ ...join }))),
|
|
333
|
+
where: freeze(set.where.map((constraint) => freeze({ ...constraint }))),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return freeze({
|
|
337
|
+
...plan,
|
|
338
|
+
sets: freeze(sets),
|
|
339
|
+
where: freezeVisibilityExpression(plan.where),
|
|
340
|
+
});
|
|
341
|
+
};
|
|
342
|
+
/** Declare and validate one source table's language-neutral visibility plan. */
|
|
343
|
+
export function visibility(options) {
|
|
344
|
+
validateVisibilityPlan(options, "visibility");
|
|
345
|
+
return freezeVisibilityPlan(options);
|
|
346
|
+
}
|
|
347
|
+
const stableValue = (value) => {
|
|
348
|
+
if (Array.isArray(value))
|
|
349
|
+
return value.map(stableValue);
|
|
350
|
+
if (isRecord(value)) {
|
|
351
|
+
const sorted = {};
|
|
352
|
+
for (const key of Object.keys(value).sort())
|
|
353
|
+
sorted[key] = stableValue(value[key]);
|
|
354
|
+
return sorted;
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
};
|
|
358
|
+
/** JSON serialization with recursively sorted object keys for reproducible artifacts. */
|
|
359
|
+
export const stableJsonStringify = (value, space) => JSON.stringify(stableValue(value), null, space);
|
|
360
|
+
const validateCron = (options) => {
|
|
361
|
+
const name = options.name.trim();
|
|
362
|
+
const functionPath = options.function.trim();
|
|
363
|
+
if (!name)
|
|
364
|
+
throw new Error("cron name is required");
|
|
365
|
+
if (!functionPath)
|
|
366
|
+
throw new Error(`cron ${JSON.stringify(name)} requires a function path`);
|
|
367
|
+
const hasInterval = options.intervalMs !== undefined;
|
|
368
|
+
const hasExpression = options.expression !== undefined;
|
|
369
|
+
if (hasInterval === hasExpression) {
|
|
370
|
+
throw new Error(`cron ${JSON.stringify(name)} requires exactly one of intervalMs or expression`);
|
|
371
|
+
}
|
|
372
|
+
if (hasInterval && (!Number.isSafeInteger(options.intervalMs) || options.intervalMs <= 0)) {
|
|
373
|
+
throw new Error(`cron ${JSON.stringify(name)} intervalMs must be a positive safe integer`);
|
|
374
|
+
}
|
|
375
|
+
const expression = options.expression?.trim();
|
|
376
|
+
if (hasExpression && !expression)
|
|
377
|
+
throw new Error(`cron ${JSON.stringify(name)} expression must be non-empty`);
|
|
378
|
+
return freeze({
|
|
379
|
+
name,
|
|
380
|
+
function: functionPath,
|
|
381
|
+
scope: options.scope,
|
|
382
|
+
...(options.args === undefined ? {} : { args: options.args }),
|
|
383
|
+
...(hasInterval ? { intervalMs: options.intervalMs } : { expression: expression }),
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
/** Declare a project-wide recurring Reducer or Action. */
|
|
387
|
+
export function cron(options) {
|
|
388
|
+
return validateCron({ ...options, scope: "project" });
|
|
389
|
+
}
|
|
390
|
+
/** Declare a recurring Reducer or Action once for every tenant. */
|
|
391
|
+
export function tenantCron(options) {
|
|
392
|
+
return validateCron({ ...options, scope: "tenant" });
|
|
393
|
+
}
|
|
394
|
+
export class ModuleManifestCollector {
|
|
395
|
+
entries = new Map();
|
|
396
|
+
visibilityEntries = new Map();
|
|
397
|
+
cronEntries = new Map();
|
|
398
|
+
metadata;
|
|
399
|
+
constructor(metadata) {
|
|
400
|
+
const { visibility: initialVisibility, crons: initialCrons, ...baseMetadata } = metadata;
|
|
401
|
+
this.metadata = baseMetadata;
|
|
402
|
+
for (const sourceTable of Object.keys(initialVisibility ?? {}).sort()) {
|
|
403
|
+
const plan = initialVisibility[sourceTable];
|
|
404
|
+
if (sourceTable !== plan.table) {
|
|
405
|
+
throw new Error(`visibility map key ${sourceTable} does not match source table ${plan.table}`);
|
|
406
|
+
}
|
|
407
|
+
this.registerVisibility(plan);
|
|
408
|
+
}
|
|
409
|
+
for (const spec of initialCrons ?? [])
|
|
410
|
+
this.registerCron(spec);
|
|
411
|
+
}
|
|
412
|
+
register(path, entry) {
|
|
413
|
+
const normalized = normalizePath(path);
|
|
414
|
+
if (this.entries.has(normalized))
|
|
415
|
+
throw new Error(`duplicate module function: ${normalized}`);
|
|
416
|
+
const result = freeze({ path: normalized, ...entry });
|
|
417
|
+
this.entries.set(normalized, result);
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
registerVisibility(options) {
|
|
421
|
+
const plan = visibility(options);
|
|
422
|
+
if (this.visibilityEntries.has(plan.table))
|
|
423
|
+
throw new Error(`duplicate visibility plan: ${plan.table}`);
|
|
424
|
+
this.visibilityEntries.set(plan.table, plan);
|
|
425
|
+
return plan;
|
|
426
|
+
}
|
|
427
|
+
registerCron(options) {
|
|
428
|
+
const spec = validateCron(options);
|
|
429
|
+
if (this.cronEntries.has(spec.name))
|
|
430
|
+
throw new Error(`duplicate cron: ${spec.name}`);
|
|
431
|
+
this.cronEntries.set(spec.name, spec);
|
|
432
|
+
return spec;
|
|
433
|
+
}
|
|
434
|
+
manifest() {
|
|
435
|
+
const functions = {};
|
|
436
|
+
for (const path of [...this.entries.keys()].sort())
|
|
437
|
+
functions[path] = this.entries.get(path);
|
|
438
|
+
const visibilityPlans = {};
|
|
439
|
+
for (const table of [...this.visibilityEntries.keys()].sort())
|
|
440
|
+
visibilityPlans[table] = this.visibilityEntries.get(table);
|
|
441
|
+
const crons = [...this.cronEntries.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
442
|
+
for (const spec of crons) {
|
|
443
|
+
const target = this.entries.get(spec.function);
|
|
444
|
+
if (!target)
|
|
445
|
+
throw new Error(`cron ${JSON.stringify(spec.name)} targets unknown function ${JSON.stringify(spec.function)}`);
|
|
446
|
+
if (target.kind === "query")
|
|
447
|
+
throw new Error(`cron ${JSON.stringify(spec.name)} must target a reducer or action`);
|
|
448
|
+
}
|
|
449
|
+
for (const [path, definition] of this.entries) {
|
|
450
|
+
for (const [name, binding] of Object.entries(definition.actionCapabilities?.tools ?? {})) {
|
|
451
|
+
const target = this.entries.get(binding.function);
|
|
452
|
+
if (!target)
|
|
453
|
+
throw new Error(`action ${JSON.stringify(path)} tool ${JSON.stringify(name)} targets unknown function ${JSON.stringify(binding.function)}`);
|
|
454
|
+
if (target.kind !== binding.kind)
|
|
455
|
+
throw new Error(`action ${JSON.stringify(path)} tool ${JSON.stringify(name)} kind does not match ${JSON.stringify(binding.function)}`);
|
|
456
|
+
if (binding.kind === "query" && (!target.internal || target.delivery !== "oneShot"))
|
|
457
|
+
throw new Error(`action ${JSON.stringify(path)} tool ${JSON.stringify(name)} must target an internal one-shot Query`);
|
|
458
|
+
if (binding.kind === "reducer" && target.internal)
|
|
459
|
+
throw new Error(`action ${JSON.stringify(path)} tool ${JSON.stringify(name)} must target a public business-intent Reducer`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return freeze({
|
|
463
|
+
...this.metadata,
|
|
464
|
+
functions: freeze(functions),
|
|
465
|
+
crons: crons.length === 0 ? undefined : freeze(crons),
|
|
466
|
+
visibility: this.visibilityEntries.size === 0 ? undefined : freeze(visibilityPlans),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
serialize(space) {
|
|
470
|
+
return stableJsonStringify(this.manifest(), space);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const executableOptions = (options) => freeze({ ...options });
|
|
474
|
+
const queryDefinition = (options, deliveryOverride) => {
|
|
475
|
+
const liveQueryPlan = options.liveQueryPlan;
|
|
476
|
+
const replica = options.replica;
|
|
477
|
+
// `query()` is always a one-shot declaration. Live and replica delivery are
|
|
478
|
+
// selected only by the dedicated helpers below; a structured plan does not
|
|
479
|
+
// silently change the execution mode.
|
|
480
|
+
const delivery = deliveryOverride ?? options.delivery ?? "oneShot";
|
|
481
|
+
if (delivery === "live" && !liveQueryPlan)
|
|
482
|
+
throw new Error("live query requires a live query plan");
|
|
483
|
+
if (delivery === "oneShot") {
|
|
484
|
+
if (!liveQueryPlan)
|
|
485
|
+
throw new Error("one-shot query requires a structured live query plan");
|
|
486
|
+
validateStructuredQueryPlan(liveQueryPlan, "<export>");
|
|
487
|
+
}
|
|
488
|
+
if (delivery === "replica") {
|
|
489
|
+
if (!replica)
|
|
490
|
+
throw new Error("replica collection requires a replica definition");
|
|
491
|
+
validateReplicaCollection(replica, "<export>");
|
|
492
|
+
}
|
|
493
|
+
return freeze({
|
|
494
|
+
kind: "query",
|
|
495
|
+
internal: options.internal,
|
|
496
|
+
delivery,
|
|
497
|
+
liveQueryPlan,
|
|
498
|
+
replica,
|
|
499
|
+
options: executableOptions(options),
|
|
500
|
+
handler: options.run,
|
|
501
|
+
});
|
|
502
|
+
};
|
|
503
|
+
/** Declare an executable one-shot, live, or replica query export. */
|
|
504
|
+
export function query(options = {}) {
|
|
505
|
+
return queryDefinition(options);
|
|
506
|
+
}
|
|
507
|
+
/** Declare a one-shot Query that is unreachable from clients and may be bound to an Action tool. */
|
|
508
|
+
export function internalQuery(options = {}) {
|
|
509
|
+
return queryDefinition({ ...options, internal: true, delivery: "oneShot" });
|
|
510
|
+
}
|
|
511
|
+
/** Declare an executable live query export with a structured live plan. */
|
|
512
|
+
export function liveQuery(options = {}) {
|
|
513
|
+
return queryDefinition({ ...options, delivery: "live" }, "live");
|
|
514
|
+
}
|
|
515
|
+
/** Declare an executable bounded replica collection export. */
|
|
516
|
+
export function replicaCollection(options) {
|
|
517
|
+
return queryDefinition({ ...options, delivery: "replica", replica: options.replica }, "replica");
|
|
518
|
+
}
|
|
519
|
+
const reducerDefinition = (options, internal = false) => {
|
|
520
|
+
validateOfflinePolicy(options.offline, "<export>");
|
|
521
|
+
if (options.optimistic !== undefined)
|
|
522
|
+
validateOptimisticTransaction(options.optimistic, "<export>");
|
|
523
|
+
if (options.interactive === false && options.optimistic !== undefined) {
|
|
524
|
+
throw new Error("non-interactive reducer <export> cannot declare optimistic metadata");
|
|
525
|
+
}
|
|
526
|
+
if (options.interactive !== false && options.optimistic === undefined && !options.nonOptimisticReason?.trim()) {
|
|
527
|
+
throw new Error("interactive reducer <export> requires an optimistic transaction or nonOptimisticReason");
|
|
528
|
+
}
|
|
529
|
+
return freeze({
|
|
530
|
+
kind: "reducer",
|
|
531
|
+
internal: internal || options.internal,
|
|
532
|
+
options: executableOptions(options),
|
|
533
|
+
handler: options.run,
|
|
534
|
+
});
|
|
535
|
+
};
|
|
536
|
+
/** Declare an executable public reducer export. */
|
|
537
|
+
export function reducer(options) {
|
|
538
|
+
return reducerDefinition(options);
|
|
539
|
+
}
|
|
540
|
+
/** Declare an executable non-interactive internal reducer export. */
|
|
541
|
+
export function internalReducer(options) {
|
|
542
|
+
return reducerDefinition({
|
|
543
|
+
...options,
|
|
544
|
+
offline: options.offline ?? { mode: "forbidden" },
|
|
545
|
+
interactive: false,
|
|
546
|
+
internal: true,
|
|
547
|
+
}, true);
|
|
548
|
+
}
|
|
549
|
+
/** Declare an executable action export. */
|
|
550
|
+
export function action(options = {}) {
|
|
551
|
+
validateActionCapabilities(options.profile ?? "standard", options.capabilities, options.name?.trim() || "<export>");
|
|
552
|
+
return freeze({ kind: "action", options: executableOptions(options), handler: options.run });
|
|
553
|
+
}
|
|
554
|
+
export class ModuleBuilder {
|
|
555
|
+
manifestCollector;
|
|
556
|
+
runtimeEntries = new Map();
|
|
557
|
+
constructor(metadata) {
|
|
558
|
+
this.manifestCollector = new ModuleManifestCollector({
|
|
559
|
+
format: "gonvex.module.v1",
|
|
560
|
+
name: metadata.name,
|
|
561
|
+
version: metadata.version,
|
|
562
|
+
language: metadata.language ?? "typescript",
|
|
563
|
+
engine: metadata.engine ?? "v8",
|
|
564
|
+
schema: metadata.schema,
|
|
565
|
+
artifact: metadata.artifact,
|
|
566
|
+
visibility: metadata.visibility,
|
|
567
|
+
crons: metadata.crons,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
visibility(options) {
|
|
571
|
+
return this.manifestCollector.registerVisibility(options);
|
|
572
|
+
}
|
|
573
|
+
cron(options) {
|
|
574
|
+
return this.manifestCollector.registerCron(cron(options));
|
|
575
|
+
}
|
|
576
|
+
tenantCron(options) {
|
|
577
|
+
return this.manifestCollector.registerCron(tenantCron(options));
|
|
578
|
+
}
|
|
579
|
+
query(path, options = {}) {
|
|
580
|
+
const liveQueryPlan = options.liveQueryPlan;
|
|
581
|
+
const replica = options.replica;
|
|
582
|
+
// `ModuleBuilder.query()` follows the same contract as the static artifact
|
|
583
|
+
// parser: a plan describes the SQL source, not the delivery mode.
|
|
584
|
+
const delivery = options.delivery ?? "oneShot";
|
|
585
|
+
if (delivery === "live" && !liveQueryPlan)
|
|
586
|
+
throw new Error(`live query ${normalizePath(path)} requires a live query plan`);
|
|
587
|
+
if (delivery === "oneShot") {
|
|
588
|
+
if (!liveQueryPlan)
|
|
589
|
+
throw new Error(`one-shot query ${normalizePath(path)} requires a structured live query plan`);
|
|
590
|
+
validateStructuredQueryPlan(liveQueryPlan, normalizePath(path));
|
|
591
|
+
}
|
|
592
|
+
if (delivery === "replica") {
|
|
593
|
+
if (!replica)
|
|
594
|
+
throw new Error(`replica collection ${normalizePath(path)} requires a replica definition`);
|
|
595
|
+
validateReplicaCollection(replica, normalizePath(path));
|
|
596
|
+
}
|
|
597
|
+
const definition = this.manifestCollector.register(path, {
|
|
598
|
+
kind: "query",
|
|
599
|
+
args: options.args,
|
|
600
|
+
result: options.result,
|
|
601
|
+
delivery,
|
|
602
|
+
liveQueryPlan,
|
|
603
|
+
replica,
|
|
604
|
+
internal: options.internal,
|
|
605
|
+
});
|
|
606
|
+
const registration = freeze({ path: definition.path, kind: definition.kind, definition, handler: options.run });
|
|
607
|
+
this.runtimeEntries.set(definition.path, registration);
|
|
608
|
+
return registration;
|
|
609
|
+
}
|
|
610
|
+
/** Register a Query delivered as a live, structured query stream. */
|
|
611
|
+
liveQuery(path, options = {}) {
|
|
612
|
+
return this.query(path, { ...options, delivery: "live" });
|
|
613
|
+
}
|
|
614
|
+
/** Register a Query delivered as a bounded local replica collection. */
|
|
615
|
+
replicaCollection(path, options) {
|
|
616
|
+
return this.query(path, { ...options, delivery: "replica", replica: options.replica });
|
|
617
|
+
}
|
|
618
|
+
reducer(path, options) {
|
|
619
|
+
const normalized = normalizePath(path);
|
|
620
|
+
validateOfflinePolicy(options.offline, normalized);
|
|
621
|
+
if (options.optimistic !== undefined)
|
|
622
|
+
validateOptimisticTransaction(options.optimistic, normalized);
|
|
623
|
+
if (options.interactive === false && options.optimistic !== undefined) {
|
|
624
|
+
throw new Error(`non-interactive reducer ${normalized} cannot declare optimistic metadata`);
|
|
625
|
+
}
|
|
626
|
+
if (options.interactive !== false && options.optimistic === undefined && !options.nonOptimisticReason?.trim()) {
|
|
627
|
+
throw new Error(`interactive reducer ${normalized} requires an optimistic transaction or nonOptimisticReason`);
|
|
628
|
+
}
|
|
629
|
+
const definition = this.manifestCollector.register(path, {
|
|
630
|
+
kind: "reducer",
|
|
631
|
+
args: options.args,
|
|
632
|
+
result: options.result,
|
|
633
|
+
offline: options.offline,
|
|
634
|
+
interactive: options.interactive ?? true,
|
|
635
|
+
internal: options.internal,
|
|
636
|
+
optimistic: options.optimistic,
|
|
637
|
+
nonOptimisticReason: options.nonOptimisticReason?.trim() || undefined,
|
|
638
|
+
});
|
|
639
|
+
const registration = freeze({ path: definition.path, kind: definition.kind, definition, handler: options.run });
|
|
640
|
+
this.runtimeEntries.set(definition.path, registration);
|
|
641
|
+
return registration;
|
|
642
|
+
}
|
|
643
|
+
/** Register a non-public Reducer while retaining kind `reducer` in the manifest. */
|
|
644
|
+
internalReducer(path, options) {
|
|
645
|
+
return this.reducer(path, {
|
|
646
|
+
...options,
|
|
647
|
+
offline: options.offline ?? { mode: "forbidden" },
|
|
648
|
+
interactive: false,
|
|
649
|
+
internal: true,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
action(path, options = {}) {
|
|
653
|
+
const profile = options.profile ?? "standard";
|
|
654
|
+
validateActionCapabilities(profile, options.capabilities, normalizePath(path));
|
|
655
|
+
const definition = this.manifestCollector.register(path, {
|
|
656
|
+
kind: "action",
|
|
657
|
+
args: options.args,
|
|
658
|
+
result: options.result,
|
|
659
|
+
actionProfile: profile,
|
|
660
|
+
actionCapabilities: options.capabilities,
|
|
661
|
+
});
|
|
662
|
+
const registration = freeze({ path: definition.path, kind: definition.kind, definition, handler: options.run });
|
|
663
|
+
this.runtimeEntries.set(definition.path, registration);
|
|
664
|
+
return registration;
|
|
665
|
+
}
|
|
666
|
+
manifest() {
|
|
667
|
+
return this.manifestCollector.manifest();
|
|
668
|
+
}
|
|
669
|
+
serialize(space) {
|
|
670
|
+
return this.manifestCollector.serialize(space);
|
|
671
|
+
}
|
|
672
|
+
/** Executable registrations sorted by path for deterministic host loading. */
|
|
673
|
+
runtimeRegistrations() {
|
|
674
|
+
return Object.freeze([...this.runtimeEntries.values()].sort((a, b) => a.path.localeCompare(b.path)));
|
|
675
|
+
}
|
|
676
|
+
runtimePayload() {
|
|
677
|
+
const registrations = this.runtimeRegistrations().map(({ path, kind, definition }) => ({ path, kind, definition }));
|
|
678
|
+
return freeze({
|
|
679
|
+
format: "gonvex.module.runtime.v1",
|
|
680
|
+
manifest: this.manifest(),
|
|
681
|
+
registrations: freeze(registrations),
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
serializeRuntimePayload(space) {
|
|
685
|
+
return stableJsonStringify(this.runtimePayload(), space);
|
|
686
|
+
}
|
|
687
|
+
createRuntimeRegistry() {
|
|
688
|
+
return new ModuleRuntimeRegistry(this);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Host-side executable registry. It is deliberately unaware of V8,
|
|
693
|
+
* Postgres, or network transport; an engine supplies the capability-bearing
|
|
694
|
+
* context and this registry only selects and invokes the registered handler.
|
|
695
|
+
*/
|
|
696
|
+
export class ModuleRuntimeRegistry {
|
|
697
|
+
entries = new Map();
|
|
698
|
+
baseManifest;
|
|
699
|
+
constructor(source) {
|
|
700
|
+
this.baseManifest = source.manifest();
|
|
701
|
+
for (const registration of source.runtimeRegistrations())
|
|
702
|
+
this.register(registration);
|
|
703
|
+
}
|
|
704
|
+
register(registration) {
|
|
705
|
+
const path = normalizePath(registration.path);
|
|
706
|
+
if (path !== registration.definition.path) {
|
|
707
|
+
throw new Error(`runtime registration path does not match its manifest: ${path}`);
|
|
708
|
+
}
|
|
709
|
+
if (registration.kind !== registration.definition.kind) {
|
|
710
|
+
throw new Error(`runtime registration kind does not match its manifest: ${path}`);
|
|
711
|
+
}
|
|
712
|
+
if (registration.kind === "reducer") {
|
|
713
|
+
validateOfflinePolicy(registration.definition.offline, path);
|
|
714
|
+
if (registration.definition.optimistic !== undefined) {
|
|
715
|
+
validateOptimisticTransaction(registration.definition.optimistic, path);
|
|
716
|
+
}
|
|
717
|
+
if (registration.definition.interactive !== false && registration.definition.optimistic === undefined && !registration.definition.nonOptimisticReason?.trim()) {
|
|
718
|
+
throw new Error(`interactive reducer ${path} requires an optimistic transaction or nonOptimisticReason`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (registration.kind === "query" && (registration.definition.delivery ?? "oneShot") === "oneShot") {
|
|
722
|
+
if (!registration.definition.liveQueryPlan)
|
|
723
|
+
throw new Error(`one-shot query ${path} requires a structured live query plan`);
|
|
724
|
+
validateStructuredQueryPlan(registration.definition.liveQueryPlan, path);
|
|
725
|
+
}
|
|
726
|
+
if (registration.definition.delivery === "replica") {
|
|
727
|
+
if (!registration.definition.replica)
|
|
728
|
+
throw new Error(`replica query ${path} requires a replica definition`);
|
|
729
|
+
validateReplicaCollection(registration.definition.replica, path);
|
|
730
|
+
}
|
|
731
|
+
if (this.entries.has(path))
|
|
732
|
+
throw new Error(`duplicate runtime registration: ${path}`);
|
|
733
|
+
this.entries.set(path, freeze({ ...registration, path }));
|
|
734
|
+
}
|
|
735
|
+
has(path, kind) {
|
|
736
|
+
const registration = this.entries.get(normalizePath(path));
|
|
737
|
+
return registration !== undefined && (kind === undefined || registration.kind === kind);
|
|
738
|
+
}
|
|
739
|
+
registration(path) {
|
|
740
|
+
return this.entries.get(normalizePath(path));
|
|
741
|
+
}
|
|
742
|
+
registrations() {
|
|
743
|
+
return Object.freeze([...this.entries.values()].sort((a, b) => a.path.localeCompare(b.path)));
|
|
744
|
+
}
|
|
745
|
+
manifest() {
|
|
746
|
+
const functions = {};
|
|
747
|
+
for (const registration of this.registrations())
|
|
748
|
+
functions[registration.path] = registration.definition;
|
|
749
|
+
return freeze({
|
|
750
|
+
format: this.baseManifest.format,
|
|
751
|
+
name: this.baseManifest.name,
|
|
752
|
+
version: this.baseManifest.version,
|
|
753
|
+
language: this.baseManifest.language,
|
|
754
|
+
engine: this.baseManifest.engine,
|
|
755
|
+
schema: this.baseManifest.schema,
|
|
756
|
+
artifact: this.baseManifest.artifact,
|
|
757
|
+
crons: this.baseManifest.crons,
|
|
758
|
+
visibility: this.baseManifest.visibility,
|
|
759
|
+
functions: freeze(functions),
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
registrationPayload() {
|
|
763
|
+
return freeze({
|
|
764
|
+
format: "gonvex.module.runtime.v1",
|
|
765
|
+
manifest: this.manifest(),
|
|
766
|
+
registrations: freeze(this.registrations().map(({ path, kind, definition }) => ({ path, kind, definition }))),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
serializeRegistrationPayload(space) {
|
|
770
|
+
return stableJsonStringify(this.registrationPayload(), space);
|
|
771
|
+
}
|
|
772
|
+
async query(path, context, args) {
|
|
773
|
+
return this.dispatch({ path, kind: "query", context, args });
|
|
774
|
+
}
|
|
775
|
+
async reducer(path, context, args) {
|
|
776
|
+
return this.dispatch({ path, kind: "reducer", context, args });
|
|
777
|
+
}
|
|
778
|
+
async action(path, context, args) {
|
|
779
|
+
return this.dispatch({ path, kind: "action", context, args });
|
|
780
|
+
}
|
|
781
|
+
async dispatch(invocation) {
|
|
782
|
+
const path = normalizePath(invocation.path);
|
|
783
|
+
const registration = this.entries.get(path);
|
|
784
|
+
if (!registration)
|
|
785
|
+
throw new Error(`unknown module function: ${path}`);
|
|
786
|
+
if (registration.kind !== invocation.kind) {
|
|
787
|
+
throw new Error(`module function ${path} is ${registration.kind}, not ${invocation.kind}`);
|
|
788
|
+
}
|
|
789
|
+
if (!registration.handler) {
|
|
790
|
+
throw new Error(`module function ${path} has no executable handler`);
|
|
791
|
+
}
|
|
792
|
+
switch (invocation.kind) {
|
|
793
|
+
case "query":
|
|
794
|
+
return registration.handler(invocation.context, invocation.args);
|
|
795
|
+
case "reducer":
|
|
796
|
+
return registration.handler(invocation.context, invocation.args);
|
|
797
|
+
case "action":
|
|
798
|
+
return registration.handler(invocation.context, invocation.args);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
export function createModule(metadata) {
|
|
803
|
+
return new ModuleBuilder(metadata);
|
|
804
|
+
}
|
|
805
|
+
//# sourceMappingURL=index.js.map
|