bunsane 0.3.1 → 0.4.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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. package/tests/utils/test-context.ts +0 -149
@@ -1,400 +1,401 @@
1
- /**
2
- * Remote Communication: RemoteManager
3
- *
4
- * Phase 1: events + RPC over Redis Streams.
5
- * - emit(target, event, data): fire-and-forget XADD to `remote:<target>`
6
- * - call(target, method, data, options): RPC with correlationId + deadline
7
- * - Dedicated Redis connections:
8
- * publisher — XADD only (non-blocking, retries enabled)
9
- * consumer — XREADGROUP BLOCK (retries=null, required for blocking)
10
- * rpcListener — XREAD BLOCK on per-instance response stream
11
- * - Graceful shutdown drains pending RPC calls within `shutdownDrainMs`
12
- */
13
-
14
- import Redis, { type RedisOptions } from "ioredis";
15
- import { logger } from "../Logger";
16
- import { StreamConsumer } from "./StreamConsumer";
17
- import { RpcCaller } from "./RpcCaller";
18
- import { OutboxWorker } from "./OutboxWorker";
19
- import { ensureOutboxSchema } from "./outboxSchema";
20
- import { CircuitBreaker } from "./CircuitBreaker";
21
- import { RemoteMetrics, type RemoteMetricsSnapshot } from "./metrics";
22
- import { collectRemoteHealth, type RemoteHealthCheck } from "./health";
23
- import db from "../../database";
24
- import type {
25
- CallOptions,
26
- EmitOptions,
27
- RemoteHandler,
28
- RemoteManagerConfig,
29
- RpcHandler,
30
- } from "./types";
31
-
32
- const loggerInstance = logger.child({ scope: "RemoteManager" });
33
-
34
- function buildRedisOptions(blocking: boolean): RedisOptions {
35
- return {
36
- host: process.env.REDIS_HOST || "localhost",
37
- port: parseInt(process.env.REDIS_PORT || "6379", 10),
38
- password: process.env.REDIS_PASSWORD,
39
- db: parseInt(process.env.REDIS_DB || "0", 10),
40
- maxRetriesPerRequest: blocking ? null : 3,
41
- enableReadyCheck: false,
42
- retryStrategy: (times: number) => Math.min(times * 50, 2000),
43
- };
44
- }
45
-
46
- export class RemoteManager {
47
- private publisher: Redis | null = null;
48
- private consumerRedis: Redis | null = null;
49
- private rpcListenerRedis: Redis | null = null;
50
- private consumer: StreamConsumer | null = null;
51
- private caller: RpcCaller | null = null;
52
- private outboxWorker: OutboxWorker | null = null;
53
- private breaker: CircuitBreaker;
54
- private metrics = new RemoteMetrics();
55
- private config: RemoteManagerConfig;
56
- private _instanceId: string;
57
- private started = false;
58
-
59
- constructor(config: RemoteManagerConfig) {
60
- this.config = config;
61
- this._instanceId = crypto.randomUUID();
62
- this.breaker = new CircuitBreaker({
63
- threshold: config.circuitBreakerThreshold,
64
- resetTimeoutMs: config.circuitBreakerResetMs,
65
- });
66
- this.breaker.onTrip = () => this.metrics.cbTripped();
67
- this.breaker.onReject = () => this.metrics.cbRejected();
68
- }
69
-
70
- getMetrics(): RemoteMetricsSnapshot & {
71
- circuitBreaker: RemoteMetricsSnapshot["circuitBreaker"] & {
72
- state: string;
73
- };
74
- } {
75
- const snap = this.metrics.getSnapshot();
76
- return {
77
- ...snap,
78
- circuitBreaker: {
79
- ...snap.circuitBreaker,
80
- state: this.breaker.getState(),
81
- },
82
- };
83
- }
84
-
85
- getCircuitBreaker(): CircuitBreaker {
86
- return this.breaker;
87
- }
88
-
89
- async health(): Promise<RemoteHealthCheck> {
90
- return collectRemoteHealth({
91
- publisher: this.publisher,
92
- consumerRedis: this.consumerRedis,
93
- streamKey: `${this.streamPrefix}${this.config.appName}`,
94
- consumerGroup:
95
- this.config.consumerGroup ?? this.config.appName,
96
- dlqStream: `${this.streamPrefix}${this.config.appName}:dlq`,
97
- outboxEnabled: this.config.enableOutbox ?? false,
98
- db,
99
- breaker: this.breaker,
100
- });
101
- }
102
-
103
- get appName(): string {
104
- return this.config.appName;
105
- }
106
-
107
- get instanceId(): string {
108
- return this._instanceId;
109
- }
110
-
111
- get streamPrefix(): string {
112
- return this.config.streamPrefix ?? "remote:";
113
- }
114
-
115
- get responseStream(): string {
116
- return `rpc:responses:${this._instanceId}`;
117
- }
118
-
119
- /**
120
- * Emit an event.
121
- *
122
- * Without `{ trx }`: direct XADD to Redis (fire-and-forget, no DB write).
123
- * With `{ trx }`: insert a row into `remote_outbox` within the caller's
124
- * transaction. The OutboxWorker publishes the row to Redis after commit.
125
- *
126
- * Returns the Redis message id (direct path) or the outbox row id
127
- * (transactional path).
128
- */
129
- async emit(
130
- target: string,
131
- event: string,
132
- data: unknown,
133
- options: EmitOptions = {}
134
- ): Promise<string | null> {
135
- if (!this.publisher) {
136
- throw new Error(
137
- "RemoteManager not started — call start() before emit()"
138
- );
139
- }
140
-
141
- if (options.trx) {
142
- const trx = options.trx as any;
143
- const rows = await trx`
144
- INSERT INTO remote_outbox (target, event, data)
145
- VALUES (${target}, ${event}, ${data})
146
- RETURNING id
147
- `;
148
- const id = rows?.[0]?.id ?? null;
149
- this.metrics.emitOutbox();
150
- if (this.config.enableLogging) {
151
- loggerInstance.debug(
152
- `emit outbox → target=${target} event=${event} id=${id}`
153
- );
154
- }
155
- return id;
156
- }
157
-
158
- const stream = `${this.streamPrefix}${target}`;
159
- const envelope = JSON.stringify({
160
- kind: "event",
161
- sourceApp: this.config.appName,
162
- event,
163
- data,
164
- emittedAt: Date.now(),
165
- });
166
- try {
167
- const publisher = this.publisher;
168
- const id = await this.breaker.exec(() =>
169
- publisher.xadd(stream, "*", "data", envelope)
170
- );
171
- this.metrics.emitDirect();
172
- if (this.config.enableLogging) {
173
- loggerInstance.debug(
174
- `emit → ${stream} event=${event} id=${id}`
175
- );
176
- }
177
- return id;
178
- } catch (error) {
179
- this.metrics.emitFailed();
180
- throw error;
181
- }
182
- }
183
-
184
- /**
185
- * RPC call — awaits a response or rejects on timeout/error.
186
- * Throws `RemoteError { code: "INVALID_TARGET" }` for broadcast target "*".
187
- */
188
- async call<T = unknown>(
189
- target: string,
190
- method: string,
191
- data: unknown,
192
- options: CallOptions = {}
193
- ): Promise<T> {
194
- if (!this.caller) {
195
- throw new Error(
196
- "RemoteManager not started — call start() before call()"
197
- );
198
- }
199
- return this.caller.call<T>(
200
- target,
201
- method,
202
- data,
203
- this.streamPrefix,
204
- this.config.appName,
205
- options
206
- );
207
- }
208
-
209
- on(event: string, fn: RemoteHandler, handlerId: string): void {
210
- if (!this.consumer) {
211
- throw new Error(
212
- "RemoteManager consumer not initialized — call start() first"
213
- );
214
- }
215
- this.consumer.addHandler(event, fn, handlerId);
216
- }
217
-
218
- onRpc(event: string, fn: RpcHandler, handlerId: string): void {
219
- if (!this.consumer) {
220
- throw new Error(
221
- "RemoteManager consumer not initialized — call start() first"
222
- );
223
- }
224
- this.consumer.addRpcHandler(event, fn, handlerId);
225
- }
226
-
227
- async start(): Promise<void> {
228
- if (this.started) return;
229
-
230
- const factory =
231
- this.config.redisFactory ??
232
- ((blocking: boolean) => new Redis(buildRedisOptions(blocking)));
233
-
234
- this.publisher = factory(false) as Redis;
235
- this.consumerRedis = factory(true) as Redis;
236
- this.rpcListenerRedis = factory(true) as Redis;
237
-
238
- for (const [name, client] of [
239
- ["publisher", this.publisher],
240
- ["consumer", this.consumerRedis],
241
- ["rpcListener", this.rpcListenerRedis],
242
- ] as const) {
243
- client.on("error", (err) => {
244
- loggerInstance.warn(
245
- { err, name, msg: `${name} Redis error` }
246
- );
247
- });
248
- }
249
-
250
- this.consumer = new StreamConsumer(
251
- this.consumerRedis,
252
- this.publisher,
253
- this.config,
254
- this.metrics
255
- );
256
- await this.consumer.start();
257
-
258
- this.caller = new RpcCaller(
259
- this.rpcListenerRedis,
260
- this.publisher,
261
- {
262
- instanceId: this._instanceId,
263
- responseStream: this.responseStream,
264
- defaultTimeout: this.config.defaultCallTimeout ?? 5000,
265
- responseStreamMaxLen:
266
- this.config.responseStreamMaxLen ?? 1000,
267
- enableLogging: this.config.enableLogging ?? false,
268
- },
269
- this.breaker,
270
- this.metrics
271
- );
272
- await this.caller.start();
273
-
274
- if (this.config.enableOutbox) {
275
- try {
276
- await ensureOutboxSchema(db);
277
- this.outboxWorker = new OutboxWorker(
278
- db,
279
- this.publisher,
280
- {
281
- sourceApp: this.config.appName,
282
- streamPrefix: this.streamPrefix,
283
- pollIntervalMs:
284
- this.config.outboxPollIntervalMs ?? 1000,
285
- batchSize: this.config.outboxBatchSize ?? 100,
286
- enableLogging: this.config.enableLogging ?? false,
287
- },
288
- this.metrics
289
- );
290
- await this.outboxWorker.start();
291
- } catch (error) {
292
- loggerInstance.error(
293
- { err: error, msg: "Failed to start OutboxWorker" }
294
- );
295
- this.outboxWorker = null;
296
- }
297
- }
298
-
299
- this.started = true;
300
- loggerInstance.info(
301
- `RemoteManager started app="${this.config.appName}" instance=${this._instanceId} outbox=${this.config.enableOutbox ?? false}`
302
- );
303
- }
304
-
305
- async shutdown(): Promise<void> {
306
- if (!this.started) return;
307
- this.started = false;
308
-
309
- // 1. Stop outbox worker first — best-effort flush so committed rows
310
- // emitted right before shutdown still reach Redis.
311
- if (this.outboxWorker) {
312
- try {
313
- await this.outboxWorker.flush();
314
- } catch (error) {
315
- loggerInstance.warn(
316
- { err: error, msg: "OutboxWorker flush error" }
317
- );
318
- }
319
- try {
320
- await this.outboxWorker.stop();
321
- } catch (error) {
322
- loggerInstance.warn(
323
- { err: error, msg: "OutboxWorker stop error" }
324
- );
325
- }
326
- this.outboxWorker = null;
327
- }
328
-
329
- // 2. Drain pending RPC calls first (caller rejects new)
330
- const drainMs = this.config.shutdownDrainMs ?? 2000;
331
- if (this.caller) {
332
- try {
333
- await this.caller.stop(drainMs);
334
- } catch (error) {
335
- loggerInstance.warn(
336
- { err: error, msg: "RpcCaller stop error" }
337
- );
338
- }
339
- this.caller = null;
340
- }
341
-
342
- // 3. Stop consumer — waits for in-flight handler
343
- if (this.consumer) {
344
- try {
345
- await this.consumer.stop();
346
- } catch (error) {
347
- loggerInstance.warn(
348
- { err: error, msg: "Consumer stop error" }
349
- );
350
- }
351
- this.consumer = null;
352
- }
353
-
354
- // 4. Disconnect Redis conns
355
- if (this.rpcListenerRedis) {
356
- try {
357
- this.rpcListenerRedis.disconnect();
358
- } catch (error) {
359
- loggerInstance.warn(
360
- { err: error, msg: "RPC listener disconnect error" }
361
- );
362
- }
363
- this.rpcListenerRedis = null;
364
- }
365
-
366
- if (this.consumerRedis) {
367
- try {
368
- this.consumerRedis.disconnect();
369
- } catch (error) {
370
- loggerInstance.warn(
371
- { err: error, msg: "Consumer Redis disconnect error" }
372
- );
373
- }
374
- this.consumerRedis = null;
375
- }
376
-
377
- if (this.publisher) {
378
- try {
379
- await this.publisher.quit();
380
- } catch (error) {
381
- loggerInstance.warn(
382
- { err: error, msg: "Publisher quit error" }
383
- );
384
- }
385
- this.publisher = null;
386
- }
387
-
388
- loggerInstance.info("RemoteManager shutdown completed");
389
- }
390
- }
391
-
392
- let remoteManagerInstance: RemoteManager | null = null;
393
-
394
- export function getRemoteManager(): RemoteManager | null {
395
- return remoteManagerInstance;
396
- }
397
-
398
- export function setRemoteManager(instance: RemoteManager | null): void {
399
- remoteManagerInstance = instance;
400
- }
1
+ /**
2
+ * Remote Communication: RemoteManager
3
+ *
4
+ * Phase 1: events + RPC over Redis Streams.
5
+ * - emit(target, event, data): fire-and-forget XADD to `remote:<target>`
6
+ * - call(target, method, data, options): RPC with correlationId + deadline
7
+ * - Dedicated Redis connections:
8
+ * publisher — XADD only (non-blocking, retries enabled)
9
+ * consumer — XREADGROUP BLOCK (retries=null, required for blocking)
10
+ * rpcListener — XREAD BLOCK on per-instance response stream
11
+ * - Graceful shutdown drains pending RPC calls within `shutdownDrainMs`
12
+ */
13
+
14
+ import Redis, { type RedisOptions } from "ioredis";
15
+ import { logger } from "../Logger";
16
+ import { StreamConsumer } from "./StreamConsumer";
17
+ import { RpcCaller } from "./RpcCaller";
18
+ import { OutboxWorker } from "./OutboxWorker";
19
+ import { ensureOutboxSchema } from "./outboxSchema";
20
+ import { CircuitBreaker } from "./CircuitBreaker";
21
+ import { RemoteMetrics, type RemoteMetricsSnapshot } from "./metrics";
22
+ import { collectRemoteHealth, type RemoteHealthCheck } from "./health";
23
+ import db from "../../database";
24
+ import type {
25
+ CallOptions,
26
+ EmitOptions,
27
+ RemoteHandler,
28
+ RemoteManagerConfig,
29
+ RpcHandler,
30
+ } from "./types";
31
+
32
+ const loggerInstance = logger.child({ scope: "RemoteManager" });
33
+
34
+ function buildRedisOptions(blocking: boolean): RedisOptions {
35
+ return {
36
+ host: process.env.REDIS_HOST || "localhost",
37
+ port: parseInt(process.env.REDIS_PORT || "6379", 10),
38
+ password: process.env.REDIS_PASSWORD,
39
+ db: parseInt(process.env.REDIS_DB || "0", 10),
40
+ maxRetriesPerRequest: blocking ? null : 3,
41
+ enableReadyCheck: false,
42
+ retryStrategy: (times: number) => Math.min(times * 50, 2000),
43
+ };
44
+ }
45
+
46
+ export class RemoteManager {
47
+ private publisher: Redis | null = null;
48
+ private consumerRedis: Redis | null = null;
49
+ private rpcListenerRedis: Redis | null = null;
50
+ private consumer: StreamConsumer | null = null;
51
+ private caller: RpcCaller | null = null;
52
+ private outboxWorker: OutboxWorker | null = null;
53
+ private breaker: CircuitBreaker;
54
+ private metrics = new RemoteMetrics();
55
+ private config: RemoteManagerConfig;
56
+ private _instanceId: string;
57
+ private started = false;
58
+
59
+ constructor(config: RemoteManagerConfig) {
60
+ this.config = config;
61
+ this._instanceId = crypto.randomUUID();
62
+ this.breaker = new CircuitBreaker({
63
+ threshold: config.circuitBreakerThreshold,
64
+ resetTimeoutMs: config.circuitBreakerResetMs,
65
+ });
66
+ this.breaker.onTrip = () => this.metrics.cbTripped();
67
+ this.breaker.onReject = () => this.metrics.cbRejected();
68
+ }
69
+
70
+ getMetrics(): RemoteMetricsSnapshot & {
71
+ circuitBreaker: RemoteMetricsSnapshot["circuitBreaker"] & {
72
+ state: string;
73
+ };
74
+ } {
75
+ const snap = this.metrics.getSnapshot();
76
+ return {
77
+ ...snap,
78
+ circuitBreaker: {
79
+ ...snap.circuitBreaker,
80
+ state: this.breaker.getState(),
81
+ },
82
+ };
83
+ }
84
+
85
+ getCircuitBreaker(): CircuitBreaker {
86
+ return this.breaker;
87
+ }
88
+
89
+ async health(): Promise<RemoteHealthCheck> {
90
+ return collectRemoteHealth({
91
+ publisher: this.publisher,
92
+ consumerRedis: this.consumerRedis,
93
+ streamKey: `${this.streamPrefix}${this.config.appName}`,
94
+ consumerGroup:
95
+ this.config.consumerGroup ?? this.config.appName,
96
+ dlqStream: `${this.streamPrefix}${this.config.appName}:dlq`,
97
+ outboxEnabled: this.config.enableOutbox ?? false,
98
+ db,
99
+ breaker: this.breaker,
100
+ });
101
+ }
102
+
103
+ get appName(): string {
104
+ return this.config.appName;
105
+ }
106
+
107
+ get instanceId(): string {
108
+ return this._instanceId;
109
+ }
110
+
111
+ get streamPrefix(): string {
112
+ return this.config.streamPrefix ?? "remote:";
113
+ }
114
+
115
+ get responseStream(): string {
116
+ return `rpc:responses:${this._instanceId}`;
117
+ }
118
+
119
+ /**
120
+ * Emit an event.
121
+ *
122
+ * Without `{ trx }`: direct XADD to Redis (fire-and-forget, no DB write).
123
+ * With `{ trx }`: insert a row into `remote_outbox` within the caller's
124
+ * transaction. The OutboxWorker publishes the row to Redis after commit.
125
+ *
126
+ * Returns the Redis message id (direct path) or the outbox row id
127
+ * (transactional path).
128
+ */
129
+ async emit(
130
+ target: string,
131
+ event: string,
132
+ data: unknown,
133
+ options: EmitOptions = {}
134
+ ): Promise<string | null> {
135
+ if (!this.publisher) {
136
+ throw new Error(
137
+ "RemoteManager not started — call start() before emit()"
138
+ );
139
+ }
140
+
141
+ if (options.trx) {
142
+ const trx = options.trx as any;
143
+ const rows = await trx`
144
+ INSERT INTO remote_outbox (target, event, data)
145
+ VALUES (${target}, ${event}, ${data})
146
+ RETURNING id
147
+ `;
148
+ const id = rows?.[0]?.id ?? null;
149
+ this.metrics.emitOutbox();
150
+ if (this.config.enableLogging) {
151
+ loggerInstance.debug(
152
+ `emit outbox → target=${target} event=${event} id=${id}`
153
+ );
154
+ }
155
+ return id;
156
+ }
157
+
158
+ const stream = `${this.streamPrefix}${target}`;
159
+ const envelope = JSON.stringify({
160
+ kind: "event",
161
+ sourceApp: this.config.appName,
162
+ event,
163
+ data,
164
+ emittedAt: Date.now(),
165
+ });
166
+ try {
167
+ const publisher = this.publisher;
168
+ const id = await this.breaker.exec(() =>
169
+ publisher.xadd(stream, "*", "data", envelope)
170
+ );
171
+ this.metrics.emitDirect();
172
+ if (this.config.enableLogging) {
173
+ loggerInstance.debug(
174
+ `emit → ${stream} event=${event} id=${id}`
175
+ );
176
+ }
177
+ return id;
178
+ } catch (error) {
179
+ this.metrics.emitFailed();
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * RPC call — awaits a response or rejects on timeout/error.
186
+ * Throws `RemoteError { code: "INVALID_TARGET" }` for broadcast target "*".
187
+ */
188
+ async call<T = unknown>(
189
+ target: string,
190
+ method: string,
191
+ data: unknown,
192
+ options: CallOptions = {}
193
+ ): Promise<T> {
194
+ if (!this.caller) {
195
+ throw new Error(
196
+ "RemoteManager not started — call start() before call()"
197
+ );
198
+ }
199
+ return this.caller.call<T>(
200
+ target,
201
+ method,
202
+ data,
203
+ this.streamPrefix,
204
+ this.config.appName,
205
+ options
206
+ );
207
+ }
208
+
209
+ on(event: string, fn: RemoteHandler, handlerId: string): void {
210
+ if (!this.consumer) {
211
+ throw new Error(
212
+ "RemoteManager consumer not initialized — call start() first"
213
+ );
214
+ }
215
+ this.consumer.addHandler(event, fn, handlerId);
216
+ }
217
+
218
+ onRpc(event: string, fn: RpcHandler, handlerId: string): void {
219
+ if (!this.consumer) {
220
+ throw new Error(
221
+ "RemoteManager consumer not initialized — call start() first"
222
+ );
223
+ }
224
+ this.consumer.addRpcHandler(event, fn, handlerId);
225
+ }
226
+
227
+ async start(): Promise<void> {
228
+ if (this.started) return;
229
+
230
+ const factory =
231
+ this.config.redisFactory ??
232
+ ((blocking: boolean) => new Redis(buildRedisOptions(blocking)));
233
+
234
+ this.publisher = factory(false) as Redis;
235
+ this.consumerRedis = factory(true) as Redis;
236
+ this.rpcListenerRedis = factory(true) as Redis;
237
+
238
+ for (const [name, client] of [
239
+ ["publisher", this.publisher],
240
+ ["consumer", this.consumerRedis],
241
+ ["rpcListener", this.rpcListenerRedis],
242
+ ] as const) {
243
+ client.on("error", (err) => {
244
+ loggerInstance.warn(
245
+ { err, name, msg: `${name} Redis error` }
246
+ );
247
+ });
248
+ }
249
+
250
+ this.consumer = new StreamConsumer(
251
+ this.consumerRedis,
252
+ this.publisher,
253
+ this.config,
254
+ this.metrics
255
+ );
256
+ await this.consumer.start();
257
+
258
+ this.caller = new RpcCaller(
259
+ this.rpcListenerRedis,
260
+ this.publisher,
261
+ {
262
+ instanceId: this._instanceId,
263
+ responseStream: this.responseStream,
264
+ defaultTimeout: this.config.defaultCallTimeout ?? 5000,
265
+ responseStreamMaxLen:
266
+ this.config.responseStreamMaxLen ?? 1000,
267
+ enableLogging: this.config.enableLogging ?? false,
268
+ },
269
+ this.breaker,
270
+ this.metrics
271
+ );
272
+ await this.caller.start();
273
+
274
+ if (this.config.enableOutbox) {
275
+ try {
276
+ await ensureOutboxSchema(db);
277
+ this.outboxWorker = new OutboxWorker(
278
+ db,
279
+ this.publisher,
280
+ {
281
+ sourceApp: this.config.appName,
282
+ streamPrefix: this.streamPrefix,
283
+ pollIntervalMs:
284
+ this.config.outboxPollIntervalMs ?? 1000,
285
+ batchSize: this.config.outboxBatchSize ?? 100,
286
+ enableLogging: this.config.enableLogging ?? false,
287
+ retentionMs: this.config.outboxRetentionMs ?? 86_400_000,
288
+ },
289
+ this.metrics
290
+ );
291
+ await this.outboxWorker.start();
292
+ } catch (error) {
293
+ loggerInstance.error(
294
+ { err: error, msg: "Failed to start OutboxWorker" }
295
+ );
296
+ this.outboxWorker = null;
297
+ }
298
+ }
299
+
300
+ this.started = true;
301
+ loggerInstance.info(
302
+ `RemoteManager started app="${this.config.appName}" instance=${this._instanceId} outbox=${this.config.enableOutbox ?? false}`
303
+ );
304
+ }
305
+
306
+ async shutdown(): Promise<void> {
307
+ if (!this.started) return;
308
+ this.started = false;
309
+
310
+ // 1. Stop outbox worker first — best-effort flush so committed rows
311
+ // emitted right before shutdown still reach Redis.
312
+ if (this.outboxWorker) {
313
+ try {
314
+ await this.outboxWorker.flush();
315
+ } catch (error) {
316
+ loggerInstance.warn(
317
+ { err: error, msg: "OutboxWorker flush error" }
318
+ );
319
+ }
320
+ try {
321
+ await this.outboxWorker.stop();
322
+ } catch (error) {
323
+ loggerInstance.warn(
324
+ { err: error, msg: "OutboxWorker stop error" }
325
+ );
326
+ }
327
+ this.outboxWorker = null;
328
+ }
329
+
330
+ // 2. Drain pending RPC calls first (caller rejects new)
331
+ const drainMs = this.config.shutdownDrainMs ?? 2000;
332
+ if (this.caller) {
333
+ try {
334
+ await this.caller.stop(drainMs);
335
+ } catch (error) {
336
+ loggerInstance.warn(
337
+ { err: error, msg: "RpcCaller stop error" }
338
+ );
339
+ }
340
+ this.caller = null;
341
+ }
342
+
343
+ // 3. Stop consumer — waits for in-flight handler
344
+ if (this.consumer) {
345
+ try {
346
+ await this.consumer.stop();
347
+ } catch (error) {
348
+ loggerInstance.warn(
349
+ { err: error, msg: "Consumer stop error" }
350
+ );
351
+ }
352
+ this.consumer = null;
353
+ }
354
+
355
+ // 4. Disconnect Redis conns
356
+ if (this.rpcListenerRedis) {
357
+ try {
358
+ this.rpcListenerRedis.disconnect();
359
+ } catch (error) {
360
+ loggerInstance.warn(
361
+ { err: error, msg: "RPC listener disconnect error" }
362
+ );
363
+ }
364
+ this.rpcListenerRedis = null;
365
+ }
366
+
367
+ if (this.consumerRedis) {
368
+ try {
369
+ this.consumerRedis.disconnect();
370
+ } catch (error) {
371
+ loggerInstance.warn(
372
+ { err: error, msg: "Consumer Redis disconnect error" }
373
+ );
374
+ }
375
+ this.consumerRedis = null;
376
+ }
377
+
378
+ if (this.publisher) {
379
+ try {
380
+ await this.publisher.quit();
381
+ } catch (error) {
382
+ loggerInstance.warn(
383
+ { err: error, msg: "Publisher quit error" }
384
+ );
385
+ }
386
+ this.publisher = null;
387
+ }
388
+
389
+ loggerInstance.info("RemoteManager shutdown completed");
390
+ }
391
+ }
392
+
393
+ let remoteManagerInstance: RemoteManager | null = null;
394
+
395
+ export function getRemoteManager(): RemoteManager | null {
396
+ return remoteManagerInstance;
397
+ }
398
+
399
+ export function setRemoteManager(instance: RemoteManager | null): void {
400
+ remoteManagerInstance = instance;
401
+ }