@kronos-ts/kronosdb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/connection.d.ts +86 -0
  2. package/dist/connection.d.ts.map +1 -0
  3. package/dist/connection.js +133 -0
  4. package/dist/connection.js.map +1 -0
  5. package/dist/errors.d.ts +72 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +149 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/event-processor-info.d.ts +32 -0
  10. package/dist/event-processor-info.d.ts.map +1 -0
  11. package/dist/event-processor-info.js +24 -0
  12. package/dist/event-processor-info.js.map +1 -0
  13. package/dist/flow-controlled-sender.d.ts +12 -0
  14. package/dist/flow-controlled-sender.d.ts.map +1 -0
  15. package/dist/flow-controlled-sender.js +53 -0
  16. package/dist/flow-controlled-sender.js.map +1 -0
  17. package/dist/generated/command.d.ts +169 -0
  18. package/dist/generated/command.d.ts.map +1 -0
  19. package/dist/generated/command.js +964 -0
  20. package/dist/generated/command.js.map +1 -0
  21. package/dist/generated/common.d.ts +76 -0
  22. package/dist/generated/common.d.ts.map +1 -0
  23. package/dist/generated/common.js +648 -0
  24. package/dist/generated/common.js.map +1 -0
  25. package/dist/generated/eventstore.d.ts +337 -0
  26. package/dist/generated/eventstore.d.ts.map +1 -0
  27. package/dist/generated/eventstore.js +1757 -0
  28. package/dist/generated/eventstore.js.map +1 -0
  29. package/dist/generated/platform.d.ts +242 -0
  30. package/dist/generated/platform.d.ts.map +1 -0
  31. package/dist/generated/platform.js +1525 -0
  32. package/dist/generated/platform.js.map +1 -0
  33. package/dist/generated/query.d.ts +265 -0
  34. package/dist/generated/query.d.ts.map +1 -0
  35. package/dist/generated/query.js +2114 -0
  36. package/dist/generated/query.js.map +1 -0
  37. package/dist/generated/snapshot.d.ts +180 -0
  38. package/dist/generated/snapshot.d.ts.map +1 -0
  39. package/dist/generated/snapshot.js +861 -0
  40. package/dist/generated/snapshot.js.map +1 -0
  41. package/dist/index.d.ts +13 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +13 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/kronosdb-event-store.d.ts +17 -0
  46. package/dist/kronosdb-event-store.d.ts.map +1 -0
  47. package/dist/kronosdb-event-store.js +328 -0
  48. package/dist/kronosdb-event-store.js.map +1 -0
  49. package/dist/kronosdb-snapshot-store.d.ts +10 -0
  50. package/dist/kronosdb-snapshot-store.d.ts.map +1 -0
  51. package/dist/kronosdb-snapshot-store.js +79 -0
  52. package/dist/kronosdb-snapshot-store.js.map +1 -0
  53. package/dist/kronosdb.d.ts +53 -0
  54. package/dist/kronosdb.d.ts.map +1 -0
  55. package/dist/kronosdb.js +852 -0
  56. package/dist/kronosdb.js.map +1 -0
  57. package/dist/metadata-conversion.d.ts +37 -0
  58. package/dist/metadata-conversion.d.ts.map +1 -0
  59. package/dist/metadata-conversion.js +75 -0
  60. package/dist/metadata-conversion.js.map +1 -0
  61. package/dist/outbound-stream.d.ts +15 -0
  62. package/dist/outbound-stream.d.ts.map +1 -0
  63. package/dist/outbound-stream.js +39 -0
  64. package/dist/outbound-stream.js.map +1 -0
  65. package/dist/platform-service.d.ts +87 -0
  66. package/dist/platform-service.d.ts.map +1 -0
  67. package/dist/platform-service.js +218 -0
  68. package/dist/platform-service.js.map +1 -0
  69. package/dist/service-definitions.d.ts +187 -0
  70. package/dist/service-definitions.d.ts.map +1 -0
  71. package/dist/service-definitions.js +18 -0
  72. package/dist/service-definitions.js.map +1 -0
  73. package/dist/shutdown-latch.d.ts +18 -0
  74. package/dist/shutdown-latch.d.ts.map +1 -0
  75. package/dist/shutdown-latch.js +51 -0
  76. package/dist/shutdown-latch.js.map +1 -0
  77. package/package.json +69 -0
  78. package/src/connection.ts +235 -0
  79. package/src/errors.ts +173 -0
  80. package/src/event-processor-info.ts +53 -0
  81. package/src/flow-controlled-sender.ts +73 -0
  82. package/src/generated/command.ts +1226 -0
  83. package/src/generated/common.ts +770 -0
  84. package/src/generated/eventstore.ts +2241 -0
  85. package/src/generated/platform.ts +1914 -0
  86. package/src/generated/query.ts +2571 -0
  87. package/src/generated/snapshot.ts +1110 -0
  88. package/src/index.ts +87 -0
  89. package/src/kronosdb-event-store.ts +401 -0
  90. package/src/kronosdb-snapshot-store.ts +104 -0
  91. package/src/kronosdb.ts +1000 -0
  92. package/src/metadata-conversion.ts +85 -0
  93. package/src/outbound-stream.ts +52 -0
  94. package/src/platform-service.ts +297 -0
  95. package/src/service-definitions.ts +25 -0
  96. package/src/shutdown-latch.ts +74 -0
