@ignex/nova 0.1.1 → 0.1.3
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/README.md +132 -32
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +232 -0
- package/docs/architecture.md +35 -10
- package/docs/events.md +170 -0
- package/docs/generic-bindings.md +197 -0
- package/docs/publishing.md +2 -2
- package/docs/wire-format.md +9 -2
- package/index.ts +75 -27
- package/package.json +12 -2
- package/prebuilds/linux-x64/libignex_ffi.so +0 -0
- package/public/bindings.ts +24 -0
- package/public/client.ts +5 -1
- package/public/events.ts +71 -0
- package/public/generate.ts +416 -0
- package/public/internal.ts +16 -0
- package/public/nats.ts +9 -5
- package/public/server.ts +42 -16
- package/rust/src/ffi.rs +10 -0
- package/rust/src/transcode/generated.rs +2 -1
- package/src/bindings/assemble.ts +73 -0
- package/src/bindings/default.ts +65 -0
- package/src/bindings/types.ts +113 -0
- package/src/bridge/nats.ts +53 -13
- package/src/bridge/subjects.ts +3 -0
- package/src/codegen/constants.ts +18 -0
- package/src/codegen/direct-gen.ts +550 -0
- package/src/codegen/fingerprint.ts +44 -0
- package/src/codegen/hash.ts +25 -0
- package/src/codegen/registry-gen.ts +242 -0
- package/src/codegen/rust-glue-gen.ts +545 -0
- package/src/codegen/schema-model.ts +338 -0
- package/src/codegen/ts-ser-gen.ts +221 -0
- package/src/codegen/typebox-to-fbs.ts +60 -0
- package/src/core/auth.ts +2 -1
- package/src/core/client-heartbeat.ts +2 -1
- package/src/core/client-reconnect.ts +9 -2
- package/src/core/client-state.ts +21 -8
- package/src/core/client-wire.ts +10 -11
- package/src/core/client.ts +34 -29
- package/src/core/groups.ts +3 -0
- package/src/core/metrics.ts +7 -3
- package/src/core/outbound.ts +12 -5
- package/src/core/routing.ts +17 -8
- package/src/core/server.ts +108 -34
- package/src/core/state.ts +51 -13
- package/src/events/clients.ts +156 -0
- package/src/events/cluster.ts +732 -0
- package/src/events/data.ts +38 -0
- package/src/events/emit.ts +127 -0
- package/src/events/global.ts +117 -0
- package/src/events/groups.ts +118 -0
- package/src/events/hub.ts +481 -0
- package/src/events/index.ts +61 -0
- package/src/events/queue.ts +96 -0
- package/src/events/registry.ts +178 -0
- package/src/events/types.ts +378 -0
- package/src/generated/direct-ser.ts +2 -1
- package/src/generated/fbs/backend.fbs +1 -1
- package/src/generated/registry.ts +3 -1
- package/src/generated/ts-ser.ts +1 -1
- package/src/generated/wire-registry.json +1 -0
- package/src/native/ffi.ts +85 -28
- package/src/schema/index.ts +5 -2
- package/src/server.ts +7 -3
- package/src/transport/stats.ts +8 -4
- package/src/transport/transport.ts +149 -68
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import type { EmitContext, EventDef, FieldDef, Model } from "./schema-model";
|
|
2
|
+
import { isDirectableEvent, plainTsType, toSnake } from "./schema-model";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Emits `src/generated/direct-ser.ts` — the Bun-side of the zero-allocation direct
|
|
6
|
+
* fast path. For every DIRECTABLE event type (flat, or with packed vectors of
|
|
7
|
+
* flat elements) it generates:
|
|
8
|
+
* - `directSymbols`: `dlopen` specs in CANONICAL form. Strings are `cstring`
|
|
9
|
+
* ARGs (the engine transcodes them in-engine — zero JS encode, and Rust
|
|
10
|
+
* borrows via `CStr::from_ptr` with no `from_utf8` validation); vectors are
|
|
11
|
+
* `(buffer, usize)` of a packed binary blob; out is `(ptr, usize)` —
|
|
12
|
+
* upgraded to the `(buffer, buffer_length)` atomic pair at bind time by the
|
|
13
|
+
* `abi()` transformer in `src/native/ffi.ts` when the Bun build supports
|
|
14
|
+
* it. NO JSON.
|
|
15
|
+
* - `directEncoders`: per-event fns that push the object's fields straight
|
|
16
|
+
* into the (reusable) out buffer — no intermediate args array.
|
|
17
|
+
* - `directSymbolNames`: event → FFI symbol name.
|
|
18
|
+
* - `directSamples` + `directSelfTest`: per-event sample vectors and a
|
|
19
|
+
* bind-time self-test that verifies each direct symbol's frame layout
|
|
20
|
+
* (`out[0]` event id + size-prefix invariant) — a signature drift fails at
|
|
21
|
+
* bind, not under load. Returns the list of disabled symbols.
|
|
22
|
+
* - per-enum index lookup consts (string enum → u32).
|
|
23
|
+
*
|
|
24
|
+
* `ctx.schemaImport === null` (user-mode codegen) emits self-contained local
|
|
25
|
+
* payload types + imports the runtime helpers from `ctx.libraryImport`
|
|
26
|
+
* (default `"ignex-nova"`) instead of the repo's relative modules.
|
|
27
|
+
*/
|
|
28
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: string-emitting codegen is inherently branchy
|
|
29
|
+
export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
|
|
30
|
+
const directEvents = m.events.filter((ev) => isDirectableEvent(m, ev));
|
|
31
|
+
const userMode = ctx.schemaImport === null;
|
|
32
|
+
const libImport = userMode ? (ctx.libraryImport ?? "@ignex/nova") : "";
|
|
33
|
+
|
|
34
|
+
const enumConsts = new Map<string, string>(); // enumName -> const name
|
|
35
|
+
for (const e of m.enums) {
|
|
36
|
+
if (
|
|
37
|
+
directEvents.some((ev) => fieldsOf(m, ev).some((f) => isEnumKind(f) && f.enumName === e.name))
|
|
38
|
+
) {
|
|
39
|
+
enumConsts.set(e.name, `${toSnake(e.name).toUpperCase()}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// payload interfaces needed by the direct encoders (event roots + flat
|
|
44
|
+
// vector-table elements) in user mode
|
|
45
|
+
const directTables = collectDirectTables(m, directEvents);
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
lines.push("// @ts-nocheck — generated file: not subject to hand-typed strictness gates");
|
|
49
|
+
lines.push("// @generated by src/codegen/direct-gen.ts — DO NOT EDIT");
|
|
50
|
+
if (userMode) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`import { encodeUtf8Into, ensureCapacity, utf8Len, checkInt64 } from ${JSON.stringify(libImport)};`,
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
lines.push('import { encodeUtf8Into, ensureCapacity, utf8Len } from "../native/codec";');
|
|
56
|
+
lines.push('import { checkInt64 } from "../core/int64-guard";');
|
|
57
|
+
}
|
|
58
|
+
lines.push('import { anyEventNameToId, WIRE_VERSION, WIRE_HEADER_LEN } from "./registry";');
|
|
59
|
+
if (!userMode) {
|
|
60
|
+
lines.push('import type { AnyEventName, ControlEvents, Events } from "../schema";');
|
|
61
|
+
}
|
|
62
|
+
lines.push("");
|
|
63
|
+
if (userMode) lines.push(...emitLocalTypes(m, directTables, directEvents));
|
|
64
|
+
lines.push("export type DirectCall = (...args: unknown[]) => number;");
|
|
65
|
+
lines.push("");
|
|
66
|
+
|
|
67
|
+
lines.push("/** dlopen specs for the direct fast-path symbols (canonical form — the");
|
|
68
|
+
lines.push(" * output tail `(ptr, usize)` is upgraded to `(buffer, buffer_length)` by");
|
|
69
|
+
lines.push(" * the `abi()` transformer in `src/native/ffi.ts` when supported). */");
|
|
70
|
+
lines.push("export const directSymbols = {");
|
|
71
|
+
for (const ev of directEvents) {
|
|
72
|
+
const fields = fieldsOf(m, ev);
|
|
73
|
+
const args = fields.flatMap(directArgTypes).concat("ptr", "usize");
|
|
74
|
+
lines.push(` ${directSymbol(ev)}: {`);
|
|
75
|
+
lines.push(` args: [${args.map((a) => JSON.stringify(a)).join(", ")}],`);
|
|
76
|
+
lines.push(' returns: "u64_fast",');
|
|
77
|
+
lines.push(" },");
|
|
78
|
+
}
|
|
79
|
+
lines.push("} as const;");
|
|
80
|
+
lines.push("");
|
|
81
|
+
|
|
82
|
+
lines.push("/** sample objects for the bind-time self-test (representative values). */");
|
|
83
|
+
lines.push("const directSamples: Record<string, unknown> = {");
|
|
84
|
+
for (const ev of directEvents) {
|
|
85
|
+
const fields = fieldsOf(m, ev);
|
|
86
|
+
const vals = fields
|
|
87
|
+
.map((f) => `${JSON.stringify(f.jsonName)}: ${sampleValue(m, f)}`)
|
|
88
|
+
.join(", ");
|
|
89
|
+
lines.push(` ${JSON.stringify(ev.name)}: { ${vals} },`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("} as const;");
|
|
92
|
+
lines.push("");
|
|
93
|
+
|
|
94
|
+
const needsVector = directEvents.some((ev) => fieldsOf(m, ev).some((f) => isVectorKind(f.kind)));
|
|
95
|
+
if (needsVector) {
|
|
96
|
+
lines.push("/** grow the packer scratch, write the u32 count, return the DataView. */");
|
|
97
|
+
lines.push(
|
|
98
|
+
"function packHeader(holder: { v: Uint8Array }, dv: { v: DataView }, total: number, count: number): DataView {",
|
|
99
|
+
);
|
|
100
|
+
lines.push(
|
|
101
|
+
" if (holder.v.byteLength < total) { holder.v = ensureCapacity(holder.v, total); dv.v = new DataView(holder.v.buffer); }",
|
|
102
|
+
);
|
|
103
|
+
lines.push(" const b = dv.v;");
|
|
104
|
+
lines.push(" b.setUint32(0, count, true);");
|
|
105
|
+
lines.push(" return b;");
|
|
106
|
+
lines.push("}");
|
|
107
|
+
lines.push("");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [enumName, constName] of enumConsts) {
|
|
111
|
+
const e = m.enums.find((e) => e.name === enumName)!;
|
|
112
|
+
const entries = e.values.map((v) => `${v}: ${e.values.indexOf(v)}`).join(", ");
|
|
113
|
+
lines.push(`const ${constName} = { ${entries} } as const;`);
|
|
114
|
+
}
|
|
115
|
+
if (enumConsts.size > 0) lines.push("");
|
|
116
|
+
|
|
117
|
+
for (const ev of directEvents) {
|
|
118
|
+
const fields = fieldsOf(m, ev);
|
|
119
|
+
for (const f of fields) {
|
|
120
|
+
if (isVectorKind(f.kind)) {
|
|
121
|
+
lines.push(`const ${holderName(ev, f)} = { v: new Uint8Array(1024) };`);
|
|
122
|
+
lines.push(`const ${dvName(ev, f)} = { v: new DataView(${holderName(ev, f)}.v.buffer) };`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const f of fields) {
|
|
126
|
+
if (isVectorKind(f.kind)) lines.push(...emitPacker(m, ev, f, enumConsts));
|
|
127
|
+
}
|
|
128
|
+
lines.push("/** zero-alloc encoder: fields straight into the (reusable) out buffer. */");
|
|
129
|
+
lines.push(
|
|
130
|
+
`export function encode${pascal(ev.name)}(call: DirectCall, o: unknown, out: Uint8Array): number {`,
|
|
131
|
+
);
|
|
132
|
+
lines.push(
|
|
133
|
+
` const p = o as ${ev.control ? "ControlEvents" : "Events"}[${JSON.stringify(ev.name)}];`,
|
|
134
|
+
);
|
|
135
|
+
for (const f of fields) {
|
|
136
|
+
if (f.kind === "int64" && !f.bigint) {
|
|
137
|
+
// lossless-int64 guard (no-op unless enabled — see src/int64-guard.ts)
|
|
138
|
+
lines.push(` checkInt64(${JSON.stringify(`${ev.name}.${f.jsonName}`)}, p.${f.jsonName});`);
|
|
139
|
+
}
|
|
140
|
+
const prep = prepLines(ev, f);
|
|
141
|
+
if (prep) lines.push(` ${prep}`);
|
|
142
|
+
}
|
|
143
|
+
const args = fields.map((f) => argExpr(ev, f, enumConsts)).join(", ");
|
|
144
|
+
lines.push(` return call(${args}, out, out) as number;`);
|
|
145
|
+
lines.push("}");
|
|
146
|
+
lines.push("");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// per-direct-event NUL pre-scan — route payloads containing an embedded NUL
|
|
150
|
+
// to the JSON path (the `cstring` direct path would silently truncate them).
|
|
151
|
+
for (const ev of directEvents) {
|
|
152
|
+
const table = m.tables.find((t) => t.name === ev.tableName)!;
|
|
153
|
+
const checks: string[] = [];
|
|
154
|
+
const add = (expr: string) => checks.push(` ${expr}`);
|
|
155
|
+
for (const f of table.fields) {
|
|
156
|
+
const j = f.jsonName;
|
|
157
|
+
switch (f.kind) {
|
|
158
|
+
case "string":
|
|
159
|
+
add(`(typeof p.${j} === "string" && p.${j}.includes("\\0"))`);
|
|
160
|
+
break;
|
|
161
|
+
case "vector-string":
|
|
162
|
+
add(`(p.${j} ?? []).some((s) => typeof s === "string" && s.includes("\\0"))`);
|
|
163
|
+
break;
|
|
164
|
+
case "vector-table": {
|
|
165
|
+
const elem = m.tables.find((e) => e.name === f.tableName)!;
|
|
166
|
+
for (const ef of elem.fields) {
|
|
167
|
+
if (ef.kind === "string") {
|
|
168
|
+
add(
|
|
169
|
+
`(p.${j} ?? []).some((e) => typeof e.${ef.jsonName} === "string" && e.${ef.jsonName}.includes("\\0"))`,
|
|
170
|
+
);
|
|
171
|
+
} else if (ef.kind === "vector-string") {
|
|
172
|
+
add(
|
|
173
|
+
`(p.${j} ?? []).some((e) => (e.${ef.jsonName} ?? []).some((s) => typeof s === "string" && s.includes("\\0")))`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
lines.push(`function hasNul${pascal(ev.name)}(o: unknown): boolean {`);
|
|
184
|
+
lines.push(
|
|
185
|
+
` const p = o as ${ev.control ? "ControlEvents" : "Events"}[${JSON.stringify(ev.name)}];`,
|
|
186
|
+
);
|
|
187
|
+
if (checks.length === 0) {
|
|
188
|
+
lines.push(" return false;");
|
|
189
|
+
} else {
|
|
190
|
+
lines.push(" return (");
|
|
191
|
+
lines.push(checks.join(" ||\n"));
|
|
192
|
+
lines.push(" );");
|
|
193
|
+
}
|
|
194
|
+
lines.push("}");
|
|
195
|
+
lines.push("");
|
|
196
|
+
}
|
|
197
|
+
lines.push(
|
|
198
|
+
"/** per-event NUL pre-scan — true routes the payload to the JSON path (cstring truncates `\\0`). */",
|
|
199
|
+
);
|
|
200
|
+
lines.push(
|
|
201
|
+
"export const hasNulEncoders: Partial<Record<AnyEventName, (o: unknown) => boolean>> = {",
|
|
202
|
+
);
|
|
203
|
+
for (const ev of directEvents) lines.push(` ${ev.name}: hasNul${pascal(ev.name)},`);
|
|
204
|
+
lines.push("};");
|
|
205
|
+
lines.push("");
|
|
206
|
+
|
|
207
|
+
lines.push("export const directSymbolNames: Partial<Record<AnyEventName, string>> = {");
|
|
208
|
+
for (const ev of directEvents) lines.push(` ${ev.name}: ${JSON.stringify(directSymbol(ev))},`);
|
|
209
|
+
lines.push("};");
|
|
210
|
+
lines.push("");
|
|
211
|
+
lines.push(
|
|
212
|
+
"export type DirectEncoder = (call: DirectCall, o: unknown, out: Uint8Array) => number;",
|
|
213
|
+
);
|
|
214
|
+
lines.push("");
|
|
215
|
+
lines.push("export const directEncoders: Partial<Record<AnyEventName, DirectEncoder>> = {");
|
|
216
|
+
for (const ev of directEvents) lines.push(` ${ev.name}: encode${pascal(ev.name)},`);
|
|
217
|
+
lines.push("};");
|
|
218
|
+
lines.push("");
|
|
219
|
+
|
|
220
|
+
lines.push("/**");
|
|
221
|
+
lines.push(" * Bind-time self-test for the direct fast-path symbols. Encodes each");
|
|
222
|
+
lines.push(" * sample into `scratch` and verifies the frame invariant");
|
|
223
|
+
lines.push(" * `out[0]` = WIRE_VERSION, `out[1..5]` = event id AND");
|
|
224
|
+
lines.push(" * `bytes_written === WIRE_HEADER_LEN + 4 + size_prefix`");
|
|
225
|
+
lines.push(" * (envelope header + 4-byte size prefix + flatbuffer). A signature drift,");
|
|
226
|
+
lines.push(" * ABI mismatch, or encoder bug surfaces here instead of under load.");
|
|
227
|
+
lines.push(" * Returns the list of symbol names to DISABLE (route via JSON path).");
|
|
228
|
+
lines.push(" *");
|
|
229
|
+
lines.push(" * `raw` must be the adapted symbol map (mode-aware) from ffi.ts.");
|
|
230
|
+
lines.push(" */");
|
|
231
|
+
lines.push(
|
|
232
|
+
"export function directSelfTest(raw: Record<string, (...args: unknown[]) => number>, scratch: Uint8Array): string[] {",
|
|
233
|
+
);
|
|
234
|
+
lines.push(" const disabled: string[] = [];");
|
|
235
|
+
lines.push(" const dv = new DataView(scratch.buffer, scratch.byteOffset, scratch.byteLength);");
|
|
236
|
+
lines.push(" for (const [name, sample] of Object.entries(directSamples)) {");
|
|
237
|
+
lines.push(" const symName = directSymbolNames[name as AnyEventName];");
|
|
238
|
+
lines.push(" const enc = directEncoders[name as AnyEventName];");
|
|
239
|
+
lines.push(" if (!symName || !enc) continue;");
|
|
240
|
+
lines.push(" const call = raw[symName];");
|
|
241
|
+
lines.push(" if (!call) { disabled.push(symName); continue; }");
|
|
242
|
+
lines.push(" try {");
|
|
243
|
+
lines.push(" const w = enc(call, sample, scratch) as number;");
|
|
244
|
+
lines.push(" if (w === 0) { disabled.push(symName); continue; }");
|
|
245
|
+
lines.push(" if (w > scratch.byteLength) continue; // needed-size path — not a failure");
|
|
246
|
+
lines.push(" const size = dv.getUint32(scratch.byteOffset + WIRE_HEADER_LEN, true);");
|
|
247
|
+
lines.push(" const gotId = dv.getUint32(scratch.byteOffset + 1, true);");
|
|
248
|
+
lines.push(
|
|
249
|
+
" if (scratch[0] !== WIRE_VERSION || gotId !== anyEventNameToId[name] || w !== WIRE_HEADER_LEN + 4 + size) disabled.push(symName);",
|
|
250
|
+
);
|
|
251
|
+
lines.push(" } catch {");
|
|
252
|
+
lines.push(" disabled.push(symName);");
|
|
253
|
+
lines.push(" }");
|
|
254
|
+
lines.push(" }");
|
|
255
|
+
lines.push(" return disabled;");
|
|
256
|
+
lines.push("}");
|
|
257
|
+
lines.push("");
|
|
258
|
+
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Local payload types for the direct encoders (user-mode codegen). */
|
|
263
|
+
function emitLocalTypes(
|
|
264
|
+
m: Model,
|
|
265
|
+
tables: Model["tables"],
|
|
266
|
+
directEvents: Model["events"],
|
|
267
|
+
): string[] {
|
|
268
|
+
const lines: string[] = [];
|
|
269
|
+
for (const t of tables) {
|
|
270
|
+
lines.push(`export interface ${t.name}Payload {`);
|
|
271
|
+
for (const f of t.fields)
|
|
272
|
+
lines.push(` ${f.jsonName}${f.required ? "" : "?"}: ${plainTsType(m, f)};`);
|
|
273
|
+
lines.push("}");
|
|
274
|
+
lines.push("");
|
|
275
|
+
}
|
|
276
|
+
const appNames = directEvents
|
|
277
|
+
.filter((e) => !e.control)
|
|
278
|
+
.map((e) => `"${e.name}"`)
|
|
279
|
+
.join(" | ");
|
|
280
|
+
const ctlNames = directEvents
|
|
281
|
+
.filter((e) => e.control)
|
|
282
|
+
.map((e) => `"${e.name}"`)
|
|
283
|
+
.join(" | ");
|
|
284
|
+
lines.push(`export type EventName = ${appNames};`);
|
|
285
|
+
lines.push(`export type ControlEventName = ${ctlNames};`);
|
|
286
|
+
lines.push(`export type AnyEventName = EventName | ControlEventName;`);
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push("export type Events = {");
|
|
289
|
+
for (const ev of directEvents.filter((e) => !e.control))
|
|
290
|
+
lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Payload;`);
|
|
291
|
+
lines.push("};");
|
|
292
|
+
lines.push("export type ControlEvents = {");
|
|
293
|
+
for (const ev of directEvents.filter((e) => e.control))
|
|
294
|
+
lines.push(` ${JSON.stringify(ev.name)}: ${ev.tableName}Payload;`);
|
|
295
|
+
lines.push("};");
|
|
296
|
+
lines.push("");
|
|
297
|
+
return lines;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Tables referenced by the direct encoders (event roots + flat vector-table elements). */
|
|
301
|
+
function collectDirectTables(m: Model, directEvents: EventDef[]): Model["tables"] {
|
|
302
|
+
const needed = new Set<string>();
|
|
303
|
+
const visit = (name: string) => {
|
|
304
|
+
if (needed.has(name)) return;
|
|
305
|
+
needed.add(name);
|
|
306
|
+
const t = m.tables.find((t) => t.name === name)!;
|
|
307
|
+
for (const f of t.fields) {
|
|
308
|
+
if (f.kind === "vector-table") visit(f.tableName!);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
for (const ev of directEvents) visit(ev.tableName);
|
|
312
|
+
return m.tables.filter((t) => needed.has(t.name));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Representative sample value for a field kind (used by the generated self-test). */
|
|
316
|
+
function sampleValue(m: Model, f: FieldDef): string {
|
|
317
|
+
switch (f.kind) {
|
|
318
|
+
case "string":
|
|
319
|
+
return JSON.stringify("SELFTEST");
|
|
320
|
+
case "double":
|
|
321
|
+
return "1.5";
|
|
322
|
+
case "int64":
|
|
323
|
+
return "123456";
|
|
324
|
+
case "bool":
|
|
325
|
+
return "true";
|
|
326
|
+
case "enum":
|
|
327
|
+
return JSON.stringify(m.enums.find((e) => e.name === f.enumName)!.values[0]);
|
|
328
|
+
case "vector-double":
|
|
329
|
+
return "[1.5, 2.5]";
|
|
330
|
+
case "vector-int64":
|
|
331
|
+
return "[10, 20]";
|
|
332
|
+
case "vector-bool":
|
|
333
|
+
return "[true, false]";
|
|
334
|
+
case "vector-enum":
|
|
335
|
+
return `[${JSON.stringify(m.enums.find((e) => e.name === f.enumName)!.values[0])}]`;
|
|
336
|
+
case "vector-string":
|
|
337
|
+
return '["a", "b"]';
|
|
338
|
+
case "vector-table": {
|
|
339
|
+
const t = m.tables.find((t) => t.name === f.tableName)!;
|
|
340
|
+
const fields = t.fields
|
|
341
|
+
.map((ef) => `${JSON.stringify(ef.jsonName)}: ${sampleValue(m, ef)}`)
|
|
342
|
+
.join(", ");
|
|
343
|
+
return `[{ ${fields} }]`;
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
throw new Error(`direct sample: unsupported field kind ${f.kind}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function fieldsOf(m: Model, ev: EventDef): FieldDef[] {
|
|
351
|
+
return m.tables.find((t) => t.name === ev.tableName)!.fields;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function directSymbol(ev: EventDef): string {
|
|
355
|
+
return `fb_${toSnake(ev.name)}_serialize`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function isEnumKind(f: FieldDef): boolean {
|
|
359
|
+
return f.kind === "enum" || f.kind === "vector-enum";
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isVectorKind(kind: string): boolean {
|
|
363
|
+
return [
|
|
364
|
+
"vector-table",
|
|
365
|
+
"vector-string",
|
|
366
|
+
"vector-double",
|
|
367
|
+
"vector-int64",
|
|
368
|
+
"vector-bool",
|
|
369
|
+
"vector-enum",
|
|
370
|
+
].includes(kind);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function directArgTypes(f: FieldDef): string[] {
|
|
374
|
+
switch (f.kind) {
|
|
375
|
+
case "string":
|
|
376
|
+
// cstring ARG — the engine transcodes the JS string in-engine.
|
|
377
|
+
return ["cstring"];
|
|
378
|
+
case "vector-table":
|
|
379
|
+
case "vector-string":
|
|
380
|
+
case "vector-double":
|
|
381
|
+
case "vector-int64":
|
|
382
|
+
case "vector-bool":
|
|
383
|
+
case "vector-enum":
|
|
384
|
+
return ["buffer", "usize"];
|
|
385
|
+
case "double":
|
|
386
|
+
return ["f64"];
|
|
387
|
+
case "int64":
|
|
388
|
+
return ["i64"];
|
|
389
|
+
case "bool":
|
|
390
|
+
return ["u8"];
|
|
391
|
+
case "enum":
|
|
392
|
+
return ["u32"];
|
|
393
|
+
default:
|
|
394
|
+
throw new Error(`direct-args: unsupported field kind ${f.kind}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function holderName(ev: EventDef, f: FieldDef): string {
|
|
399
|
+
return `${toSnake(ev.name)}_${f.fbName}Holder`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function dvName(ev: EventDef, f: FieldDef): string {
|
|
403
|
+
return `${toSnake(ev.name)}_${f.fbName}Dv`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** statement(s) before the call — packing a vector (strings cross as cstring args). */
|
|
407
|
+
function prepLines(ev: EventDef, f: FieldDef): string {
|
|
408
|
+
const j = f.jsonName;
|
|
409
|
+
switch (f.kind) {
|
|
410
|
+
case "string":
|
|
411
|
+
return ""; // no prep — the JS string crosses directly as a cstring arg
|
|
412
|
+
default:
|
|
413
|
+
if (isVectorKind(f.kind)) {
|
|
414
|
+
return `const ${f.fbName}Len = ${packerName(ev, f)}(p.${j}, ${holderName(ev, f)}, ${dvName(ev, f)});`;
|
|
415
|
+
}
|
|
416
|
+
return "";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function argExpr(ev: EventDef, f: FieldDef, enumConsts: Map<string, string>): string {
|
|
421
|
+
const j = `p.${f.jsonName}`;
|
|
422
|
+
const h = holderName(ev, f);
|
|
423
|
+
switch (f.kind) {
|
|
424
|
+
case "string":
|
|
425
|
+
// cstring arg: pass the JS string directly; null/undefined → null (NULL
|
|
426
|
+
// ptr, Rust treats as absent). Verified empirically: '' is NOT conflated —
|
|
427
|
+
// Bun passes a non-NULL pointer to an empty NUL-terminated string, so Rust
|
|
428
|
+
// keeps Some("") and empty-vs-absent round-trips distinctly.
|
|
429
|
+
return f.required ? j : `${j} ?? null`;
|
|
430
|
+
case "double":
|
|
431
|
+
case "int64":
|
|
432
|
+
return j;
|
|
433
|
+
case "bool":
|
|
434
|
+
return `${j} ? 1 : 0`;
|
|
435
|
+
case "enum":
|
|
436
|
+
return `${enumConsts.get(f.enumName!)}[${j}]`;
|
|
437
|
+
default:
|
|
438
|
+
if (isVectorKind(f.kind)) return `${h}.v, ${f.fbName}Len`;
|
|
439
|
+
throw new Error(`direct-args: unsupported field kind ${f.kind}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function packerName(ev: EventDef, f: FieldDef): string {
|
|
444
|
+
return `pack${pascal(ev.name)}${pascal(f.fbName)}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Emit a packer fn that writes the vector into a growable scratch (zero-alloc). */
|
|
448
|
+
function emitPacker(
|
|
449
|
+
m: Model,
|
|
450
|
+
ev: EventDef,
|
|
451
|
+
f: FieldDef,
|
|
452
|
+
enumConsts: Map<string, string>,
|
|
453
|
+
): string[] {
|
|
454
|
+
const fn = packerName(ev, f);
|
|
455
|
+
const itemsType = `${ev.control ? "ControlEvents" : "Events"}[${JSON.stringify(ev.name)}][${JSON.stringify(f.jsonName)}]`;
|
|
456
|
+
const lines: string[] = [];
|
|
457
|
+
lines.push(
|
|
458
|
+
`function ${fn}(items: ${itemsType}, holder: { v: Uint8Array }, dv: { v: DataView }): number {`,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
if (f.kind === "vector-table") {
|
|
462
|
+
const elem = m.tables.find((t) => t.name === f.tableName)!;
|
|
463
|
+
lines.push(` let total = 4;`);
|
|
464
|
+
lines.push(` for (const p of items) total += ${elem.fields.map(packedSizeExpr).join(" + ")};`);
|
|
465
|
+
lines.push(` const b = packHeader(holder, dv, total, items.length);`);
|
|
466
|
+
lines.push(` let off = 4;`);
|
|
467
|
+
lines.push(` for (const p of items) {`);
|
|
468
|
+
for (const ef of elem.fields) lines.push(` ${packedWriteExpr(ef, "p", enumConsts)}`);
|
|
469
|
+
lines.push(` }`);
|
|
470
|
+
} else {
|
|
471
|
+
const per = (
|
|
472
|
+
{
|
|
473
|
+
"vector-double": "8",
|
|
474
|
+
"vector-int64": "8",
|
|
475
|
+
"vector-bool": "1",
|
|
476
|
+
"vector-enum": "4",
|
|
477
|
+
"vector-string": "0",
|
|
478
|
+
} as Record<string, string>
|
|
479
|
+
)[f.kind]!;
|
|
480
|
+
const sizeExpr = f.kind === "vector-string" ? `4 + utf8Len(x)` : per;
|
|
481
|
+
lines.push(` let total = 4;`);
|
|
482
|
+
lines.push(` for (const x of items) total += ${sizeExpr};`);
|
|
483
|
+
lines.push(` const b = packHeader(holder, dv, total, items.length);`);
|
|
484
|
+
lines.push(` let off = 4;`);
|
|
485
|
+
lines.push(` for (const x of items) {`);
|
|
486
|
+
lines.push(` ${scalarVecWrite(f, "x", enumConsts)}`);
|
|
487
|
+
lines.push(` }`);
|
|
488
|
+
}
|
|
489
|
+
lines.push(` return total;`);
|
|
490
|
+
lines.push(`}`);
|
|
491
|
+
lines.push("");
|
|
492
|
+
return lines;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function packedSizeExpr(ef: FieldDef): string {
|
|
496
|
+
switch (ef.kind) {
|
|
497
|
+
case "string":
|
|
498
|
+
return `4 + utf8Len(p.${ef.jsonName})`;
|
|
499
|
+
case "double":
|
|
500
|
+
case "int64":
|
|
501
|
+
return "8";
|
|
502
|
+
case "bool":
|
|
503
|
+
return "1";
|
|
504
|
+
case "enum":
|
|
505
|
+
return "4";
|
|
506
|
+
default:
|
|
507
|
+
throw new Error(`packed element: unsupported field kind ${ef.kind}`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function packedWriteExpr(ef: FieldDef, item: string, enumConsts: Map<string, string>): string {
|
|
512
|
+
const v = `${item}.${ef.jsonName}`;
|
|
513
|
+
switch (ef.kind) {
|
|
514
|
+
case "string":
|
|
515
|
+
return `const ${ef.fbName}L = encodeUtf8Into(${v}, holder.v, off + 4); b.setUint32(off, ${ef.fbName}L, true); off += 4; off += ${ef.fbName}L;`;
|
|
516
|
+
case "double":
|
|
517
|
+
return `b.setFloat64(off, ${v}, true); off += 8;`;
|
|
518
|
+
case "int64":
|
|
519
|
+
return `b.setInt32(off, ${v} | 0, true); b.setInt32(off + 4, Math.floor(${v} / 0x100000000), true); off += 8;`;
|
|
520
|
+
case "bool":
|
|
521
|
+
// NOTE: DataView.setUint8 has NO littleEndian param (8-bit) — do not pass `true`.
|
|
522
|
+
return `b.setUint8(off, ${v} ? 1 : 0); off += 1;`;
|
|
523
|
+
case "enum":
|
|
524
|
+
return `b.setUint32(off, ${enumConsts.get(ef.enumName!)}[${v}], true); off += 4;`;
|
|
525
|
+
default:
|
|
526
|
+
throw new Error(`packed element: unsupported field kind ${ef.kind}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function scalarVecWrite(f: FieldDef, item: string, enumConsts: Map<string, string>): string {
|
|
531
|
+
switch (f.kind) {
|
|
532
|
+
case "vector-double":
|
|
533
|
+
return `b.setFloat64(off, ${item}, true); off += 8;`;
|
|
534
|
+
case "vector-int64":
|
|
535
|
+
return `b.setInt32(off, ${item} | 0, true); b.setInt32(off + 4, Math.floor(${item} / 0x100000000), true); off += 8;`;
|
|
536
|
+
case "vector-bool":
|
|
537
|
+
// NOTE: DataView.setUint8 has NO littleEndian param (8-bit) — do not pass `true`.
|
|
538
|
+
return `b.setUint8(off, ${item} ? 1 : 0); off += 1;`;
|
|
539
|
+
case "vector-string":
|
|
540
|
+
return `const l = encodeUtf8Into(${item}, holder.v, off + 4); b.setUint32(off, l, true); off += 4; off += l;`;
|
|
541
|
+
case "vector-enum":
|
|
542
|
+
return `b.setUint32(off, ${enumConsts.get(f.enumName!)}[${item}], true); off += 4;`;
|
|
543
|
+
default:
|
|
544
|
+
throw new Error(`scalar vector: unsupported kind ${f.kind}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function pascal(s: string): string {
|
|
549
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
550
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema fingerprint — a stable FNV-1a 32-bit hash over the CANONICAL model
|
|
3
|
+
* (event registry + table shapes + enums + wire version). Emitted into BOTH
|
|
4
|
+
* the generated TS registry (`SCHEMA_FINGERPRINT`) and the generated Rust glue
|
|
5
|
+
* (`SCHEMA_FINGERPRINT` → exposed as `fb_schema_fingerprint()`), so a cdylib
|
|
6
|
+
* built from a DIFFERENT schema fails the bind-time self-test instead of
|
|
7
|
+
* producing frames the client can't decode.
|
|
8
|
+
*
|
|
9
|
+
* The value only needs to be stable per schema (any collision would just
|
|
10
|
+
* produce a false bind failure — harmless); it is NOT security.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { fnv1a32 } from "./hash";
|
|
14
|
+
import type { Model } from "./schema-model";
|
|
15
|
+
|
|
16
|
+
/** Stable comparator: ascending by name. */
|
|
17
|
+
function byName(a: { name: string }, b: { name: string }): number {
|
|
18
|
+
if (a.name < b.name) return -1;
|
|
19
|
+
if (a.name > b.name) return 1;
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function schemaFingerprint(m: Model, wireVersion: number): number {
|
|
24
|
+
const lines: string[] = [`wire=${wireVersion}`];
|
|
25
|
+
for (const e of [...m.events].sort(byName)) {
|
|
26
|
+
lines.push(`event:${e.name}:${e.tableName}:${e.control ? "control" : "app"}`);
|
|
27
|
+
}
|
|
28
|
+
for (const t of [...m.tables].sort(byName)) {
|
|
29
|
+
lines.push(`table:${t.name}`);
|
|
30
|
+
for (const f of [...t.fields].sort((a, b) => {
|
|
31
|
+
if (a.fbName < b.fbName) return -1;
|
|
32
|
+
if (a.fbName > b.fbName) return 1;
|
|
33
|
+
return 0;
|
|
34
|
+
})) {
|
|
35
|
+
lines.push(
|
|
36
|
+
` ${f.fbName}:${f.kind}:${f.required}:${f.enumName ?? ""}:${f.tableName ?? ""}:${f.bigint ? "bigint" : ""}`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const e of [...m.enums].sort(byName)) {
|
|
41
|
+
lines.push(`enum:${e.name}=${e.values.join(",")}`);
|
|
42
|
+
}
|
|
43
|
+
return fnv1a32(lines.join("\n"));
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable event-id hashing. Event ids were previously the insertion order of
|
|
3
|
+
* the `events` registry — a schema reorder silently changed every id and
|
|
4
|
+
* broke every connected client. Switching to FNV-1a 32-bit over the event
|
|
5
|
+
* name makes ids stable across reordering / field additions, which is what a
|
|
6
|
+
* versioned wire format needs.
|
|
7
|
+
*
|
|
8
|
+
* Collisions are astronomically unlikely for a handful of names and are
|
|
9
|
+
* additionally rejected at generate time (`scripts/generate.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** FNV-1a 32-bit (unsigned) — the standard 32-bit variant, seed 2166136261. */
|
|
13
|
+
export function fnv1a32(s: string): number {
|
|
14
|
+
let h = 0x811c9dc5;
|
|
15
|
+
for (let i = 0; i < s.length; i++) {
|
|
16
|
+
h ^= s.charCodeAt(i);
|
|
17
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
18
|
+
}
|
|
19
|
+
return h >>> 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Stable event id for an event name (FNV-1a 32-bit of the name). */
|
|
23
|
+
export function eventId(name: string): number {
|
|
24
|
+
return fnv1a32(name);
|
|
25
|
+
}
|