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,448 +0,0 @@
1
- /**
2
- * In-memory Redis Streams shim for Tier 2 integration tests.
3
- *
4
- * Implements only the commands the remote subsystem issues:
5
- * xadd, xreadgroup, xread, xack, xgroup CREATE, xpending,
6
- * xautoclaim, xlen, xrange, ping
7
- *
8
- * Shared server: multiple MockRedisClient instances pointing at the same
9
- * server simulate separate app processes talking to one Redis.
10
- */
11
-
12
- interface StreamEntry {
13
- id: string;
14
- fields: string[]; // flat [k,v,k,v,...]
15
- }
16
-
17
- interface PelEntry {
18
- msgId: string;
19
- consumer: string;
20
- deliveredAt: number;
21
- deliveryCount: number;
22
- }
23
-
24
- interface ConsumerGroup {
25
- name: string;
26
- consumers: Set<string>;
27
- pel: Map<string, PelEntry>;
28
- /** Highest ID delivered via ">" — next read starts after this. */
29
- lastDeliveredId: string;
30
- }
31
-
32
- interface Stream {
33
- key: string;
34
- entries: StreamEntry[];
35
- groups: Map<string, ConsumerGroup>;
36
- lastGeneratedTs: number;
37
- seqWithinMs: number;
38
- }
39
-
40
- const MIN_ID = "0-0";
41
-
42
- function parseId(id: string): [number, number] {
43
- const dash = id.indexOf("-");
44
- if (dash < 0) return [Number(id), 0];
45
- return [Number(id.slice(0, dash)), Number(id.slice(dash + 1))];
46
- }
47
-
48
- function idLess(a: string, b: string): boolean {
49
- const [at, as] = parseId(a);
50
- const [bt, bs] = parseId(b);
51
- if (at !== bt) return at < bt;
52
- return as < bs;
53
- }
54
-
55
- function idGreater(a: string, b: string): boolean {
56
- const [at, as] = parseId(a);
57
- const [bt, bs] = parseId(b);
58
- if (at !== bt) return at > bt;
59
- return as > bs;
60
- }
61
-
62
- export class MockRedisStreamServer {
63
- private streams = new Map<string, Stream>();
64
- /** Fault injection for tests that need to simulate XADD failures. */
65
- public xaddShouldFail = false;
66
-
67
- private sleep(ms: number): Promise<void> {
68
- return new Promise((r) => setTimeout(r, ms));
69
- }
70
-
71
- private getOrCreateStream(key: string): Stream {
72
- let s = this.streams.get(key);
73
- if (!s) {
74
- s = {
75
- key,
76
- entries: [],
77
- groups: new Map(),
78
- lastGeneratedTs: 0,
79
- seqWithinMs: 0,
80
- };
81
- this.streams.set(key, s);
82
- }
83
- return s;
84
- }
85
-
86
- private generateId(stream: Stream): string {
87
- const now = Date.now();
88
- if (now === stream.lastGeneratedTs) {
89
- stream.seqWithinMs++;
90
- } else {
91
- stream.lastGeneratedTs = now;
92
- stream.seqWithinMs = 0;
93
- }
94
- return `${now}-${stream.seqWithinMs}`;
95
- }
96
-
97
- /**
98
- * XADD key [MAXLEN [~] N] * field value [field value ...]
99
- * Returns the generated id.
100
- */
101
- xadd(key: string, ...args: any[]): string {
102
- if (this.xaddShouldFail) {
103
- throw new Error("MOCK_XADD_FAIL");
104
- }
105
- const stream = this.getOrCreateStream(key);
106
-
107
- // Parse leading options
108
- let i = 0;
109
- let maxLen: number | null = null;
110
- if (args[i] === "MAXLEN") {
111
- i++;
112
- if (args[i] === "~" || args[i] === "=") i++;
113
- maxLen = Number(args[i]);
114
- i++;
115
- }
116
-
117
- // Expect "*"
118
- if (args[i] !== "*") {
119
- throw new Error(`MockRedis xadd: only "*" auto-id supported, got ${args[i]}`);
120
- }
121
- i++;
122
-
123
- const fields: string[] = [];
124
- for (; i < args.length; i++) {
125
- fields.push(String(args[i]));
126
- }
127
-
128
- const id = this.generateId(stream);
129
- stream.entries.push({ id, fields });
130
-
131
- if (maxLen !== null && stream.entries.length > maxLen) {
132
- stream.entries.splice(0, stream.entries.length - maxLen);
133
- }
134
-
135
- return id;
136
- }
137
-
138
- /**
139
- * XGROUP CREATE stream group id [MKSTREAM]
140
- * id "$" = start from latest, "0" / "0-0" = start from beginning.
141
- */
142
- xgroup(op: string, key: string, groupName: string, startId: string, mkstream?: string): string {
143
- if (op !== "CREATE") {
144
- throw new Error(`MockRedis xgroup: op "${op}" not supported`);
145
- }
146
- const hasStream = this.streams.has(key);
147
- if (!hasStream && mkstream !== "MKSTREAM") {
148
- throw new Error("ERR no such key");
149
- }
150
- const stream = this.getOrCreateStream(key);
151
- if (stream.groups.has(groupName)) {
152
- const err = new Error(
153
- `BUSYGROUP Consumer Group name already exists`
154
- );
155
- throw err;
156
- }
157
- const lastDeliveredId =
158
- startId === "$"
159
- ? stream.entries.length > 0
160
- ? stream.entries[stream.entries.length - 1]!.id
161
- : MIN_ID
162
- : startId === "0" || startId === "0-0"
163
- ? MIN_ID
164
- : startId;
165
- stream.groups.set(groupName, {
166
- name: groupName,
167
- consumers: new Set(),
168
- pel: new Map(),
169
- lastDeliveredId,
170
- });
171
- return "OK";
172
- }
173
-
174
- /**
175
- * XREADGROUP GROUP g consumer [COUNT n] [BLOCK ms] STREAMS s ">"
176
- * Returns [[streamKey, [[id, fields], ...]]] or null on timeout.
177
- */
178
- async xreadgroup(...args: any[]): Promise<any> {
179
- let i = 0;
180
- if (args[i] !== "GROUP") throw new Error("expected GROUP");
181
- i++;
182
- const groupName = String(args[i++]);
183
- const consumer = String(args[i++]);
184
- let count = Infinity;
185
- let blockMs = 0;
186
- while (args[i] !== "STREAMS") {
187
- const opt = String(args[i++]).toUpperCase();
188
- if (opt === "COUNT") count = Number(args[i++]);
189
- else if (opt === "BLOCK") blockMs = Number(args[i++]);
190
- else throw new Error(`unknown XREADGROUP opt ${opt}`);
191
- }
192
- i++; // skip STREAMS
193
- const streams: string[] = [];
194
- const ids: string[] = [];
195
- const remaining = args.slice(i);
196
- const half = remaining.length / 2;
197
- for (let k = 0; k < half; k++) {
198
- streams.push(String(remaining[k]));
199
- ids.push(String(remaining[k + half]));
200
- }
201
-
202
- const deadline = Date.now() + blockMs;
203
- while (true) {
204
- const result = this.readGroupOnce(groupName, consumer, count, streams, ids);
205
- if (result) return result;
206
- if (Date.now() >= deadline) return null;
207
- await this.sleep(10);
208
- }
209
- }
210
-
211
- private readGroupOnce(
212
- groupName: string,
213
- consumer: string,
214
- count: number,
215
- streams: string[],
216
- ids: string[]
217
- ): any[] | null {
218
- const out: any[] = [];
219
- for (let s = 0; s < streams.length; s++) {
220
- const streamKey = streams[s]!;
221
- const id = ids[s]!;
222
- const stream = this.streams.get(streamKey);
223
- if (!stream) continue;
224
- const group = stream.groups.get(groupName);
225
- if (!group) continue;
226
- group.consumers.add(consumer);
227
-
228
- let newEntries: StreamEntry[];
229
- if (id === ">") {
230
- // New messages only
231
- newEntries = stream.entries.filter((e) =>
232
- idGreater(e.id, group.lastDeliveredId)
233
- );
234
- if (newEntries.length > count) {
235
- newEntries = newEntries.slice(0, count);
236
- }
237
- for (const entry of newEntries) {
238
- group.lastDeliveredId = entry.id;
239
- const existing = group.pel.get(entry.id);
240
- if (existing) {
241
- existing.deliveryCount++;
242
- existing.deliveredAt = Date.now();
243
- existing.consumer = consumer;
244
- } else {
245
- group.pel.set(entry.id, {
246
- msgId: entry.id,
247
- consumer,
248
- deliveredAt: Date.now(),
249
- deliveryCount: 1,
250
- });
251
- }
252
- }
253
- } else {
254
- // Re-read this consumer's PEL
255
- newEntries = stream.entries.filter((e) => {
256
- const p = group.pel.get(e.id);
257
- return p && p.consumer === consumer && idGreater(e.id, id);
258
- });
259
- if (newEntries.length > count) {
260
- newEntries = newEntries.slice(0, count);
261
- }
262
- }
263
-
264
- if (newEntries.length > 0) {
265
- out.push([
266
- streamKey,
267
- newEntries.map((e) => [e.id, e.fields]),
268
- ]);
269
- }
270
- }
271
- return out.length > 0 ? out : null;
272
- }
273
-
274
- /**
275
- * XREAD [COUNT n] [BLOCK ms] STREAMS s id
276
- */
277
- async xread(...args: any[]): Promise<any> {
278
- let i = 0;
279
- let count = Infinity;
280
- let blockMs = 0;
281
- while (args[i] !== "STREAMS") {
282
- const opt = String(args[i++]).toUpperCase();
283
- if (opt === "COUNT") count = Number(args[i++]);
284
- else if (opt === "BLOCK") blockMs = Number(args[i++]);
285
- else throw new Error(`unknown XREAD opt ${opt}`);
286
- }
287
- i++;
288
- const remaining = args.slice(i);
289
- const half = remaining.length / 2;
290
- const streams: string[] = [];
291
- const ids: string[] = [];
292
- for (let k = 0; k < half; k++) {
293
- streams.push(String(remaining[k]));
294
- ids.push(String(remaining[k + half]));
295
- }
296
-
297
- // Resolve "$" to the current last id per stream once, up front.
298
- // Subsequent polls compare against that snapshot so new entries get
299
- // delivered exactly once.
300
- const resolvedIds = ids.map((id, k) => {
301
- if (id !== "$") return id;
302
- const stream = this.streams.get(streams[k]!);
303
- if (!stream || stream.entries.length === 0) return MIN_ID;
304
- return stream.entries[stream.entries.length - 1]!.id;
305
- });
306
-
307
- const deadline = Date.now() + blockMs;
308
- while (true) {
309
- const out: any[] = [];
310
- for (let s = 0; s < streams.length; s++) {
311
- const streamKey = streams[s]!;
312
- const afterId = resolvedIds[s]!;
313
- const stream = this.streams.get(streamKey);
314
- if (!stream) continue;
315
- const matching = stream.entries
316
- .filter((e) => idGreater(e.id, afterId))
317
- .slice(0, count);
318
- if (matching.length > 0) {
319
- out.push([
320
- streamKey,
321
- matching.map((e) => [e.id, e.fields]),
322
- ]);
323
- }
324
- }
325
- if (out.length > 0) return out;
326
- if (Date.now() >= deadline) return null;
327
- await this.sleep(10);
328
- }
329
- }
330
-
331
- xack(key: string, groupName: string, msgId: string): number {
332
- const stream = this.streams.get(key);
333
- if (!stream) return 0;
334
- const group = stream.groups.get(groupName);
335
- if (!group) return 0;
336
- return group.pel.delete(msgId) ? 1 : 0;
337
- }
338
-
339
- /**
340
- * Two forms:
341
- * XPENDING key group -> summary
342
- * XPENDING key group minId maxId count [consumer] -> detail
343
- */
344
- xpending(key: string, groupName: string, ...args: any[]): any {
345
- const stream = this.streams.get(key);
346
- if (!stream) return [0, null, null, null];
347
- const group = stream.groups.get(groupName);
348
- if (!group) return [0, null, null, null];
349
-
350
- if (args.length === 0) {
351
- // Summary
352
- const ids = Array.from(group.pel.keys()).sort((a, b) =>
353
- idLess(a, b) ? -1 : idGreater(a, b) ? 1 : 0
354
- );
355
- if (ids.length === 0) return [0, null, null, null];
356
- const byConsumer = new Map<string, number>();
357
- for (const p of group.pel.values()) {
358
- byConsumer.set(
359
- p.consumer,
360
- (byConsumer.get(p.consumer) ?? 0) + 1
361
- );
362
- }
363
- return [
364
- ids.length,
365
- ids[0],
366
- ids[ids.length - 1],
367
- Array.from(byConsumer.entries()).map(([c, n]) => [c, String(n)]),
368
- ];
369
- }
370
-
371
- const [minId, maxId, _count] = args;
372
- const out: any[] = [];
373
- for (const p of group.pel.values()) {
374
- if (idLess(p.msgId, minId) || idGreater(p.msgId, maxId)) continue;
375
- out.push([
376
- p.msgId,
377
- p.consumer,
378
- Date.now() - p.deliveredAt,
379
- p.deliveryCount,
380
- ]);
381
- }
382
- return out;
383
- }
384
-
385
- /**
386
- * XAUTOCLAIM stream group consumer idleMs cursor [COUNT n]
387
- * Returns [nextCursor, entries]
388
- */
389
- xautoclaim(
390
- key: string,
391
- groupName: string,
392
- consumer: string,
393
- idleMs: number,
394
- cursor: string,
395
- ..._rest: any[]
396
- ): any {
397
- const stream = this.streams.get(key);
398
- if (!stream) return ["0-0", []];
399
- const group = stream.groups.get(groupName);
400
- if (!group) return ["0-0", []];
401
-
402
- const now = Date.now();
403
- const claimed: any[] = [];
404
- for (const p of group.pel.values()) {
405
- if (now - p.deliveredAt < idleMs) continue;
406
- if (idLess(p.msgId, cursor) && cursor !== "0-0") continue;
407
- p.consumer = consumer;
408
- p.deliveryCount++;
409
- p.deliveredAt = now;
410
- const entry = stream.entries.find((e) => e.id === p.msgId);
411
- if (entry) claimed.push([entry.id, entry.fields]);
412
- }
413
- return ["0-0", claimed];
414
- }
415
-
416
- xlen(key: string): number {
417
- return this.streams.get(key)?.entries.length ?? 0;
418
- }
419
-
420
- xrange(key: string, start: string, end: string, ..._rest: any[]): any[] {
421
- const stream = this.streams.get(key);
422
- if (!stream) return [];
423
- const lo = start === "-" ? MIN_ID : start;
424
- const hi = end === "+" ? "9999999999999-9999" : end;
425
- return stream.entries
426
- .filter(
427
- (e) =>
428
- !idLess(e.id, lo) && !idGreater(e.id, hi)
429
- )
430
- .map((e) => [e.id, e.fields]);
431
- }
432
-
433
- ping(): string {
434
- return "PONG";
435
- }
436
-
437
- /** Helper for tests: total PEL entries across a group. */
438
- getPelSize(streamKey: string, groupName: string): number {
439
- return (
440
- this.streams.get(streamKey)?.groups.get(groupName)?.pel.size ?? 0
441
- );
442
- }
443
-
444
- /** Helper for tests: raw stream entry count ignoring groups. */
445
- getStreamLength(key: string): number {
446
- return this.xlen(key);
447
- }
448
- }
@@ -1,241 +0,0 @@
1
- /**
2
- * Integration tests for ArcheType persistence
3
- * Tests archetype creation and loading with the database
4
- */
5
- import { describe, test, expect, beforeAll, beforeEach, afterEach } from 'bun:test';
6
- import { Entity } from '../../../core/Entity';
7
- import { Query, FilterOp } from '../../../query/Query';
8
- import { TestUser, TestProduct, TestOrder } from '../../fixtures/components';
9
- import { TestUserArchetype, TestUserWithOrdersArchetype } from '../../fixtures/archetypes/TestUserArchetype';
10
- import { createTestContext, ensureComponentsRegistered } from '../../utils';
11
-
12
- describe('ArcheType Persistence', () => {
13
- const ctx = createTestContext();
14
-
15
- beforeAll(async () => {
16
- await ensureComponentsRegistered(TestUser, TestProduct, TestOrder);
17
- });
18
-
19
- describe('createAndSaveEntity()', () => {
20
- test('creates and persists entity with archetype components', async () => {
21
- const archetype = new TestUserArchetype();
22
- archetype.fill({ user: { name: 'ArchetypeSave', email: 'archsave@example.com', age: 30 } });
23
- const entity = await archetype.createAndSaveEntity();
24
- ctx.tracker.track(entity);
25
-
26
- expect(entity._persisted).toBe(true);
27
-
28
- const loaded = await Entity.FindById(entity.id);
29
- expect(loaded).not.toBeNull();
30
-
31
- // Use async get() since component may not be in memory after FindById
32
- const userData = await loaded?.get(TestUser);
33
- expect(userData?.name).toBe('ArchetypeSave');
34
- });
35
-
36
- test('creates entity with multiple components', async () => {
37
- const archetype = new TestUserWithOrdersArchetype();
38
- archetype.fill({
39
- user: { name: 'MultiArch', email: 'multiarch@example.com', age: 28 },
40
- order: {
41
- orderNumber: 'ORD-ARCH-001',
42
- total: 199.99,
43
- status: 'completed',
44
- createdAt: new Date()
45
- }
46
- });
47
- const entity = await archetype.createAndSaveEntity();
48
- ctx.tracker.track(entity);
49
-
50
- const loaded = await Entity.FindById(entity.id);
51
- expect(loaded).not.toBeNull();
52
-
53
- // Load components and verify
54
- const userData = await loaded?.get(TestUser);
55
- const orderData = await loaded?.get(TestOrder);
56
- expect(userData).toBeDefined();
57
- expect(orderData).toBeDefined();
58
- });
59
- });
60
-
61
- describe('createEntity() with fill()', () => {
62
- test('creates entity from archetype with data', async () => {
63
- const archetype = new TestUserArchetype();
64
- archetype.fill({ user: { name: 'FillCreate', email: 'fillcreate@example.com', age: 25 } });
65
- const entity = archetype.createEntity();
66
- ctx.tracker.track(entity);
67
-
68
- expect(entity).toBeInstanceOf(Entity);
69
- expect(entity.id).toBeDefined();
70
- expect((entity as any)._dirty).toBe(true);
71
- expect(entity._persisted).toBe(false);
72
-
73
- // Component should be in memory after createEntity
74
- const userData = entity.getInMemory(TestUser);
75
- expect(userData?.name).toBe('FillCreate');
76
- });
77
-
78
- test('entity can be saved after creation', async () => {
79
- const archetype = new TestUserArchetype();
80
- archetype.fill({ user: { name: 'SaveAfter', email: 'saveafter@example.com', age: 30 } });
81
- const entity = archetype.createEntity();
82
- ctx.tracker.track(entity);
83
-
84
- await entity.save();
85
-
86
- expect(entity._persisted).toBe(true);
87
- expect((entity as any)._dirty).toBe(false);
88
- });
89
- });
90
-
91
- describe('getEntityWithID()', () => {
92
- test('loads entity by ID with archetype components', async () => {
93
- // Create and save an entity
94
- const archetype = new TestUserArchetype();
95
- archetype.fill({ user: { name: 'GetWithId', email: 'getwithid@example.com', age: 25 } });
96
- const entity = await archetype.createAndSaveEntity();
97
- ctx.tracker.track(entity);
98
-
99
- // Load using archetype's getEntityWithID
100
- const loadArchetype = new TestUserArchetype();
101
- const loaded = await loadArchetype.getEntityWithID(entity.id);
102
-
103
- expect(loaded).not.toBeNull();
104
- expect(loaded?.id).toBe(entity.id);
105
-
106
- // Component should be loaded
107
- const userData = await loaded?.get(TestUser);
108
- expect(userData?.name).toBe('GetWithId');
109
- });
110
-
111
- test('returns null for non-existent ID', async () => {
112
- const archetype = new TestUserArchetype();
113
- const loaded = await archetype.getEntityWithID('00000000-0000-0000-0000-000000000000');
114
- expect(loaded).toBeNull();
115
- });
116
-
117
- test('returns null for invalid ID', async () => {
118
- const archetype = new TestUserArchetype();
119
- const loaded = await archetype.getEntityWithID('');
120
- expect(loaded).toBeNull();
121
- });
122
- });
123
-
124
- describe('updateEntity()', () => {
125
- test('updates entity with new data', async () => {
126
- const archetype = new TestUserArchetype();
127
- archetype.fill({ user: { name: 'ToUpdate', email: 'toupdate@example.com', age: 30 } });
128
- const entity = await archetype.createAndSaveEntity();
129
- ctx.tracker.track(entity);
130
-
131
- // Update the entity
132
- await archetype.updateEntity(entity, {
133
- user: { name: 'Updated', age: 31 }
134
- });
135
- await entity.save();
136
-
137
- const loaded = await Entity.FindById(entity.id);
138
- const userData = await loaded?.get(TestUser);
139
- expect(userData?.name).toBe('Updated');
140
- expect(userData?.age).toBe(31);
141
- });
142
-
143
- test('preserves unchanged fields', async () => {
144
- const archetype = new TestUserArchetype();
145
- archetype.fill({ user: { name: 'Preserve', email: 'preserve@example.com', age: 25 } });
146
- const entity = await archetype.createAndSaveEntity();
147
- ctx.tracker.track(entity);
148
-
149
- // Update only name
150
- await archetype.updateEntity(entity, {
151
- user: { name: 'PreserveUpdated' }
152
- });
153
- await entity.save();
154
-
155
- const loaded = await Entity.FindById(entity.id);
156
- const userData = await loaded?.get(TestUser);
157
- expect(userData?.name).toBe('PreserveUpdated');
158
- expect(userData?.email).toBe('preserve@example.com'); // Email unchanged
159
- });
160
- });
161
-
162
- describe('querying entities with archetype components', () => {
163
- beforeEach(async () => {
164
- // Create test data
165
- for (let i = 0; i < 3; i++) {
166
- const archetype = new TestUserArchetype();
167
- archetype.fill({
168
- user: {
169
- name: `QueryArchUser${i}`,
170
- email: `queryarch${i}@example.com`,
171
- age: 20 + i * 10
172
- }
173
- });
174
- const entity = await archetype.createAndSaveEntity();
175
- ctx.tracker.track(entity);
176
- }
177
- });
178
-
179
- test('finds entities via Query with archetype components', async () => {
180
- const results = await new Query()
181
- .with(TestUser, {
182
- filters: [Query.filter('email', FilterOp.LIKE, 'queryarch%@example.com')]
183
- })
184
- .populate()
185
- .exec();
186
-
187
- expect(results.length).toBeGreaterThanOrEqual(3);
188
- });
189
-
190
- test('filters by component field values', async () => {
191
- const results = await new Query()
192
- .with(TestUser, {
193
- filters: [Query.filter('name', FilterOp.EQ, 'QueryArchUser0')]
194
- })
195
- .populate()
196
- .exec();
197
-
198
- expect(results.length).toBeGreaterThanOrEqual(1);
199
- const userData = await results[0]?.get(TestUser);
200
- expect(userData?.name).toBe('QueryArchUser0');
201
- });
202
- });
203
-
204
- describe('Unwrap()', () => {
205
- test('unwraps entity to plain object', async () => {
206
- const archetype = new TestUserArchetype();
207
- archetype.fill({ user: { name: 'Unwrap', email: 'unwrap@example.com', age: 30 } });
208
- const entity = await archetype.createAndSaveEntity();
209
- ctx.tracker.track(entity);
210
-
211
- const unwrapped = await archetype.Unwrap(entity);
212
-
213
- expect(unwrapped.id).toBe(entity.id);
214
- // The unwrapped format may vary - check that user data is present
215
- expect(unwrapped.user || unwrapped).toBeDefined();
216
- });
217
- });
218
-
219
- describe('validation', () => {
220
- test('withValidation validates input data', () => {
221
- const archetype = new TestUserArchetype();
222
-
223
- // Valid data should pass
224
- const validResult = archetype.withValidation({
225
- user: { name: 'Valid', email: 'valid@example.com', age: 25 }
226
- });
227
-
228
- expect(validResult).toBeDefined();
229
- });
230
- });
231
-
232
- describe('component properties', () => {
233
- test('getComponentsToLoad returns component constructors', () => {
234
- const archetype = new TestUserArchetype();
235
- const components = (archetype as any).getComponentsToLoad();
236
-
237
- expect(Array.isArray(components)).toBe(true);
238
- expect(components.length).toBeGreaterThan(0);
239
- });
240
- });
241
- });