@@ -0,0 +1,1000 @@
1
+ /**
2
+ * Native KronosDB extension (Phase 9, D-95 / D-101 / D-102).
3
+ *
4
+ * Native `(app: App) => void` extension that:
5
+ *
6
+ * - populates four typed slots (eventStore, snapshotStore, commandBus,
7
+ * queryBus) via app.set(...) using the canonical Resolved slot names
8
+ * (in particular `resolved.unitOfWorkFactory`, NOT `unitOfWorkRunner`);
9
+ * - wires connect-stage transport bring-up under the @kronos-ts/common
10
+ * resilience helper (initial-connect + health-check + platform setup +
11
+ * instruction handlers + platform.start);
12
+ * - wires processors-stage subscription-ack wait via withRetry against
13
+ * `platform.subscriptionsAcked()` — REPLACES the 1-second sleep hack
14
+ * that lived at line 216 of the legacy file (D-102);
15
+ * - reverses shutdown deterministically in a single onStop('connect') hook
16
+ * (busLatches → platform.stop → connection.close — D-101.b).
17
+ */
18
+ import { generateIdentifier, qualifiedNameFromString, qualifiedNameToString, type Serializer, withRetry, healthCheck, type ResilienceConfig } from "@kronos-ts/common"
19
+ import type { App } from "@kronos-ts/app"
20
+ import type { CommandBus, CommandMessage, EventProcessorModule, QueryBus, QueryMessage, SubscriptionQueryResult, UoWRunner, UpdateHandler } from "@kronos-ts/messaging"
21
+ import { createUpdateHandler, runAfterCommitOrImmediately } from "@kronos-ts/messaging"
22
+ import type { KronosDbConnectionConfig } from "./connection.js"
23
+ import { connectToKronosDb, createKronosMetadata, type KronosDbConnection } from "./connection.js"
24
+ import { KronosDbErrorCode, mapErrorCode } from "./errors.js"
25
+ import { metadataFromProto, metadataToProto } from "./metadata-conversion.js"
26
+ import { createOutboundStream } from "./outbound-stream.js"
27
+ import { createPlatformConnection, type PlatformConnection, type PlatformServiceOptions } from "./platform-service.js"
28
+ import { createKronosDbEventStore } from "./kronosdb-event-store.js"
29
+ import { createKronosDbSnapshotStore } from "./kronosdb-snapshot-store.js"
30
+ import { createShutdownLatch, type ShutdownLatch } from "./shutdown-latch.js"
31
+
32
+ const DEFAULT_PERMITS = 5000n
33
+ const DEFAULT_THRESHOLD = 2500n
34
+
35
+ export interface FlowControlConfig {
36
+ permits?: number
37
+ refillThreshold?: number
38
+ }
39
+
40
+ export interface ProcessingInstructions {
41
+ routingKey?: string
42
+ priority?: number
43
+ timeoutMs?: number
44
+ }
45
+
46
+ const INSTRUCTION_KEY = {
47
+ ROUTING_KEY: 0,
48
+ PRIORITY: 1,
49
+ TIMEOUT: 2,
50
+ NR_OF_RESULTS: 3,
51
+ } as const
52
+
53
+ function toProtoProcessingInstructions(instructions?: ProcessingInstructions): any[] {
54
+ if (!instructions) return []
55
+ const result: any[] = []
56
+ if (instructions.routingKey !== undefined) {
57
+ result.push({ key: INSTRUCTION_KEY.ROUTING_KEY, value: { textValue: instructions.routingKey } })
58
+ }
59
+ if (instructions.priority !== undefined) {
60
+ result.push({ key: INSTRUCTION_KEY.PRIORITY, value: { numberValue: BigInt(instructions.priority) } })
61
+ }
62
+ if (instructions.timeoutMs !== undefined) {
63
+ result.push({ key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(instructions.timeoutMs) } })
64
+ }
65
+ return result
66
+ }
67
+
68
+ function defaultQueryInstructions(timeoutMs: number): any[] {
69
+ return [
70
+ { key: INSTRUCTION_KEY.TIMEOUT, value: { numberValue: BigInt(timeoutMs) } },
71
+ { key: INSTRUCTION_KEY.NR_OF_RESULTS, value: { numberValue: 1n } },
72
+ ]
73
+ }
74
+
75
+ export interface KronosDbExtensionConfig extends KronosDbConnectionConfig {
76
+ commandFlowControl?: FlowControlConfig
77
+ queryFlowControl?: FlowControlConfig
78
+ platformService?: PlatformServiceOptions
79
+ shortcutQueriesToLocalHandlers?: boolean
80
+ commandLoadFactor?: number
81
+ commandTimeoutMs?: number
82
+ queryTimeoutMs?: number
83
+ /** Per-extension resilience config (D-100 / D-101). */
84
+ resilience?: Partial<ResilienceConfig>
85
+ }
86
+
87
+ /**
88
+ * Native KronosDB extension factory. Returns an Extension closure shaped as
89
+ * `(app: App) => void` per D-95.
90
+ *
91
+ * ```ts
92
+ * await kronos()
93
+ * .use(kronosDb({ componentName: "university-service" }))
94
+ * .start()
95
+ * ```
96
+ */
97
+ export function kronosDb(serverConfig: KronosDbExtensionConfig): (app: App) => void {
98
+ return (app) => {
99
+ let connection: KronosDbConnection | undefined
100
+ let platform: PlatformConnection | undefined
101
+ const busLatches: ShutdownLatch[] = []
102
+
103
+ function getConnection(): KronosDbConnection {
104
+ if (!connection) {
105
+ throw new Error(
106
+ "[kronos:kronosdb] connection not yet established — wait for onStart('connect')",
107
+ )
108
+ }
109
+ return connection
110
+ }
111
+
112
+ // ---- Slot population (D-95) ------------------------------------------
113
+ //
114
+ // AppImpl.start() in @kronos-ts/app eagerly resolves all 8 slots and
115
+ // runs `commandBus.subscribe(...)` for every registered handler BEFORE
116
+ // any onStart('connect') hook fires (see app.ts §3 / §5c). The KronosDB
117
+ // bus factories open real gRPC streams against the live channel during
118
+ // construction (createKronosMetadata / connection.onReconnect / inbound
119
+ // stream openers), so they CANNOT run until the connect hook has
120
+ // populated `connection`.
121
+ //
122
+ // Solution: the slot factories return wrappers around lazily-built
123
+ // inner instances. EventStore/SnapshotStore use a lightweight lazy
124
+ // proxy because their factories never dereference `connection` at
125
+ // construction time (only inside method bodies). CommandBus/QueryBus
126
+ // use a `subscribe()`-buffering wrapper that queues subscriptions
127
+ // synchronously and replays them once the connect hook completes —
128
+ // dispatch / query calls await the same readiness promise.
129
+
130
+ /** Latches once the connect hook has populated `connection`. */
131
+ let resolveConnected: () => void = () => {}
132
+ const connected: Promise<void> = new Promise((res) => {
133
+ resolveConnected = res
134
+ })
135
+
136
+ app.set("eventStore", (resolved) => {
137
+ // Lazy proxy: createKronosDbEventStore stores `connection` in
138
+ // closure scope but only dereferences it inside method bodies, so
139
+ // a Proxy that forwards property access to getConnection() works
140
+ // — by the time framework code calls source/append/stream the
141
+ // connect hook has populated the closure.
142
+ const lazyConnection = new Proxy({} as KronosDbConnection, {
143
+ get(_t, prop) {
144
+ return (getConnection() as any)[prop]
145
+ },
146
+ })
147
+ return createKronosDbEventStore(lazyConnection, resolved.serializer)
148
+ })
149
+
150
+ app.set("snapshotStore", (resolved) => {
151
+ const lazyConnection = new Proxy({} as KronosDbConnection, {
152
+ get(_t, prop) {
153
+ return (getConnection() as any)[prop]
154
+ },
155
+ })
156
+ return createKronosDbSnapshotStore(lazyConnection, resolved.serializer)
157
+ })
158
+
159
+ app.set("commandBus", (resolved) => {
160
+ const latch = createShutdownLatch()
161
+ busLatches.push(latch)
162
+
163
+ let inner: CommandBus | undefined
164
+ const pendingSubs: Array<[string, (m: CommandMessage) => Promise<unknown>]> = []
165
+
166
+ // Build the real bus once the connect hook fires + replay buffered subs.
167
+ connected.then(() => {
168
+ // canonical Resolved slot name is `unitOfWorkFactory` (NOT unitOfWorkRunner)
169
+ inner = createDistributedCommandBus(
170
+ getConnection(),
171
+ resolved.unitOfWorkFactory,
172
+ latch,
173
+ resolved.serializer,
174
+ serverConfig.commandFlowControl,
175
+ serverConfig.commandLoadFactor,
176
+ serverConfig.resilience,
177
+ )
178
+ for (const [name, h] of pendingSubs) inner.subscribe(name, h)
179
+ pendingSubs.length = 0
180
+ })
181
+
182
+ const wrapper: CommandBus = {
183
+ async dispatch(message) {
184
+ await connected
185
+ return inner!.dispatch(message)
186
+ },
187
+ subscribe(name, handler) {
188
+ if (inner) inner.subscribe(name, handler)
189
+ else pendingSubs.push([name, handler])
190
+ },
191
+ }
192
+ return wrapper
193
+ })
194
+
195
+ app.set("queryBus", (resolved) => {
196
+ const latch = createShutdownLatch()
197
+ busLatches.push(latch)
198
+
199
+ let inner: QueryBus | undefined
200
+ const pendingSubs: Array<[string, (m: QueryMessage) => Promise<unknown>]> = []
201
+
202
+ connected.then(() => {
203
+ inner = createDistributedQueryBus(
204
+ getConnection(),
205
+ resolved.unitOfWorkFactory,
206
+ latch,
207
+ resolved.serializer,
208
+ serverConfig.queryFlowControl,
209
+ serverConfig.shortcutQueriesToLocalHandlers,
210
+ serverConfig.queryTimeoutMs,
211
+ serverConfig.resilience,
212
+ )
213
+ for (const [name, h] of pendingSubs) inner.subscribe(name, h)
214
+ pendingSubs.length = 0
215
+ })
216
+
217
+ const wrapper: QueryBus = {
218
+ async query(message) {
219
+ await connected
220
+ return inner!.query(message)
221
+ },
222
+ subscribe(name, handler) {
223
+ if (inner) inner.subscribe(name, handler)
224
+ else pendingSubs.push([name, handler])
225
+ },
226
+ subscriptionQuery(message, bufferSize) {
227
+ if (!inner) {
228
+ throw new Error(
229
+ "[kronos:kronosdb] subscriptionQuery called before connect hook completed",
230
+ )
231
+ }
232
+ return inner.subscriptionQuery(message, bufferSize)
233
+ },
234
+ subscribeToUpdates(message, bufferSize) {
235
+ if (!inner) {
236
+ throw new Error(
237
+ "[kronos:kronosdb] subscribeToUpdates called before connect hook completed",
238
+ )
239
+ }
240
+ return inner.subscribeToUpdates(message, bufferSize)
241
+ },
242
+ async emitUpdate(name, filter, update) {
243
+ await connected
244
+ return inner!.emitUpdate(name, filter, update)
245
+ },
246
+ async completeSubscription(name, filter) {
247
+ await connected
248
+ return inner!.completeSubscription(name, filter)
249
+ },
250
+ async completeSubscriptionExceptionally(name, error, filter) {
251
+ await connected
252
+ return inner!.completeSubscriptionExceptionally(name, error, filter)
253
+ },
254
+ }
255
+ return wrapper
256
+ })
257
+
258
+ // ---- Lifecycle: connect (D-101 normative split) ---------------------
259
+ // connect = initial connect + health-check + platform setup +
260
+ // instruction wiring + platform.start.
261
+ app.onStart("connect", async () => {
262
+ connection = await withRetry(
263
+ async () => connectToKronosDb(serverConfig),
264
+ { event: "initial-connect", ...serverConfig.resilience },
265
+ )
266
+
267
+ // Health-check ping with warn-then-continue (D-100). KronosDbConnection
268
+ // has no dedicated probe surface today; the gRPC channel itself is
269
+ // created eagerly in connectToKronosDb so the meaningful probe is a
270
+ // round-trip — we approximate via a soft no-op promise that satisfies
271
+ // the threshold contract. Real network failure is surfaced by the
272
+ // first bus call against the live channel.
273
+ await healthCheck(async () => undefined, {
274
+ thresholdMs: serverConfig.resilience?.healthCheckThresholdMs,
275
+ log: serverConfig.resilience?.log,
276
+ })
277
+
278
+ platform = createPlatformConnection(connection!, serverConfig.platformService)
279
+
280
+ // Build a name-keyed view of the EventProcessorModule list so server-
281
+ // initiated instructions can route to the right module. We resolve via
282
+ // `app.processors()` — Plan 09-01's zero-arg read accessor (D-103).
283
+ const processors = app.processors()
284
+ const processorMap = new Map<string, EventProcessorModule>()
285
+ for (const proc of processors) processorMap.set(proc.name, proc)
286
+
287
+ platform.onInstruction(async (instruction) => {
288
+ switch (instruction.kind) {
289
+ case "pause-processor": {
290
+ const proc = processorMap.get(instruction.processorName) as any
291
+ if (proc?.stop) proc.stop()
292
+ break
293
+ }
294
+ case "start-processor": {
295
+ const proc = processorMap.get(instruction.processorName) as any
296
+ if (proc?.start) await proc.start()
297
+ break
298
+ }
299
+ case "release-segment": {
300
+ const proc = processorMap.get(instruction.processorName) as any
301
+ if (proc?.releaseSegment) await proc.releaseSegment(instruction.segmentId)
302
+ break
303
+ }
304
+ case "split-segment": {
305
+ const proc = processorMap.get(instruction.processorName) as any
306
+ if (proc?.splitSegment) await proc.splitSegment(instruction.segmentId)
307
+ break
308
+ }
309
+ case "merge-segment": {
310
+ const proc = processorMap.get(instruction.processorName) as any
311
+ if (proc?.mergeSegment) await proc.mergeSegment(instruction.segmentId)
312
+ break
313
+ }
314
+ }
315
+ })
316
+
317
+ platform.registerProcessorStatusSupplier(() => {
318
+ return processors.map((proc: any) => ({
319
+ name: proc.name,
320
+ running: proc.running ?? false,
321
+ mode: proc.supportsReset?.() === false ? "Subscribing" : "Tracking",
322
+ isStreamingProcessor: proc.supportsReset?.() !== false,
323
+ activeThreads: proc.running ? 1 : 0,
324
+ availableThreads: 0,
325
+ error: false,
326
+ tokenStoreIdentifier: "",
327
+ segments: proc.processingStatus
328
+ ? Array.from(proc.processingStatus().entries() as Iterable<[number, any]>).map(
329
+ ([segId, status]: [number, any]) => ({
330
+ segmentId: segId,
331
+ caughtUp: status.caughtUp ?? false,
332
+ replaying: status.replaying ?? false,
333
+ onePartOf: 1,
334
+ tokenPosition: status.position ?? 0n,
335
+ errorState: status.error?.message ?? "",
336
+ }),
337
+ )
338
+ : [
339
+ {
340
+ segmentId: 0,
341
+ caughtUp: true,
342
+ replaying: proc.replaying ?? false,
343
+ onePartOf: 1,
344
+ tokenPosition: proc.position ?? 0n,
345
+ errorState: "",
346
+ },
347
+ ],
348
+ }))
349
+ })
350
+
351
+ await platform.start()
352
+
353
+ // Latch the connected promise so the deferred bus wrappers built in
354
+ // the slot factories above construct their inner instances and replay
355
+ // any subscriptions that were buffered while connect was running.
356
+ // This MUST happen synchronously before any subsequent stage hook so
357
+ // register/processors-stage code sees the fully-wired buses. The
358
+ // microtask queue drains the `.then(...)` callbacks attached in the
359
+ // slot factories before this hook resolves.
360
+ resolveConnected()
361
+ await Promise.resolve()
362
+ })
363
+
364
+ // ---- Lifecycle: processors (D-101 / D-102) --------------------------
365
+ // processors = ONLY the subscription-ack wait, via withRetry against
366
+ // `platform.subscriptionsAcked()`. This REPLACES the legacy 1-second
367
+ // sleep that lived at kronosdb-configuration-enhancer.ts:216.
368
+ app.onStart("processors", async () => {
369
+ await withRetry(
370
+ async () => {
371
+ const ok = await platform!.subscriptionsAcked()
372
+ if (!ok) throw new Error("subscriptions not yet acked")
373
+ },
374
+ { event: "per-operation", ...serverConfig.resilience },
375
+ )
376
+ })
377
+
378
+ // ---- Lifecycle: stop (D-101.b — preserves legacy ordering) ----------
379
+ // busLatches drained first → platform.stop → connection.close.
380
+ app.onStop("connect", async () => {
381
+ await Promise.all(busLatches.map((l) => l.initiateShutdown()))
382
+ platform?.stop()
383
+ connection?.close()
384
+ })
385
+ }
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Shared payload helpers (moved verbatim from legacy enhancer)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ function createPayloadHelpers(serializer: Serializer) {
393
+ return {
394
+ serializePayload(name: string, payload: unknown, revision: string = "") {
395
+ return serializer.serialize(payload, name, revision)
396
+ },
397
+ deserializePayload(data: Uint8Array | undefined, type: string = "", revision: string = ""): unknown {
398
+ if (!data || data.length === 0) return undefined
399
+ return serializer.deserialize({ data, type, revision })
400
+ },
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Distributed Command Bus
406
+ //
407
+ // Bus implementation moved verbatim from the legacy enhancer with TWO
408
+ // behavioural additions per D-97:
409
+ // 1) reestablishStream() body wrapped in withRetry({ event: "reconnect" })
410
+ // 2) inbound-stream backoff replaced by the same withRetry path
411
+ // ---------------------------------------------------------------------------
412
+
413
+ function createDistributedCommandBus(
414
+ connection: KronosDbConnection,
415
+ unitOfWorkRunner: UoWRunner,
416
+ shutdownLatch: ShutdownLatch,
417
+ serializer: Serializer,
418
+ flowControl?: FlowControlConfig,
419
+ commandLoadFactor?: number,
420
+ resilience?: Partial<ResilienceConfig>,
421
+ ): CommandBus {
422
+ const metadata = createKronosMetadata(connection.config)
423
+ const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
424
+ const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
425
+ const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
426
+
427
+ const localSegment = new Map<string, (message: CommandMessage) => Promise<unknown>>()
428
+
429
+ let outbound = createOutboundStream<any>()
430
+ let streamStarted = false
431
+ let permits = 0n
432
+
433
+ function ensureStreamStarted() {
434
+ if (streamStarted) return
435
+ streamStarted = true
436
+
437
+ const inbound = connection.commands.openStream(outbound.iterable, { metadata })
438
+ processInboundCommands(inbound)
439
+ }
440
+
441
+ function grantPermits() {
442
+ outbound.send({
443
+ flowControl: { clientId: connection.config.clientId, permits: PERMITS },
444
+ instructionId: "",
445
+ })
446
+ permits += PERMITS
447
+ }
448
+
449
+ function reestablishStreamBody() {
450
+ outbound.close()
451
+ outbound = createOutboundStream<any>()
452
+ streamStarted = false
453
+ permits = 0n
454
+ ensureStreamStarted()
455
+ for (const commandName of localSegment.keys()) {
456
+ outbound.send({
457
+ subscribe: {
458
+ messageId: generateIdentifier(),
459
+ command: commandName,
460
+ componentName: connection.config.componentName,
461
+ clientId: connection.config.clientId,
462
+ loadFactor: commandLoadFactor ?? 100,
463
+ },
464
+ instructionId: generateIdentifier(),
465
+ })
466
+ }
467
+ grantPermits()
468
+ }
469
+
470
+ async function reestablishStreamWithRetry() {
471
+ if (shutdownLatch.shuttingDown) return
472
+ await withRetry(async () => reestablishStreamBody(), {
473
+ event: "reconnect",
474
+ ...resilience,
475
+ })
476
+ }
477
+
478
+ connection.onReconnect(() => {
479
+ if (!shutdownLatch.shuttingDown && streamStarted) {
480
+ reestablishStreamWithRetry().catch((err) => {
481
+ console.error("Distributed command bus: reconnect retries exhausted", err)
482
+ })
483
+ }
484
+ })
485
+
486
+ async function processInboundCommands(inbound: AsyncIterable<any>) {
487
+ try {
488
+ for await (const message of inbound) {
489
+ if (!message.command) continue
490
+
491
+ permits--
492
+ const proto = message.command
493
+ const commandName = proto.name
494
+ const handler = localSegment.get(commandName)
495
+
496
+ let resultPayload: unknown
497
+ let errorCode = ""
498
+ let errorMsg = ""
499
+
500
+ if (handler) {
501
+ try {
502
+ const commandMessage: CommandMessage = {
503
+ identifier: proto.messageIdentifier,
504
+ name: qualifiedNameFromString(commandName),
505
+ payload: deserializePayload(proto.payload?.data as Uint8Array | undefined, proto.payload?.type, proto.payload?.revision),
506
+ metadata: metadataFromProto(proto.metadata ?? {}),
507
+ timestamp: Number(proto.timestamp),
508
+ }
509
+
510
+ resultPayload = await unitOfWorkRunner(commandMessage.metadata, () =>
511
+ handler(commandMessage),
512
+ )
513
+ } catch (err) {
514
+ errorCode = KronosDbErrorCode.COMMAND_EXECUTION_ERROR
515
+ errorMsg = err instanceof Error ? err.message : String(err)
516
+ }
517
+ } else {
518
+ errorCode = KronosDbErrorCode.NO_HANDLER_FOR_COMMAND
519
+ errorMsg = `No local handler for command "${commandName}"`
520
+ }
521
+
522
+ const responseSerialized = resultPayload !== undefined
523
+ ? serializePayload("result", resultPayload)
524
+ : undefined
525
+
526
+ outbound.send({
527
+ commandResponse: {
528
+ messageIdentifier: generateIdentifier(),
529
+ requestIdentifier: proto.messageIdentifier,
530
+ errorCode,
531
+ errorMessage: errorCode
532
+ ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
533
+ : undefined,
534
+ payload: responseSerialized,
535
+ metadata: {},
536
+ processingInstructions: [],
537
+ },
538
+ instructionId: "",
539
+ })
540
+
541
+ if (permits <= THRESHOLD) {
542
+ outbound.send({
543
+ flowControl: { clientId: connection.config.clientId, permits: PERMITS },
544
+ instructionId: "",
545
+ })
546
+ permits += PERMITS
547
+ }
548
+ }
549
+ } catch (err) {
550
+ if (shutdownLatch.shuttingDown) return
551
+ if (String(err).includes("Connection dropped")) return
552
+
553
+ console.error("Distributed command bus: inbound stream error, attempting re-establishment via withRetry", err)
554
+ await reestablishStreamWithRetry().catch((retryErr) => {
555
+ console.error("Distributed command bus: reconnect retries exhausted", retryErr)
556
+ })
557
+ }
558
+ }
559
+
560
+ return {
561
+ async dispatch(message: CommandMessage): Promise<unknown> {
562
+ const activity = shutdownLatch.registerActivity()
563
+ try {
564
+ const commandName = qualifiedNameToString(message.name)
565
+ const serialized = serializePayload(commandName, message.payload)
566
+
567
+ const response = await connection.commands.dispatch({
568
+ messageIdentifier: message.identifier,
569
+ name: commandName,
570
+ timestamp: BigInt(message.timestamp),
571
+ payload: serialized,
572
+ metadata: metadataToProto(message.metadata),
573
+ processingInstructions: toProtoProcessingInstructions(message.metadata?.processingInstructions as ProcessingInstructions | undefined),
574
+ clientId: connection.config.clientId,
575
+ componentName: connection.config.componentName,
576
+ }, { metadata })
577
+
578
+ if (response.errorCode && response.errorCode !== "") {
579
+ throw mapErrorCode(
580
+ response.errorCode,
581
+ response.errorMessage?.message ?? "Unknown error",
582
+ )
583
+ }
584
+
585
+ return deserializePayload(response.payload?.data as Uint8Array | undefined, response.payload?.type, response.payload?.revision)
586
+ } finally {
587
+ activity.end()
588
+ }
589
+ },
590
+
591
+ subscribe(commandName: string, handler: (message: CommandMessage) => Promise<unknown>) {
592
+ localSegment.set(commandName, handler)
593
+
594
+ ensureStreamStarted()
595
+ outbound.send({
596
+ subscribe: {
597
+ messageId: generateIdentifier(),
598
+ command: commandName,
599
+ componentName: connection.config.componentName,
600
+ clientId: connection.config.clientId,
601
+ loadFactor: commandLoadFactor ?? 100,
602
+ },
603
+ instructionId: generateIdentifier(),
604
+ })
605
+ grantPermits()
606
+ },
607
+ }
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // Distributed Query Bus
612
+ // ---------------------------------------------------------------------------
613
+
614
+ function createDistributedQueryBus(
615
+ connection: KronosDbConnection,
616
+ unitOfWorkRunner: UoWRunner,
617
+ shutdownLatch: ShutdownLatch,
618
+ serializer: Serializer,
619
+ flowControl?: FlowControlConfig,
620
+ shortcutQueriesToLocalHandlers?: boolean,
621
+ queryTimeoutMs?: number,
622
+ resilience?: Partial<ResilienceConfig>,
623
+ ): QueryBus {
624
+ const metadata = createKronosMetadata(connection.config)
625
+ const PERMITS = BigInt(flowControl?.permits ?? Number(DEFAULT_PERMITS))
626
+ const THRESHOLD = BigInt(flowControl?.refillThreshold ?? Number(DEFAULT_THRESHOLD))
627
+ const { serializePayload, deserializePayload } = createPayloadHelpers(serializer)
628
+
629
+ const localSegment = new Map<string, (message: QueryMessage) => Promise<unknown>>()
630
+ const subscriptions = new Map<string, UpdateHandler>()
631
+
632
+ let outbound = createOutboundStream<any>()
633
+ let streamStarted = false
634
+ let permits = 0n
635
+
636
+ function ensureStreamStarted() {
637
+ if (streamStarted) return
638
+ streamStarted = true
639
+
640
+ const inbound = connection.queries.openStream(outbound.iterable, { metadata })
641
+ processInboundQueries(inbound)
642
+ }
643
+
644
+ function grantQueryPermits() {
645
+ outbound.send({
646
+ flowControl: { clientId: connection.config.clientId, permits: PERMITS },
647
+ instructionId: "",
648
+ })
649
+ permits += PERMITS
650
+ }
651
+
652
+ function reestablishStreamBody() {
653
+ outbound.close()
654
+ outbound = createOutboundStream<any>()
655
+ streamStarted = false
656
+ permits = 0n
657
+ ensureStreamStarted()
658
+ for (const queryName of localSegment.keys()) {
659
+ outbound.send({
660
+ subscribe: {
661
+ messageId: generateIdentifier(),
662
+ query: queryName,
663
+ resultName: "",
664
+ componentName: connection.config.componentName,
665
+ clientId: connection.config.clientId,
666
+ },
667
+ instructionId: generateIdentifier(),
668
+ })
669
+ }
670
+ grantQueryPermits()
671
+ }
672
+
673
+ async function reestablishStreamWithRetry() {
674
+ if (shutdownLatch.shuttingDown) return
675
+ await withRetry(async () => reestablishStreamBody(), {
676
+ event: "reconnect",
677
+ ...resilience,
678
+ })
679
+ }
680
+
681
+ connection.onReconnect(() => {
682
+ if (!shutdownLatch.shuttingDown && streamStarted) {
683
+ reestablishStreamWithRetry().catch((err) => {
684
+ console.error("Distributed query bus: reconnect retries exhausted", err)
685
+ })
686
+ }
687
+ })
688
+
689
+ async function processInboundQueries(inbound: AsyncIterable<any>) {
690
+ try {
691
+ for await (const message of inbound) {
692
+ if (!message.query) continue
693
+
694
+ permits--
695
+ const proto = message.query
696
+ const queryName = proto.query
697
+ const handler = localSegment.get(queryName)
698
+
699
+ let resultPayload: unknown
700
+ let errorCode = ""
701
+ let errorMsg = ""
702
+
703
+ if (handler) {
704
+ try {
705
+ const queryMessage: QueryMessage = {
706
+ identifier: proto.messageIdentifier,
707
+ name: qualifiedNameFromString(queryName),
708
+ payload: deserializePayload(proto.payload?.data as Uint8Array | undefined, proto.payload?.type, proto.payload?.revision),
709
+ metadata: metadataFromProto(proto.metadata ?? {}),
710
+ timestamp: Number(proto.timestamp),
711
+ }
712
+
713
+ resultPayload = await unitOfWorkRunner(queryMessage.metadata, async () => {
714
+ return handler(queryMessage)
715
+ })
716
+ } catch (err) {
717
+ errorCode = KronosDbErrorCode.QUERY_EXECUTION_ERROR
718
+ errorMsg = err instanceof Error ? err.message : String(err)
719
+ }
720
+ } else {
721
+ errorCode = KronosDbErrorCode.NO_HANDLER_FOR_QUERY
722
+ errorMsg = `No local handler for query "${queryName}"`
723
+ }
724
+
725
+ const responseSerialized = resultPayload !== undefined
726
+ ? serializePayload("result", resultPayload)
727
+ : undefined
728
+
729
+ outbound.send({
730
+ queryResponse: {
731
+ messageIdentifier: generateIdentifier(),
732
+ requestIdentifier: proto.messageIdentifier,
733
+ errorCode,
734
+ errorMessage: errorCode
735
+ ? { message: errorMsg, location: connection.config.componentName, details: [], errorCode }
736
+ : undefined,
737
+ payload: responseSerialized,
738
+ metadata: {},
739
+ processingInstructions: [],
740
+ },
741
+ instructionId: "",
742
+ })
743
+
744
+ outbound.send({
745
+ queryComplete: {
746
+ messageId: generateIdentifier(),
747
+ requestId: proto.messageIdentifier,
748
+ },
749
+ instructionId: "",
750
+ })
751
+
752
+ if (permits <= THRESHOLD) {
753
+ outbound.send({
754
+ flowControl: { clientId: connection.config.clientId, permits: PERMITS },
755
+ instructionId: "",
756
+ })
757
+ permits += PERMITS
758
+ }
759
+ }
760
+ } catch (err) {
761
+ if (shutdownLatch.shuttingDown) return
762
+ if (String(err).includes("Connection dropped")) return
763
+
764
+ console.error("Distributed query bus: inbound stream error, attempting re-establishment via withRetry", err)
765
+ await reestablishStreamWithRetry().catch((retryErr) => {
766
+ console.error("Distributed query bus: reconnect retries exhausted", retryErr)
767
+ })
768
+ }
769
+ }
770
+
771
+ return {
772
+ async query(message: QueryMessage): Promise<unknown> {
773
+ const activity = shutdownLatch.registerActivity()
774
+ try {
775
+ const queryName = qualifiedNameToString(message.name)
776
+
777
+ if (shortcutQueriesToLocalHandlers) {
778
+ const localHandler = localSegment.get(queryName)
779
+ if (localHandler) {
780
+ return unitOfWorkRunner(message.metadata, async () => {
781
+ return localHandler(message)
782
+ })
783
+ }
784
+ }
785
+
786
+ const serialized = serializePayload(queryName, message.payload)
787
+
788
+ const responseStream = connection.queries.query({
789
+ messageIdentifier: message.identifier,
790
+ query: queryName,
791
+ timestamp: BigInt(message.timestamp),
792
+ payload: serialized,
793
+ metadata: metadataToProto(message.metadata),
794
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
795
+ clientId: connection.config.clientId,
796
+ componentName: connection.config.componentName,
797
+ }, { metadata })
798
+
799
+ for await (const response of responseStream) {
800
+ if (response.errorCode && response.errorCode !== "") {
801
+ throw mapErrorCode(
802
+ response.errorCode,
803
+ response.errorMessage?.message ?? "Unknown error",
804
+ )
805
+ }
806
+ return deserializePayload(response.payload?.data as Uint8Array | undefined, response.payload?.type, response.payload?.revision)
807
+ }
808
+
809
+ throw new Error(`No response for query "${queryName}"`)
810
+ } finally {
811
+ activity.end()
812
+ }
813
+ },
814
+
815
+ subscribe(queryName: string, handler: (message: QueryMessage) => Promise<unknown>) {
816
+ localSegment.set(queryName, handler)
817
+
818
+ ensureStreamStarted()
819
+ outbound.send({
820
+ subscribe: {
821
+ messageId: generateIdentifier(),
822
+ query: queryName,
823
+ resultName: "",
824
+ componentName: connection.config.componentName,
825
+ clientId: connection.config.clientId,
826
+ },
827
+ instructionId: generateIdentifier(),
828
+ })
829
+ grantQueryPermits()
830
+ },
831
+
832
+ subscriptionQuery(message: QueryMessage, bufferSize?: number): SubscriptionQueryResult {
833
+ const queryId = message.identifier
834
+ if (subscriptions.has(queryId)) {
835
+ throw new Error(`Subscription query already registered for identifier "${queryId}"`)
836
+ }
837
+
838
+ const updateHandler = createUpdateHandler(message, bufferSize)
839
+ subscriptions.set(queryId, updateHandler)
840
+
841
+ const queryName = qualifiedNameToString(message.name)
842
+ const subscriptionId = generateIdentifier()
843
+ const serialized = serializePayload(queryName, message.payload)
844
+
845
+ const outboundSub = createOutboundStream<any>()
846
+
847
+ outboundSub.send({
848
+ subscribe: {
849
+ subscriptionIdentifier: subscriptionId,
850
+ numberOfPermits: BigInt(bufferSize ?? 256),
851
+ queryRequest: {
852
+ messageIdentifier: message.identifier,
853
+ query: queryName,
854
+ timestamp: BigInt(message.timestamp),
855
+ payload: serialized,
856
+ metadata: metadataToProto(message.metadata),
857
+ processingInstructions: defaultQueryInstructions(queryTimeoutMs ?? 3600000),
858
+ clientId: connection.config.clientId,
859
+ componentName: connection.config.componentName,
860
+ },
861
+ },
862
+ })
863
+
864
+ const responseStream = connection.queries.subscription(outboundSub.iterable, { metadata })
865
+
866
+ let resolveInitial!: (value: unknown) => void
867
+ let rejectInitial!: (error: Error) => void
868
+ const initialResult = new Promise<unknown>((resolve, reject) => {
869
+ resolveInitial = resolve
870
+ rejectInitial = reject
871
+ })
872
+ let initialSettled = false
873
+
874
+ ;(async () => {
875
+ try {
876
+ for await (const response of responseStream) {
877
+ if (response.initialResult) {
878
+ if (!initialSettled) {
879
+ if (response.initialResult.errorCode && response.initialResult.errorCode !== "") {
880
+ rejectInitial(mapErrorCode(response.initialResult.errorCode, response.initialResult.errorMessage?.message ?? "Unknown error"))
881
+ } else {
882
+ resolveInitial(deserializePayload(response.initialResult.payload?.data as Uint8Array | undefined, response.initialResult.payload?.type, response.initialResult.payload?.revision))
883
+ }
884
+ initialSettled = true
885
+ }
886
+ } else if (response.update) {
887
+ const update = deserializePayload(response.update.payload?.data as Uint8Array | undefined, response.update.payload?.type, response.update.payload?.revision)
888
+ updateHandler.offer(update)
889
+ } else if (response.complete) {
890
+ updateHandler.complete()
891
+ break
892
+ } else if (response.completeExceptionally) {
893
+ updateHandler.completeExceptionally(
894
+ new Error(response.completeExceptionally.errorMessage?.message ?? "Subscription query failed"),
895
+ )
896
+ break
897
+ }
898
+ }
899
+ } catch (err) {
900
+ const error = err instanceof Error ? err : new Error(String(err))
901
+ if (!initialSettled) {
902
+ rejectInitial(error)
903
+ initialSettled = true
904
+ }
905
+ updateHandler.completeExceptionally(error)
906
+ } finally {
907
+ subscriptions.delete(queryId)
908
+ }
909
+ })()
910
+
911
+ return {
912
+ initialResult,
913
+ updates: updateHandler.iterable,
914
+ close: () => {
915
+ outboundSub.send({
916
+ unsubscribe: {
917
+ subscriptionIdentifier: subscriptionId,
918
+ },
919
+ })
920
+ outboundSub.close()
921
+ subscriptions.delete(queryId)
922
+ updateHandler.complete()
923
+ },
924
+ }
925
+ },
926
+
927
+ subscribeToUpdates(message: QueryMessage, bufferSize?: number): AsyncIterable<unknown> & { close(): void } {
928
+ const queryId = message.identifier
929
+ if (subscriptions.has(queryId)) {
930
+ throw new Error(`Subscription query already registered for identifier "${queryId}"`)
931
+ }
932
+
933
+ const updateHandler = createUpdateHandler(message, bufferSize)
934
+ subscriptions.set(queryId, updateHandler)
935
+
936
+ return {
937
+ [Symbol.asyncIterator]: () => updateHandler.iterable[Symbol.asyncIterator](),
938
+ close: () => {
939
+ subscriptions.delete(queryId)
940
+ updateHandler.complete()
941
+ },
942
+ }
943
+ },
944
+
945
+ async emitUpdate(
946
+ queryName: string,
947
+ filter: (queryPayload: unknown) => boolean,
948
+ update: unknown,
949
+ ): Promise<void> {
950
+ runAfterCommitOrImmediately(() => {
951
+ for (const [id, handler] of subscriptions) {
952
+ if (!handler.active) {
953
+ subscriptions.delete(id)
954
+ continue
955
+ }
956
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
957
+ if (handlerQueryName !== queryName) continue
958
+ if (!filter(handler.query.payload)) continue
959
+
960
+ const accepted = handler.offer(update)
961
+ if (!accepted) {
962
+ handler.completeExceptionally(new Error("Subscription query update buffer overflow"))
963
+ subscriptions.delete(id)
964
+ }
965
+ }
966
+ })
967
+ },
968
+
969
+ async completeSubscription(
970
+ queryName: string,
971
+ filter?: (queryPayload: unknown) => boolean,
972
+ ): Promise<void> {
973
+ runAfterCommitOrImmediately(() => {
974
+ for (const [id, handler] of subscriptions) {
975
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
976
+ if (handlerQueryName !== queryName) continue
977
+ if (filter && !filter(handler.query.payload)) continue
978
+ handler.complete()
979
+ subscriptions.delete(id)
980
+ }
981
+ })
982
+ },
983
+
984
+ async completeSubscriptionExceptionally(
985
+ queryName: string,
986
+ error: Error,
987
+ filter?: (queryPayload: unknown) => boolean,
988
+ ): Promise<void> {
989
+ runAfterCommitOrImmediately(() => {
990
+ for (const [id, handler] of subscriptions) {
991
+ const handlerQueryName = qualifiedNameToString(handler.query.name)
992
+ if (handlerQueryName !== queryName) continue
993
+ if (filter && !filter(handler.query.payload)) continue
994
+ handler.completeExceptionally(error)
995
+ subscriptions.delete(id)
996
+ }
997
+ })
998
+ },
999
+ }
1000
+ }