@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,99 @@
1
+ /**
2
+ * Presence table — in-memory index of OTHER instances' connections, learned
3
+ * from presence join/leave/sync messages and pruned by TTL.
4
+ *
5
+ * Encapsulated factory (like `createMetrics`): all mutation is private; the
6
+ * surface is queries + pure-ish updates. No timers, no I/O — the sync layer
7
+ * drives it from broker messages and the heartbeat tick.
8
+ */
9
+ import type { RemoteClient } from "../types";
10
+
11
+ export interface PresenceTable {
12
+ /** record that `instance` was heard from at epoch ms `at` */
13
+ touch(instance: string, at: number): void;
14
+ /** heartbeat from `instance`: refresh the instance AND its reported clients */
15
+ refreshInstance(instance: string, at: number): void;
16
+ /** a connection joined on a remote instance */
17
+ join(clientId: string, instanceId: string, userId: string | undefined, at: number): void;
18
+ /**
19
+ * a connection left a remote instance — only honored when the reporting
20
+ * instance still owns the record (stale leaves from older epochs are ignored)
21
+ */
22
+ leave(clientId: string, instanceId: string): void;
23
+ /** drop clients/instances not heard from within `ttlMs` */
24
+ prune(ttlMs: number, now?: number): void;
25
+ /** instances that currently hold `clientId` ([] = unknown) */
26
+ instancesForClient(clientId: string): string[];
27
+ /** unique instances holding any connection of `userId` */
28
+ instancesForUser(userId: string): string[];
29
+ /** every other instance heard from recently */
30
+ knownInstances(): string[];
31
+ /** snapshot of remote connection records */
32
+ remoteClients(): RemoteClient[];
33
+ }
34
+
35
+ export function createPresenceTable(): PresenceTable {
36
+ // clientId → remote record
37
+ const remote = new Map<string, RemoteClient>();
38
+ // other instanceId → last-seen epoch ms
39
+ const instanceSeen = new Map<string, number>();
40
+
41
+ return {
42
+ touch(instance, at) {
43
+ instanceSeen.set(instance, at);
44
+ },
45
+
46
+ refreshInstance(instance, at) {
47
+ instanceSeen.set(instance, at);
48
+ for (const r of remote.values()) {
49
+ if (r.instanceId === instance) r.lastSeen = at;
50
+ }
51
+ },
52
+
53
+ join(clientId, instanceId, userId, at) {
54
+ instanceSeen.set(instanceId, at);
55
+ remote.set(clientId, {
56
+ clientId,
57
+ instanceId,
58
+ ...(userId !== undefined ? { userId } : {}),
59
+ lastSeen: at,
60
+ });
61
+ },
62
+
63
+ leave(clientId, instanceId) {
64
+ instanceSeen.set(instanceId, Date.now());
65
+ const r = remote.get(clientId);
66
+ if (r && r.instanceId === instanceId) remote.delete(clientId);
67
+ },
68
+
69
+ prune(ttlMs, now = Date.now()) {
70
+ for (const [clientId, r] of remote) {
71
+ if (now - r.lastSeen > ttlMs) remote.delete(clientId);
72
+ }
73
+ for (const [inst, at] of instanceSeen) {
74
+ if (now - at > ttlMs) instanceSeen.delete(inst);
75
+ }
76
+ },
77
+
78
+ instancesForClient(clientId) {
79
+ const r = remote.get(clientId);
80
+ return r ? [r.instanceId] : [];
81
+ },
82
+
83
+ instancesForUser(userId) {
84
+ const out: string[] = [];
85
+ for (const r of remote.values()) {
86
+ if (r.userId === userId && !out.includes(r.instanceId)) out.push(r.instanceId);
87
+ }
88
+ return out;
89
+ },
90
+
91
+ knownInstances() {
92
+ return [...instanceSeen.keys()];
93
+ },
94
+
95
+ remoteClients() {
96
+ return [...remote.values()];
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Presence messages — the tiny JSON payloads exchanged on the `presence`
3
+ * channel (kind = "presence", frame = JSON) so instances learn about each
4
+ * other's connections WITHOUT any shared state.
5
+ *
6
+ * Pure codec module.
7
+ */
8
+
9
+ /** A connection joined on instance `i`. */
10
+ export interface PresenceJoin {
11
+ t: "j";
12
+ i: string;
13
+ c: string;
14
+ u?: string;
15
+ at: number;
16
+ }
17
+
18
+ /** A connection left instance `i`. */
19
+ export interface PresenceLeave {
20
+ t: "l";
21
+ i: string;
22
+ c: string;
23
+ }
24
+
25
+ /** Periodic per-instance heartbeat (also refreshes liveness). */
26
+ export interface PresenceSync {
27
+ t: "s";
28
+ i: string;
29
+ at: number;
30
+ }
31
+
32
+ export type PresenceMessage = PresenceJoin | PresenceLeave | PresenceSync;
33
+
34
+ // module-global codecs: allocated once, reused for every message
35
+ const enc = new TextEncoder();
36
+ const dec = new TextDecoder();
37
+
38
+ /** Encode a presence message into the envelope's frame bytes. */
39
+ export function encodePresence(msg: PresenceMessage): Uint8Array {
40
+ return enc.encode(JSON.stringify(msg));
41
+ }
42
+
43
+ /**
44
+ * Decode presence bytes; `null` when malformed (callers drop silently —
45
+ * presence is advisory and self-healing via heartbeat/TTL).
46
+ */
47
+ export function decodePresence(bytes: Uint8Array): PresenceMessage | null {
48
+ try {
49
+ return JSON.parse(dec.decode(bytes)) as PresenceMessage;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Lazy `ioredis` loader — shared by the Redis cluster transport and the Redis
3
+ * state store. Redis is an OPTIONAL peer dependency: it is never bundled and
4
+ * only loaded when a Redis option is actually used.
5
+ *
6
+ * The structural types below describe the small slice of ioredis nova uses;
7
+ * they are intentionally loose (the library is untyped here) but ABI-exact.
8
+ */
9
+ import { createRequire } from "node:module";
10
+ import type { RedisConnectionOptions } from "../types";
11
+
12
+ const nodeRequire = createRequire(import.meta.url);
13
+
14
+ /** Structural type of the ioredis client surface nova relies on. */
15
+ export interface IoredisClient {
16
+ publish(channel: string, data: Buffer): Promise<unknown>;
17
+ subscribe(...channels: string[]): Promise<unknown>;
18
+ psubscribe(...patterns: string[]): Promise<unknown>;
19
+ unsubscribe(...channels: string[]): Promise<unknown>;
20
+ punsubscribe(...patterns: string[]): Promise<unknown>;
21
+ on(event: string, cb: (...args: unknown[]) => void): unknown;
22
+ get(key: string): Promise<unknown>;
23
+ set(...args: unknown[]): Promise<unknown>;
24
+ del(...keys: string[]): Promise<unknown>;
25
+ sadd(key: string, member: string): Promise<unknown>;
26
+ srem(key: string, member: string): Promise<unknown>;
27
+ smembers(key: string): Promise<unknown>;
28
+ expire(key: string, seconds: number): Promise<unknown>;
29
+ quit(): Promise<unknown>;
30
+ readonly status: string;
31
+ }
32
+
33
+ /** Synchronously load the ioredis constructor (throws with install guidance). */
34
+ export function loadRedis(): new (...args: unknown[]) => IoredisClient {
35
+ try {
36
+ return nodeRequire("ioredis") as new (...args: unknown[]) => IoredisClient;
37
+ } catch {
38
+ throw new Error(
39
+ "ignex events cluster: Redis configured but 'ioredis' is not installed — run `bun add ioredis` (or pass a custom cluster.transport / cluster.state)",
40
+ );
41
+ }
42
+ }
43
+
44
+ /** Split connection options into ioredis ctor args ({url} vs {options}). */
45
+ export function redisConnArgs(opts: RedisConnectionOptions): {
46
+ url?: string;
47
+ options?: Record<string, unknown>;
48
+ } {
49
+ return typeof opts === "string" ? { url: opts } : { options: opts };
50
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * In-memory state store — per-process default `ClusterStateStore`.
3
+ * Pass a SHARED `Map` to simulate a cross-instance store in tests /
4
+ * single-process multi-instance setups.
5
+ *
6
+ * TTLs are modeled with a parallel `__ttl:` key checked lazily on read.
7
+ */
8
+ import type { ClusterStateStore } from "../types";
9
+
10
+ export function createMemoryStateStore(
11
+ shared?: Map<string, unknown>,
12
+ ): ClusterStateStore & { close(): Promise<void> } {
13
+ const data = shared ?? new Map<string, unknown>();
14
+
15
+ const ttlKey = (key: string): string => `__ttl:${key}`;
16
+
17
+ /** Lazily expire: drop the key when its TTL has passed; report liveness. */
18
+ const alive = (key: string): boolean => {
19
+ const ttl = data.get(ttlKey(key));
20
+ if (ttl === undefined) return true;
21
+ if (Date.now() > Number(ttl)) {
22
+ data.delete(key);
23
+ data.delete(ttlKey(key));
24
+ return false;
25
+ }
26
+ return true;
27
+ };
28
+
29
+ return {
30
+ async get(key) {
31
+ if (!alive(key)) return null;
32
+ const v = data.get(key);
33
+ return typeof v === "string" ? v : null;
34
+ },
35
+ async set(key, value, ttlMs) {
36
+ data.set(key, value);
37
+ if (ttlMs !== undefined) data.set(ttlKey(key), Date.now() + ttlMs);
38
+ },
39
+ async del(key) {
40
+ data.delete(key);
41
+ data.delete(ttlKey(key));
42
+ },
43
+ async sadd(key, member) {
44
+ let s = data.get(key);
45
+ if (!(s instanceof Set)) {
46
+ s = new Set<string>();
47
+ data.set(key, s);
48
+ }
49
+ (s as Set<string>).add(member);
50
+ },
51
+ async srem(key, member) {
52
+ const s = data.get(key);
53
+ if (s instanceof Set) (s as Set<string>).delete(member);
54
+ },
55
+ async smembers(key) {
56
+ if (!alive(key)) return [];
57
+ const s = data.get(key);
58
+ return s instanceof Set ? [...(s as Set<string>)] : [];
59
+ },
60
+ async expire(key, ttlMs) {
61
+ data.set(ttlKey(key), Date.now() + ttlMs);
62
+ },
63
+ async close() {
64
+ data.clear();
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Redis state store (lazy `ioredis`). The production `ClusterStateStore` for
3
+ * horizontally scaled deployments — every call is issued from the offload
4
+ * queue, never from the WS hot path.
5
+ */
6
+ import type { ClusterStateStore, RedisConnectionOptions } from "../types";
7
+ import { loadRedis } from "./redis-client";
8
+
9
+ export function createRedisStateStore(
10
+ opts: RedisConnectionOptions = {},
11
+ ): ClusterStateStore & { close(): Promise<void> } {
12
+ const Redis = loadRedis();
13
+ const r = new Redis(opts);
14
+ return {
15
+ async get(key) {
16
+ const v = await r.get(key);
17
+ return v == null ? null : String(v);
18
+ },
19
+ async set(key, value, ttlMs) {
20
+ if (ttlMs !== undefined) await r.set(key, value, "PX", ttlMs);
21
+ else await r.set(key, value);
22
+ },
23
+ async del(key) {
24
+ await r.del(key);
25
+ },
26
+ async sadd(key, member) {
27
+ await r.sadd(key, member);
28
+ },
29
+ async srem(key, member) {
30
+ await r.srem(key, member);
31
+ },
32
+ async smembers(key) {
33
+ const v = await r.smembers(key);
34
+ return Array.isArray(v) ? v.map(String) : [];
35
+ },
36
+ // redis EXPIRE is second-granularity — round up so short TTLs still hold
37
+ async expire(key, ttlMs) {
38
+ await r.expire(key, Math.max(1, Math.ceil(ttlMs / 1000)));
39
+ },
40
+ async close() {
41
+ await r.quit();
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cluster subject space — the broker channel names derived from the prefix.
3
+ *
4
+ * Every instance subscribes ONE wildcard channel (full-mesh kinds + presence)
5
+ * plus its own per-instance channel (routed targeted delivery). The envelope
6
+ * carries routing, so these names are pure syntax.
7
+ *
8
+ * Pure factory — no I/O.
9
+ */
10
+ import type { EmitTargetKind } from "../types";
11
+
12
+ /** The three subject shapes used by cluster messaging. */
13
+ export interface ClusterSubjects {
14
+ /** the one channel every instance subscribes to (full-mesh kinds) */
15
+ all(): string;
16
+ /** per-instance channel — routed targeted delivery lands here only */
17
+ instance(id: string): string;
18
+ /** publish channel for a target kind (also the external visibility of the subject space) */
19
+ event(kind: EmitTargetKind, key: string | undefined, name: string): string;
20
+ }
21
+
22
+ export function createClusterSubjects(prefix: string): ClusterSubjects {
23
+ const base = `${prefix}.cluster`;
24
+ return {
25
+ all: () => `${base}.>`,
26
+ instance: (id) => `${base}.instance.${id}`,
27
+ event: (kind, key, name) =>
28
+ key === undefined ? `${base}.${kind}.${name}` : `${base}.${kind}.${key}.${name}`,
29
+ };
30
+ }