@ignex/nova 0.1.3 → 0.1.6

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.
Files changed (98) hide show
  1. package/README.md +4 -1
  2. package/docs/ai/TREE.md +69 -9
  3. package/docs/architecture.md +75 -27
  4. package/docs/events.md +83 -1
  5. package/docs/generic-bindings.md +10 -0
  6. package/docs/wire-format.md +65 -18
  7. package/package.json +2 -1
  8. package/prebuilds/linux-x64/libignex_ffi.so +0 -0
  9. package/public/generate.ts +97 -3
  10. package/public/server.ts +10 -0
  11. package/rust/src/generated/backend.rs +503 -0
  12. package/rust/src/transcode/generated.rs +376 -17
  13. package/src/bridge/nats/inbound.ts +46 -0
  14. package/src/bridge/nats/index.ts +131 -0
  15. package/src/bridge/nats/real-transport.ts +133 -0
  16. package/src/bridge/nats/types.ts +80 -0
  17. package/src/codegen/constants.ts +14 -4
  18. package/src/codegen/direct-gen.ts +20 -6
  19. package/src/codegen/registry-gen.ts +10 -6
  20. package/src/codegen/rust-glue-gen.ts +10 -3
  21. package/src/codegen/schema-model.ts +28 -3
  22. package/src/codegen/ts-ser-gen.ts +12 -3
  23. package/src/core/auth.ts +65 -4
  24. package/src/core/client-rpc.ts +75 -0
  25. package/src/core/client-state.ts +53 -0
  26. package/src/core/client-wire.ts +183 -8
  27. package/src/core/client.ts +84 -4
  28. package/src/core/groups.ts +5 -0
  29. package/src/core/metrics.ts +38 -21
  30. package/src/core/outbound.ts +50 -6
  31. package/src/core/rate-limit.ts +69 -0
  32. package/src/core/replay.ts +41 -1
  33. package/src/core/resume.ts +181 -0
  34. package/src/core/rooms.ts +10 -3
  35. package/src/core/routing.ts +128 -5
  36. package/src/core/server/client-info.ts +37 -0
  37. package/src/core/server/http-routes.ts +59 -0
  38. package/src/core/{server.ts → server/index.ts} +112 -120
  39. package/src/core/server/metrics-view.ts +53 -0
  40. package/src/core/server/socket-lifecycle.ts +57 -0
  41. package/src/core/state.ts +73 -1
  42. package/src/core/topic-log.ts +86 -0
  43. package/src/events/clients.ts +18 -0
  44. package/src/events/cluster/dedupe.ts +43 -0
  45. package/src/events/cluster/envelope.ts +149 -0
  46. package/src/events/cluster/index.ts +50 -0
  47. package/src/events/cluster/keys.ts +33 -0
  48. package/src/events/cluster/kinds.ts +32 -0
  49. package/src/events/cluster/presence-table.ts +99 -0
  50. package/src/events/cluster/presence.ts +53 -0
  51. package/src/events/cluster/redis-client.ts +50 -0
  52. package/src/events/cluster/store-memory.ts +67 -0
  53. package/src/events/cluster/store-redis.ts +44 -0
  54. package/src/events/cluster/subjects.ts +30 -0
  55. package/src/events/cluster/sync.ts +476 -0
  56. package/src/events/cluster/transport-nats.ts +24 -0
  57. package/src/events/cluster/transport-redis.ts +120 -0
  58. package/src/events/cluster-rpc.ts +196 -0
  59. package/src/events/delivery.ts +83 -0
  60. package/src/events/emit.ts +57 -11
  61. package/src/events/hub/context-factory.ts +79 -0
  62. package/src/events/hub/dispatch.ts +86 -0
  63. package/src/events/hub/index.ts +536 -0
  64. package/src/events/hub/internal.ts +31 -0
  65. package/src/events/hub/metrics-snapshot.ts +84 -0
  66. package/src/events/hub/resolve-cluster.ts +49 -0
  67. package/src/events/queue.ts +36 -9
  68. package/src/events/registry.ts +90 -54
  69. package/src/events/schedule.ts +73 -0
  70. package/src/events/trace.ts +283 -0
  71. package/src/events/types/client.ts +68 -0
  72. package/src/events/types/cluster.ts +40 -0
  73. package/src/events/types/context.ts +50 -0
  74. package/src/events/types/emit-target.ts +29 -0
  75. package/src/events/types/groups.ts +35 -0
  76. package/src/events/types/hub.ts +124 -0
  77. package/src/events/types/index.ts +30 -0
  78. package/src/events/types/metrics.ts +52 -0
  79. package/src/events/types/options.ts +62 -0
  80. package/src/generated/direct-ser.ts +146 -59
  81. package/src/generated/fbs/backend.fbs +23 -0
  82. package/src/generated/registry.ts +92 -33
  83. package/src/generated/rust/backend_generated.rs +503 -0
  84. package/src/generated/ts/backend.ts +4 -0
  85. package/src/generated/ts/resume.ts +74 -0
  86. package/src/generated/ts/resumed.ts +88 -0
  87. package/src/generated/ts/rpc-call.ts +112 -0
  88. package/src/generated/ts/rpc-result.ts +126 -0
  89. package/src/generated/ts/snapshot-request.ts +19 -5
  90. package/src/generated/ts-ser.ts +109 -16
  91. package/src/generated/wire-registry.json +7 -3
  92. package/src/schema/index.ts +45 -1
  93. package/src/transport/transport.ts +117 -77
  94. package/src/bridge/nats.ts +0 -309
  95. package/src/events/cluster.ts +0 -732
  96. package/src/events/hub.ts +0 -481
  97. package/src/events/types.ts +0 -378
  98. package/src/transport/stats.ts +0 -48
