@human-synthesis/norns 0.0.16 → 0.1.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/bin/norns.js +146 -6
- package/package.json +9 -3
- package/src/auto-import.js +7 -3
- package/src/config.js +17 -3
- package/src/kernel/absorb.js +279 -0
- package/src/kernel/address.js +224 -0
- package/src/kernel/adopt.js +157 -0
- package/src/kernel/emit-machines.js +87 -0
- package/src/kernel/emit-schema.js +199 -0
- package/src/kernel/emit-units.js +587 -0
- package/src/kernel/emit-wrangler.js +114 -0
- package/src/kernel/expr-compile.js +188 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/generate.js +397 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +71 -0
- package/src/kernel/meta.js +237 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +199 -0
- package/src/kernel/trace.js +277 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +72 -0
- package/src/server/boot.js +61 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +61 -0
- package/src/server/events.js +86 -0
- package/src/server/guard.js +48 -0
- package/src/server/handle/auth.js +54 -0
- package/src/server/index.js +12 -1
- package/src/server/live.js +134 -0
- package/src/server/machine.js +35 -0
- package/src/server/page.js +7 -3
- package/src/server/room.js +162 -0
- package/src/server/storage.js +97 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace runner (K-18): execute Action `examples` against a sandboxed
|
|
3
|
+
* in-memory SQLite and report per-case pass/fail with step values.
|
|
4
|
+
*
|
|
5
|
+
* The traced artifact is the *generated* code, not a re-interpretation of
|
|
6
|
+
* the spec: the generated tree is compiled (Civet → JS) into a scratch
|
|
7
|
+
* dir with imports rewritten to resolvable paths, then imported and run.
|
|
8
|
+
*
|
|
9
|
+
* Conventions:
|
|
10
|
+
* - `$name` input values are fixture handles: a row with that id is
|
|
11
|
+
* seeded in the action's entity table; if `name` is one of the
|
|
12
|
+
* entity's states, the row starts in that state. Owner/ref fields
|
|
13
|
+
* are seeded to the trace user so `owner` policies pass.
|
|
14
|
+
* - `expect` keys are checked against the entity row after the run,
|
|
15
|
+
* falling back to the action's return value.
|
|
16
|
+
* - external calls (`container.resolve(...)`) hit `opts.fixtures` when
|
|
17
|
+
* provided, otherwise a recording stub — every call is reported.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
import { actionEntity } from './emit-units.js';
|
|
26
|
+
import { normalizeField } from './emit-schema.js';
|
|
27
|
+
import { generateApp } from './generate.js';
|
|
28
|
+
import { loadSpecs } from './validate.js';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
|
|
32
|
+
export const TRACE_USER = { id: 'trace-user', roles: ['admin'] };
|
|
33
|
+
|
|
34
|
+
/* ------------------------------------------------------------------ */
|
|
35
|
+
/* Scratch tree: compile generated Civet to importable JS */
|
|
36
|
+
/* ------------------------------------------------------------------ */
|
|
37
|
+
|
|
38
|
+
const BARE = {
|
|
39
|
+
'@human-synthesis/norns/server': new URL('../server/index.js', import.meta.url).href,
|
|
40
|
+
'@human-synthesis/norns/kernel': new URL('./index.js', import.meta.url).href
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function resolveBare(spec) {
|
|
44
|
+
if (BARE[spec]) return BARE[spec];
|
|
45
|
+
return import.meta.resolve(spec);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rewriteImports(js, { scratch, appRoot, compileCivet }) {
|
|
49
|
+
return js.replace(/(from\s+)(['"])([^'"]+)\2/g, (whole, from, q, spec) => {
|
|
50
|
+
if (spec.startsWith('.')) return `${from}${q}${spec.replace(/\.c$/, '.js')}${q}`;
|
|
51
|
+
if (spec.startsWith('$lib/')) {
|
|
52
|
+
const target = join(scratch, 'lib', spec.slice('$lib/'.length).replace(/\.c$/, '.js'));
|
|
53
|
+
return `${from}${q}${pathToFileURL(target).href}${q}`;
|
|
54
|
+
}
|
|
55
|
+
if (spec.startsWith('$custom/')) {
|
|
56
|
+
const rel = spec.slice('$custom/'.length);
|
|
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
|
+
}
|
|
73
|
+
return `${from}${q}${pathToFileURL(target).href}${q}`;
|
|
74
|
+
}
|
|
75
|
+
return `${from}${q}${resolveBare(spec)}${q}`;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Compile `lib/**` of the generated tree into `<scratch>/lib` as JS. */
|
|
80
|
+
function buildScratch(genRoot, scratch, appRoot) {
|
|
81
|
+
const { compile } = require('@danielx/civet');
|
|
82
|
+
const compileCivet = (src) => compile(src, { sync: true, js: true });
|
|
83
|
+
const libRoot = join(genRoot, 'lib');
|
|
84
|
+
if (!existsSync(libRoot)) return;
|
|
85
|
+
for (const moduleName of readdirSync(libRoot)) {
|
|
86
|
+
const dir = join(libRoot, moduleName);
|
|
87
|
+
for (const file of readdirSync(dir)) {
|
|
88
|
+
if (!file.endsWith('.c')) continue;
|
|
89
|
+
const js = rewriteImports(compileCivet(readFileSync(join(dir, file), 'utf-8')), {
|
|
90
|
+
scratch,
|
|
91
|
+
appRoot,
|
|
92
|
+
compileCivet
|
|
93
|
+
});
|
|
94
|
+
const out = join(scratch, 'lib', moduleName, file.replace(/\.c$/, '.js'));
|
|
95
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
96
|
+
writeFileSync(out, js);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ------------------------------------------------------------------ */
|
|
102
|
+
/* Sandbox database */
|
|
103
|
+
/* ------------------------------------------------------------------ */
|
|
104
|
+
|
|
105
|
+
const sqlLit = (v) =>
|
|
106
|
+
typeof v === 'number' ? String(v) : typeof v === 'boolean' ? (v ? '1' : '0') : `'${String(v).replaceAll("'", "''")}'`;
|
|
107
|
+
|
|
108
|
+
async function tableDDL(tables) {
|
|
109
|
+
const { getTableConfig } = await import('drizzle-orm/sqlite-core');
|
|
110
|
+
return tables.map((table) => {
|
|
111
|
+
const cfg = getTableConfig(table);
|
|
112
|
+
const cols = cfg.columns.map((c) => {
|
|
113
|
+
let s = `"${c.name}" ${c.getSQLType()}`;
|
|
114
|
+
if (c.primary) s += ' PRIMARY KEY';
|
|
115
|
+
else if (c.notNull) s += ' NOT NULL';
|
|
116
|
+
if (c.hasDefault && c.default !== undefined) s += ` DEFAULT ${sqlLit(c.default)}`;
|
|
117
|
+
return s;
|
|
118
|
+
});
|
|
119
|
+
return `CREATE TABLE "${cfg.name}" (${cols.join(', ')})`;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Collect every sqliteTable export across the compiled schema modules. */
|
|
124
|
+
async function loadTables(scratch, specs) {
|
|
125
|
+
const tables = {};
|
|
126
|
+
for (const moduleName of Object.keys(specs.modules)) {
|
|
127
|
+
const file = join(scratch, 'lib', moduleName, 'schema.js');
|
|
128
|
+
if (!existsSync(file)) continue;
|
|
129
|
+
const mod = await import(pathToFileURL(file).href);
|
|
130
|
+
for (const [entity] of Object.entries(specs.modules[moduleName].entities ?? {})) {
|
|
131
|
+
if (mod[entity]) tables[`${moduleName}.${entity}`] = mod[entity];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return tables;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const SEED_VALUES = {
|
|
138
|
+
text: 'trace',
|
|
139
|
+
email: 'trace@example.com',
|
|
140
|
+
url: 'https://example.com',
|
|
141
|
+
file: 'trace.bin',
|
|
142
|
+
int: 0,
|
|
143
|
+
money: 0,
|
|
144
|
+
number: 0,
|
|
145
|
+
bool: false,
|
|
146
|
+
json: {}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function seedRow(id, entitySpec) {
|
|
150
|
+
const row = { id };
|
|
151
|
+
const owner = entitySpec.owner;
|
|
152
|
+
for (const [field, def0] of Object.entries(entitySpec.fields ?? {})) {
|
|
153
|
+
const def = normalizeField(def0);
|
|
154
|
+
if (def.optional) continue;
|
|
155
|
+
if (field === owner || def.type === 'ref') row[field] = TRACE_USER.id;
|
|
156
|
+
else if (def.type === 'date' || def.type === 'datetime') row[field] = new Date(0);
|
|
157
|
+
else row[field] = def.default ?? SEED_VALUES[def.type] ?? 'trace';
|
|
158
|
+
}
|
|
159
|
+
const states = Object.keys(entitySpec.status ?? {});
|
|
160
|
+
const name = id.startsWith('$') ? id.slice(1) : null;
|
|
161
|
+
if (name && states.includes(name)) row.status = name;
|
|
162
|
+
return row;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/* ------------------------------------------------------------------ */
|
|
166
|
+
/* Runner */
|
|
167
|
+
/* ------------------------------------------------------------------ */
|
|
168
|
+
|
|
169
|
+
function recordingContainer(db, { fixtures = {}, events, calls }) {
|
|
170
|
+
return {
|
|
171
|
+
resolve(name) {
|
|
172
|
+
if (name === 'db') return db;
|
|
173
|
+
if (name === 'events') {
|
|
174
|
+
return {
|
|
175
|
+
emit: async (evt, payload) => {
|
|
176
|
+
events.push({ name: evt, payload });
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (name in fixtures) return fixtures[name];
|
|
181
|
+
return async (...args) => {
|
|
182
|
+
calls.push({ name, args });
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const looseEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Run every Action example in the app's specs.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} [dir] specs directory, defaults to `<cwd>/specs`
|
|
194
|
+
* @param {{ out?: string, fixtures?: Record<string, *>, user?: * }} [opts]
|
|
195
|
+
* @returns {Promise<{ version: string, pass: number, fail: number, cases: * }>}
|
|
196
|
+
*/
|
|
197
|
+
export async function traceApp(dir, opts = {}) {
|
|
198
|
+
const specs = loadSpecs(dir);
|
|
199
|
+
const appRoot = dirname(specs.dir);
|
|
200
|
+
const genRoot = opts.out ?? join(appRoot, '.norns', 'generated');
|
|
201
|
+
generateApp(dir, { out: opts.out });
|
|
202
|
+
|
|
203
|
+
const scratch = join(appRoot, '.norns', 'cache', 'trace', `${specs.version.slice(0, 8)}-${Date.now()}`);
|
|
204
|
+
const user = opts.user ?? TRACE_USER;
|
|
205
|
+
const cases = [];
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
buildScratch(genRoot, scratch, appRoot);
|
|
209
|
+
const tables = await loadTables(scratch, specs);
|
|
210
|
+
const ddl = await tableDDL(Object.values(tables));
|
|
211
|
+
const { betterSqlite } = await import('../server/db.js');
|
|
212
|
+
const { sql, eq } = await import('drizzle-orm');
|
|
213
|
+
|
|
214
|
+
for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
|
|
215
|
+
const actionsFile = join(scratch, 'lib', moduleName, 'actions.js');
|
|
216
|
+
const actionNames = Object.entries(moduleSpec.actions ?? {})
|
|
217
|
+
.filter(([, a]) => (a.examples ?? []).length > 0)
|
|
218
|
+
.map(([n]) => n)
|
|
219
|
+
.sort();
|
|
220
|
+
if (actionNames.length === 0 || !existsSync(actionsFile)) continue;
|
|
221
|
+
const mod = await import(pathToFileURL(actionsFile).href);
|
|
222
|
+
|
|
223
|
+
for (const name of actionNames) {
|
|
224
|
+
const actionSpec = moduleSpec.actions[name];
|
|
225
|
+
const address = `${moduleName}.Action.${name}`;
|
|
226
|
+
const target = actionEntity(moduleName, actionSpec, specs);
|
|
227
|
+
|
|
228
|
+
for (const [index, example] of (actionSpec.examples ?? []).entries()) {
|
|
229
|
+
const events = [];
|
|
230
|
+
const calls = [];
|
|
231
|
+
const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
|
|
232
|
+
cases.push(record);
|
|
233
|
+
try {
|
|
234
|
+
const db = await betterSqlite(':memory:');
|
|
235
|
+
for (const stmt of ddl) await db.run(sql.raw(stmt));
|
|
236
|
+
|
|
237
|
+
// seed every `$handle` input value into its entity table
|
|
238
|
+
let seededId = null;
|
|
239
|
+
for (const [key, value] of Object.entries(example.input ?? {})) {
|
|
240
|
+
if (typeof value !== 'string' || !value.startsWith('$')) continue;
|
|
241
|
+
const ref = actionSpec.input?.[key];
|
|
242
|
+
const entity = typeof ref === 'string' ? ref.replace(/\?$/, '').split('.')[0] : target?.entity;
|
|
243
|
+
const entityModule = target && target.entity === entity ? target.module : moduleName;
|
|
244
|
+
const table = tables[`${entityModule}.${entity}`];
|
|
245
|
+
const entitySpec = specs.modules[entityModule]?.entities?.[entity];
|
|
246
|
+
if (!table || !entitySpec) continue;
|
|
247
|
+
await db.insert(table).values(seedRow(value, entitySpec));
|
|
248
|
+
if (ref?.endsWith('.id')) seededId = { table, value };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const container = recordingContainer(db, { fixtures: opts.fixtures, events, calls });
|
|
252
|
+
record.result = await mod[name].run({ input: example.input ?? {}, container, user });
|
|
253
|
+
|
|
254
|
+
if (seededId && target) {
|
|
255
|
+
record.row = (
|
|
256
|
+
await db.select().from(seededId.table).where(eq(seededId.table.id, seededId.value)).limit(1)
|
|
257
|
+
)[0];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
record.pass = Object.entries(record.expect).every(
|
|
261
|
+
([k, want]) => looseEq(record.row?.[k], want) || looseEq(record.result?.[k], want)
|
|
262
|
+
);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
record.pass = false;
|
|
265
|
+
record.error = e?.body?.message ?? e?.message ?? String(e);
|
|
266
|
+
record.status = e?.status;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const pass = cases.filter((c) => c.pass).length;
|
|
276
|
+
return { version: specs.version, pass, fail: cases.length - pass, cases };
|
|
277
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { APP_SPEC, readSpecs } from '@human-synthesis/norns-tron/spec';
|
|
5
|
+
|
|
6
|
+
import { indexUnits, listUnits } from './address.js';
|
|
7
|
+
import { APP_SCHEMA, MODULE_SCHEMA, UNIT_SCHEMAS, schemaIssues } from './meta.js';
|
|
8
|
+
import { refineSpecs } from './refine.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ level: 'error' | 'warning', address: string, message: string }} Issue
|
|
12
|
+
* @typedef {{
|
|
13
|
+
* dir: string,
|
|
14
|
+
* app: *,
|
|
15
|
+
* modules: Record<string, *>,
|
|
16
|
+
* hashes: Record<string, string>,
|
|
17
|
+
* version: string
|
|
18
|
+
* }} LoadedSpecs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Load a `specs/` directory. Throws when the directory is missing —
|
|
23
|
+
* everything past this point can assume specs exist.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} [dir] defaults to `<cwd>/specs`
|
|
26
|
+
* @returns {LoadedSpecs}
|
|
27
|
+
*/
|
|
28
|
+
export function loadSpecs(dir = resolve(process.cwd(), 'specs')) {
|
|
29
|
+
const abs = resolve(dir);
|
|
30
|
+
if (!existsSync(abs)) {
|
|
31
|
+
throw new Error(`norns: no specs directory at ${abs} (expected specs/*.tron)`);
|
|
32
|
+
}
|
|
33
|
+
return { dir: abs, ...readSpecs(abs) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validate a specs directory and return every issue found: structural
|
|
38
|
+
* checks, per-kind meta-schemas, uid uniqueness. Cross-unit refinements
|
|
39
|
+
* (K-06) register here as they land.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} [dir]
|
|
42
|
+
* @returns {{ ok: boolean, version: string, modules: string[], issues: Issue[] }}
|
|
43
|
+
*/
|
|
44
|
+
export function validateSpecs(dir) {
|
|
45
|
+
const specs = loadSpecs(dir);
|
|
46
|
+
/** @type {Issue[]} */
|
|
47
|
+
const issues = [];
|
|
48
|
+
|
|
49
|
+
if (specs.app === null) {
|
|
50
|
+
issues.push({
|
|
51
|
+
level: 'error',
|
|
52
|
+
address: APP_SPEC,
|
|
53
|
+
message: `missing ${APP_SPEC}.tron — every app needs an app spec`
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const [name, value] of Object.entries(specs.modules)) {
|
|
58
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
59
|
+
issues.push({
|
|
60
|
+
level: 'error',
|
|
61
|
+
address: name,
|
|
62
|
+
message: `${name}.tron must be an object, got ${Array.isArray(value) ? 'array' : typeof value}`
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (value.module !== name) {
|
|
67
|
+
issues.push({
|
|
68
|
+
level: 'error',
|
|
69
|
+
address: name,
|
|
70
|
+
message: `module field ${JSON.stringify(value.module)} does not match file name "${name}"`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
issues.push(...schemaIssues(MODULE_SCHEMA, value, name));
|
|
74
|
+
for (const unit of listUnits(name, value)) {
|
|
75
|
+
issues.push(...schemaIssues(UNIT_SCHEMAS[unit.kind], unit.value, unit.address));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (specs.app !== null) {
|
|
80
|
+
issues.push(...schemaIssues(APP_SCHEMA, specs.app, APP_SPEC));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
issues.push(...indexUnits(specs.modules).issues);
|
|
84
|
+
issues.push(...refineSpecs(specs));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: !issues.some((i) => i.level === 'error'),
|
|
88
|
+
version: specs.version,
|
|
89
|
+
modules: Object.keys(specs.modules),
|
|
90
|
+
issues
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser side of the live-query bridge (R-11). Generated pages with
|
|
3
|
+
* `live: true` queries call `liveQueries` from an `$effect`; the server
|
|
4
|
+
* counterpart streams refresh signals from `/_norns/live` (see
|
|
5
|
+
* `src/server/live.js`).
|
|
6
|
+
*
|
|
7
|
+
* No SvelteKit import here — the caller passes `invalidate` from
|
|
8
|
+
* `$app/navigation` so this module stays framework-neutral and testable.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** SvelteKit `depends`/`invalidate` key for a query address. */
|
|
12
|
+
export const dependsKey = (address) => `norns:${address}`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe to refresh signals and invalidate the given query addresses
|
|
16
|
+
* when they change. Returns a cleanup function (safe for `$effect`).
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} addresses query addresses this page depends on
|
|
19
|
+
* @param {(key: string) => *} invalidate `invalidate` from `$app/navigation`
|
|
20
|
+
* @param {{ path?: string, EventSource?: typeof EventSource }} [opts]
|
|
21
|
+
* @returns {() => void}
|
|
22
|
+
*/
|
|
23
|
+
export function liveQueries(addresses, invalidate, opts = {}) {
|
|
24
|
+
const ES = opts.EventSource ?? globalThis.EventSource;
|
|
25
|
+
if (typeof ES !== 'function') return () => {};
|
|
26
|
+
|
|
27
|
+
const wanted = new Set(addresses);
|
|
28
|
+
const source = new ES(opts.path ?? '/_norns/live');
|
|
29
|
+
source.onmessage = (e) => {
|
|
30
|
+
let payload;
|
|
31
|
+
try {
|
|
32
|
+
payload = JSON.parse(e.data);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
for (const address of Array.isArray(payload?.queries) ? payload.queries : []) {
|
|
37
|
+
if (wanted.has(address)) invalidate(dependsKey(address));
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return () => source.close();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Call a `transport: remote` action endpoint
|
|
45
|
+
* (`module.Action.name` → POST `/api/<module>/<name>`).
|
|
46
|
+
*
|
|
47
|
+
* @param {string} address action address
|
|
48
|
+
* @param {*} [input]
|
|
49
|
+
* @param {{ fetch?: typeof fetch }} [opts]
|
|
50
|
+
*/
|
|
51
|
+
export async function remoteCall(address, input, opts = {}) {
|
|
52
|
+
const parts = String(address).split('.');
|
|
53
|
+
if (parts.length !== 3 || parts[1] !== 'Action') {
|
|
54
|
+
throw new Error(`remoteCall: "${address}" is not an Action address`);
|
|
55
|
+
}
|
|
56
|
+
const f = opts.fetch ?? globalThis.fetch;
|
|
57
|
+
const res = await f(`/api/${parts[0]}/${parts[2]}`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json' },
|
|
60
|
+
body: JSON.stringify(input ?? {})
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
let detail = '';
|
|
64
|
+
try {
|
|
65
|
+
detail = (await res.json())?.message ?? '';
|
|
66
|
+
} catch {
|
|
67
|
+
/* body may not be JSON */
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`remoteCall ${address}: ${res.status}${detail ? ` — ${detail}` : ''}`);
|
|
70
|
+
}
|
|
71
|
+
return res.json();
|
|
72
|
+
}
|
package/src/server/boot.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { sequence } from '@sveltejs/kit/hooks';
|
|
2
2
|
import { Container } from './container.js';
|
|
3
3
|
import { contextHandle } from './handle/context.js';
|
|
4
|
+
import { authHandle } from './handle/auth.js';
|
|
4
5
|
import { errorHandle } from './handle/error.js';
|
|
5
6
|
import { setSerializer } from './route.js';
|
|
7
|
+
import { createEvents, registerTriggers } from './events.js';
|
|
8
|
+
import { createLive } from './live.js';
|
|
9
|
+
import { scheduledHandler, startCronShim } from './cron.js';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Create a fresh root container with no features registered. Useful for tests
|
|
@@ -34,16 +38,42 @@ export function createApp() {
|
|
|
34
38
|
* Each `module.c` must default-export a function `(app) -> ...` that calls
|
|
35
39
|
* `app.bind(...)` / `app.single(...)` / `app.migrations(...)`.
|
|
36
40
|
*
|
|
41
|
+
* Spec-first extras:
|
|
42
|
+
* - `triggers` — generated trigger tables (`lib/<m>/triggers.c` exports),
|
|
43
|
+
* nested arrays are flattened. Event triggers are wired into the bus;
|
|
44
|
+
* cron ones are served by the returned `scheduled` handler.
|
|
45
|
+
* - `queue` — Cloudflare Queues producer binding; `emit` enqueues instead of
|
|
46
|
+
* dispatching in-process.
|
|
47
|
+
* - `cronShim: true` — local minute-timer for cron triggers (`norns dev`).
|
|
48
|
+
* - `auth` — a better-auth-shaped instance (`.handler(request)` +
|
|
49
|
+
* `.api.getSession({ headers })`); requests under `authBasePath`
|
|
50
|
+
* (default `/api/auth`) are handed to it, every other request gets
|
|
51
|
+
* `event.locals.user` / `event.locals.session` and scope bindings.
|
|
52
|
+
* - `room` — the `ROOM` Durable Object namespace binding (Workers prod);
|
|
53
|
+
* live-query publishes and the `/_norns/live` stream go through it. Omit
|
|
54
|
+
* in dev: signals ride the in-process bus.
|
|
55
|
+
* - an `events` singleton is bound automatically unless a feature bound one,
|
|
56
|
+
* and a `live` bridge singleton likewise (actions with `refresh` lists
|
|
57
|
+
* publish through it).
|
|
58
|
+
*
|
|
37
59
|
* @param {{
|
|
38
60
|
* features?: Record<string, FeatureModule>,
|
|
39
61
|
* extraHandle?: import('@sveltejs/kit').Handle | import('@sveltejs/kit').Handle[],
|
|
40
62
|
* handleError?: import('@sveltejs/kit').HandleServerError,
|
|
41
|
-
* serializer?: import('./route.js').Serializer | null
|
|
63
|
+
* serializer?: import('./route.js').Serializer | null,
|
|
64
|
+
* triggers?: *[],
|
|
65
|
+
* queue?: { send(body: *): Promise<void> | void },
|
|
66
|
+
* cronShim?: boolean,
|
|
67
|
+
* room?: *,
|
|
68
|
+
* auth?: { handler(request: Request): Promise<Response> | Response, api: { getSession(input: *): Promise<*> } },
|
|
69
|
+
* authBasePath?: string
|
|
42
70
|
* }} [opts]
|
|
43
71
|
* @returns {Promise<{
|
|
44
72
|
* container: Container,
|
|
45
73
|
* handle: import('@sveltejs/kit').Handle,
|
|
46
|
-
* handleError: import('@sveltejs/kit').HandleServerError
|
|
74
|
+
* handleError: import('@sveltejs/kit').HandleServerError,
|
|
75
|
+
* scheduled: (event: *) => Promise<void>,
|
|
76
|
+
* stopCronShim: () => void
|
|
47
77
|
* }>}
|
|
48
78
|
*/
|
|
49
79
|
export async function boot(opts = {}) {
|
|
@@ -73,8 +103,35 @@ export async function boot(opts = {}) {
|
|
|
73
103
|
: [opts.extraHandle]
|
|
74
104
|
: [];
|
|
75
105
|
|
|
76
|
-
|
|
106
|
+
if (!container.has('events')) {
|
|
107
|
+
container.single('events', () => createEvents(opts.queue ? { queue: opts.queue } : {}));
|
|
108
|
+
}
|
|
109
|
+
if (!container.has('live')) {
|
|
110
|
+
container.single('live', () =>
|
|
111
|
+
createLive({ events: container.resolve('events'), room: opts.room })
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const triggers = (opts.triggers ?? []).flat(Infinity);
|
|
116
|
+
if (triggers.length > 0) {
|
|
117
|
+
registerTriggers(container, triggers.filter((t) => !t.schedule));
|
|
118
|
+
}
|
|
119
|
+
const stopCronShim = opts.cronShim ? startCronShim(container, triggers) : () => {};
|
|
120
|
+
|
|
121
|
+
// contextHandle must come first so authHandle can bind user/session into
|
|
122
|
+
// the per-request scope it creates.
|
|
123
|
+
const handle = sequence(
|
|
124
|
+
contextHandle(container),
|
|
125
|
+
...(opts.auth ? [authHandle(opts.auth, { basePath: opts.authBasePath })] : []),
|
|
126
|
+
...extras
|
|
127
|
+
);
|
|
77
128
|
const handleError = opts.handleError ?? errorHandle();
|
|
78
129
|
|
|
79
|
-
return {
|
|
130
|
+
return {
|
|
131
|
+
container,
|
|
132
|
+
handle,
|
|
133
|
+
handleError,
|
|
134
|
+
scheduled: scheduledHandler(container, triggers),
|
|
135
|
+
stopCronShim
|
|
136
|
+
};
|
|
80
137
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron triggers: Workers `scheduled` adapter + local timer shim for
|
|
3
|
+
* `norns dev`. Triggers are the generated tables (`[{ on, action, schedule? }]`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Match a 5-field cron expression (min hour dom mon dow) against a Date.
|
|
8
|
+
* Supports `*`, `n`, `a-b`, `*/n`, `a-b/n`, and comma lists; dow 0/7 = Sunday.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} expr
|
|
11
|
+
* @param {Date} date
|
|
12
|
+
*/
|
|
13
|
+
export function cronMatches(expr, date) {
|
|
14
|
+
const fields = expr.trim().split(/\s+/);
|
|
15
|
+
if (fields.length !== 5) return false;
|
|
16
|
+
const values = [
|
|
17
|
+
date.getUTCMinutes(),
|
|
18
|
+
date.getUTCHours(),
|
|
19
|
+
date.getUTCDate(),
|
|
20
|
+
date.getUTCMonth() + 1,
|
|
21
|
+
date.getUTCDay()
|
|
22
|
+
];
|
|
23
|
+
return fields.every((field, i) => fieldMatches(field, values[i], i === 4));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fieldMatches(field, value, isDow) {
|
|
27
|
+
return field.split(',').some((part) => {
|
|
28
|
+
const [range, stepStr] = part.split('/');
|
|
29
|
+
const step = stepStr === undefined ? 1 : Number(stepStr);
|
|
30
|
+
if (!Number.isInteger(step) || step < 1) return false;
|
|
31
|
+
let lo;
|
|
32
|
+
let hi;
|
|
33
|
+
if (range === '*') {
|
|
34
|
+
lo = 0;
|
|
35
|
+
hi = Infinity;
|
|
36
|
+
} else if (range.includes('-')) {
|
|
37
|
+
[lo, hi] = range.split('-').map(Number);
|
|
38
|
+
} else {
|
|
39
|
+
lo = hi = Number(range);
|
|
40
|
+
if (isDow && lo === 7) lo = hi = 0;
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isInteger(lo) || (hi !== Infinity && !Number.isInteger(hi))) return false;
|
|
43
|
+
return value >= lo && value <= hi && (value - (lo === 0 || range === '*' ? 0 : lo)) % step === 0;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {{ schedule?: string }[]} triggers */
|
|
48
|
+
export function cronTriggers(triggers) {
|
|
49
|
+
return triggers.filter((t) => typeof t.schedule === 'string');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runTrigger(container, trigger) {
|
|
53
|
+
await trigger.action.run({ input: {}, container });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cloudflare Workers `scheduled` handler. Runs every cron trigger whose
|
|
58
|
+
* schedule equals `event.cron` (how Workers routes multi-cron Workers), or —
|
|
59
|
+
* when `event.cron` is absent — whose schedule matches the event time.
|
|
60
|
+
*
|
|
61
|
+
* @param {*} container
|
|
62
|
+
* @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
|
|
63
|
+
*/
|
|
64
|
+
export function scheduledHandler(container, triggers) {
|
|
65
|
+
const crons = cronTriggers(triggers);
|
|
66
|
+
return async (event) => {
|
|
67
|
+
const due = event?.cron
|
|
68
|
+
? crons.filter((t) => t.schedule === event.cron)
|
|
69
|
+
: crons.filter((t) => cronMatches(t.schedule, new Date(event?.scheduledTime ?? Date.now())));
|
|
70
|
+
for (const t of due) await runTrigger(container, t);
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Local dev shim: checks once per minute (aligned to the minute) and runs
|
|
76
|
+
* matching cron triggers. Returns a stop function.
|
|
77
|
+
*
|
|
78
|
+
* @param {*} container
|
|
79
|
+
* @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
|
|
80
|
+
* @param {{ onError?: (err: *, trigger: *) => void }} [opts]
|
|
81
|
+
*/
|
|
82
|
+
export function startCronShim(container, triggers, { onError } = {}) {
|
|
83
|
+
const crons = cronTriggers(triggers);
|
|
84
|
+
if (crons.length === 0) return () => {};
|
|
85
|
+
let timer;
|
|
86
|
+
const tick = async () => {
|
|
87
|
+
const now = new Date();
|
|
88
|
+
for (const t of crons.filter((t) => cronMatches(t.schedule, now))) {
|
|
89
|
+
try {
|
|
90
|
+
await runTrigger(container, t);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
onError?.(err, t);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const arm = () => {
|
|
97
|
+
const msToMinute = 60_000 - (Date.now() % 60_000);
|
|
98
|
+
timer = setTimeout(async () => {
|
|
99
|
+
await tick();
|
|
100
|
+
arm();
|
|
101
|
+
}, msToMinute);
|
|
102
|
+
};
|
|
103
|
+
arm();
|
|
104
|
+
return () => clearTimeout(timer);
|
|
105
|
+
}
|