@affectively/aeon-pages 1.3.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 (124) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +625 -0
  3. package/examples/basic/aeon.config.ts +39 -0
  4. package/examples/basic/components/Cursor.tsx +86 -0
  5. package/examples/basic/components/OfflineIndicator.tsx +103 -0
  6. package/examples/basic/components/PresenceBar.tsx +77 -0
  7. package/examples/basic/package.json +20 -0
  8. package/examples/basic/pages/index.tsx +80 -0
  9. package/package.json +101 -0
  10. package/packages/analytics/README.md +309 -0
  11. package/packages/analytics/build.ts +35 -0
  12. package/packages/analytics/package.json +50 -0
  13. package/packages/analytics/src/click-tracker.ts +368 -0
  14. package/packages/analytics/src/context-bridge.ts +319 -0
  15. package/packages/analytics/src/data-layer.ts +302 -0
  16. package/packages/analytics/src/gtm-loader.ts +239 -0
  17. package/packages/analytics/src/index.ts +230 -0
  18. package/packages/analytics/src/merkle-tree.ts +489 -0
  19. package/packages/analytics/src/provider.tsx +300 -0
  20. package/packages/analytics/src/types.ts +320 -0
  21. package/packages/analytics/src/use-analytics.ts +296 -0
  22. package/packages/analytics/tsconfig.json +19 -0
  23. package/packages/benchmarks/src/benchmark.test.ts +691 -0
  24. package/packages/cli/dist/index.js +61899 -0
  25. package/packages/cli/package.json +43 -0
  26. package/packages/cli/src/commands/build.test.ts +682 -0
  27. package/packages/cli/src/commands/build.ts +890 -0
  28. package/packages/cli/src/commands/dev.ts +473 -0
  29. package/packages/cli/src/commands/init.ts +409 -0
  30. package/packages/cli/src/commands/start.ts +297 -0
  31. package/packages/cli/src/index.ts +105 -0
  32. package/packages/directives/src/use-aeon.ts +272 -0
  33. package/packages/mcp-server/package.json +51 -0
  34. package/packages/mcp-server/src/index.ts +178 -0
  35. package/packages/mcp-server/src/resources.ts +346 -0
  36. package/packages/mcp-server/src/tools/index.ts +36 -0
  37. package/packages/mcp-server/src/tools/navigation.ts +545 -0
  38. package/packages/mcp-server/tsconfig.json +21 -0
  39. package/packages/react/package.json +40 -0
  40. package/packages/react/src/Link.tsx +388 -0
  41. package/packages/react/src/components/InstallPrompt.tsx +286 -0
  42. package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
  43. package/packages/react/src/components/PushNotifications.tsx +453 -0
  44. package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
  45. package/packages/react/src/hooks/useConflicts.ts +277 -0
  46. package/packages/react/src/hooks/useNetworkState.ts +209 -0
  47. package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
  48. package/packages/react/src/hooks/useServiceWorker.ts +278 -0
  49. package/packages/react/src/hooks.ts +195 -0
  50. package/packages/react/src/index.ts +151 -0
  51. package/packages/react/src/provider.tsx +467 -0
  52. package/packages/react/tsconfig.json +19 -0
  53. package/packages/runtime/README.md +399 -0
  54. package/packages/runtime/build.ts +48 -0
  55. package/packages/runtime/package.json +71 -0
  56. package/packages/runtime/schema.sql +40 -0
  57. package/packages/runtime/src/api-routes.ts +465 -0
  58. package/packages/runtime/src/benchmark.ts +171 -0
  59. package/packages/runtime/src/cache.ts +479 -0
  60. package/packages/runtime/src/durable-object.ts +1341 -0
  61. package/packages/runtime/src/index.ts +360 -0
  62. package/packages/runtime/src/navigation.test.ts +421 -0
  63. package/packages/runtime/src/navigation.ts +422 -0
  64. package/packages/runtime/src/nextjs-adapter.ts +272 -0
  65. package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
  66. package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
  67. package/packages/runtime/src/offline/encryption.test.ts +412 -0
  68. package/packages/runtime/src/offline/encryption.ts +397 -0
  69. package/packages/runtime/src/offline/types.ts +465 -0
  70. package/packages/runtime/src/predictor.ts +371 -0
  71. package/packages/runtime/src/registry.ts +351 -0
  72. package/packages/runtime/src/router/context-extractor.ts +661 -0
  73. package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
  74. package/packages/runtime/src/router/esi-control.ts +541 -0
  75. package/packages/runtime/src/router/esi-cyrano.ts +779 -0
  76. package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
  77. package/packages/runtime/src/router/esi-react.tsx +1065 -0
  78. package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
  79. package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
  80. package/packages/runtime/src/router/esi-translate.ts +503 -0
  81. package/packages/runtime/src/router/esi.ts +666 -0
  82. package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
  83. package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
  84. package/packages/runtime/src/router/index.ts +298 -0
  85. package/packages/runtime/src/router/merkle-capability.ts +473 -0
  86. package/packages/runtime/src/router/speculation.ts +451 -0
  87. package/packages/runtime/src/router/types.ts +630 -0
  88. package/packages/runtime/src/router.test.ts +470 -0
  89. package/packages/runtime/src/router.ts +302 -0
  90. package/packages/runtime/src/server.ts +481 -0
  91. package/packages/runtime/src/service-worker-push.ts +319 -0
  92. package/packages/runtime/src/service-worker.ts +553 -0
  93. package/packages/runtime/src/skeleton-hydrate.ts +237 -0
  94. package/packages/runtime/src/speculation.test.ts +389 -0
  95. package/packages/runtime/src/speculation.ts +486 -0
  96. package/packages/runtime/src/storage.test.ts +1297 -0
  97. package/packages/runtime/src/storage.ts +1048 -0
  98. package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
  99. package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
  100. package/packages/runtime/src/sync/coordinator.test.ts +608 -0
  101. package/packages/runtime/src/sync/coordinator.ts +596 -0
  102. package/packages/runtime/src/tree-compiler.ts +295 -0
  103. package/packages/runtime/src/types.ts +728 -0
  104. package/packages/runtime/src/worker.ts +327 -0
  105. package/packages/runtime/tsconfig.json +20 -0
  106. package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
  107. package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
  108. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
  109. package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
  110. package/packages/runtime/wasm/package.json +21 -0
  111. package/packages/runtime/wrangler.toml +41 -0
  112. package/packages/runtime-wasm/Cargo.lock +436 -0
  113. package/packages/runtime-wasm/Cargo.toml +29 -0
  114. package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
  115. package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
  116. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
  117. package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
  118. package/packages/runtime-wasm/pkg/package.json +21 -0
  119. package/packages/runtime-wasm/src/hydrate.rs +352 -0
  120. package/packages/runtime-wasm/src/lib.rs +191 -0
  121. package/packages/runtime-wasm/src/render.rs +629 -0
  122. package/packages/runtime-wasm/src/router.rs +298 -0
  123. package/packages/runtime-wasm/src/skeleton.rs +430 -0
  124. package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
