@human-synthesis/norns 0.1.0 → 0.2.1
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 +1 -1
- package/src/kernel/address.js +4 -0
- package/src/kernel/emit-units.js +283 -7
- package/src/kernel/emit-wrangler.js +22 -2
- package/src/kernel/expr-compile.js +4 -1
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +500 -23
- package/src/kernel/index.js +9 -2
- package/src/kernel/meta.js +145 -1
- package/src/kernel/refine.js +78 -0
- package/src/kernel/trace.js +215 -27
- package/src/live-client.js +144 -0
- package/src/server/boot.js +22 -0
- package/src/server/db.js +5 -1
- package/src/server/endpoint.js +142 -0
- package/src/server/index.js +3 -0
- package/src/server/job.js +102 -0
- package/src/server/room.js +17 -0
- package/src/server/service.js +188 -0
package/src/kernel/meta.js
CHANGED
|
@@ -151,6 +151,128 @@ const Component = v.strictObject({
|
|
|
151
151
|
slots: v.optional(v.array(ident))
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
// Snippet (U-07) — a typed render fragment for a palette slot (cell
|
|
155
|
+
// renderers, empty states). Declared args are the slot's calling
|
|
156
|
+
// convention; the body lives in `src/<m>/snippets/<name>.n` and the page
|
|
157
|
+
// emitter wraps it in a `+snippet` forwarding the args as props.
|
|
158
|
+
const Snippet = v.strictObject({
|
|
159
|
+
uid,
|
|
160
|
+
args: v.optional(v.array(ident)),
|
|
161
|
+
description: v.optional(v.string())
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Service (D15) — an external system as a typed operation manifest.
|
|
165
|
+
// Credentials never appear here: `auth.binding` is an env binding *name*
|
|
166
|
+
// (UPPER_SNAKE); a literal secret anywhere in a service is refused at
|
|
167
|
+
// generate time (SECRET_IN_SPEC, generate.js).
|
|
168
|
+
const bindingName = v.pipe(
|
|
169
|
+
v.string(),
|
|
170
|
+
v.regex(/^[A-Z][A-Z0-9_]*$/, 'must be an UPPER_SNAKE env binding name, never a secret value')
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const ServiceAuth = v.pipe(
|
|
174
|
+
v.strictObject({
|
|
175
|
+
mode: v.picklist(['none', 'bearer', 'basic', 'hmac', 'header']),
|
|
176
|
+
binding: v.optional(bindingName),
|
|
177
|
+
header: v.optional(v.pipe(v.string(), v.regex(/^[A-Za-z][A-Za-z0-9-]*$/, 'must be a header name')))
|
|
178
|
+
}),
|
|
179
|
+
v.check(
|
|
180
|
+
(a) => (a.mode === 'none' ? a.binding === undefined : a.binding !== undefined),
|
|
181
|
+
"auth modes other than 'none' require a `binding` name; 'none' must not have one"
|
|
182
|
+
),
|
|
183
|
+
v.check((a) => a.mode === 'header' || a.header === undefined, "`header` is only valid with mode 'header'"),
|
|
184
|
+
v.check((a) => a.mode !== 'header' || a.header !== undefined, "auth mode 'header' requires `header`")
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const ServiceOperation = v.strictObject({
|
|
188
|
+
method: v.optional(v.picklist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])),
|
|
189
|
+
path: v.optional(v.pipe(v.string(), v.regex(/^\//, 'path must start with "/"'))),
|
|
190
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
191
|
+
output: v.optional(v.unknown())
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const Service = v.strictObject({
|
|
195
|
+
uid,
|
|
196
|
+
base: v.pipe(v.string(), v.url('base must be an absolute URL')),
|
|
197
|
+
auth: ServiceAuth,
|
|
198
|
+
operations: v.pipe(
|
|
199
|
+
v.record(ident, ServiceOperation),
|
|
200
|
+
v.check((ops) => Object.keys(ops).length > 0, 'services require at least one operation')
|
|
201
|
+
)
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Job (D14/K-22) — durable work. Retry policy is required by guardrail:
|
|
205
|
+
// a job without declared failure behavior is refused at the shape level.
|
|
206
|
+
// Jobs run from `enqueue` steps via the events bus (`job:<address>`
|
|
207
|
+
// messages) — Cloudflare Queues in production, inline in dev.
|
|
208
|
+
const queueName = v.pipe(
|
|
209
|
+
v.string(),
|
|
210
|
+
v.regex(/^[a-z][a-z0-9-]*$/, 'must be a queue name (lowercase, digits, dashes)')
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const Job = v.pipe(
|
|
214
|
+
v.strictObject({
|
|
215
|
+
uid,
|
|
216
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
217
|
+
retry: v.strictObject({
|
|
218
|
+
attempts: v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(20)),
|
|
219
|
+
backoff: v.picklist(['none', 'fixed', 'exponential']),
|
|
220
|
+
baseMs: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)))
|
|
221
|
+
}),
|
|
222
|
+
dlq: v.optional(queueName),
|
|
223
|
+
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100))),
|
|
224
|
+
steps: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
225
|
+
emits: v.optional(v.array(v.string())),
|
|
226
|
+
examples: v.optional(v.array(example)),
|
|
227
|
+
impl: v.optional(v.picklist(['generated', 'custom']))
|
|
228
|
+
}),
|
|
229
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES),
|
|
230
|
+
v.check(
|
|
231
|
+
(j) => j.impl === 'custom' || (Array.isArray(j.steps) && j.steps.length > 0),
|
|
232
|
+
'generated jobs need at least one step (or `impl: custom`)'
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Endpoint (D14/K-23) — a Route grown up: declared route/method/auth and
|
|
237
|
+
// IO contract in spec, body in `src/<m>/endpoints/<name>.c`. `stream`
|
|
238
|
+
// declares an SSE output mode with typed frames (the chat/AI-token path).
|
|
239
|
+
// `auth` is required — public endpoints declare `{ mode: 'none' }` explicitly.
|
|
240
|
+
const Endpoint = v.pipe(
|
|
241
|
+
v.strictObject({
|
|
242
|
+
uid,
|
|
243
|
+
route: v.pipe(v.string(), v.regex(/^\//, 'route must start with "/"')),
|
|
244
|
+
method: v.optional(v.picklist(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])),
|
|
245
|
+
auth: ServiceAuth,
|
|
246
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
247
|
+
output: v.optional(v.record(ident, v.unknown())),
|
|
248
|
+
stream: v.optional(
|
|
249
|
+
v.strictObject({
|
|
250
|
+
frame: v.pipe(
|
|
251
|
+
v.record(ident, v.unknown()),
|
|
252
|
+
v.check((f) => Object.keys(f).length > 0, 'stream frames need at least one field')
|
|
253
|
+
)
|
|
254
|
+
})
|
|
255
|
+
),
|
|
256
|
+
impl: v.optional(v.literal('custom')),
|
|
257
|
+
examples: v.optional(v.array(example))
|
|
258
|
+
}),
|
|
259
|
+
v.check((e) => !(e.output && e.stream), 'declare either `output` or `stream`, not both'),
|
|
260
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Room contract (D14) — Workers with `room: true` may declare state and
|
|
264
|
+
// message schemas plus script examples: message sequences driven against
|
|
265
|
+
// the Room class headless, checked as expected state + broadcasts (K-25).
|
|
266
|
+
const roomScriptStep = v.strictObject({
|
|
267
|
+
send: ident,
|
|
268
|
+
with: v.optional(v.record(v.string(), v.unknown()))
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const roomExample = v.strictObject({
|
|
272
|
+
script: v.pipe(v.array(roomScriptStep), v.minLength(1, 'room examples need at least one script step')),
|
|
273
|
+
expect: v.optional(v.unknown())
|
|
274
|
+
});
|
|
275
|
+
|
|
154
276
|
// L3 kinds: whole Civet files with declared auth + capabilities.
|
|
155
277
|
// `validate` refuses them without an auth declaration (PLAN §6).
|
|
156
278
|
const level3 = (extra = {}) =>
|
|
@@ -179,8 +301,26 @@ export const UNIT_SCHEMAS = {
|
|
|
179
301
|
Trigger,
|
|
180
302
|
Function,
|
|
181
303
|
Component,
|
|
304
|
+
Snippet,
|
|
305
|
+
Service,
|
|
306
|
+
Job,
|
|
307
|
+
Endpoint,
|
|
182
308
|
Route: level3(),
|
|
183
|
-
Worker: level3({
|
|
309
|
+
Worker: level3({
|
|
310
|
+
room: v.optional(v.boolean()),
|
|
311
|
+
state: v.optional(v.record(ident, v.string())),
|
|
312
|
+
messages: v.optional(
|
|
313
|
+
v.record(
|
|
314
|
+
ident,
|
|
315
|
+
v.strictObject({
|
|
316
|
+
in: v.optional(v.record(v.string(), v.unknown())),
|
|
317
|
+
out: v.optional(v.record(v.string(), v.unknown()))
|
|
318
|
+
})
|
|
319
|
+
)
|
|
320
|
+
),
|
|
321
|
+
tickMs: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
|
|
322
|
+
examples: v.optional(v.array(roomExample))
|
|
323
|
+
}),
|
|
184
324
|
Adapter: level3(),
|
|
185
325
|
Middleware: level3(),
|
|
186
326
|
Plugin
|
|
@@ -201,6 +341,10 @@ export const MODULE_SCHEMA = v.strictObject({
|
|
|
201
341
|
triggers: collection,
|
|
202
342
|
functions: collection,
|
|
203
343
|
components: collection,
|
|
344
|
+
snippets: collection,
|
|
345
|
+
services: collection,
|
|
346
|
+
jobs: collection,
|
|
347
|
+
endpoints: collection,
|
|
204
348
|
routes: collection,
|
|
205
349
|
workers: collection,
|
|
206
350
|
adapters: collection,
|
package/src/kernel/refine.js
CHANGED
|
@@ -69,6 +69,54 @@ export function refineSpecs(specs) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* `call` steps may target a service operation
|
|
74
|
+
* (`<module>.Service.<name>.<op>`) — the service and operation must both
|
|
75
|
+
* exist. Other call targets are container tokens, resolved at runtime.
|
|
76
|
+
*/
|
|
77
|
+
function checkServiceCall(at, fromModule, call) {
|
|
78
|
+
const m = /^([a-z][a-z0-9_]*)\.Service\.([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(
|
|
79
|
+
call
|
|
80
|
+
);
|
|
81
|
+
if (!m) return;
|
|
82
|
+
const [, module, service, op] = m;
|
|
83
|
+
if (!(module in modules)) {
|
|
84
|
+
if (dependsOf(fromModule).includes(module)) return;
|
|
85
|
+
issues.push({
|
|
86
|
+
level: 'error',
|
|
87
|
+
address: at,
|
|
88
|
+
message: `call "${call}" points into unknown module "${module}" (not loaded, not in depends)`
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const svc = modules[module]?.services?.[service];
|
|
93
|
+
if (!svc) {
|
|
94
|
+
issues.push({
|
|
95
|
+
level: 'error',
|
|
96
|
+
address: at,
|
|
97
|
+
message: `call "${call}" does not resolve (no ${module}.Service.${service})`
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!svc.operations?.[op]) {
|
|
102
|
+
issues.push({
|
|
103
|
+
level: 'error',
|
|
104
|
+
address: at,
|
|
105
|
+
message: `call "${call}": service "${service}" has no operation "${op}"`
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Shared step checks for Actions and Jobs: service calls and job enqueues resolve. */
|
|
111
|
+
function checkFlowSteps(at, fromModule, unitValue) {
|
|
112
|
+
for (const step of Array.isArray(unitValue?.steps) ? unitValue.steps : []) {
|
|
113
|
+
if (typeof step?.call === 'string') checkServiceCall(at, fromModule, step.call);
|
|
114
|
+
if (typeof step?.enqueue === 'string') {
|
|
115
|
+
checkRef(at, fromModule, step.enqueue, 'Job', 'enqueue');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
// depends: modules exist (or are external-by-convention? no — depends
|
|
73
121
|
// names must be loaded or the well-known platform module "core") and form a DAG.
|
|
74
122
|
for (const [name, spec] of Object.entries(modules)) {
|
|
@@ -144,6 +192,7 @@ export function refineSpecs(specs) {
|
|
|
144
192
|
for (const ref of Array.isArray(value?.refresh) ? value.refresh : []) {
|
|
145
193
|
checkRef(at, mod, ref, 'Query', 'refresh');
|
|
146
194
|
}
|
|
195
|
+
checkFlowSteps(at, mod, value);
|
|
147
196
|
if (value?.transport === 'remote' && !remoteEnabled) {
|
|
148
197
|
issues.push({
|
|
149
198
|
level: 'error',
|
|
@@ -186,6 +235,35 @@ export function refineSpecs(specs) {
|
|
|
186
235
|
checkRef(at, mod, action, 'Action', 'trigger action');
|
|
187
236
|
break;
|
|
188
237
|
}
|
|
238
|
+
case 'Job': {
|
|
239
|
+
checkFlowSteps(at, mod, value);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'Worker': {
|
|
243
|
+
if (value?.messages) {
|
|
244
|
+
for (const [i, ex] of (value.examples ?? []).entries()) {
|
|
245
|
+
for (const step of ex.script ?? []) {
|
|
246
|
+
if (!(step.send in value.messages)) {
|
|
247
|
+
issues.push({
|
|
248
|
+
level: 'error',
|
|
249
|
+
address: at,
|
|
250
|
+
message: `example ${i}: script sends undeclared message "${step.send}"`
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case 'Route': {
|
|
259
|
+
issues.push({
|
|
260
|
+
level: 'warning',
|
|
261
|
+
address: at,
|
|
262
|
+
message:
|
|
263
|
+
'schema-less Route is deprecated — declare an Endpoint (route/method/auth/input/output) instead; v3.1 refuses bare Routes'
|
|
264
|
+
});
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
189
267
|
case 'Component': {
|
|
190
268
|
for (const [event, target] of Object.entries(value?.events ?? {})) {
|
|
191
269
|
checkRef(at, mod, target, 'Action', `events.${event}`);
|
package/src/kernel/trace.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dirname, join } from 'node:path';
|
|
|
23
23
|
import { pathToFileURL } from 'node:url';
|
|
24
24
|
|
|
25
25
|
import { actionEntity } from './emit-units.js';
|
|
26
|
+
import { shapeIssues } from '../server/service.js';
|
|
26
27
|
import { normalizeField } from './emit-schema.js';
|
|
27
28
|
import { generateApp } from './generate.js';
|
|
28
29
|
import { loadSpecs } from './validate.js';
|
|
@@ -53,29 +54,34 @@ function rewriteImports(js, { scratch, appRoot, compileCivet }) {
|
|
|
53
54
|
return `${from}${q}${pathToFileURL(target).href}${q}`;
|
|
54
55
|
}
|
|
55
56
|
if (spec.startsWith('$custom/')) {
|
|
56
|
-
const
|
|
57
|
-
const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
|
|
58
|
-
if (!existsSync(target)) {
|
|
59
|
-
const src = join(appRoot, 'src', rel);
|
|
60
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
61
|
-
if (existsSync(src)) {
|
|
62
|
-
writeFileSync(
|
|
63
|
-
target,
|
|
64
|
-
rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
|
|
65
|
-
);
|
|
66
|
-
} else {
|
|
67
|
-
writeFileSync(
|
|
68
|
-
target,
|
|
69
|
-
`export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
57
|
+
const target = ensureCustom(spec.slice('$custom/'.length), { scratch, appRoot, compileCivet });
|
|
73
58
|
return `${from}${q}${pathToFileURL(target).href}${q}`;
|
|
74
59
|
}
|
|
75
60
|
return `${from}${q}${resolveBare(spec)}${q}`;
|
|
76
61
|
});
|
|
77
62
|
}
|
|
78
63
|
|
|
64
|
+
/** Compile a custom body `src/<rel>` into the scratch tree; missing bodies throw when invoked. */
|
|
65
|
+
function ensureCustom(rel, { scratch, appRoot, compileCivet }) {
|
|
66
|
+
const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
|
|
67
|
+
if (!existsSync(target)) {
|
|
68
|
+
const src = join(appRoot, 'src', rel);
|
|
69
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
70
|
+
if (existsSync(src)) {
|
|
71
|
+
writeFileSync(
|
|
72
|
+
target,
|
|
73
|
+
rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
writeFileSync(
|
|
77
|
+
target,
|
|
78
|
+
`export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
/** Compile `lib/**` of the generated tree into `<scratch>/lib` as JS. */
|
|
80
86
|
function buildScratch(genRoot, scratch, appRoot) {
|
|
81
87
|
const { compile } = require('@danielx/civet');
|
|
@@ -166,7 +172,7 @@ function seedRow(id, entitySpec) {
|
|
|
166
172
|
/* Runner */
|
|
167
173
|
/* ------------------------------------------------------------------ */
|
|
168
174
|
|
|
169
|
-
function recordingContainer(db, { fixtures = {}, events, calls }) {
|
|
175
|
+
function recordingContainer(db, { fixtures = {}, auto = {}, events, calls }) {
|
|
170
176
|
return {
|
|
171
177
|
resolve(name) {
|
|
172
178
|
if (name === 'db') return db;
|
|
@@ -177,18 +183,84 @@ function recordingContainer(db, { fixtures = {}, events, calls }) {
|
|
|
177
183
|
}
|
|
178
184
|
};
|
|
179
185
|
}
|
|
186
|
+
if (name === 'jobs') {
|
|
187
|
+
return {
|
|
188
|
+
enqueue: async (address, input) => {
|
|
189
|
+
calls.push({ name: 'jobs.enqueue', args: [address, input] });
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
}
|
|
180
193
|
if (name in fixtures) return fixtures[name];
|
|
194
|
+
if (name in auto) {
|
|
195
|
+
return async (...args) => {
|
|
196
|
+
calls.push({ name, args });
|
|
197
|
+
return auto[name];
|
|
198
|
+
};
|
|
199
|
+
}
|
|
181
200
|
return async (...args) => {
|
|
182
201
|
calls.push({ name, args });
|
|
183
202
|
};
|
|
184
|
-
}
|
|
203
|
+
},
|
|
204
|
+
// `has` makes serviceClient's op-level fixture hook fire for every
|
|
205
|
+
// declared service operation — traced flows never touch the network.
|
|
206
|
+
has: (name) => name in fixtures || name in auto
|
|
185
207
|
};
|
|
186
208
|
}
|
|
187
209
|
|
|
210
|
+
/** Service op address → response fabricated from the op's declared output shape. */
|
|
211
|
+
function serviceAutoFixtures(specs) {
|
|
212
|
+
const auto = {};
|
|
213
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
214
|
+
for (const [serviceName, svc] of Object.entries(moduleSpec.services ?? {})) {
|
|
215
|
+
for (const [opName, op] of Object.entries(svc.operations ?? {})) {
|
|
216
|
+
const out = {};
|
|
217
|
+
for (const [key, t] of Object.entries(op.output && typeof op.output === 'object' ? op.output : {})) {
|
|
218
|
+
const type = (typeof t === 'string' ? t : (t?.type ?? 'json')).replace(/\?$/, '');
|
|
219
|
+
out[key] = SEED_VALUES[type] ?? 'trace';
|
|
220
|
+
}
|
|
221
|
+
auto[`${moduleName}.Service.${serviceName}.${opName}`] = out;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return auto;
|
|
226
|
+
}
|
|
227
|
+
|
|
188
228
|
const looseEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
189
229
|
|
|
230
|
+
function matchCalls(calls, want) {
|
|
231
|
+
return (Array.isArray(want) ? want : []).every((w) => {
|
|
232
|
+
const name = typeof w === 'string' ? w : w?.name;
|
|
233
|
+
return (calls ?? []).some(
|
|
234
|
+
(c) => c.name === name && (typeof w === 'string' || w.with === undefined || looseEq(c.args[0], w.with))
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* `expect` semantics shared by every traced kind: plain keys check the
|
|
241
|
+
* entity row then the return value; `calls` checks recorded external
|
|
242
|
+
* calls (by name, optionally `with` args); `frames` the collected SSE
|
|
243
|
+
* frames; `state`/`broadcasts` the Room script outcome.
|
|
244
|
+
*/
|
|
245
|
+
function checkExpect(record) {
|
|
246
|
+
record.pass = Object.entries(record.expect).every(([key, want]) => {
|
|
247
|
+
if (key === 'calls') return matchCalls(record.calls, want);
|
|
248
|
+
if (key === 'frames') return looseEq(record.frames, want);
|
|
249
|
+
if (key === 'state') {
|
|
250
|
+
return Object.entries(want ?? {}).every(([field, v2]) => looseEq(record.state?.[field], v2));
|
|
251
|
+
}
|
|
252
|
+
if (key === 'broadcasts') {
|
|
253
|
+
return (Array.isArray(want) ? want : []).every((n) => (record.broadcasts ?? []).some((b) => b?.type === n));
|
|
254
|
+
}
|
|
255
|
+
return looseEq(record.row?.[key], want) || looseEq(record.result?.[key], want);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
190
259
|
/**
|
|
191
|
-
* Run every
|
|
260
|
+
* Run every example in the app's specs: Actions against the sandbox DB,
|
|
261
|
+
* plus L3 units (K-25) — Jobs and custom Functions/Endpoints run with the
|
|
262
|
+
* same recording container (Service calls auto-fixtured from their output
|
|
263
|
+
* schemas), Room workers run their script examples headless.
|
|
192
264
|
*
|
|
193
265
|
* @param {string} [dir] specs directory, defaults to `<cwd>/specs`
|
|
194
266
|
* @param {{ out?: string, fixtures?: Record<string, *>, user?: * }} [opts]
|
|
@@ -210,6 +282,27 @@ export async function traceApp(dir, opts = {}) {
|
|
|
210
282
|
const ddl = await tableDDL(Object.values(tables));
|
|
211
283
|
const { betterSqlite } = await import('../server/db.js');
|
|
212
284
|
const { sql, eq } = await import('drizzle-orm');
|
|
285
|
+
const { compile } = require('@danielx/civet');
|
|
286
|
+
const ctxc = { scratch, appRoot, compileCivet: (src) => compile(src, { sync: true, js: true }) };
|
|
287
|
+
const auto = serviceAutoFixtures(specs);
|
|
288
|
+
|
|
289
|
+
const runL3 = async (address, index, example, invoke) => {
|
|
290
|
+
const events = [];
|
|
291
|
+
const calls = [];
|
|
292
|
+
const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
|
|
293
|
+
try {
|
|
294
|
+
const db = await betterSqlite(':memory:');
|
|
295
|
+
for (const stmt of ddl) await db.run(sql.raw(stmt));
|
|
296
|
+
const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
|
|
297
|
+
record.result = await invoke({ input: record.input, container, user, event: null });
|
|
298
|
+
checkExpect(record);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
record.pass = false;
|
|
301
|
+
record.error = e?.body?.message ?? e?.message ?? String(e);
|
|
302
|
+
record.status = e?.status;
|
|
303
|
+
}
|
|
304
|
+
return record;
|
|
305
|
+
};
|
|
213
306
|
|
|
214
307
|
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
215
308
|
const actionsFile = join(scratch, 'lib', moduleName, 'actions.js');
|
|
@@ -217,10 +310,10 @@ export async function traceApp(dir, opts = {}) {
|
|
|
217
310
|
.filter(([, a]) => (a.examples ?? []).length > 0)
|
|
218
311
|
.map(([n]) => n)
|
|
219
312
|
.sort();
|
|
220
|
-
|
|
221
|
-
|
|
313
|
+
const mod =
|
|
314
|
+
actionNames.length > 0 && existsSync(actionsFile) ? await import(pathToFileURL(actionsFile).href) : null;
|
|
222
315
|
|
|
223
|
-
for (const name of actionNames) {
|
|
316
|
+
for (const name of mod ? actionNames : []) {
|
|
224
317
|
const actionSpec = moduleSpec.actions[name];
|
|
225
318
|
const address = `${moduleName}.Action.${name}`;
|
|
226
319
|
const target = actionEntity(moduleName, actionSpec, specs);
|
|
@@ -248,7 +341,7 @@ export async function traceApp(dir, opts = {}) {
|
|
|
248
341
|
if (ref?.endsWith('.id')) seededId = { table, value };
|
|
249
342
|
}
|
|
250
343
|
|
|
251
|
-
const container = recordingContainer(db, { fixtures: opts.fixtures, events, calls });
|
|
344
|
+
const container = recordingContainer(db, { fixtures: opts.fixtures, auto, events, calls });
|
|
252
345
|
record.result = await mod[name].run({ input: example.input ?? {}, container, user });
|
|
253
346
|
|
|
254
347
|
if (seededId && target) {
|
|
@@ -257,9 +350,7 @@ export async function traceApp(dir, opts = {}) {
|
|
|
257
350
|
)[0];
|
|
258
351
|
}
|
|
259
352
|
|
|
260
|
-
|
|
261
|
-
([k, want]) => looseEq(record.row?.[k], want) || looseEq(record.result?.[k], want)
|
|
262
|
-
);
|
|
353
|
+
checkExpect(record);
|
|
263
354
|
} catch (e) {
|
|
264
355
|
record.pass = false;
|
|
265
356
|
record.error = e?.body?.message ?? e?.message ?? String(e);
|
|
@@ -267,6 +358,103 @@ export async function traceApp(dir, opts = {}) {
|
|
|
267
358
|
}
|
|
268
359
|
}
|
|
269
360
|
}
|
|
361
|
+
|
|
362
|
+
// Jobs — run the generated (or custom) `run` inline (K-25)
|
|
363
|
+
const jobsFile = join(scratch, 'lib', moduleName, 'jobs.js');
|
|
364
|
+
const jobNames = Object.entries(moduleSpec.jobs ?? {})
|
|
365
|
+
.filter(([, j]) => (j.examples ?? []).length > 0)
|
|
366
|
+
.map(([n]) => n)
|
|
367
|
+
.sort();
|
|
368
|
+
if (jobNames.length > 0 && existsSync(jobsFile)) {
|
|
369
|
+
const jobsMod = await import(pathToFileURL(jobsFile).href);
|
|
370
|
+
for (const name of jobNames) {
|
|
371
|
+
for (const [index, example] of moduleSpec.jobs[name].examples.entries()) {
|
|
372
|
+
cases.push(
|
|
373
|
+
await runL3(`${moduleName}.Job.${name}`, index, example, (ctx) => jobsMod[name].run(ctx))
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Functions — custom bodies with contract examples
|
|
380
|
+
for (const name of Object.keys(moduleSpec.functions ?? {}).sort()) {
|
|
381
|
+
const fnSpec = moduleSpec.functions[name];
|
|
382
|
+
if ((fnSpec.examples ?? []).length === 0) continue;
|
|
383
|
+
const target = ensureCustom(`${moduleName}/functions/${name}.c`, ctxc);
|
|
384
|
+
const body = (await import(pathToFileURL(target).href)).default;
|
|
385
|
+
for (const [index, example] of fnSpec.examples.entries()) {
|
|
386
|
+
cases.push(await runL3(`${moduleName}.Function.${name}`, index, example, (ctx) => body(ctx)));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Endpoints — body-level trace with the declared IO contract enforced
|
|
391
|
+
for (const name of Object.keys(moduleSpec.endpoints ?? {}).sort()) {
|
|
392
|
+
const ep = moduleSpec.endpoints[name];
|
|
393
|
+
if ((ep.examples ?? []).length === 0) continue;
|
|
394
|
+
const target = ensureCustom(`${moduleName}/endpoints/${name}.c`, ctxc);
|
|
395
|
+
const body = (await import(pathToFileURL(target).href)).default;
|
|
396
|
+
for (const [index, example] of ep.examples.entries()) {
|
|
397
|
+
const record = await runL3(`${moduleName}.Endpoint.${name}`, index, example, async (ctx) => {
|
|
398
|
+
let result = body(ctx);
|
|
399
|
+
if (ep.stream) {
|
|
400
|
+
if (!result?.[Symbol.asyncIterator]) result = await result;
|
|
401
|
+
const frames = [];
|
|
402
|
+
for await (const frame of result) {
|
|
403
|
+
const issues = shapeIssues(ep.stream.frame ?? {}, frame, 'frame');
|
|
404
|
+
if (issues.length > 0) throw new Error(issues.join('; '));
|
|
405
|
+
frames.push(frame);
|
|
406
|
+
}
|
|
407
|
+
return frames;
|
|
408
|
+
}
|
|
409
|
+
result = await result;
|
|
410
|
+
if (ep.output) {
|
|
411
|
+
const issues = shapeIssues(ep.output, result, 'output');
|
|
412
|
+
if (issues.length > 0) throw new Error(`output contract: ${issues.join('; ')}`);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
});
|
|
416
|
+
if (ep.stream && Array.isArray(record.result)) {
|
|
417
|
+
record.frames = record.result;
|
|
418
|
+
checkExpect(record);
|
|
419
|
+
}
|
|
420
|
+
cases.push(record);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Room workers — script examples driven headless against the class
|
|
425
|
+
for (const name of Object.keys(moduleSpec.workers ?? {}).sort()) {
|
|
426
|
+
const w = moduleSpec.workers[name];
|
|
427
|
+
if (w?.room !== true || (w.examples ?? []).length === 0) continue;
|
|
428
|
+
const rel = w.source.startsWith('src/') ? w.source.slice(4) : w.source;
|
|
429
|
+
const target = ensureCustom(rel, ctxc);
|
|
430
|
+
const RoomClass = (await import(pathToFileURL(target).href)).default;
|
|
431
|
+
for (const [index, example] of w.examples.entries()) {
|
|
432
|
+
const record = {
|
|
433
|
+
address: `${moduleName}.Worker.${name}`,
|
|
434
|
+
index,
|
|
435
|
+
script: example.script ?? [],
|
|
436
|
+
expect: example.expect ?? {},
|
|
437
|
+
broadcasts: []
|
|
438
|
+
};
|
|
439
|
+
cases.push(record);
|
|
440
|
+
try {
|
|
441
|
+
const instance = new RoomClass({}, {});
|
|
442
|
+
instance.tickMs = 0;
|
|
443
|
+
instance.broadcast = (message) => {
|
|
444
|
+
record.broadcasts.push(typeof message === 'string' ? JSON.parse(message) : message);
|
|
445
|
+
return 0;
|
|
446
|
+
};
|
|
447
|
+
for (const step of example.script ?? []) {
|
|
448
|
+
await instance.onMessage(JSON.stringify({ type: step.send, ...(step.with ?? {}) }), null);
|
|
449
|
+
}
|
|
450
|
+
record.state = Object.fromEntries(Object.keys(w.state ?? {}).map((f) => [f, instance[f]]));
|
|
451
|
+
checkExpect(record);
|
|
452
|
+
} catch (e) {
|
|
453
|
+
record.pass = false;
|
|
454
|
+
record.error = e?.message ?? String(e);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
270
458
|
}
|
|
271
459
|
} finally {
|
|
272
460
|
rmSync(scratch, { recursive: true, force: true });
|