@@ -0,0 +1,131 @@
1
+ /**
2
+ * NATS bridge — bidirectional FlatBuffer transport over NATS (composition
3
+ * root). Decomposed by concern:
4
+ *
5
+ * types.ts — options / stats / NatsTransport / NatsBridge contracts
6
+ * real-transport.ts — the eager, non-blocking connection with retry loop
7
+ * inbound.ts — the NATS→clients decode + filter pipeline
8
+ *
9
+ * OUTBOUND: the server encodes each event ONCE (Rust FFI → scratch), fans the
10
+ * same frame out to WS clients, then hands a COPY to `bridge.publish(subject,
11
+ * frame)` so other applications consume the identical wire bytes. Best-effort:
12
+ * if NATS is down the frame is dropped and counted in `bridgeErrors` — it
13
+ * never blocks or throws on the WS hot path.
14
+ *
15
+ * INBOUND: when `inbound` is enabled the bridge subscribes to `{prefix}.
16
+ * inbound.>` and forwards decodable app events to `onInbound` (wired by the
17
+ * server to fan out to clients). Control frames and unknown ids are dropped.
18
+ *
19
+ * HORIZONTAL SCALING: when `bridgeClientEvents` is set, the server re-publishes
20
+ * every accepted client-sent event to `{prefix}.inbound.<event>` so OTHER
21
+ * server instances (and BE consumers) receive it. Loop prevention: frames that
22
+ * arrive via NATS are forwarded through `onInbound` and never re-bridged.
23
+ *
24
+ * GENERIC: decodes inbound frames with the given `Bindings` (default: the
25
+ * built-in registry), so the bridge works for ANY schema. Tests can inject an
26
+ * `NatsTransport` fake — no broker needed in CI.
27
+ */
28
+ import { defaultBindings } from "../../bindings/default";
29
+ import type { Bindings } from "../../bindings/types";
30
+ import { createSubjectBuilder } from "../subjects";
31
+ import { subscribeInboundSubject } from "./inbound";
32
+ import { createRealTransport } from "./real-transport";
33
+ import type {
34
+ NatsBridge,
35
+ NatsBridgeOptions,
36
+ NatsBridgeStats,
37
+ NatsBridgeStatus,
38
+ NatsTransport,
39
+ } from "./types";
40
+
41
+ export type {
42
+ NatsBridge,
43
+ NatsBridgeOptions,
44
+ NatsBridgeStats,
45
+ NatsBridgeStatus,
46
+ NatsTransport,
47
+ } from "./types";
48
+
49
+ export function createNatsBridge(
50
+ opts: NatsBridgeOptions = {},
51
+ transport?: NatsTransport,
52
+ bindings?: Bindings,
53
+ ): NatsBridge {
54
+ const b = bindings ?? opts.bindings ?? defaultBindings;
55
+ const t = transport ?? createRealTransport(opts);
56
+ const subjects = createSubjectBuilder(opts.subjectPrefix ?? b.subjectPrefix ?? "ignex");
57
+ const stats: NatsBridgeStats = {
58
+ bridged: 0,
59
+ bridgedBytes: 0,
60
+ bridgeErrors: 0,
61
+ bridgeInbound: 0,
62
+ bridgeInboundErrors: 0,
63
+ };
64
+ let closed = false;
65
+ let onInbound: ((name: string, payload: unknown) => void) | null = null;
66
+ // stable accessor (the callback slot is set once by the server later)
67
+ const getOnInbound = (): ((name: string, payload: unknown) => void) | null => onInbound;
68
+ const allowlist = opts.inboundEvents ? new Set(opts.inboundEvents) : null;
69
+
70
+ // inbound subscriptions (lazy — the transport queues them until connected)
71
+ const unsubs: Array<() => void> = [];
72
+ if (opts.inbound) {
73
+ const subjectsList = opts.inboundSubjects?.length
74
+ ? opts.inboundSubjects
75
+ : [subjects.inboundPrefix()];
76
+ for (const subject of subjectsList) {
77
+ unsubs.push(
78
+ subscribeInboundSubject({
79
+ transport: t,
80
+ bindings: b,
81
+ stats,
82
+ subject,
83
+ allowlist,
84
+ getOnInbound,
85
+ }),
86
+ );
87
+ }
88
+ }
89
+
90
+ return {
91
+ get status(): NatsBridgeStatus {
92
+ if (closed) return "closed";
93
+ return t.connected ? "connected" : "connecting";
94
+ },
95
+ get subjects() {
96
+ return subjects;
97
+ },
98
+ get stats() {
99
+ return stats;
100
+ },
101
+ get clientEvents(): boolean {
102
+ return opts.bridgeClientEvents ?? false;
103
+ },
104
+ publish(subject, frame) {
105
+ if (!t.connected) {
106
+ stats.bridgeErrors++;
107
+ return;
108
+ }
109
+ // the frame view is a reused scratch — copy before handing to NATS
110
+ const copy = frame.slice();
111
+ try {
112
+ t.publish(subject, copy);
113
+ stats.bridged++;
114
+ stats.bridgedBytes += copy.byteLength;
115
+ } catch {
116
+ stats.bridgeErrors++;
117
+ }
118
+ },
119
+ setOnInbound(cb) {
120
+ onInbound = cb;
121
+ },
122
+ subscribeRaw(subject, cb) {
123
+ return t.subscribe(subject, (data) => cb(data));
124
+ },
125
+ async close() {
126
+ closed = true;
127
+ for (const u of unsubs) u();
128
+ await t.close();
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Real NATS transport — an eager, non-blocking `NatsConnection` wrapper with
3
+ * an initial-connect retry loop, so a server can start while NATS is down.
4
+ *
5
+ * Subscriptions are RE-SYNCED on every (re)connect: the desired subject set
6
+ * is kept locally and replayed onto whichever connection is current —
7
+ * nats.js re-subscribes automatically after a reconnect, but the resync
8
+ * guards against edge cases (e.g. a replaced connection).
9
+ */
10
+ import { connect, type NatsConnection } from "nats";
11
+ import type { NatsBridgeOptions, NatsTransport } from "./types";
12
+
13
+ interface PendingSub {
14
+ subject: string;
15
+ cb: (data: Uint8Array) => void;
16
+ }
17
+
18
+ export function createRealTransport(opts: NatsBridgeOptions): NatsTransport {
19
+ let nc: NatsConnection | null = null;
20
+ let connected = false;
21
+ let closed = false;
22
+ // desired subscriptions — replayed on every (re)connect via sync()
23
+ const subs: Array<PendingSub> = [];
24
+ let unsubs: Array<() => void> = [];
25
+
26
+ /** Tear down the current subscription iterators and re-subscribe all. */
27
+ const sync = (): void => {
28
+ for (const u of unsubs) u();
29
+ unsubs = [];
30
+ if (!nc) return;
31
+ for (const s of subs) {
32
+ const sub = nc.subscribe(s.subject);
33
+ unsubs.push(() => sub.unsubscribe());
34
+ void (async () => {
35
+ try {
36
+ for await (const m of sub) s.cb(new Uint8Array(m.data));
37
+ } catch {
38
+ // subscription ended / connection closed
39
+ }
40
+ })();
41
+ }
42
+ };
43
+
44
+ /** Track connection liveness + trigger resyncs from nats.js status events. */
45
+ const attachStatus = (conn: NatsConnection): void => {
46
+ void conn
47
+ .closed()
48
+ .then(() => {
49
+ connected = false;
50
+ if (nc === conn) nc = null;
51
+ })
52
+ .catch(() => {
53
+ connected = false;
54
+ });
55
+ void (async () => {
56
+ try {
57
+ for await (const st of conn.status()) {
58
+ if (st.type === "disconnect") connected = false;
59
+ else if (st.type === "reconnect") {
60
+ connected = true;
61
+ sync(); // nats.js re-subscribes automatically; resync to be safe
62
+ }
63
+ }
64
+ } catch {
65
+ connected = false;
66
+ }
67
+ })();
68
+ };
69
+
70
+ let connecting = false;
71
+ const tryConnect = async (): Promise<void> => {
72
+ // one attempt at a time; a live connection (even mid-reconnect, which
73
+ // nats.js drives internally) is never replaced by a duplicate dial
74
+ if (closed || connecting || nc !== null) return;
75
+ connecting = true;
76
+ try {
77
+ const conn = await connect({
78
+ servers: opts.servers ?? ["nats://localhost:4222"],
79
+ ...(opts.token !== undefined ? { token: opts.token } : {}),
80
+ timeout: opts.connectTimeout ?? 5000,
81
+ reconnect: opts.reconnect ?? true,
82
+ maxReconnectAttempts: -1,
83
+ });
84
+ nc = conn;
85
+ connected = true;
86
+ attachStatus(conn);
87
+ sync();
88
+ } catch {
89
+ connected = false;
90
+ } finally {
91
+ connecting = false;
92
+ }
93
+ };
94
+
95
+ // background retry loop — the caller NEVER awaits a dial
96
+ void (async () => {
97
+ while (!closed) {
98
+ if (nc === null) await tryConnect();
99
+ await Bun.sleep(opts.connectRetryMs ?? 2000);
100
+ }
101
+ })();
102
+
103
+ return {
104
+ get connected() {
105
+ return connected;
106
+ },
107
+ publish(subject, data) {
108
+ if (!nc) throw new Error("nats: not connected");
109
+ nc.publish(subject, data);
110
+ },
111
+ subscribe(subject, cb) {
112
+ subs.push({ subject, cb });
113
+ sync();
114
+ return () => {
115
+ const i = subs.findIndex((s) => s.subject === subject && s.cb === cb);
116
+ if (i >= 0) subs.splice(i, 1);
117
+ sync();
118
+ };
119
+ },
120
+ async close() {
121
+ closed = true;
122
+ if (nc) {
123
+ try {
124
+ await nc.close();
125
+ } catch {
126
+ // already closed
127
+ }
128
+ }
129
+ nc = null;
130
+ connected = false;
131
+ },
132
+ };
133
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * NATS bridge contracts — options, stats, and the transport/bridge surfaces.
3
+ *
4
+ * Type + contract module: the runtime lives in `real-transport.ts` (the
5
+ * eager connection) and `index.ts` (the bridge itself).
6
+ */
7
+ import type { SubjectBuilder } from "../subjects";
8
+ import type { Bindings } from "../../bindings/types";
9
+
10
+ export type NatsBridgeStatus = "connected" | "connecting" | "closed";
11
+
12
+ export interface NatsBridgeOptions {
13
+ /** NATS servers, default ["nats://localhost:4222"] */
14
+ servers?: string[];
15
+ /** subject prefix, default "ignex" (or the bindings' subjectPrefix) */
16
+ subjectPrefix?: string;
17
+ /**
18
+ * The wire stack used to decode inbound frames (default: built-in registry).
19
+ * Pass your own generated bindings so the bridge decodes YOUR events.
20
+ */
21
+ bindings?: Bindings;
22
+ /** connect timeout (ms), default 5000 */
23
+ connectTimeout?: number;
24
+ /** how long to wait before retrying a failed initial connect (ms), default 2000 */
25
+ connectRetryMs?: number;
26
+ /** reconnect handled by nats.js (core NATS, no durable queues), default true */
27
+ reconnect?: boolean;
28
+ /** optional NATS token (auth) */
29
+ token?: string;
30
+ /** subscribe to inbound subjects and forward events to clients, default false */
31
+ inbound?: boolean;
32
+ /** inbound subjects (default `{prefix}.inbound.>`), requires `inbound` */
33
+ inboundSubjects?: string[];
34
+ /** only forward these inbound events (default: every app event) */
35
+ inboundEvents?: string[];
36
+ /**
37
+ * Re-publish every accepted client-sent event to `{prefix}.inbound.<event>`
38
+ * so other servers in the cluster (and BE consumers) receive it, default
39
+ * false. See the horizontal-scaling docs.
40
+ */
41
+ bridgeClientEvents?: boolean;
42
+ }
43
+
44
+ /** Counters folded into `server.getMetrics()`. */
45
+ export interface NatsBridgeStats {
46
+ bridged: number;
47
+ bridgedBytes: number;
48
+ bridgeErrors: number;
49
+ bridgeInbound: number;
50
+ bridgeInboundErrors: number;
51
+ }
52
+
53
+ /** Minimal transport — a real NATS connection or a test fake. */
54
+ export interface NatsTransport {
55
+ readonly connected: boolean;
56
+ /** synchronously send bytes; throws when not connected (bridge catches + counts) */
57
+ publish(subject: string, data: Uint8Array): void;
58
+ /** subscribe; `cb` receives message bytes; returns an unsubscribe function */
59
+ subscribe(subject: string, cb: (data: Uint8Array) => void): () => void;
60
+ close(): Promise<void>;
61
+ }
62
+
63
+ export interface NatsBridge {
64
+ readonly status: NatsBridgeStatus;
65
+ readonly subjects: SubjectBuilder;
66
+ readonly stats: NatsBridgeStats;
67
+ /** whether client-sent events are re-published to `{prefix}.inbound.<event>` */
68
+ readonly clientEvents: boolean;
69
+ /** publish a frame to `subject` (copies the bytes — safe after scratch reuse) */
70
+ publish(subject: string, frame: Uint8Array): void;
71
+ /**
72
+ * Raw byte subscription (used by the events cluster layer). Unlike the
73
+ * inbound path this does NOT decode or forward — bytes are handed to `cb`
74
+ * verbatim, re-subscribed automatically after a NATS reconnect.
75
+ */
76
+ subscribeRaw(subject: string, cb: (data: Uint8Array) => void): () => void;
77
+ /** wire the inbound → clients forward (set once by the server) */
78
+ setOnInbound(cb: (name: string, payload: unknown) => void): void;
79
+ close(): Promise<void>;
80
+ }
@@ -9,10 +9,20 @@
9
9
  */
10
10
 
11
11
  /** Wire format version. Bump on any BREAKING envelope change. */
12
- export const WIRE_VERSION = 1;
12
+ export const WIRE_VERSION = 2;
13
13
 
14
14
  /**
15
- * Envelope header length in bytes: `[version:1][event_id:u32 LE]`. The
16
- * size-prefixed FlatBuffer payload follows immediately after.
15
+ * Envelope header length in bytes:
16
+ * [version:1][event_id:u32 LE][flags:1][seq:u64 LE]
17
+ * The size-prefixed FlatBuffer payload follows immediately after.
18
+ *
19
+ * `flags` bit0 = seq-valid: the server stamps a per-CONNECTION delivery seq on
20
+ * every frame it writes to a socket (mutated in place just before `ws.send`,
21
+ * which copies). Clients use it for gap detection + resume. Frames that were
22
+ * not per-destination stamped (client-encoded, replay history copies before
23
+ * stamping) carry flags=0 / seq=0.
17
24
  */
18
- export const WIRE_HEADER_LEN = 5;
25
+ export const WIRE_HEADER_LEN = 14;
26
+
27
+ /** flags bit: `seq` field carries a valid per-connection delivery sequence. */
28
+ export const WIRE_FLAG_SEQ = 1;
@@ -29,7 +29,13 @@ import { isDirectableEvent, plainTsType, toSnake } from "./schema-model";
29
29
  export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
30
30
  const directEvents = m.events.filter((ev) => isDirectableEvent(m, ev));
31
31
  const userMode = ctx.schemaImport === null;
32
- const libImport = userMode ? (ctx.libraryImport ?? "@ignex/nova") : "";
32
+ let libImport = "";
33
+ if (userMode) {
34
+ libImport =
35
+ ctx.libraryImport === undefined || ctx.libraryImport === "@ignex/nova"
36
+ ? "@ignex/nova/internal"
37
+ : ctx.libraryImport;
38
+ }
33
39
 
34
40
  const enumConsts = new Map<string, string>(); // enumName -> const name
35
41
  for (const e of m.enums) {
@@ -71,7 +77,9 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
71
77
  for (const ev of directEvents) {
72
78
  const fields = fieldsOf(m, ev);
73
79
  const args = fields.flatMap(directArgTypes).concat("ptr", "usize");
74
- lines.push(` ${directSymbol(ev)}: {`);
80
+ // Quote the key — dotted event names ("chat.send") would otherwise emit
81
+ // an invalid identifier (`fb_chat.send_serialize:`).
82
+ lines.push(` ${JSON.stringify(directSymbol(ev))}: {`);
75
83
  lines.push(` args: [${args.map((a) => JSON.stringify(a)).join(", ")}],`);
76
84
  lines.push(' returns: "u64_fast",');
77
85
  lines.push(" },");
@@ -200,12 +208,12 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
200
208
  lines.push(
201
209
  "export const hasNulEncoders: Partial<Record<AnyEventName, (o: unknown) => boolean>> = {",
202
210
  );
203
- for (const ev of directEvents) lines.push(` ${ev.name}: hasNul${pascal(ev.name)},`);
211
+ for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: hasNul${pascal(ev.name)},`);
204
212
  lines.push("};");
205
213
  lines.push("");
206
214
 
207
215
  lines.push("export const directSymbolNames: Partial<Record<AnyEventName, string>> = {");
208
- for (const ev of directEvents) lines.push(` ${ev.name}: ${JSON.stringify(directSymbol(ev))},`);
216
+ for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: ${JSON.stringify(directSymbol(ev))},`);
209
217
  lines.push("};");
210
218
  lines.push("");
211
219
  lines.push(
@@ -213,7 +221,7 @@ export function emitDirectSer(m: Model, ctx: EmitContext = {}): string {
213
221
  );
214
222
  lines.push("");
215
223
  lines.push("export const directEncoders: Partial<Record<AnyEventName, DirectEncoder>> = {");
216
- for (const ev of directEvents) lines.push(` ${ev.name}: encode${pascal(ev.name)},`);
224
+ for (const ev of directEvents) lines.push(` ${JSON.stringify(ev.name)}: encode${pascal(ev.name)},`);
217
225
  lines.push("};");
218
226
  lines.push("");
219
227
 
@@ -546,5 +554,11 @@ function scalarVecWrite(f: FieldDef, item: string, enumConsts: Map<string, strin
546
554
  }
547
555
 
548
556
  function pascal(s: string): string {
549
- return s.charAt(0).toUpperCase() + s.slice(1);
557
+ // "chat.send" "ChatSend": split on non-alphanumerics so event names with
558
+ // dots/hyphens still produce valid identifiers (encodeChatSendPayload...).
559
+ return s
560
+ .split(/[^a-zA-Z0-9]+/)
561
+ .filter((part) => part.length > 0)
562
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
563
+ .join("");
550
564
  }
@@ -39,9 +39,13 @@ export function emitRegistry(m: Model, fingerprint: number, ctx: EmitContext = {
39
39
 
40
40
  const userMode = ctx.schemaImport === null;
41
41
  const schemaImport = userMode ? "" : (ctx.schemaImport ?? "../schema");
42
- const poolImport = userMode
43
- ? (ctx.libraryImport ?? "@ignex/nova")
44
- : "../transport/byte-buffer-pool";
42
+ let poolImport = "../transport/byte-buffer-pool";
43
+ if (userMode) {
44
+ poolImport =
45
+ ctx.libraryImport === undefined || ctx.libraryImport === "@ignex/nova"
46
+ ? "@ignex/nova/internal"
47
+ : ctx.libraryImport;
48
+ }
45
49
 
46
50
  const lines: string[] = [];
47
51
  lines.push("// @ts-nocheck — generated file: not subject to hand-typed strictness gates");
@@ -72,17 +76,17 @@ export function emitRegistry(m: Model, fingerprint: number, ctx: EmitContext = {
72
76
  if (userMode) lines.push(...emitLocalTypes(m, neededTables, appEvents, controlEvents));
73
77
  lines.push("export const eventNameToId: Record<EventName, number> = {");
74
78
  for (const ev of appEvents)
75
- lines.push(` ${ev.name}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
79
+ lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
76
80
  lines.push("};");
77
81
  lines.push("");
78
82
  lines.push("export const controlEventNameToId: Record<ControlEventName, number> = {");
79
83
  for (const ev of controlEvents)
80
- lines.push(` ${ev.name}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
84
+ lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)}, // fnv1a32("${ev.name}")`);
81
85
  lines.push("};");
82
86
  lines.push("");
83
87
  lines.push("/** merged app + control registry (used by encodeToScratch / JS encoder). */");
84
88
  lines.push("export const anyEventNameToId: Record<string, number> = {");
85
- for (const ev of m.events) lines.push(` ${ev.name}: ${eventId(ev.name)},`);
89
+ for (const ev of m.events) lines.push(` ${JSON.stringify(ev.name)}: ${eventId(ev.name)},`);
86
90
  lines.push("};");
87
91
  lines.push("");
88
92
  lines.push("export const idToEventName: Record<number, EventName> = {");
@@ -368,6 +368,9 @@ function emitDirectFn(m: Model, ev: EventDef): string {
368
368
  const table = m.tables.find((t) => t.name === ev.tableName)!;
369
369
  const id = eventId(ev.name);
370
370
  const snake = toSnake(ev.name);
371
+ // Dotted event names ("chat.send") must not leak into the Rust fn
372
+ // identifier — keep the C-ABI symbol exact via #[export_name] instead.
373
+ const fnId = snake.replace(/[^a-zA-Z0-9_]/g, "_");
371
374
  const decls = table.fields.map(directArgDecl).join("\n ");
372
375
  const decodes = table.fields
373
376
  .map(directDecodeLines)
@@ -379,8 +382,8 @@ function emitDirectFn(m: Model, ev: EventDef): string {
379
382
  .join("\n ");
380
383
  const assigns = table.fields.map(directArgAssign).join("\n ");
381
384
  return [
382
- `#[no_mangle]`,
383
- `pub unsafe extern "C" fn fb_${snake}_serialize(`,
385
+ `#[export_name = "fb_${snake}_serialize"]`,
386
+ `pub unsafe extern "C" fn fb_${fnId}_serialize(`,
384
387
  ` ${decls}`,
385
388
  ` out: *mut u8,`,
386
389
  ` out_cap: usize,`,
@@ -409,6 +412,10 @@ function emitDirectFn(m: Model, ev: EventDef): string {
409
412
  ` *out.add(0) = WIRE_VERSION;`,
410
413
  ` let id_bytes = ${id}u32.to_le_bytes();`,
411
414
  ` std::ptr::copy_nonoverlapping(id_bytes.as_ptr(), out.add(1), 4);`,
415
+ ` // [flags:1][seq:u64 LE] — pristine (flags=0, seq=0); the`,
416
+ ` // server stamps per-destination delivery seqs on send.`,
417
+ ` *out.add(5) = 0;`,
418
+ ` std::ptr::write_bytes(out.add(6), 0, 8);`,
412
419
  ` }`,
413
420
  ` needed`,
414
421
  ` }`,
@@ -444,7 +451,7 @@ export function emitRustGlue(m: Model, fingerprint: number): string {
444
451
  lines.push("");
445
452
  lines.push(`pub const WIRE_VERSION: u8 = ${WIRE_VERSION};`);
446
453
  lines.push(
447
- `pub const WIRE_HEADER_LEN: usize = ${WIRE_HEADER_LEN}; // [version:1][event_id:u32 LE]`,
454
+ `pub const WIRE_HEADER_LEN: usize = ${WIRE_HEADER_LEN}; // [version:1][event_id:u32 LE][flags:1][seq:u64 LE]`,
448
455
  );
449
456
  lines.push(`pub const SCHEMA_FINGERPRINT: u64 = ${fingerprint}; // fnv1a32(canonical model)`);
450
457
  lines.push("");
@@ -199,6 +199,17 @@ function ensureTable(ctx: Ctx, schema: AnySchema, fallbackName: string): string
199
199
  return name;
200
200
  }
201
201
 
202
+ /**
203
+ * Error for TypeBox field kinds that have no FlatBuffers representation. These
204
+ * used to be silently coerced to `string` fields (which forced JSON.stringify
205
+ * on the app side and put raw JSON text on the wire) — fail loudly instead.
206
+ */
207
+ function unsupportedFieldType(parentName: string, jsonName: string, reason: string): Error {
208
+ return new Error(
209
+ `generateBindings: field "${jsonName}" in "${parentName}" has no FlatBuffers representation (${reason}) — model it explicitly: Type.String() for a JSON payload, a typed object/table, or a flat scalar/vector type`,
210
+ );
211
+ }
212
+
202
213
  function resolveFieldType(
203
214
  ctx: Ctx,
204
215
  schema: AnySchema,
@@ -216,7 +227,11 @@ function resolveFieldType(
216
227
  const tableName = ensureTable(ctx, tableMember, `${parentName}${toPascal(jsonName)}`);
217
228
  return { kind: "table", tableName };
218
229
  }
219
- return { kind: "string" };
230
+ throw unsupportedFieldType(
231
+ parentName,
232
+ jsonName,
233
+ "union of mixed members (only string-literal enums and single-object unions are supported)",
234
+ );
220
235
  }
221
236
 
222
237
  switch (schema.type) {
@@ -262,12 +277,22 @@ function resolveFieldType(
262
277
  }
263
278
  }
264
279
  case "object": {
265
- if (!isObjectLike(schema)) return { kind: "string" };
280
+ if (!isObjectLike(schema)) {
281
+ throw unsupportedFieldType(
282
+ parentName,
283
+ jsonName,
284
+ "dynamic-key object (Type.Record / additionalProperties) has no FlatBuffers table representation",
285
+ );
286
+ }
266
287
  const tableName = ensureTable(ctx, schema, `${parentName}${toPascal(jsonName)}`);
267
288
  return { kind: "table", tableName };
268
289
  }
269
290
  default:
270
- return { kind: "string" };
291
+ throw unsupportedFieldType(
292
+ parentName,
293
+ jsonName,
294
+ `unrecognized TypeBox type ${schema.type === undefined ? "(Type.Any()/Type.Unknown()?)" : `"${String(schema.type)}"`}`,
295
+ );
271
296
  }
272
297
  }
273
298
 
@@ -122,7 +122,7 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
122
122
  lines.push("");
123
123
  lines.push("export const jsEncoders: Record<string, JsEncoder> = {");
124
124
  for (const ev of m.events)
125
- lines.push(` ${ev.name}: encode${pascal(ev.name)}Payload as JsEncoder,`);
125
+ lines.push(` ${JSON.stringify(ev.name)}: encode${pascal(ev.name)}Payload as JsEncoder,`);
126
126
  lines.push("};");
127
127
  lines.push("");
128
128
  lines.push("/** Encode a payload (app or control event) → size-prefixed FlatBuffer. */");
@@ -135,7 +135,7 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
135
135
  lines.push("}");
136
136
  lines.push("");
137
137
  lines.push(
138
- "/** Encode a payload into a full wire frame `[version][event_id:u32][size-prefixed FB]`. */",
138
+ "/** Encode a payload into a full wire frame `[version][event_id:u32][flags:1][seq:u64 LE][size-prefixed FB]`. */",
139
139
  );
140
140
  lines.push(
141
141
  "export function encodeEventFrame(name: AnyEventName, o: unknown, b?: flatbuffers.Builder): Uint8Array {",
@@ -145,6 +145,9 @@ export function emitTsSer(m: Model, ctx: EmitContext = {}): string {
145
145
  lines.push(" const frame = new Uint8Array(WIRE_HEADER_LEN + payload.byteLength);");
146
146
  lines.push(" frame[0] = WIRE_VERSION;");
147
147
  lines.push(" new DataView(frame.buffer).setUint32(1, id, true);");
148
+ lines.push(" // pristine delivery header (flags=0, seq=0) — client-sent frames are not stamped");
149
+ lines.push(" frame[WIRE_HEADER_LEN - 9] = 0;");
150
+ lines.push(" new DataView(frame.buffer).setBigUint64(WIRE_HEADER_LEN - 8, 0n, true);");
148
151
  lines.push(" frame.set(payload, WIRE_HEADER_LEN);");
149
152
  lines.push(" return frame;");
150
153
  lines.push("}");
@@ -217,5 +220,11 @@ function ctorArg(f: FieldDef, o: string): string {
217
220
  }
218
221
 
219
222
  function pascal(s: string): string {
220
- return s.charAt(0).toUpperCase() + s.slice(1);
223
+ // "chat.send" "ChatSend": split on non-alphanumerics so event names with
224
+ // dots/hyphens still produce valid identifiers (encodeChatSendPayload...).
225
+ return s
226
+ .split(/[^a-zA-Z0-9]+/)
227
+ .filter((part) => part.length > 0)
228
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
229
+ .join("");
221
230
  }