@human-synthesis/norns 0.0.16 → 0.2.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 +228 -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 +855 -0
- package/src/kernel/emit-wrangler.js +134 -0
- package/src/kernel/expr-compile.js +191 -0
- package/src/kernel/expr.js +290 -0
- package/src/kernel/flow.js +444 -0
- package/src/kernel/generate.js +840 -0
- package/src/kernel/graph.js +222 -0
- package/src/kernel/index.js +78 -0
- package/src/kernel/meta.js +381 -0
- package/src/kernel/migrate.js +134 -0
- package/src/kernel/refine.js +277 -0
- package/src/kernel/trace.js +465 -0
- package/src/kernel/validate.js +92 -0
- package/src/live-client.js +216 -0
- package/src/server/boot.js +83 -4
- package/src/server/cron.js +105 -0
- package/src/server/db.js +66 -1
- package/src/server/endpoint.js +142 -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 +15 -1
- package/src/server/job.js +102 -0
- 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 +179 -0
- package/src/server/service.js +188 -0
- package/src/server/storage.js +97 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec edge graph (K-07) — derived, never stored (PLAN §4.3).
|
|
3
|
+
*
|
|
4
|
+
* Nodes are unit addresses plus `event:<name>` nodes for the emit/on
|
|
5
|
+
* namespace. Edges are derived per module, so incremental invalidation is
|
|
6
|
+
* a per-module cache keyed by the module's spec hash. Code edges (custom
|
|
7
|
+
* body imports via civet-bridge) are appended by the MCP indexer later —
|
|
8
|
+
* this module covers the spec-derived edges.
|
|
9
|
+
*
|
|
10
|
+
* Edge types: reads · writes · binds · guards · emits · on · calls · refreshes
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { formatAddress, isAddress, listUnits } from './address.js';
|
|
14
|
+
|
|
15
|
+
/** @typedef {{ from: string, to: string, type: string }} Edge */
|
|
16
|
+
|
|
17
|
+
const eventNode = (name) => `event:${name}`;
|
|
18
|
+
|
|
19
|
+
/** Resolve a written ref to a full address: bare names bind in-module. */
|
|
20
|
+
function refAddress(moduleName, kind, ref) {
|
|
21
|
+
if (typeof ref !== 'string' || ref === '') return null;
|
|
22
|
+
if (isAddress(ref)) return ref;
|
|
23
|
+
return formatAddress({ module: moduleName, kind, name: ref.split('.')[0] });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Derive all edges that originate from one module's units.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} moduleName
|
|
30
|
+
* @param {*} moduleSpec
|
|
31
|
+
* @returns {Edge[]}
|
|
32
|
+
*/
|
|
33
|
+
export function moduleEdges(moduleName, moduleSpec) {
|
|
34
|
+
/** @type {Edge[]} */
|
|
35
|
+
const edges = [];
|
|
36
|
+
const add = (from, to, type) => {
|
|
37
|
+
if (to) edges.push({ from, to, type });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const unit of listUnits(moduleName, moduleSpec)) {
|
|
41
|
+
const { address: at, value, kind, name } = unit;
|
|
42
|
+
switch (kind) {
|
|
43
|
+
case 'Query':
|
|
44
|
+
add(at, refAddress(moduleName, 'Entity', value?.from), 'reads');
|
|
45
|
+
break;
|
|
46
|
+
case 'Action': {
|
|
47
|
+
for (const step of Array.isArray(value?.steps) ? value.steps : []) {
|
|
48
|
+
if (step === null || typeof step !== 'object') continue;
|
|
49
|
+
for (const [stepKind, stepValue] of Object.entries(step)) {
|
|
50
|
+
if (stepKind === 'emit' && typeof stepValue === 'string') {
|
|
51
|
+
add(at, eventNode(stepValue), 'emits');
|
|
52
|
+
} else if (typeof stepValue?.entity === 'string') {
|
|
53
|
+
add(at, refAddress(moduleName, 'Entity', stepValue.entity), 'writes');
|
|
54
|
+
} else if (stepKind === 'call' && typeof stepValue === 'string') {
|
|
55
|
+
add(at, refAddress(moduleName, 'Function', stepValue), 'calls');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const ev of Array.isArray(value?.emits) ? value.emits : []) {
|
|
60
|
+
if (typeof ev === 'string') add(at, eventNode(ev), 'emits');
|
|
61
|
+
}
|
|
62
|
+
for (const q of Array.isArray(value?.refresh) ? value.refresh : []) {
|
|
63
|
+
add(at, refAddress(moduleName, 'Query', q), 'refreshes');
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case 'Page': {
|
|
68
|
+
for (const comp of Array.isArray(value?.components) ? value.components : []) {
|
|
69
|
+
if (comp === null || typeof comp !== 'object') continue;
|
|
70
|
+
for (const bound of Object.values(comp)) {
|
|
71
|
+
if (typeof bound === 'string' && isAddress(bound)) add(at, bound, 'binds');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
case 'Policy': {
|
|
77
|
+
add(at, formatAddress({ module: moduleName, kind: 'Entity', name }), 'guards');
|
|
78
|
+
for (const actionName of Object.keys(value?.run ?? {})) {
|
|
79
|
+
add(at, refAddress(moduleName, 'Action', actionName), 'guards');
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case 'Trigger': {
|
|
84
|
+
add(eventNode(name), at, 'on');
|
|
85
|
+
const action = typeof value === 'string' ? value : value?.action;
|
|
86
|
+
add(at, refAddress(moduleName, 'Action', action), 'calls');
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'Component': {
|
|
90
|
+
for (const target of Object.values(value?.events ?? {})) {
|
|
91
|
+
add(at, refAddress(moduleName, 'Action', target), 'calls');
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return edges;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @typedef {{
|
|
102
|
+
* edges: Edge[],
|
|
103
|
+
* outbound: Map<string, Edge[]>,
|
|
104
|
+
* inbound: Map<string, Edge[]>
|
|
105
|
+
* }} Graph
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/** @param {Edge[]} edges @returns {Graph} */
|
|
109
|
+
function assemble(edges) {
|
|
110
|
+
const outbound = new Map();
|
|
111
|
+
const inbound = new Map();
|
|
112
|
+
for (const edge of edges) {
|
|
113
|
+
if (!outbound.has(edge.from)) outbound.set(edge.from, []);
|
|
114
|
+
outbound.get(edge.from).push(edge);
|
|
115
|
+
if (!inbound.has(edge.to)) inbound.set(edge.to, []);
|
|
116
|
+
inbound.get(edge.to).push(edge);
|
|
117
|
+
}
|
|
118
|
+
return { edges, outbound, inbound };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Build the full graph from scratch.
|
|
123
|
+
*
|
|
124
|
+
* @param {Record<string, *>} modules
|
|
125
|
+
* @returns {Graph}
|
|
126
|
+
*/
|
|
127
|
+
export function buildGraph(modules) {
|
|
128
|
+
const edges = [];
|
|
129
|
+
for (const [name, spec] of Object.entries(modules)) edges.push(...moduleEdges(name, spec));
|
|
130
|
+
return assemble(edges);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** @returns {{ hashes: Record<string, string>, edges: Record<string, Edge[]> }} */
|
|
134
|
+
export function createGraphCache() {
|
|
135
|
+
return { hashes: {}, edges: {} };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Incrementally update the graph: only modules whose hash changed are
|
|
140
|
+
* re-derived; removed modules drop out. Mutates and returns the cache.
|
|
141
|
+
*
|
|
142
|
+
* @param {{ hashes: Record<string, string>, edges: Record<string, Edge[]> }} cache
|
|
143
|
+
* @param {Record<string, *>} modules
|
|
144
|
+
* @param {Record<string, string>} hashes per-module spec hashes (e.g. from readSpecs)
|
|
145
|
+
* @returns {{ graph: Graph, changed: string[] }}
|
|
146
|
+
*/
|
|
147
|
+
export function updateGraph(cache, modules, hashes) {
|
|
148
|
+
const changed = [];
|
|
149
|
+
for (const name of Object.keys(cache.edges)) {
|
|
150
|
+
if (!(name in modules)) {
|
|
151
|
+
delete cache.edges[name];
|
|
152
|
+
delete cache.hashes[name];
|
|
153
|
+
changed.push(name);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const [name, spec] of Object.entries(modules)) {
|
|
157
|
+
if (cache.hashes[name] !== hashes[name]) {
|
|
158
|
+
cache.edges[name] = moduleEdges(name, spec);
|
|
159
|
+
cache.hashes[name] = hashes[name];
|
|
160
|
+
changed.push(name);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { graph: assemble(Object.values(cache.edges).flat()), changed };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Addresses reachable within `depth` hops of `address`, in either
|
|
168
|
+
* direction — the unit's working context.
|
|
169
|
+
*
|
|
170
|
+
* @param {Graph} graph
|
|
171
|
+
* @param {string} address
|
|
172
|
+
* @param {number} [depth]
|
|
173
|
+
* @returns {Set<string>}
|
|
174
|
+
*/
|
|
175
|
+
export function neighborhood(graph, address, depth = 1) {
|
|
176
|
+
const seen = new Set([address]);
|
|
177
|
+
let frontier = [address];
|
|
178
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
179
|
+
const next = [];
|
|
180
|
+
for (const node of frontier) {
|
|
181
|
+
for (const edge of graph.outbound.get(node) ?? []) {
|
|
182
|
+
if (!seen.has(edge.to)) {
|
|
183
|
+
seen.add(edge.to);
|
|
184
|
+
next.push(edge.to);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const edge of graph.inbound.get(node) ?? []) {
|
|
188
|
+
if (!seen.has(edge.from)) {
|
|
189
|
+
seen.add(edge.from);
|
|
190
|
+
next.push(edge.from);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
frontier = next;
|
|
195
|
+
}
|
|
196
|
+
return seen;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Everything that (transitively) depends on `address` — the units to
|
|
201
|
+
* re-check or regenerate when it changes. Follows inbound edges only;
|
|
202
|
+
* excludes the address itself.
|
|
203
|
+
*
|
|
204
|
+
* @param {Graph} graph
|
|
205
|
+
* @param {string} address
|
|
206
|
+
* @returns {Set<string>}
|
|
207
|
+
*/
|
|
208
|
+
export function impact(graph, address) {
|
|
209
|
+
const seen = new Set();
|
|
210
|
+
const stack = [address];
|
|
211
|
+
while (stack.length > 0) {
|
|
212
|
+
const node = stack.pop();
|
|
213
|
+
for (const edge of graph.inbound.get(node) ?? []) {
|
|
214
|
+
if (!seen.has(edge.from)) {
|
|
215
|
+
seen.add(edge.from);
|
|
216
|
+
stack.push(edge.from);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
seen.delete(address);
|
|
221
|
+
return seen;
|
|
222
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Norns kernel — the spec-canonical engine (`@human-synthesis/norns/kernel`).
|
|
3
|
+
*
|
|
4
|
+
* Owns the pipeline: load `specs/*.tron` → validate → generate code into
|
|
5
|
+
* `.norns/generated/`. Kept as a subpath of the norns package (not its own
|
|
6
|
+
* package) but with no imports from the rest of `src/`, so it stays
|
|
7
|
+
* extractable if it ever needs its own release cadence.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
KINDS,
|
|
12
|
+
KIND_KEYS,
|
|
13
|
+
KEY_KINDS,
|
|
14
|
+
formatAddress,
|
|
15
|
+
parseAddress,
|
|
16
|
+
isAddress,
|
|
17
|
+
newUid,
|
|
18
|
+
listUnits,
|
|
19
|
+
ensureUids,
|
|
20
|
+
indexUnits,
|
|
21
|
+
resolvePath
|
|
22
|
+
} from './address.js';
|
|
23
|
+
export { CMP_OPS, parseExpr, printExpr, isExpr } from './expr.js';
|
|
24
|
+
export { evalExpr, compileGuard, compileWhere } from './expr-compile.js';
|
|
25
|
+
export {
|
|
26
|
+
FIELD_TYPES,
|
|
27
|
+
DIALECTS,
|
|
28
|
+
UNIT_SCHEMAS,
|
|
29
|
+
MODULE_SCHEMA,
|
|
30
|
+
APP_SCHEMA,
|
|
31
|
+
schemaIssues
|
|
32
|
+
} from './meta.js';
|
|
33
|
+
export {
|
|
34
|
+
moduleEdges,
|
|
35
|
+
buildGraph,
|
|
36
|
+
createGraphCache,
|
|
37
|
+
updateGraph,
|
|
38
|
+
neighborhood,
|
|
39
|
+
impact
|
|
40
|
+
} from './graph.js';
|
|
41
|
+
export { refineSpecs } from './refine.js';
|
|
42
|
+
export {
|
|
43
|
+
CUSTOM_RATIO_THRESHOLD,
|
|
44
|
+
customUnits,
|
|
45
|
+
customRatio,
|
|
46
|
+
customBodyPath,
|
|
47
|
+
absorbUnit,
|
|
48
|
+
absorbApp
|
|
49
|
+
} from './absorb.js';
|
|
50
|
+
export { inferKind, inferCapabilities, inferAuth, adoptUnit, adoptFiles } from './adopt.js';
|
|
51
|
+
export { loadSpecs, validateSpecs } from './validate.js';
|
|
52
|
+
export { generateApp, checkGenerate, checkBindings, checkServiceSecrets, checkNetworkInBodies, checkTokenOverrides, tokenOverrides, tokensFile, layoutFile, liveRouteFile, selfCheck, GenerateError, EMITTERS } from './generate.js';
|
|
53
|
+
export { indexBody, unitFlow, buildModuleFlow, emitFlow, flowApp, flowDelta } from './flow.js';
|
|
54
|
+
export { wranglerConfig, wranglerFile } from './emit-wrangler.js';
|
|
55
|
+
export { emitModuleMachines, machinesEmitter } from './emit-machines.js';
|
|
56
|
+
export { migrateApp } from './migrate.js';
|
|
57
|
+
export { traceApp, TRACE_USER } from './trace.js';
|
|
58
|
+
export { emitModuleSchema, schemaEmitter } from './emit-schema.js';
|
|
59
|
+
export {
|
|
60
|
+
emitModulePolicies,
|
|
61
|
+
emitModuleQueries,
|
|
62
|
+
emitModuleActions,
|
|
63
|
+
emitModuleServices,
|
|
64
|
+
emitModuleJobs,
|
|
65
|
+
emitModuleTriggers,
|
|
66
|
+
emitModulePages,
|
|
67
|
+
emitModuleRemotes,
|
|
68
|
+
emitModuleEndpoints,
|
|
69
|
+
policiesEmitter,
|
|
70
|
+
queriesEmitter,
|
|
71
|
+
actionsEmitter,
|
|
72
|
+
servicesEmitter,
|
|
73
|
+
jobsEmitter,
|
|
74
|
+
triggersEmitter,
|
|
75
|
+
pagesEmitter,
|
|
76
|
+
remotesEmitter,
|
|
77
|
+
endpointsEmitter
|
|
78
|
+
} from './emit-units.js';
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valibot meta-schemas — one per resource kind. These make invalid specs
|
|
3
|
+
* unrepresentable at the shape level: unknown keys are rejected
|
|
4
|
+
* (strictObject), expressions must parse, references must look like
|
|
5
|
+
* addresses. Cross-unit refinements (refs resolve, depends DAG, closed
|
|
6
|
+
* status machines) live in refine.js (K-06), not here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as v from 'valibot';
|
|
10
|
+
|
|
11
|
+
import { isAddress } from './address.js';
|
|
12
|
+
import { isExpr } from './expr.js';
|
|
13
|
+
|
|
14
|
+
export const FIELD_TYPES = [
|
|
15
|
+
'text',
|
|
16
|
+
'number',
|
|
17
|
+
'int',
|
|
18
|
+
'money',
|
|
19
|
+
'bool',
|
|
20
|
+
'date',
|
|
21
|
+
'datetime',
|
|
22
|
+
'email',
|
|
23
|
+
'url',
|
|
24
|
+
'json',
|
|
25
|
+
'file',
|
|
26
|
+
'ref'
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const DIALECTS = ['d1', 'sqlite', 'postgres'];
|
|
30
|
+
|
|
31
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
32
|
+
|
|
33
|
+
const ident = v.pipe(v.string(), v.regex(IDENT_RE, 'must be an identifier'));
|
|
34
|
+
const uid = v.optional(
|
|
35
|
+
v.pipe(v.string(), v.regex(/^[0-9A-HJKMNP-TV-Z]{26}$/, 'must be a 26-char ULID'))
|
|
36
|
+
);
|
|
37
|
+
const expr = v.pipe(
|
|
38
|
+
v.string(),
|
|
39
|
+
v.check(isExpr, 'must be a valid expression (see the CEL-subset grammar)')
|
|
40
|
+
);
|
|
41
|
+
const address = v.pipe(
|
|
42
|
+
v.string(),
|
|
43
|
+
v.check(isAddress, 'must be a unit address (module.Kind.name)')
|
|
44
|
+
);
|
|
45
|
+
/** Full address or a bare/dotted local name like `Order` or `Order.id`. */
|
|
46
|
+
const unitRef = v.pipe(
|
|
47
|
+
v.string(),
|
|
48
|
+
v.check(
|
|
49
|
+
(s) => isAddress(s) || s.split('.').every((seg) => IDENT_RE.test(seg)),
|
|
50
|
+
'must be a unit reference'
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const fieldObject = v.pipe(
|
|
55
|
+
v.strictObject({
|
|
56
|
+
type: v.picklist(FIELD_TYPES),
|
|
57
|
+
ref: v.optional(unitRef),
|
|
58
|
+
optional: v.optional(v.boolean()),
|
|
59
|
+
unique: v.optional(v.boolean()),
|
|
60
|
+
default: v.optional(v.unknown())
|
|
61
|
+
}),
|
|
62
|
+
v.check((f) => (f.type === 'ref') === (f.ref !== undefined), 'ref fields need `ref`, others must not have it')
|
|
63
|
+
);
|
|
64
|
+
const field = v.union([v.picklist(FIELD_TYPES), fieldObject]);
|
|
65
|
+
|
|
66
|
+
const example = v.strictObject({
|
|
67
|
+
input: v.optional(v.unknown()),
|
|
68
|
+
expect: v.optional(v.unknown())
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const requiresExamplesWhenCustom = (unit) =>
|
|
72
|
+
unit.impl !== 'custom' || (Array.isArray(unit.examples) && unit.examples.length > 0);
|
|
73
|
+
const CUSTOM_NEEDS_EXAMPLES = '`impl: custom` requires at least one example';
|
|
74
|
+
|
|
75
|
+
const Entity = v.strictObject({
|
|
76
|
+
uid,
|
|
77
|
+
owner: v.optional(ident),
|
|
78
|
+
fields: v.record(ident, field),
|
|
79
|
+
status: v.optional(v.record(ident, v.array(ident)))
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const Query = v.strictObject({
|
|
83
|
+
uid,
|
|
84
|
+
from: unitRef,
|
|
85
|
+
live: v.optional(v.boolean()),
|
|
86
|
+
groupBy: v.optional(v.string()),
|
|
87
|
+
filter: v.optional(expr),
|
|
88
|
+
sort: v.optional(v.union([v.string(), v.array(v.string())])),
|
|
89
|
+
limit: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)))
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const Action = v.pipe(
|
|
93
|
+
v.strictObject({
|
|
94
|
+
uid,
|
|
95
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
96
|
+
requires: v.optional(expr),
|
|
97
|
+
steps: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
98
|
+
emits: v.optional(v.array(v.string())),
|
|
99
|
+
refresh: v.optional(v.array(address)),
|
|
100
|
+
examples: v.optional(v.array(example)),
|
|
101
|
+
impl: v.optional(v.picklist(['generated', 'custom'])),
|
|
102
|
+
transport: v.optional(v.picklist(['form', 'remote']))
|
|
103
|
+
}),
|
|
104
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const Policy = v.strictObject({
|
|
108
|
+
uid,
|
|
109
|
+
roles: v.optional(v.array(ident)),
|
|
110
|
+
read: v.optional(expr),
|
|
111
|
+
write: v.optional(expr),
|
|
112
|
+
run: v.optional(v.record(ident, expr))
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const Page = v.pipe(
|
|
116
|
+
v.strictObject({
|
|
117
|
+
uid,
|
|
118
|
+
route: v.pipe(v.string(), v.regex(/^\//, 'route must start with "/"')),
|
|
119
|
+
params: v.optional(v.record(ident, v.string())),
|
|
120
|
+
layout: v.optional(v.string()),
|
|
121
|
+
state: v.optional(v.record(ident, v.string())),
|
|
122
|
+
components: v.optional(v.array(v.record(v.string(), v.unknown()))),
|
|
123
|
+
slots: v.optional(v.array(ident)),
|
|
124
|
+
examples: v.optional(v.array(example)),
|
|
125
|
+
impl: v.optional(v.picklist(['generated', 'custom']))
|
|
126
|
+
}),
|
|
127
|
+
v.check(requiresExamplesWhenCustom, CUSTOM_NEEDS_EXAMPLES)
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const Trigger = v.union([
|
|
131
|
+
address,
|
|
132
|
+
v.strictObject({
|
|
133
|
+
uid,
|
|
134
|
+
action: address,
|
|
135
|
+
schedule: v.optional(v.string()),
|
|
136
|
+
source: v.optional(v.string())
|
|
137
|
+
})
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
const Function = v.strictObject({
|
|
141
|
+
uid,
|
|
142
|
+
input: v.optional(v.record(ident, v.union([v.string(), v.record(v.string(), v.unknown())]))),
|
|
143
|
+
output: v.optional(v.unknown()),
|
|
144
|
+
examples: v.pipe(v.array(example), v.minLength(1, 'functions require at least one example'))
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const Component = v.strictObject({
|
|
148
|
+
uid,
|
|
149
|
+
props: v.optional(v.record(ident, v.unknown())),
|
|
150
|
+
events: v.optional(v.record(ident, address)),
|
|
151
|
+
slots: v.optional(v.array(ident))
|
|
152
|
+
});
|
|
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
|
+
|
|
276
|
+
// L3 kinds: whole Civet files with declared auth + capabilities.
|
|
277
|
+
// `validate` refuses them without an auth declaration (PLAN §6).
|
|
278
|
+
const level3 = (extra = {}) =>
|
|
279
|
+
v.strictObject({
|
|
280
|
+
uid,
|
|
281
|
+
source: v.string(),
|
|
282
|
+
auth: v.union([v.string(), v.record(v.string(), v.unknown())]),
|
|
283
|
+
capabilities: v.optional(v.array(v.string())),
|
|
284
|
+
...extra
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const Plugin = v.strictObject({
|
|
288
|
+
uid,
|
|
289
|
+
kind: v.picklist(['field', 'step', 'component', 'trigger']),
|
|
290
|
+
source: v.string(),
|
|
291
|
+
contract: v.optional(v.record(v.string(), v.unknown()))
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/** Kind → valibot schema for one unit's spec value. */
|
|
295
|
+
export const UNIT_SCHEMAS = {
|
|
296
|
+
Entity,
|
|
297
|
+
Query,
|
|
298
|
+
Action,
|
|
299
|
+
Policy,
|
|
300
|
+
Page,
|
|
301
|
+
Trigger,
|
|
302
|
+
Function,
|
|
303
|
+
Component,
|
|
304
|
+
Snippet,
|
|
305
|
+
Service,
|
|
306
|
+
Job,
|
|
307
|
+
Endpoint,
|
|
308
|
+
Route: 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
|
+
}),
|
|
324
|
+
Adapter: level3(),
|
|
325
|
+
Middleware: level3(),
|
|
326
|
+
Plugin
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const collection = v.optional(v.record(v.string(), v.unknown()));
|
|
330
|
+
|
|
331
|
+
/** Module spec shape — collection contents are validated per unit. */
|
|
332
|
+
export const MODULE_SCHEMA = v.strictObject({
|
|
333
|
+
module: ident,
|
|
334
|
+
depends: v.optional(v.array(ident)),
|
|
335
|
+
settings: v.optional(v.record(v.string(), v.unknown())),
|
|
336
|
+
entities: collection,
|
|
337
|
+
queries: collection,
|
|
338
|
+
actions: collection,
|
|
339
|
+
policies: collection,
|
|
340
|
+
pages: collection,
|
|
341
|
+
triggers: collection,
|
|
342
|
+
functions: collection,
|
|
343
|
+
components: collection,
|
|
344
|
+
snippets: collection,
|
|
345
|
+
services: collection,
|
|
346
|
+
jobs: collection,
|
|
347
|
+
endpoints: collection,
|
|
348
|
+
routes: collection,
|
|
349
|
+
workers: collection,
|
|
350
|
+
adapters: collection,
|
|
351
|
+
middleware: collection,
|
|
352
|
+
plugins: collection
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export const APP_SCHEMA = v.strictObject({
|
|
356
|
+
name: v.optional(v.string()),
|
|
357
|
+
modules: v.optional(v.array(ident)),
|
|
358
|
+
dialect: v.optional(v.picklist(DIALECTS)),
|
|
359
|
+
settings: v.optional(v.record(v.string(), v.unknown()))
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Run a valibot schema and convert its issues to kernel Issues.
|
|
364
|
+
*
|
|
365
|
+
* @param {*} schema
|
|
366
|
+
* @param {*} value
|
|
367
|
+
* @param {string} addr issue address (unit address or module name)
|
|
368
|
+
* @returns {{ level: 'error', address: string, message: string }[]}
|
|
369
|
+
*/
|
|
370
|
+
export function schemaIssues(schema, value, addr) {
|
|
371
|
+
const result = v.safeParse(schema, value);
|
|
372
|
+
if (result.success) return [];
|
|
373
|
+
return result.issues.map((issue) => {
|
|
374
|
+
const path = v.getDotPath(issue);
|
|
375
|
+
return {
|
|
376
|
+
level: 'error',
|
|
377
|
+
address: addr,
|
|
378
|
+
message: path ? `${path}: ${issue.message}` : issue.message
|
|
379
|
+
};
|
|
380
|
+
});
|
|
381
|
+
}
|