bunsane 0.3.2 → 0.5.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 (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +8 -7
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +25 -10
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. package/tests/utils/test-context.ts +0 -149
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'async_hooks';
2
2
  import type { Middleware } from '../Middleware';
3
+ import { setResponseHeaders } from './headers';
3
4
 
4
5
  /**
5
6
  * AsyncLocalStorage to propagate requestId to any code running within a request.
@@ -24,15 +25,7 @@ export function requestId(): Middleware {
24
25
 
25
26
  return requestStore.run({ requestId: id }, async () => {
26
27
  const response = await next();
27
-
28
- const newHeaders = new Headers(response.headers);
29
- newHeaders.set('X-Request-Id', id);
30
-
31
- return new Response(response.body, {
32
- status: response.status,
33
- statusText: response.statusText,
34
- headers: newHeaders,
35
- });
28
+ return setResponseHeaders(response, [['X-Request-Id', id]]);
36
29
  });
37
30
  };
38
31
  }
@@ -1,4 +1,5 @@
1
1
  import type { Middleware } from '../Middleware';
2
+ import { setResponseHeaders } from './headers';
2
3
 
3
4
  export type SecurityHeadersOptions = {
4
5
  /** Enable HSTS header. Default: true in production */
@@ -47,16 +48,6 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
47
48
 
48
49
  return async (req, next) => {
49
50
  const response = await next();
50
-
51
- const newHeaders = new Headers(response.headers);
52
- for (const [key, value] of headersToSet) {
53
- newHeaders.set(key, value);
54
- }
55
-
56
- return new Response(response.body, {
57
- status: response.status,
58
- statusText: response.statusText,
59
- headers: newHeaders,
60
- });
51
+ return setResponseHeaders(response, headersToSet);
61
52
  };
62
53
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Attempt to mutate `response.headers` in-place.
3
+ * Constructed Responses (Yoga, REST handlers) allow mutation; only proxied
4
+ * fetch Responses guard their headers as immutable. Fall back to a single
5
+ * clone so the chain never needs more than one new Response.
6
+ */
7
+ export function setResponseHeaders(
8
+ response: Response,
9
+ headers: Iterable<[string, string]>,
10
+ ): Response {
11
+ try {
12
+ for (const [key, value] of headers) {
13
+ response.headers.set(key, value);
14
+ }
15
+ return response;
16
+ } catch {
17
+ // Immutable guard hit (e.g. proxied fetch Response) — clone once.
18
+ const cloned = new Headers(response.headers);
19
+ for (const [key, value] of headers) {
20
+ cloned.set(key, value);
21
+ }
22
+ return new Response(response.body, {
23
+ status: response.status,
24
+ statusText: response.statusText,
25
+ headers: cloned,
26
+ });
27
+ }
28
+ }
@@ -1,183 +1,213 @@
1
- /**
2
- * Remote Communication: OutboxWorker
3
- *
4
- * Polls `remote_outbox` for unpublished rows, publishes each to Redis, and
5
- * marks the row published. Uses `FOR UPDATE SKIP LOCKED` so multiple
6
- * instances can run workers concurrently without double-publishing:
7
- * each row is claimed by exactly one worker per batch.
8
- *
9
- * At-least-once semantics: if the worker crashes after XADD but before the
10
- * UPDATE commits, the row stays pending and will be republished. Consumers
11
- * must be idempotent — enforce this at the handler level (e.g., dedup on
12
- * `ctx.messageId` or domain-level idempotency keys).
13
- */
14
-
15
- import type Redis from "ioredis";
16
- import { sql as sqlHelper, type SQL } from "bun";
17
- import { logger } from "../Logger";
18
- import type { RemoteMetrics } from "./metrics";
19
-
20
- const loggerInstance = logger.child({ scope: "OutboxWorker" });
21
-
22
- export interface OutboxWorkerConfig {
23
- sourceApp: string;
24
- streamPrefix: string;
25
- pollIntervalMs: number;
26
- batchSize: number;
27
- enableLogging: boolean;
28
- }
29
-
30
- interface OutboxRow {
31
- id: string;
32
- target: string;
33
- event: string;
34
- data: unknown;
35
- created_at: Date;
36
- }
37
-
38
- export class OutboxWorker {
39
- private db: SQL;
40
- private publisher: Redis;
41
- private config: OutboxWorkerConfig;
42
- private running = false;
43
- private timer: ReturnType<typeof setTimeout> | null = null;
44
- private currentTick: Promise<void> | null = null;
45
- private metrics?: RemoteMetrics;
46
-
47
- constructor(
48
- db: SQL,
49
- publisher: Redis,
50
- config: OutboxWorkerConfig,
51
- metrics?: RemoteMetrics
52
- ) {
53
- this.db = db;
54
- this.publisher = publisher;
55
- this.config = config;
56
- this.metrics = metrics;
57
- }
58
-
59
- async start(): Promise<void> {
60
- if (this.running) return;
61
- this.running = true;
62
- this.scheduleNext(0);
63
- loggerInstance.info(
64
- `OutboxWorker started pollMs=${this.config.pollIntervalMs} batch=${this.config.batchSize}`
65
- );
66
- }
67
-
68
- async stop(): Promise<void> {
69
- if (!this.running) return;
70
- this.running = false;
71
- if (this.timer) {
72
- clearTimeout(this.timer);
73
- this.timer = null;
74
- }
75
- if (this.currentTick) {
76
- await this.currentTick.catch(() => {});
77
- }
78
- loggerInstance.info("OutboxWorker stopped");
79
- }
80
-
81
- /**
82
- * Force an immediate tick. Used during shutdown to flush any
83
- * committed-but-unpublished rows before the process exits.
84
- */
85
- async flush(): Promise<void> {
86
- await this.tick();
87
- }
88
-
89
- private scheduleNext(delayMs: number): void {
90
- if (!this.running) return;
91
- this.timer = setTimeout(() => {
92
- this.currentTick = this.tick().finally(() => {
93
- this.currentTick = null;
94
- this.scheduleNext(this.config.pollIntervalMs);
95
- });
96
- }, delayMs);
97
- }
98
-
99
- private async tick(): Promise<void> {
100
- if (!this.running) return;
101
- try {
102
- await this.processBatch();
103
- } catch (error: any) {
104
- loggerInstance.error(
105
- { err: error, msg: "OutboxWorker tick error" }
106
- );
107
- }
108
- }
109
-
110
- private async processBatch(): Promise<void> {
111
- const db = this.db as any;
112
- await db.begin(async (trx: any) => {
113
- const rows: OutboxRow[] = await trx`
114
- SELECT id, target, event, data, created_at
115
- FROM remote_outbox
116
- WHERE published_at IS NULL
117
- ORDER BY created_at
118
- LIMIT ${this.config.batchSize}
119
- FOR UPDATE SKIP LOCKED
120
- `;
121
-
122
- if (rows.length === 0) return;
123
-
124
- this.metrics?.outboxClaimed(rows.length);
125
- if (this.config.enableLogging) {
126
- loggerInstance.debug(`Claimed ${rows.length} outbox rows`);
127
- }
128
-
129
- // Publish concurrently rather than serially. Each xadd is bounded
130
- // by the publisher client's `commandTimeout`; with serial awaits a
131
- // batch of N slow rows would hold PG row locks for N × timeout.
132
- // Parallel keeps worst-case lock hold ≈ single-xadd timeout.
133
- // (H-DB-1 partial — full fix requires a claim-via-column design
134
- // so Redis latency no longer sits inside a PG transaction at all.)
135
- const publishResults = await Promise.allSettled(
136
- rows.map((row) => {
137
- const stream = `${this.config.streamPrefix}${row.target}`;
138
- const envelope = JSON.stringify({
139
- kind: "event",
140
- sourceApp: this.config.sourceApp,
141
- event: row.event,
142
- data: row.data,
143
- emittedAt: row.created_at.getTime(),
144
- });
145
- return this.publisher.xadd(stream, "*", "data", envelope);
146
- })
147
- );
148
-
149
- const successIds: string[] = [];
150
- for (let i = 0; i < publishResults.length; i++) {
151
- const r = publishResults[i];
152
- const row = rows[i]!;
153
- if (r!.status === "fulfilled") {
154
- successIds.push(row.id);
155
- } else {
156
- this.metrics?.outboxPublishFailed();
157
- loggerInstance.error({
158
- err: r!.reason,
159
- outboxId: row.id,
160
- target: row.target,
161
- event: row.event,
162
- msg: "Outbox XADD failed row will retry next tick",
163
- });
164
- // Leave row unpublished; SKIP LOCKED releases on tx end
165
- // so next tick (or another instance) picks it up.
166
- }
167
- }
168
-
169
- if (successIds.length > 0) {
170
- // Single bulk UPDATE instead of N round-trips holding row
171
- // locks (H-DB-3). Previously each success fired its own
172
- // UPDATE statement serially. Uses Bun SQL's `sql(...)` helper
173
- // for the IN-list so ids are parameterised individually.
174
- await trx`
175
- UPDATE remote_outbox
176
- SET published_at = NOW()
177
- WHERE id IN ${sqlHelper(successIds)}
178
- `;
179
- this.metrics?.outboxPublished(successIds.length);
180
- }
181
- });
182
- }
183
- }
1
+ /**
2
+ * Remote Communication: OutboxWorker
3
+ *
4
+ * Polls `remote_outbox` for unpublished rows, publishes each to Redis, and
5
+ * marks the row published. Uses `FOR UPDATE SKIP LOCKED` so multiple
6
+ * instances can run workers concurrently without double-publishing:
7
+ * each row is claimed by exactly one worker per batch.
8
+ *
9
+ * At-least-once semantics: if the worker crashes after XADD but before the
10
+ * UPDATE commits, the row stays pending and will be republished. Consumers
11
+ * must be idempotent — enforce this at the handler level (e.g., dedup on
12
+ * `ctx.messageId` or domain-level idempotency keys).
13
+ */
14
+
15
+ import type Redis from "ioredis";
16
+ import { sql as sqlHelper, type SQL } from "bun";
17
+ import { logger } from "../Logger";
18
+ import type { RemoteMetrics } from "./metrics";
19
+
20
+ const loggerInstance = logger.child({ scope: "OutboxWorker" });
21
+
22
+ export interface OutboxWorkerConfig {
23
+ sourceApp: string;
24
+ streamPrefix: string;
25
+ pollIntervalMs: number;
26
+ batchSize: number;
27
+ enableLogging: boolean;
28
+ /** Retention window for published rows in ms. 0 disables trimming. Default 24h. */
29
+ retentionMs: number;
30
+ }
31
+
32
+ interface OutboxRow {
33
+ id: string;
34
+ target: string;
35
+ event: string;
36
+ data: unknown;
37
+ created_at: Date;
38
+ }
39
+
40
+ export class OutboxWorker {
41
+ private db: SQL;
42
+ private publisher: Redis;
43
+ private config: OutboxWorkerConfig;
44
+ private running = false;
45
+ private timer: ReturnType<typeof setTimeout> | null = null;
46
+ private currentTick: Promise<void> | null = null;
47
+ private metrics?: RemoteMetrics;
48
+ private lastTrimAt = 0;
49
+
50
+ constructor(
51
+ db: SQL,
52
+ publisher: Redis,
53
+ config: OutboxWorkerConfig,
54
+ metrics?: RemoteMetrics
55
+ ) {
56
+ this.db = db;
57
+ this.publisher = publisher;
58
+ this.config = config;
59
+ this.metrics = metrics;
60
+ }
61
+
62
+ async start(): Promise<void> {
63
+ if (this.running) return;
64
+ this.running = true;
65
+ this.scheduleNext(0);
66
+ loggerInstance.info(
67
+ `OutboxWorker started pollMs=${this.config.pollIntervalMs} batch=${this.config.batchSize}`
68
+ );
69
+ }
70
+
71
+ async stop(): Promise<void> {
72
+ if (!this.running) return;
73
+ this.running = false;
74
+ if (this.timer) {
75
+ clearTimeout(this.timer);
76
+ this.timer = null;
77
+ }
78
+ if (this.currentTick) {
79
+ await this.currentTick.catch(() => {});
80
+ }
81
+ loggerInstance.info("OutboxWorker stopped");
82
+ }
83
+
84
+ /**
85
+ * Force an immediate tick. Used during shutdown to flush any
86
+ * committed-but-unpublished rows before the process exits.
87
+ */
88
+ async flush(): Promise<void> {
89
+ await this.tick();
90
+ }
91
+
92
+ private scheduleNext(delayMs: number): void {
93
+ if (!this.running) return;
94
+ this.timer = setTimeout(() => {
95
+ this.currentTick = this.tick().finally(() => {
96
+ this.currentTick = null;
97
+ this.scheduleNext(this.config.pollIntervalMs);
98
+ });
99
+ }, delayMs);
100
+ }
101
+
102
+ private async tick(): Promise<void> {
103
+ if (!this.running) return;
104
+ try {
105
+ await this.processBatch();
106
+ await this.maybeTrimPublished();
107
+ } catch (error: any) {
108
+ loggerInstance.error(
109
+ { err: error, msg: "OutboxWorker tick error" }
110
+ );
111
+ }
112
+ }
113
+
114
+ private async maybeTrimPublished(): Promise<void> {
115
+ const { retentionMs } = this.config;
116
+ if (!retentionMs) return;
117
+
118
+ const now = Date.now();
119
+ // At most once per hour to avoid frequent lock contention
120
+ if (now - this.lastTrimAt < 3_600_000) return;
121
+ this.lastTrimAt = now;
122
+
123
+ const cutoff = new Date(now - retentionMs);
124
+ const db = this.db as any;
125
+ const result = await db`
126
+ DELETE FROM remote_outbox
127
+ WHERE id IN (
128
+ SELECT id FROM remote_outbox
129
+ WHERE published_at IS NOT NULL
130
+ AND published_at < ${cutoff}
131
+ LIMIT 10000
132
+ )
133
+ `;
134
+ const count = result.count ?? result.length ?? 0;
135
+ if (count > 0) {
136
+ loggerInstance.debug(`Trimmed ${count} published outbox rows older than ${cutoff.toISOString()}`);
137
+ }
138
+ }
139
+
140
+ private async processBatch(): Promise<void> {
141
+ const db = this.db as any;
142
+ await db.begin(async (trx: any) => {
143
+ const rows: OutboxRow[] = await trx`
144
+ SELECT id, target, event, data, created_at
145
+ FROM remote_outbox
146
+ WHERE published_at IS NULL
147
+ ORDER BY created_at
148
+ LIMIT ${this.config.batchSize}
149
+ FOR UPDATE SKIP LOCKED
150
+ `;
151
+
152
+ if (rows.length === 0) return;
153
+
154
+ this.metrics?.outboxClaimed(rows.length);
155
+ if (this.config.enableLogging) {
156
+ loggerInstance.debug(`Claimed ${rows.length} outbox rows`);
157
+ }
158
+
159
+ // Publish concurrently rather than serially. Each xadd is bounded
160
+ // by the publisher client's `commandTimeout`; with serial awaits a
161
+ // batch of N slow rows would hold PG row locks for N × timeout.
162
+ // Parallel keeps worst-case lock hold single-xadd timeout.
163
+ // (H-DB-1 partial — full fix requires a claim-via-column design
164
+ // so Redis latency no longer sits inside a PG transaction at all.)
165
+ const publishResults = await Promise.allSettled(
166
+ rows.map((row) => {
167
+ const stream = `${this.config.streamPrefix}${row.target}`;
168
+ const envelope = JSON.stringify({
169
+ kind: "event",
170
+ sourceApp: this.config.sourceApp,
171
+ event: row.event,
172
+ data: row.data,
173
+ emittedAt: row.created_at.getTime(),
174
+ });
175
+ return this.publisher.xadd(stream, "*", "data", envelope);
176
+ })
177
+ );
178
+
179
+ const successIds: string[] = [];
180
+ for (let i = 0; i < publishResults.length; i++) {
181
+ const r = publishResults[i];
182
+ const row = rows[i]!;
183
+ if (r!.status === "fulfilled") {
184
+ successIds.push(row.id);
185
+ } else {
186
+ this.metrics?.outboxPublishFailed();
187
+ loggerInstance.error({
188
+ err: r!.reason,
189
+ outboxId: row.id,
190
+ target: row.target,
191
+ event: row.event,
192
+ msg: "Outbox XADD failed — row will retry next tick",
193
+ });
194
+ // Leave row unpublished; SKIP LOCKED releases on tx end
195
+ // so next tick (or another instance) picks it up.
196
+ }
197
+ }
198
+
199
+ if (successIds.length > 0) {
200
+ // Single bulk UPDATE instead of N round-trips holding row
201
+ // locks (H-DB-3). Previously each success fired its own
202
+ // UPDATE statement serially. Uses Bun SQL's `sql(...)` helper
203
+ // for the IN-list so ids are parameterised individually.
204
+ await trx`
205
+ UPDATE remote_outbox
206
+ SET published_at = NOW()
207
+ WHERE id IN ${sqlHelper(successIds)}
208
+ `;
209
+ this.metrics?.outboxPublished(successIds.length);
210
+ }
211
+ });
212
+ }
213
+ }