@@ -0,0 +1,1297 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import {
3
+ FileStorageAdapter,
4
+ D1StorageAdapter,
5
+ DurableObjectStorageAdapter,
6
+ HybridStorageAdapter,
7
+ DashStorageAdapter,
8
+ createStorageAdapter,
9
+ } from './storage';
10
+ import type {
11
+ RouteDefinition,
12
+ PageSession,
13
+ SerializedComponent,
14
+ } from './types';
15
+ import { mkdir, rm } from 'fs/promises';
16
+ import { join } from 'path';
17
+
18
+ describe('FileStorageAdapter', () => {
19
+ const testDataDir = '.aeon/test-data';
20
+ let adapter: FileStorageAdapter;
21
+
22
+ beforeEach(async () => {
23
+ adapter = new FileStorageAdapter({
24
+ pagesDir: './pages',
25
+ dataDir: testDataDir,
26
+ });
27
+ await adapter.init();
28
+ });
29
+
30
+ afterEach(async () => {
31
+ // Clean up test data
32
+ await rm(testDataDir, { recursive: true, force: true });
33
+ });
34
+
35
+ describe('routes', () => {
36
+ test('saves and retrieves a route', async () => {
37
+ const route: RouteDefinition = {
38
+ pattern: '/about',
39
+ sessionId: 'about',
40
+ componentId: 'about',
41
+ isAeon: true,
42
+ };
43
+
44
+ await adapter.saveRoute(route);
45
+ const retrieved = await adapter.getRoute('/about');
46
+
47
+ expect(retrieved).toEqual(route);
48
+ });
49
+
50
+ test('returns null for non-existent route', async () => {
51
+ const route = await adapter.getRoute('/nonexistent');
52
+ expect(route).toBeNull();
53
+ });
54
+
55
+ test('gets all routes', async () => {
56
+ const routes: RouteDefinition[] = [
57
+ {
58
+ pattern: '/',
59
+ sessionId: 'index',
60
+ componentId: 'index',
61
+ isAeon: true,
62
+ },
63
+ {
64
+ pattern: '/about',
65
+ sessionId: 'about',
66
+ componentId: 'about',
67
+ isAeon: true,
68
+ },
69
+ {
70
+ pattern: '/contact',
71
+ sessionId: 'contact',
72
+ componentId: 'contact',
73
+ isAeon: false,
74
+ },
75
+ ];
76
+
77
+ for (const route of routes) {
78
+ await adapter.saveRoute(route);
79
+ }
80
+
81
+ const allRoutes = await adapter.getAllRoutes();
82
+
83
+ expect(allRoutes).toHaveLength(3);
84
+ expect(allRoutes.map((r) => r.pattern).sort()).toEqual([
85
+ '/',
86
+ '/about',
87
+ '/contact',
88
+ ]);
89
+ });
90
+
91
+ test('deletes a route', async () => {
92
+ const route: RouteDefinition = {
93
+ pattern: '/to-delete',
94
+ sessionId: 'to-delete',
95
+ componentId: 'to-delete',
96
+ isAeon: true,
97
+ };
98
+
99
+ await adapter.saveRoute(route);
100
+ expect(await adapter.getRoute('/to-delete')).not.toBeNull();
101
+
102
+ await adapter.deleteRoute('/to-delete');
103
+ expect(await adapter.getRoute('/to-delete')).toBeNull();
104
+ });
105
+
106
+ test('handles routes with slashes in pattern', async () => {
107
+ const route: RouteDefinition = {
108
+ pattern: '/blog/posts/featured',
109
+ sessionId: 'blog-posts-featured',
110
+ componentId: 'blog-posts-featured',
111
+ isAeon: true,
112
+ };
113
+
114
+ await adapter.saveRoute(route);
115
+ const retrieved = await adapter.getRoute('/blog/posts/featured');
116
+
117
+ expect(retrieved).toEqual(route);
118
+ });
119
+ });
120
+
121
+ describe('sessions', () => {
122
+ test('saves and retrieves a session', async () => {
123
+ const session: PageSession = {
124
+ route: '/test-page',
125
+ tree: {
126
+ type: 'div',
127
+ props: { className: 'container' },
128
+ children: [{ type: 'h1', props: {}, children: ['Hello World'] }],
129
+ },
130
+ data: { title: 'Test Page' },
131
+ schema: { version: '1.0.0' },
132
+ presence: [],
133
+ };
134
+
135
+ await adapter.saveSession(session);
136
+ const retrieved = await adapter.getSession('test-page');
137
+
138
+ expect(retrieved).toEqual(session);
139
+ });
140
+
141
+ test('returns null for non-existent session', async () => {
142
+ const session = await adapter.getSession('nonexistent');
143
+ expect(session).toBeNull();
144
+ });
145
+ });
146
+
147
+ describe('trees', () => {
148
+ test('saves and retrieves a tree', async () => {
149
+ // First create a session
150
+ const session: PageSession = {
151
+ route: '/tree-test',
152
+ tree: {
153
+ type: 'div',
154
+ props: {},
155
+ children: ['Initial'],
156
+ },
157
+ data: {},
158
+ schema: { version: '1.0.0' },
159
+ presence: [],
160
+ };
161
+
162
+ await adapter.saveSession(session);
163
+
164
+ // Update tree
165
+ const newTree: SerializedComponent = {
166
+ type: 'div',
167
+ props: { className: 'updated' },
168
+ children: [{ type: 'p', props: {}, children: ['Updated content'] }],
169
+ };
170
+
171
+ await adapter.saveTree('tree-test', newTree);
172
+ const retrieved = await adapter.getTree('tree-test');
173
+
174
+ expect(retrieved).toEqual(newTree);
175
+ });
176
+
177
+ test('returns null for non-existent tree', async () => {
178
+ const tree = await adapter.getTree('nonexistent');
179
+ expect(tree).toBeNull();
180
+ });
181
+ });
182
+ });
183
+
184
+ describe('createStorageAdapter', () => {
185
+ test('creates file adapter by default', () => {
186
+ const adapter = createStorageAdapter({ type: 'file' });
187
+ expect(adapter.name).toBe('file');
188
+ });
189
+
190
+ test('creates file adapter with custom dirs', () => {
191
+ const adapter = createStorageAdapter({
192
+ type: 'file',
193
+ pagesDir: './custom-pages',
194
+ dataDir: './custom-data',
195
+ });
196
+ expect(adapter.name).toBe('file');
197
+ });
198
+
199
+ test('throws for d1 without database', () => {
200
+ expect(() => createStorageAdapter({ type: 'd1' })).toThrow(
201
+ 'D1 database required',
202
+ );
203
+ });
204
+
205
+ test('throws for durable-object without namespace', () => {
206
+ expect(() => createStorageAdapter({ type: 'durable-object' })).toThrow(
207
+ 'Durable Object namespace required',
208
+ );
209
+ });
210
+
211
+ test('throws for hybrid without both deps', () => {
212
+ expect(() => createStorageAdapter({ type: 'hybrid' })).toThrow(
213
+ 'Both Durable Object namespace and D1 database required',
214
+ );
215
+ });
216
+
217
+ test('throws for dash without client', () => {
218
+ expect(() => createStorageAdapter({ type: 'dash' })).toThrow(
219
+ 'Dash client required',
220
+ );
221
+ });
222
+
223
+ test('throws for custom without adapter', () => {
224
+ expect(() => createStorageAdapter({ type: 'custom' })).toThrow(
225
+ 'Custom adapter required',
226
+ );
227
+ });
228
+
229
+ test('uses custom adapter when provided', () => {
230
+ const customAdapter = {
231
+ name: 'my-custom',
232
+ init: async () => {},
233
+ getRoute: async () => null,
234
+ getAllRoutes: async () => [],
235
+ saveRoute: async () => {},
236
+ deleteRoute: async () => {},
237
+ getSession: async () => null,
238
+ saveSession: async () => {},
239
+ getTree: async () => null,
240
+ saveTree: async () => {},
241
+ };
242
+
243
+ const adapter = createStorageAdapter({
244
+ type: 'custom',
245
+ custom: customAdapter,
246
+ });
247
+
248
+ expect(adapter).toBe(customAdapter);
249
+ expect(adapter.name).toBe('my-custom');
250
+ });
251
+ });
252
+
253
+ describe('StorageAdapter interface', () => {
254
+ test('FileStorageAdapter implements interface', async () => {
255
+ const adapter = new FileStorageAdapter({
256
+ pagesDir: './pages',
257
+ dataDir: '.aeon/interface-test',
258
+ });
259
+
260
+ // Verify all interface methods exist
261
+ expect(typeof adapter.name).toBe('string');
262
+ expect(typeof adapter.init).toBe('function');
263
+ expect(typeof adapter.getRoute).toBe('function');
264
+ expect(typeof adapter.getAllRoutes).toBe('function');
265
+ expect(typeof adapter.saveRoute).toBe('function');
266
+ expect(typeof adapter.deleteRoute).toBe('function');
267
+ expect(typeof adapter.getSession).toBe('function');
268
+ expect(typeof adapter.saveSession).toBe('function');
269
+ expect(typeof adapter.getTree).toBe('function');
270
+ expect(typeof adapter.saveTree).toBe('function');
271
+
272
+ // Clean up
273
+ await rm('.aeon/interface-test', { recursive: true, force: true });
274
+ });
275
+ });
276
+
277
+ // Mock D1 Database
278
+ function createMockD1() {
279
+ const store: Record<string, Record<string, unknown>> = {
280
+ routes: {},
281
+ sessions: {},
282
+ presence: {},
283
+ };
284
+
285
+ return {
286
+ exec: async () => {},
287
+ prepare: (query: string) => {
288
+ const bindValues: unknown[] = [];
289
+ return {
290
+ bind: (...values: unknown[]) => {
291
+ bindValues.push(...values);
292
+ return {
293
+ bind: (...moreValues: unknown[]) => {
294
+ bindValues.push(...moreValues);
295
+ return { first, all, run };
296
+ },
297
+ first,
298
+ all,
299
+ run,
300
+ };
301
+
302
+ async function first() {
303
+ if (query.includes('SELECT * FROM routes')) {
304
+ const path = bindValues[0] as string;
305
+ return store.routes[path] || null;
306
+ }
307
+ if (query.includes('SELECT * FROM sessions')) {
308
+ const sessionId = bindValues[0] as string;
309
+ return store.sessions[sessionId] || null;
310
+ }
311
+ if (query.includes('SELECT tree FROM sessions')) {
312
+ const sessionId = bindValues[0] as string;
313
+ const session = store.sessions[sessionId];
314
+ return session ? { tree: (session as any).tree } : null;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ async function all() {
320
+ if (query.includes('SELECT * FROM routes')) {
321
+ return { results: Object.values(store.routes) };
322
+ }
323
+ if (query.includes('SELECT * FROM presence')) {
324
+ const sessionId = bindValues[0] as string;
325
+ return {
326
+ results: Object.values(store.presence).filter(
327
+ (p: any) => p.session_id === sessionId,
328
+ ),
329
+ };
330
+ }
331
+ return { results: [] };
332
+ }
333
+
334
+ async function run() {
335
+ if (query.includes('INSERT OR REPLACE INTO routes')) {
336
+ const [path, pattern, session_id, component_id, layout, is_aeon] =
337
+ bindValues;
338
+ store.routes[path as string] = {
339
+ path,
340
+ pattern,
341
+ session_id,
342
+ component_id,
343
+ layout,
344
+ is_aeon,
345
+ };
346
+ }
347
+ if (query.includes('INSERT OR REPLACE INTO sessions')) {
348
+ const [session_id, route, tree, data, schema_version] =
349
+ bindValues;
350
+ store.sessions[session_id as string] = {
351
+ session_id,
352
+ route,
353
+ tree,
354
+ data,
355
+ schema_version,
356
+ };
357
+ }
358
+ if (query.includes('UPDATE sessions SET tree')) {
359
+ const [tree, session_id] = bindValues;
360
+ if (store.sessions[session_id as string]) {
361
+ (store.sessions[session_id as string] as any).tree = tree;
362
+ }
363
+ }
364
+ if (query.includes('DELETE FROM routes')) {
365
+ const path = bindValues[0] as string;
366
+ delete store.routes[path];
367
+ }
368
+ }
369
+ },
370
+ first: async () => null,
371
+ all: async () => ({ results: [] }),
372
+ run: async () => {},
373
+ };
374
+ },
375
+ };
376
+ }
377
+
378
+ describe('D1StorageAdapter', () => {
379
+ let adapter: D1StorageAdapter;
380
+ let mockDb: ReturnType<typeof createMockD1>;
381
+
382
+ beforeEach(async () => {
383
+ mockDb = createMockD1();
384
+ adapter = new D1StorageAdapter(mockDb as any);
385
+ await adapter.init();
386
+ });
387
+
388
+ test('has correct name', () => {
389
+ expect(adapter.name).toBe('d1');
390
+ });
391
+
392
+ test('saves and retrieves a route', async () => {
393
+ const route: RouteDefinition = {
394
+ pattern: '/test',
395
+ sessionId: 'test',
396
+ componentId: 'test',
397
+ isAeon: true,
398
+ };
399
+
400
+ await adapter.saveRoute(route);
401
+ const retrieved = await adapter.getRoute('/test');
402
+
403
+ expect(retrieved).not.toBeNull();
404
+ expect(retrieved!.pattern).toBe('/test');
405
+ });
406
+
407
+ test('gets all routes', async () => {
408
+ await adapter.saveRoute({
409
+ pattern: '/a',
410
+ sessionId: 'a',
411
+ componentId: 'a',
412
+ isAeon: true,
413
+ });
414
+ await adapter.saveRoute({
415
+ pattern: '/b',
416
+ sessionId: 'b',
417
+ componentId: 'b',
418
+ isAeon: true,
419
+ });
420
+
421
+ const routes = await adapter.getAllRoutes();
422
+ // Mock returns results from internal store
423
+ expect(Array.isArray(routes)).toBe(true);
424
+ });
425
+
426
+ test('deletes a route', async () => {
427
+ await adapter.saveRoute({
428
+ pattern: '/delete-me',
429
+ sessionId: 'delete-me',
430
+ componentId: 'delete-me',
431
+ isAeon: true,
432
+ });
433
+
434
+ await adapter.deleteRoute('/delete-me');
435
+ const route = await adapter.getRoute('/delete-me');
436
+ expect(route).toBeNull();
437
+ });
438
+
439
+ test('saves and retrieves a session', async () => {
440
+ const session: PageSession = {
441
+ route: '/session-test',
442
+ tree: { type: 'div', props: {}, children: [] },
443
+ data: { key: 'value' },
444
+ schema: { version: '1.0.0' },
445
+ presence: [],
446
+ };
447
+
448
+ await adapter.saveSession(session);
449
+ const retrieved = await adapter.getSession('session-test');
450
+
451
+ expect(retrieved).not.toBeNull();
452
+ });
453
+
454
+ test('saves and retrieves tree', async () => {
455
+ // First save a session
456
+ await adapter.saveSession({
457
+ route: '/tree-test',
458
+ tree: { type: 'div', props: {}, children: [] },
459
+ data: {},
460
+ schema: { version: '1.0.0' },
461
+ presence: [],
462
+ });
463
+
464
+ const newTree: SerializedComponent = {
465
+ type: 'section',
466
+ props: { id: 'main' },
467
+ children: ['Hello'],
468
+ };
469
+
470
+ await adapter.saveTree('tree-test', newTree);
471
+ const tree = await adapter.getTree('tree-test');
472
+
473
+ expect(tree).not.toBeNull();
474
+ });
475
+ });
476
+
477
+ // Mock Durable Object Namespace
478
+ function createMockDONamespace() {
479
+ const objects: Record<string, Record<string, unknown>> = {};
480
+
481
+ return {
482
+ idFromName: (name: string) => ({
483
+ toString: () => name,
484
+ equals: (o: any) => o.toString() === name,
485
+ }),
486
+ idFromString: (id: string) => ({
487
+ toString: () => id,
488
+ equals: (o: any) => o.toString() === id,
489
+ }),
490
+ newUniqueId: () => ({
491
+ toString: () => `unique-${Date.now()}`,
492
+ equals: () => false,
493
+ }),
494
+ get: (id: { toString: () => string }) => ({
495
+ id,
496
+ fetch: async (input: RequestInfo, init?: RequestInit) => {
497
+ const request =
498
+ typeof input === 'string' ? new Request(input, init) : input;
499
+ const url = new URL(request.url);
500
+ const objectId = id.toString();
501
+
502
+ if (!objects[objectId]) {
503
+ objects[objectId] = {};
504
+ }
505
+
506
+ if (url.pathname === '/route' && request.method === 'POST') {
507
+ const body = (await request.json()) as {
508
+ action: string;
509
+ path: string;
510
+ };
511
+ if (body.action === 'get') {
512
+ const route = objects['__routes__']?.[body.path];
513
+ return new Response(route ? JSON.stringify(route) : 'null', {
514
+ status: route ? 200 : 404,
515
+ });
516
+ }
517
+ }
518
+
519
+ if (url.pathname === '/route' && request.method === 'PUT') {
520
+ const route = await request.json();
521
+ if (!objects['__routes__']) objects['__routes__'] = {};
522
+ objects['__routes__'][(route as any).pattern] = route;
523
+ return new Response('ok');
524
+ }
525
+
526
+ if (url.pathname === '/route' && request.method === 'DELETE') {
527
+ const body = (await request.json()) as { path: string };
528
+ if (objects['__routes__']) {
529
+ delete objects['__routes__'][body.path];
530
+ }
531
+ return new Response('ok');
532
+ }
533
+
534
+ if (url.pathname === '/routes' && request.method === 'GET') {
535
+ const routes = objects['__routes__']
536
+ ? Object.values(objects['__routes__'])
537
+ : [];
538
+ return new Response(JSON.stringify(routes));
539
+ }
540
+
541
+ if (url.pathname === '/session' && request.method === 'GET') {
542
+ const session = objects[objectId]?.session;
543
+ return new Response(session ? JSON.stringify(session) : 'null', {
544
+ status: session ? 200 : 404,
545
+ });
546
+ }
547
+
548
+ if (url.pathname === '/session' && request.method === 'PUT') {
549
+ const session = await request.json();
550
+ objects[objectId] = { ...objects[objectId], session };
551
+ return new Response('ok');
552
+ }
553
+
554
+ if (url.pathname === '/tree' && request.method === 'GET') {
555
+ const tree = objects[objectId]?.tree;
556
+ return new Response(tree ? JSON.stringify(tree) : 'null', {
557
+ status: tree ? 200 : 404,
558
+ });
559
+ }
560
+
561
+ if (url.pathname === '/tree' && request.method === 'PUT') {
562
+ const tree = await request.json();
563
+ objects[objectId] = { ...objects[objectId], tree };
564
+ return new Response('ok');
565
+ }
566
+
567
+ return new Response('not found', { status: 404 });
568
+ },
569
+ }),
570
+ };
571
+ }
572
+
573
+ describe('DurableObjectStorageAdapter', () => {
574
+ let adapter: DurableObjectStorageAdapter;
575
+ let mockNamespace: ReturnType<typeof createMockDONamespace>;
576
+
577
+ beforeEach(async () => {
578
+ mockNamespace = createMockDONamespace();
579
+ adapter = new DurableObjectStorageAdapter(mockNamespace as any);
580
+ await adapter.init();
581
+ });
582
+
583
+ test('has correct name', () => {
584
+ expect(adapter.name).toBe('durable-object');
585
+ });
586
+
587
+ test('saves and retrieves a route', async () => {
588
+ const route: RouteDefinition = {
589
+ pattern: '/do-test',
590
+ sessionId: 'do-test',
591
+ componentId: 'do-test',
592
+ isAeon: true,
593
+ };
594
+
595
+ await adapter.saveRoute(route);
596
+ const retrieved = await adapter.getRoute('/do-test');
597
+
598
+ expect(retrieved).not.toBeNull();
599
+ expect(retrieved!.pattern).toBe('/do-test');
600
+ });
601
+
602
+ test('gets all routes', async () => {
603
+ await adapter.saveRoute({
604
+ pattern: '/do-a',
605
+ sessionId: 'do-a',
606
+ componentId: 'do-a',
607
+ isAeon: true,
608
+ });
609
+
610
+ const routes = await adapter.getAllRoutes();
611
+ expect(Array.isArray(routes)).toBe(true);
612
+ });
613
+
614
+ test('deletes a route', async () => {
615
+ await adapter.saveRoute({
616
+ pattern: '/do-delete',
617
+ sessionId: 'do-delete',
618
+ componentId: 'do-delete',
619
+ isAeon: true,
620
+ });
621
+
622
+ await adapter.deleteRoute('/do-delete');
623
+ // Route should be removed from cache
624
+ });
625
+
626
+ test('saves and retrieves a session', async () => {
627
+ const session: PageSession = {
628
+ route: '/do-session',
629
+ tree: { type: 'div', props: {}, children: [] },
630
+ data: {},
631
+ schema: { version: '1.0.0' },
632
+ presence: [],
633
+ };
634
+
635
+ await adapter.saveSession(session);
636
+ const retrieved = await adapter.getSession('do-session');
637
+
638
+ expect(retrieved).not.toBeNull();
639
+ });
640
+
641
+ test('saves and retrieves tree', async () => {
642
+ const tree: SerializedComponent = {
643
+ type: 'article',
644
+ props: {},
645
+ children: ['Content'],
646
+ };
647
+
648
+ await adapter.saveTree('do-tree', tree);
649
+ const retrieved = await adapter.getTree('do-tree');
650
+
651
+ expect(retrieved).not.toBeNull();
652
+ });
653
+
654
+ test('getSessionStub returns a stub', () => {
655
+ const stub = adapter.getSessionStub('test-session');
656
+ expect(stub).toBeDefined();
657
+ expect(typeof stub.fetch).toBe('function');
658
+ });
659
+ });
660
+
661
+ describe('HybridStorageAdapter', () => {
662
+ let adapter: HybridStorageAdapter;
663
+
664
+ beforeEach(async () => {
665
+ const mockDb = createMockD1();
666
+ const mockNamespace = createMockDONamespace();
667
+ adapter = new HybridStorageAdapter({
668
+ namespace: mockNamespace as any,
669
+ db: mockDb as any,
670
+ });
671
+ await adapter.init();
672
+ });
673
+
674
+ test('has correct name', () => {
675
+ expect(adapter.name).toBe('hybrid');
676
+ });
677
+
678
+ test('saves and retrieves a route', async () => {
679
+ const route: RouteDefinition = {
680
+ pattern: '/hybrid-test',
681
+ sessionId: 'hybrid-test',
682
+ componentId: 'hybrid-test',
683
+ isAeon: true,
684
+ };
685
+
686
+ await adapter.saveRoute(route);
687
+ const retrieved = await adapter.getRoute('/hybrid-test');
688
+
689
+ expect(retrieved).not.toBeNull();
690
+ });
691
+
692
+ test('deletes route and propagates to D1', async () => {
693
+ await adapter.saveRoute({
694
+ pattern: '/hybrid-delete',
695
+ sessionId: 'hybrid-delete',
696
+ componentId: 'hybrid-delete',
697
+ isAeon: true,
698
+ });
699
+
700
+ await adapter.deleteRoute('/hybrid-delete');
701
+ // Wait for async propagation
702
+ await new Promise((resolve) => setTimeout(resolve, 10));
703
+ // Should complete without error
704
+ });
705
+
706
+ test('saves and gets session', async () => {
707
+ const session: PageSession = {
708
+ route: '/hybrid-session',
709
+ tree: { type: 'div', props: {}, children: [] },
710
+ data: {},
711
+ schema: { version: '1.0.0' },
712
+ presence: [],
713
+ };
714
+
715
+ await adapter.saveSession(session);
716
+
717
+ // Get session from hybrid adapter
718
+ const retrieved = await adapter.getSession('hybrid-session');
719
+ // May be null depending on mock, but the call should work
720
+ });
721
+
722
+ test('saves session and tree', async () => {
723
+ const session: PageSession = {
724
+ route: '/hybrid-tree-test',
725
+ tree: { type: 'div', props: {}, children: [] },
726
+ data: {},
727
+ schema: { version: '1.0.0' },
728
+ presence: [],
729
+ };
730
+
731
+ await adapter.saveSession(session);
732
+ await adapter.saveTree('hybrid-tree-test', {
733
+ type: 'span',
734
+ children: ['new'],
735
+ });
736
+
737
+ const tree = await adapter.getTree('hybrid-tree-test');
738
+ expect(tree).not.toBeNull();
739
+ });
740
+
741
+ test('getSessionStub returns a stub', () => {
742
+ const stub = adapter.getSessionStub('test');
743
+ expect(stub).toBeDefined();
744
+ });
745
+
746
+ test('getHistoricalSession falls back to D1', async () => {
747
+ const session = await adapter.getHistoricalSession('nonexistent');
748
+ expect(session).toBeNull();
749
+ });
750
+ });
751
+
752
+ // Mock Dash Client
753
+ function createMockDashClient() {
754
+ const collections: Record<string, Record<string, unknown>> = {};
755
+ let connected = false;
756
+
757
+ return {
758
+ connect: async () => {
759
+ connected = true;
760
+ },
761
+ disconnect: async () => {
762
+ connected = false;
763
+ },
764
+ isConnected: () => connected,
765
+ get: async <T>(collection: string, id: string): Promise<T | null> => {
766
+ return (collections[collection]?.[id] as T) ?? null;
767
+ },
768
+ query: async <T>(collection: string): Promise<T[]> => {
769
+ return Object.values(collections[collection] || {}) as T[];
770
+ },
771
+ set: async <T>(collection: string, id: string, data: T): Promise<void> => {
772
+ if (!collections[collection]) collections[collection] = {};
773
+ collections[collection][id] = data;
774
+ },
775
+ delete: async (collection: string, id: string): Promise<void> => {
776
+ if (collections[collection]) {
777
+ delete collections[collection][id];
778
+ }
779
+ },
780
+ subscribe: <T>() => ({
781
+ unsubscribe: () => {},
782
+ }),
783
+ batch: async () => {},
784
+ };
785
+ }
786
+
787
+ describe('DashStorageAdapter', () => {
788
+ let adapter: DashStorageAdapter;
789
+ let mockClient: ReturnType<typeof createMockDashClient>;
790
+
791
+ beforeEach(async () => {
792
+ mockClient = createMockDashClient();
793
+ adapter = new DashStorageAdapter(mockClient as any, {
794
+ routesCollection: 'test-routes',
795
+ sessionsCollection: 'test-sessions',
796
+ presenceCollection: 'test-presence',
797
+ });
798
+ await adapter.init();
799
+ });
800
+
801
+ test('has correct name', () => {
802
+ expect(adapter.name).toBe('dash');
803
+ });
804
+
805
+ test('connects on init if not connected', async () => {
806
+ const disconnectedClient = createMockDashClient();
807
+ const newAdapter = new DashStorageAdapter(disconnectedClient as any);
808
+ await newAdapter.init();
809
+ expect(disconnectedClient.isConnected()).toBe(true);
810
+ });
811
+
812
+ test('saves and retrieves a route', async () => {
813
+ const route: RouteDefinition = {
814
+ pattern: '/dash-test',
815
+ sessionId: 'dash-test',
816
+ componentId: 'dash-test',
817
+ isAeon: true,
818
+ };
819
+
820
+ await adapter.saveRoute(route);
821
+ const retrieved = await adapter.getRoute('/dash-test');
822
+
823
+ expect(retrieved).not.toBeNull();
824
+ expect(retrieved!.pattern).toBe('/dash-test');
825
+ });
826
+
827
+ test('gets all routes', async () => {
828
+ await adapter.saveRoute({
829
+ pattern: '/dash-a',
830
+ sessionId: 'dash-a',
831
+ componentId: 'dash-a',
832
+ isAeon: true,
833
+ });
834
+
835
+ const routes = await adapter.getAllRoutes();
836
+ expect(routes.length).toBeGreaterThanOrEqual(1);
837
+ });
838
+
839
+ test('deletes a route', async () => {
840
+ await adapter.saveRoute({
841
+ pattern: '/dash-delete',
842
+ sessionId: 'dash-delete',
843
+ componentId: 'dash-delete',
844
+ isAeon: true,
845
+ });
846
+
847
+ await adapter.deleteRoute('/dash-delete');
848
+ const route = await adapter.getRoute('/dash-delete');
849
+ expect(route).toBeNull();
850
+ });
851
+
852
+ test('saves and retrieves a session', async () => {
853
+ const session: PageSession = {
854
+ route: '/dash-session',
855
+ tree: { type: 'div', props: {}, children: [] },
856
+ data: { foo: 'bar' },
857
+ schema: { version: '1.0.0' },
858
+ presence: [],
859
+ };
860
+
861
+ await adapter.saveSession(session);
862
+ const retrieved = await adapter.getSession('dash-session');
863
+
864
+ expect(retrieved).not.toBeNull();
865
+ });
866
+
867
+ test('getSession includes presence data', async () => {
868
+ // Create a mock that returns presence data
869
+ const mockWithPresence = {
870
+ ...createMockDashClient(),
871
+ query: async <T>(collection: string): Promise<T[]> => {
872
+ if (collection === 'test-presence') {
873
+ return [
874
+ {
875
+ sessionId: 'with-presence',
876
+ userId: 'user1',
877
+ role: 'user',
878
+ cursor: { x: 10, y: 20 },
879
+ editing: 'header',
880
+ status: 'online',
881
+ lastActivity: '2024-01-01',
882
+ },
883
+ ] as T[];
884
+ }
885
+ return [];
886
+ },
887
+ get: async <T>(collection: string, id: string): Promise<T | null> => {
888
+ if (collection === 'test-sessions' && id === 'with-presence') {
889
+ return {
890
+ route: '/with-presence',
891
+ tree: { type: 'div', children: [] },
892
+ data: {},
893
+ schema: { version: '1.0.0' },
894
+ } as T;
895
+ }
896
+ return null;
897
+ },
898
+ };
899
+
900
+ const adapterWithPresence = new DashStorageAdapter(
901
+ mockWithPresence as any,
902
+ {
903
+ routesCollection: 'test-routes',
904
+ sessionsCollection: 'test-sessions',
905
+ presenceCollection: 'test-presence',
906
+ },
907
+ );
908
+ await adapterWithPresence.init();
909
+
910
+ const session = await adapterWithPresence.getSession('with-presence');
911
+
912
+ expect(session).not.toBeNull();
913
+ expect(session!.presence).toHaveLength(1);
914
+ expect(session!.presence[0].userId).toBe('user1');
915
+ expect(session!.presence[0].cursor).toEqual({ x: 10, y: 20 });
916
+ });
917
+
918
+ test('saves and retrieves tree', async () => {
919
+ // First save session
920
+ await adapter.saveSession({
921
+ route: '/dash-tree',
922
+ tree: { type: 'div', props: {}, children: [] },
923
+ data: {},
924
+ schema: { version: '1.0.0' },
925
+ presence: [],
926
+ });
927
+
928
+ const newTree: SerializedComponent = {
929
+ type: 'main',
930
+ props: {},
931
+ children: ['Updated'],
932
+ };
933
+
934
+ await adapter.saveTree('dash-tree', newTree);
935
+ const tree = await adapter.getTree('dash-tree');
936
+
937
+ expect(tree).not.toBeNull();
938
+ });
939
+
940
+ test('subscribeToRoutes returns subscription', () => {
941
+ const sub = adapter.subscribeToRoutes(() => {});
942
+ expect(sub).toBeDefined();
943
+ expect(typeof sub.unsubscribe).toBe('function');
944
+ sub.unsubscribe();
945
+ });
946
+
947
+ test('subscribeToSession returns subscription', () => {
948
+ const sub = adapter.subscribeToSession('test-session', () => {});
949
+ expect(sub).toBeDefined();
950
+ sub.unsubscribe();
951
+ });
952
+
953
+ test('subscribeToPresence returns subscription', () => {
954
+ const sub = adapter.subscribeToPresence('test-session', () => {});
955
+ expect(sub).toBeDefined();
956
+ sub.unsubscribe();
957
+ });
958
+
959
+ test('updatePresence saves presence record', async () => {
960
+ await adapter.updatePresence('session-1', 'user-1', {
961
+ role: 'user',
962
+ status: 'online',
963
+ });
964
+ // Should complete without error
965
+ });
966
+
967
+ test('destroy cleans up subscriptions', () => {
968
+ adapter.subscribeToRoutes(() => {});
969
+ adapter.subscribeToSession('test', () => {});
970
+ adapter.destroy();
971
+ // Should complete without error
972
+ });
973
+
974
+ test('getTree returns null for non-existent session', async () => {
975
+ const tree = await adapter.getTree('nonexistent');
976
+ expect(tree).toBeNull();
977
+ });
978
+
979
+ test('saveTree does nothing for non-existent session', async () => {
980
+ // Should not throw
981
+ await adapter.saveTree('nonexistent', { type: 'div', children: [] });
982
+ });
983
+ });
984
+
985
+ describe('D1StorageAdapter - advanced', () => {
986
+ test('getAllRoutes returns properly mapped routes', async () => {
987
+ // Create a more complete mock that returns actual data
988
+ const routes = [
989
+ {
990
+ pattern: '/a',
991
+ session_id: 'a',
992
+ component_id: 'a',
993
+ layout: null,
994
+ is_aeon: 1,
995
+ },
996
+ {
997
+ pattern: '/b',
998
+ session_id: 'b',
999
+ component_id: 'b',
1000
+ layout: 'main',
1001
+ is_aeon: 0,
1002
+ },
1003
+ ];
1004
+
1005
+ const mockDb = {
1006
+ exec: async () => {},
1007
+ prepare: () => ({
1008
+ bind: () => ({
1009
+ first: async () => null,
1010
+ all: async () => ({ results: routes }),
1011
+ run: async () => {},
1012
+ }),
1013
+ first: async () => null,
1014
+ all: async () => ({ results: routes }),
1015
+ run: async () => {},
1016
+ }),
1017
+ };
1018
+
1019
+ const adapter = new D1StorageAdapter(mockDb as any);
1020
+ const allRoutes = await adapter.getAllRoutes();
1021
+
1022
+ expect(allRoutes).toHaveLength(2);
1023
+ expect(allRoutes[0].pattern).toBe('/a');
1024
+ expect(allRoutes[0].isAeon).toBe(true);
1025
+ expect(allRoutes[1].layout).toBe('main');
1026
+ expect(allRoutes[1].isAeon).toBe(false);
1027
+ });
1028
+
1029
+ test('getSession includes presence data', async () => {
1030
+ const sessionData = {
1031
+ route: '/test',
1032
+ tree: '{"type":"div"}',
1033
+ data: '{}',
1034
+ schema_version: '1.0.0',
1035
+ };
1036
+
1037
+ const presenceData = [
1038
+ {
1039
+ user_id: 'user1',
1040
+ role: 'user',
1041
+ cursor_x: 100,
1042
+ cursor_y: 200,
1043
+ editing: 'title',
1044
+ status: 'online',
1045
+ last_activity: '2024-01-01T00:00:00Z',
1046
+ },
1047
+ {
1048
+ user_id: 'user2',
1049
+ role: 'admin',
1050
+ cursor_x: null,
1051
+ cursor_y: null,
1052
+ editing: null,
1053
+ status: 'away',
1054
+ last_activity: '2024-01-01T00:01:00Z',
1055
+ },
1056
+ ];
1057
+
1058
+ let callCount = 0;
1059
+ const mockDb = {
1060
+ exec: async () => {},
1061
+ prepare: (query: string) => ({
1062
+ bind: () => ({
1063
+ first: async () => (query.includes('sessions') ? sessionData : null),
1064
+ all: async () => ({
1065
+ results: query.includes('presence') ? presenceData : [],
1066
+ }),
1067
+ run: async () => {},
1068
+ }),
1069
+ first: async () => (query.includes('sessions') ? sessionData : null),
1070
+ all: async () => ({
1071
+ results: query.includes('presence') ? presenceData : [],
1072
+ }),
1073
+ run: async () => {},
1074
+ }),
1075
+ };
1076
+
1077
+ const adapter = new D1StorageAdapter(mockDb as any);
1078
+ const session = await adapter.getSession('test');
1079
+
1080
+ expect(session).not.toBeNull();
1081
+ expect(session!.presence).toHaveLength(2);
1082
+ expect(session!.presence[0].userId).toBe('user1');
1083
+ expect(session!.presence[0].cursor).toEqual({ x: 100, y: 200 });
1084
+ expect(session!.presence[0].editing).toBe('title');
1085
+ expect(session!.presence[1].cursor).toBeUndefined();
1086
+ });
1087
+ });
1088
+
1089
+ describe('HybridStorageAdapter - propagation', () => {
1090
+ test('propagate helper silently handles errors', async () => {
1091
+ // Test that propagate doesn't throw even when promise rejects
1092
+ const mockDb = {
1093
+ exec: async () => {},
1094
+ prepare: () => ({
1095
+ bind: () => ({
1096
+ first: async () => null,
1097
+ all: async () => ({ results: [] }),
1098
+ run: async () => {
1099
+ throw new Error('D1 error');
1100
+ },
1101
+ }),
1102
+ first: async () => null,
1103
+ all: async () => ({ results: [] }),
1104
+ run: async () => {
1105
+ throw new Error('D1 error');
1106
+ },
1107
+ }),
1108
+ };
1109
+
1110
+ const mockNamespace = createMockDONamespace();
1111
+
1112
+ const adapter = new HybridStorageAdapter({
1113
+ namespace: mockNamespace as any,
1114
+ db: mockDb as any,
1115
+ });
1116
+
1117
+ // These should not throw even though D1 operations fail
1118
+ await adapter.saveRoute({
1119
+ pattern: '/test',
1120
+ sessionId: 'test',
1121
+ componentId: 'test',
1122
+ isAeon: true,
1123
+ });
1124
+
1125
+ await adapter.deleteRoute('/test');
1126
+
1127
+ await adapter.saveSession({
1128
+ route: '/test',
1129
+ tree: { type: 'div', children: [] },
1130
+ data: {},
1131
+ schema: { version: '1.0.0' },
1132
+ presence: [],
1133
+ });
1134
+
1135
+ await adapter.saveTree('test', { type: 'span', children: [] });
1136
+
1137
+ // Give time for async operations to complete
1138
+ await new Promise((resolve) => setTimeout(resolve, 10));
1139
+ // If we got here without throwing, the test passes
1140
+ });
1141
+
1142
+ test('getAllRoutes falls back to D1', async () => {
1143
+ const mockDb = {
1144
+ exec: async () => {},
1145
+ prepare: () => ({
1146
+ bind: () => ({
1147
+ first: async () => null,
1148
+ all: async () => ({
1149
+ results: [
1150
+ {
1151
+ pattern: '/from-d1',
1152
+ session_id: 'd1',
1153
+ component_id: 'd1',
1154
+ is_aeon: 1,
1155
+ },
1156
+ ],
1157
+ }),
1158
+ run: async () => {},
1159
+ }),
1160
+ first: async () => null,
1161
+ all: async () => ({
1162
+ results: [
1163
+ {
1164
+ pattern: '/from-d1',
1165
+ session_id: 'd1',
1166
+ component_id: 'd1',
1167
+ is_aeon: 1,
1168
+ },
1169
+ ],
1170
+ }),
1171
+ run: async () => {},
1172
+ }),
1173
+ };
1174
+
1175
+ const mockNamespace = createMockDONamespace();
1176
+
1177
+ const adapter = new HybridStorageAdapter({
1178
+ namespace: mockNamespace as any,
1179
+ db: mockDb as any,
1180
+ });
1181
+
1182
+ const routes = await adapter.getAllRoutes();
1183
+ expect(routes).toHaveLength(1);
1184
+ expect(routes[0].pattern).toBe('/from-d1');
1185
+ });
1186
+
1187
+ test('saveRoute propagates to D1 asynchronously', async () => {
1188
+ let d1Called = false;
1189
+ const mockDb = {
1190
+ exec: async () => {},
1191
+ prepare: () => ({
1192
+ bind: () => ({
1193
+ first: async () => null,
1194
+ all: async () => ({ results: [] }),
1195
+ run: async () => {
1196
+ d1Called = true;
1197
+ },
1198
+ }),
1199
+ first: async () => null,
1200
+ all: async () => ({ results: [] }),
1201
+ run: async () => {
1202
+ d1Called = true;
1203
+ },
1204
+ }),
1205
+ };
1206
+
1207
+ const mockNamespace = createMockDONamespace();
1208
+
1209
+ const adapter = new HybridStorageAdapter({
1210
+ namespace: mockNamespace as any,
1211
+ db: mockDb as any,
1212
+ });
1213
+
1214
+ await adapter.saveRoute({
1215
+ pattern: '/hybrid',
1216
+ sessionId: 'hybrid',
1217
+ componentId: 'hybrid',
1218
+ isAeon: true,
1219
+ });
1220
+
1221
+ // Wait a bit for async propagation
1222
+ await new Promise((resolve) => setTimeout(resolve, 10));
1223
+ expect(d1Called).toBe(true);
1224
+ });
1225
+
1226
+ test('saveSession propagates to D1 asynchronously', async () => {
1227
+ let d1Called = false;
1228
+ const mockDb = {
1229
+ exec: async () => {},
1230
+ prepare: () => ({
1231
+ bind: () => ({
1232
+ first: async () => null,
1233
+ all: async () => ({ results: [] }),
1234
+ run: async () => {
1235
+ d1Called = true;
1236
+ },
1237
+ }),
1238
+ first: async () => null,
1239
+ all: async () => ({ results: [] }),
1240
+ run: async () => {
1241
+ d1Called = true;
1242
+ },
1243
+ }),
1244
+ };
1245
+
1246
+ const mockNamespace = createMockDONamespace();
1247
+
1248
+ const adapter = new HybridStorageAdapter({
1249
+ namespace: mockNamespace as any,
1250
+ db: mockDb as any,
1251
+ });
1252
+
1253
+ await adapter.saveSession({
1254
+ route: '/hybrid-session',
1255
+ tree: { type: 'div', children: [] },
1256
+ data: {},
1257
+ schema: { version: '1.0.0' },
1258
+ presence: [],
1259
+ });
1260
+
1261
+ await new Promise((resolve) => setTimeout(resolve, 10));
1262
+ expect(d1Called).toBe(true);
1263
+ });
1264
+
1265
+ test('saveTree propagates to D1 asynchronously', async () => {
1266
+ let d1Called = false;
1267
+ const mockDb = {
1268
+ exec: async () => {},
1269
+ prepare: () => ({
1270
+ bind: () => ({
1271
+ first: async () => null,
1272
+ all: async () => ({ results: [] }),
1273
+ run: async () => {
1274
+ d1Called = true;
1275
+ },
1276
+ }),
1277
+ first: async () => null,
1278
+ all: async () => ({ results: [] }),
1279
+ run: async () => {
1280
+ d1Called = true;
1281
+ },
1282
+ }),
1283
+ };
1284
+
1285
+ const mockNamespace = createMockDONamespace();
1286
+
1287
+ const adapter = new HybridStorageAdapter({
1288
+ namespace: mockNamespace as any,
1289
+ db: mockDb as any,
1290
+ });
1291
+
1292
+ await adapter.saveTree('hybrid-tree', { type: 'span', children: ['test'] });
1293
+
1294
+ await new Promise((resolve) => setTimeout(resolve, 10));
1295
+ // D1 propagation attempted (may fail silently which is expected)
1296
+ });
1297
+ });