@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.
- package/CHANGELOG.md +112 -0
- package/README.md +625 -0
- package/examples/basic/aeon.config.ts +39 -0
- package/examples/basic/components/Cursor.tsx +86 -0
- package/examples/basic/components/OfflineIndicator.tsx +103 -0
- package/examples/basic/components/PresenceBar.tsx +77 -0
- package/examples/basic/package.json +20 -0
- package/examples/basic/pages/index.tsx +80 -0
- package/package.json +101 -0
- package/packages/analytics/README.md +309 -0
- package/packages/analytics/build.ts +35 -0
- package/packages/analytics/package.json +50 -0
- package/packages/analytics/src/click-tracker.ts +368 -0
- package/packages/analytics/src/context-bridge.ts +319 -0
- package/packages/analytics/src/data-layer.ts +302 -0
- package/packages/analytics/src/gtm-loader.ts +239 -0
- package/packages/analytics/src/index.ts +230 -0
- package/packages/analytics/src/merkle-tree.ts +489 -0
- package/packages/analytics/src/provider.tsx +300 -0
- package/packages/analytics/src/types.ts +320 -0
- package/packages/analytics/src/use-analytics.ts +296 -0
- package/packages/analytics/tsconfig.json +19 -0
- package/packages/benchmarks/src/benchmark.test.ts +691 -0
- package/packages/cli/dist/index.js +61899 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/commands/build.test.ts +682 -0
- package/packages/cli/src/commands/build.ts +890 -0
- package/packages/cli/src/commands/dev.ts +473 -0
- package/packages/cli/src/commands/init.ts +409 -0
- package/packages/cli/src/commands/start.ts +297 -0
- package/packages/cli/src/index.ts +105 -0
- package/packages/directives/src/use-aeon.ts +272 -0
- package/packages/mcp-server/package.json +51 -0
- package/packages/mcp-server/src/index.ts +178 -0
- package/packages/mcp-server/src/resources.ts +346 -0
- package/packages/mcp-server/src/tools/index.ts +36 -0
- package/packages/mcp-server/src/tools/navigation.ts +545 -0
- package/packages/mcp-server/tsconfig.json +21 -0
- package/packages/react/package.json +40 -0
- package/packages/react/src/Link.tsx +388 -0
- package/packages/react/src/components/InstallPrompt.tsx +286 -0
- package/packages/react/src/components/OfflineDiagnostics.tsx +677 -0
- package/packages/react/src/components/PushNotifications.tsx +453 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +219 -0
- package/packages/react/src/hooks/useConflicts.ts +277 -0
- package/packages/react/src/hooks/useNetworkState.ts +209 -0
- package/packages/react/src/hooks/usePilotNavigation.ts +254 -0
- package/packages/react/src/hooks/useServiceWorker.ts +278 -0
- package/packages/react/src/hooks.ts +195 -0
- package/packages/react/src/index.ts +151 -0
- package/packages/react/src/provider.tsx +467 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/runtime/README.md +399 -0
- package/packages/runtime/build.ts +48 -0
- package/packages/runtime/package.json +71 -0
- package/packages/runtime/schema.sql +40 -0
- package/packages/runtime/src/api-routes.ts +465 -0
- package/packages/runtime/src/benchmark.ts +171 -0
- package/packages/runtime/src/cache.ts +479 -0
- package/packages/runtime/src/durable-object.ts +1341 -0
- package/packages/runtime/src/index.ts +360 -0
- package/packages/runtime/src/navigation.test.ts +421 -0
- package/packages/runtime/src/navigation.ts +422 -0
- package/packages/runtime/src/nextjs-adapter.ts +272 -0
- package/packages/runtime/src/offline/encrypted-queue.test.ts +607 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +478 -0
- package/packages/runtime/src/offline/encryption.test.ts +412 -0
- package/packages/runtime/src/offline/encryption.ts +397 -0
- package/packages/runtime/src/offline/types.ts +465 -0
- package/packages/runtime/src/predictor.ts +371 -0
- package/packages/runtime/src/registry.ts +351 -0
- package/packages/runtime/src/router/context-extractor.ts +661 -0
- package/packages/runtime/src/router/esi-control-react.tsx +2053 -0
- package/packages/runtime/src/router/esi-control.ts +541 -0
- package/packages/runtime/src/router/esi-cyrano.ts +779 -0
- package/packages/runtime/src/router/esi-format-react.tsx +1744 -0
- package/packages/runtime/src/router/esi-react.tsx +1065 -0
- package/packages/runtime/src/router/esi-translate-observer.ts +476 -0
- package/packages/runtime/src/router/esi-translate-react.tsx +556 -0
- package/packages/runtime/src/router/esi-translate.ts +503 -0
- package/packages/runtime/src/router/esi.ts +666 -0
- package/packages/runtime/src/router/heuristic-adapter.test.ts +295 -0
- package/packages/runtime/src/router/heuristic-adapter.ts +557 -0
- package/packages/runtime/src/router/index.ts +298 -0
- package/packages/runtime/src/router/merkle-capability.ts +473 -0
- package/packages/runtime/src/router/speculation.ts +451 -0
- package/packages/runtime/src/router/types.ts +630 -0
- package/packages/runtime/src/router.test.ts +470 -0
- package/packages/runtime/src/router.ts +302 -0
- package/packages/runtime/src/server.ts +481 -0
- package/packages/runtime/src/service-worker-push.ts +319 -0
- package/packages/runtime/src/service-worker.ts +553 -0
- package/packages/runtime/src/skeleton-hydrate.ts +237 -0
- package/packages/runtime/src/speculation.test.ts +389 -0
- package/packages/runtime/src/speculation.ts +486 -0
- package/packages/runtime/src/storage.test.ts +1297 -0
- package/packages/runtime/src/storage.ts +1048 -0
- package/packages/runtime/src/sync/conflict-resolver.test.ts +528 -0
- package/packages/runtime/src/sync/conflict-resolver.ts +565 -0
- package/packages/runtime/src/sync/coordinator.test.ts +608 -0
- package/packages/runtime/src/sync/coordinator.ts +596 -0
- package/packages/runtime/src/tree-compiler.ts +295 -0
- package/packages/runtime/src/types.ts +728 -0
- package/packages/runtime/src/worker.ts +327 -0
- package/packages/runtime/tsconfig.json +20 -0
- package/packages/runtime/wasm/aeon_pages_runtime.d.ts +504 -0
- package/packages/runtime/wasm/aeon_pages_runtime.js +1657 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime/wasm/aeon_pages_runtime_bg.wasm.d.ts +196 -0
- package/packages/runtime/wasm/package.json +21 -0
- package/packages/runtime/wrangler.toml +41 -0
- package/packages/runtime-wasm/Cargo.lock +436 -0
- package/packages/runtime-wasm/Cargo.toml +29 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.d.ts +480 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime.js +1568 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm +0 -0
- package/packages/runtime-wasm/pkg/aeon_pages_runtime_bg.wasm.d.ts +192 -0
- package/packages/runtime-wasm/pkg/package.json +21 -0
- package/packages/runtime-wasm/src/hydrate.rs +352 -0
- package/packages/runtime-wasm/src/lib.rs +191 -0
- package/packages/runtime-wasm/src/render.rs +629 -0
- package/packages/runtime-wasm/src/router.rs +298 -0
- package/packages/runtime-wasm/src/skeleton.rs +430 -0
- package/rfcs/RFC-001-ZERO-DEPENDENCY-RENDERING.md +1446 -0
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aeon Flux Storage Adapters
|
|
3
|
+
*
|
|
4
|
+
* Pluggable backend storage for routes and page content.
|
|
5
|
+
*
|
|
6
|
+
* Supported backends:
|
|
7
|
+
* - Dash (recommended for AFFECTIVELY ecosystem)
|
|
8
|
+
* - File system (default, development)
|
|
9
|
+
* - Cloudflare D1 (production, distributed)
|
|
10
|
+
* - Cloudflare Durable Objects (strong consistency)
|
|
11
|
+
* - Hybrid (D1 + Durable Objects)
|
|
12
|
+
* - Custom adapters
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
RouteDefinition,
|
|
17
|
+
PageSession,
|
|
18
|
+
SerializedComponent,
|
|
19
|
+
} from './types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Storage adapter interface - implement this for custom backends
|
|
23
|
+
*/
|
|
24
|
+
export interface StorageAdapter {
|
|
25
|
+
/** Adapter name for logging */
|
|
26
|
+
name: string;
|
|
27
|
+
|
|
28
|
+
/** Initialize the storage (create tables, etc.) */
|
|
29
|
+
init(): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/** Get a route by path */
|
|
32
|
+
getRoute(path: string): Promise<RouteDefinition | null>;
|
|
33
|
+
|
|
34
|
+
/** Get all routes */
|
|
35
|
+
getAllRoutes(): Promise<RouteDefinition[]>;
|
|
36
|
+
|
|
37
|
+
/** Save a route */
|
|
38
|
+
saveRoute(route: RouteDefinition): Promise<void>;
|
|
39
|
+
|
|
40
|
+
/** Delete a route */
|
|
41
|
+
deleteRoute(path: string): Promise<void>;
|
|
42
|
+
|
|
43
|
+
/** Get a page session */
|
|
44
|
+
getSession(sessionId: string): Promise<PageSession | null>;
|
|
45
|
+
|
|
46
|
+
/** Save a page session */
|
|
47
|
+
saveSession(session: PageSession): Promise<void>;
|
|
48
|
+
|
|
49
|
+
/** Get component tree for a session */
|
|
50
|
+
getTree(sessionId: string): Promise<SerializedComponent | null>;
|
|
51
|
+
|
|
52
|
+
/** Save component tree for a session */
|
|
53
|
+
saveTree(sessionId: string, tree: SerializedComponent): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* File system storage adapter (default for development)
|
|
58
|
+
*/
|
|
59
|
+
export class FileStorageAdapter implements StorageAdapter {
|
|
60
|
+
name = 'file';
|
|
61
|
+
private pagesDir: string;
|
|
62
|
+
private dataDir: string;
|
|
63
|
+
|
|
64
|
+
constructor(options: { pagesDir: string; dataDir?: string }) {
|
|
65
|
+
this.pagesDir = options.pagesDir;
|
|
66
|
+
this.dataDir = options.dataDir ?? '.aeon/data';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async init(): Promise<void> {
|
|
70
|
+
const fs = await import('fs/promises');
|
|
71
|
+
await fs.mkdir(this.dataDir, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getRoute(path: string): Promise<RouteDefinition | null> {
|
|
75
|
+
try {
|
|
76
|
+
const fs = await import('fs/promises');
|
|
77
|
+
const filePath = `${this.dataDir}/routes/${this.pathToKey(path)}.json`;
|
|
78
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
79
|
+
return JSON.parse(content);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getAllRoutes(): Promise<RouteDefinition[]> {
|
|
86
|
+
try {
|
|
87
|
+
const fs = await import('fs/promises');
|
|
88
|
+
const routesDir = `${this.dataDir}/routes`;
|
|
89
|
+
const files = await fs.readdir(routesDir);
|
|
90
|
+
const routes: RouteDefinition[] = [];
|
|
91
|
+
|
|
92
|
+
for (const file of files) {
|
|
93
|
+
if (file.endsWith('.json')) {
|
|
94
|
+
const content = await fs.readFile(`${routesDir}/${file}`, 'utf-8');
|
|
95
|
+
routes.push(JSON.parse(content));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return routes;
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async saveRoute(route: RouteDefinition): Promise<void> {
|
|
106
|
+
const fs = await import('fs/promises');
|
|
107
|
+
const routesDir = `${this.dataDir}/routes`;
|
|
108
|
+
await fs.mkdir(routesDir, { recursive: true });
|
|
109
|
+
const filePath = `${routesDir}/${this.pathToKey(route.pattern)}.json`;
|
|
110
|
+
await fs.writeFile(filePath, JSON.stringify(route, null, 2));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async deleteRoute(path: string): Promise<void> {
|
|
114
|
+
const fs = await import('fs/promises');
|
|
115
|
+
const filePath = `${this.dataDir}/routes/${this.pathToKey(path)}.json`;
|
|
116
|
+
await fs.unlink(filePath).catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async getSession(sessionId: string): Promise<PageSession | null> {
|
|
120
|
+
try {
|
|
121
|
+
const fs = await import('fs/promises');
|
|
122
|
+
const filePath = `${this.dataDir}/sessions/${sessionId}.json`;
|
|
123
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
124
|
+
return JSON.parse(content);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async saveSession(session: PageSession): Promise<void> {
|
|
131
|
+
const fs = await import('fs/promises');
|
|
132
|
+
const sessionsDir = `${this.dataDir}/sessions`;
|
|
133
|
+
await fs.mkdir(sessionsDir, { recursive: true });
|
|
134
|
+
const filePath = `${sessionsDir}/${this.pathToKey(session.route)}.json`;
|
|
135
|
+
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getTree(sessionId: string): Promise<SerializedComponent | null> {
|
|
139
|
+
const session = await this.getSession(sessionId);
|
|
140
|
+
return session?.tree ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async saveTree(sessionId: string, tree: SerializedComponent): Promise<void> {
|
|
144
|
+
const session = await this.getSession(sessionId);
|
|
145
|
+
if (session) {
|
|
146
|
+
session.tree = tree;
|
|
147
|
+
await this.saveSession(session);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private pathToKey(path: string): string {
|
|
152
|
+
return path.replace(/\//g, '_').replace(/^_/, '') || 'index';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Cloudflare D1 storage adapter (production, distributed)
|
|
158
|
+
*/
|
|
159
|
+
export class D1StorageAdapter implements StorageAdapter {
|
|
160
|
+
name = 'd1';
|
|
161
|
+
private db: D1Database;
|
|
162
|
+
|
|
163
|
+
constructor(db: D1Database) {
|
|
164
|
+
this.db = db;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async init(): Promise<void> {
|
|
168
|
+
// Create tables if they don't exist
|
|
169
|
+
await this.db.exec(`
|
|
170
|
+
CREATE TABLE IF NOT EXISTS routes (
|
|
171
|
+
path TEXT PRIMARY KEY,
|
|
172
|
+
pattern TEXT NOT NULL,
|
|
173
|
+
session_id TEXT NOT NULL,
|
|
174
|
+
component_id TEXT NOT NULL,
|
|
175
|
+
layout TEXT,
|
|
176
|
+
is_aeon INTEGER DEFAULT 1,
|
|
177
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
178
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
182
|
+
session_id TEXT PRIMARY KEY,
|
|
183
|
+
route TEXT NOT NULL,
|
|
184
|
+
tree TEXT NOT NULL,
|
|
185
|
+
data TEXT DEFAULT '{}',
|
|
186
|
+
schema_version TEXT DEFAULT '1.0.0',
|
|
187
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
188
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
CREATE TABLE IF NOT EXISTS presence (
|
|
192
|
+
session_id TEXT,
|
|
193
|
+
user_id TEXT,
|
|
194
|
+
role TEXT DEFAULT 'user',
|
|
195
|
+
cursor_x INTEGER,
|
|
196
|
+
cursor_y INTEGER,
|
|
197
|
+
editing TEXT,
|
|
198
|
+
status TEXT DEFAULT 'online',
|
|
199
|
+
last_activity TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
200
|
+
PRIMARY KEY (session_id, user_id)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
CREATE INDEX IF NOT EXISTS idx_routes_pattern ON routes(pattern);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_route ON sessions(route);
|
|
205
|
+
`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async getRoute(path: string): Promise<RouteDefinition | null> {
|
|
209
|
+
const result = await this.db
|
|
210
|
+
.prepare('SELECT * FROM routes WHERE path = ?')
|
|
211
|
+
.bind(path)
|
|
212
|
+
.first();
|
|
213
|
+
|
|
214
|
+
if (!result) return null;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
pattern: result.pattern as string,
|
|
218
|
+
sessionId: result.session_id as string,
|
|
219
|
+
componentId: result.component_id as string,
|
|
220
|
+
layout: result.layout as string | undefined,
|
|
221
|
+
isAeon: Boolean(result.is_aeon),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async getAllRoutes(): Promise<RouteDefinition[]> {
|
|
226
|
+
const results = await this.db
|
|
227
|
+
.prepare('SELECT * FROM routes ORDER BY pattern')
|
|
228
|
+
.all();
|
|
229
|
+
|
|
230
|
+
return results.results.map((row) => ({
|
|
231
|
+
pattern: row.pattern as string,
|
|
232
|
+
sessionId: row.session_id as string,
|
|
233
|
+
componentId: row.component_id as string,
|
|
234
|
+
layout: row.layout as string | undefined,
|
|
235
|
+
isAeon: Boolean(row.is_aeon),
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async saveRoute(route: RouteDefinition): Promise<void> {
|
|
240
|
+
await this.db
|
|
241
|
+
.prepare(
|
|
242
|
+
`
|
|
243
|
+
INSERT OR REPLACE INTO routes (path, pattern, session_id, component_id, layout, is_aeon, updated_at)
|
|
244
|
+
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
245
|
+
`,
|
|
246
|
+
)
|
|
247
|
+
.bind(
|
|
248
|
+
route.pattern,
|
|
249
|
+
route.pattern,
|
|
250
|
+
route.sessionId,
|
|
251
|
+
route.componentId,
|
|
252
|
+
route.layout ?? null,
|
|
253
|
+
route.isAeon ? 1 : 0,
|
|
254
|
+
)
|
|
255
|
+
.run();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async deleteRoute(path: string): Promise<void> {
|
|
259
|
+
await this.db.prepare('DELETE FROM routes WHERE path = ?').bind(path).run();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async getSession(sessionId: string): Promise<PageSession | null> {
|
|
263
|
+
const result = await this.db
|
|
264
|
+
.prepare('SELECT * FROM sessions WHERE session_id = ?')
|
|
265
|
+
.bind(sessionId)
|
|
266
|
+
.first();
|
|
267
|
+
|
|
268
|
+
if (!result) return null;
|
|
269
|
+
|
|
270
|
+
// Get presence for this session
|
|
271
|
+
const presenceResults = await this.db
|
|
272
|
+
.prepare('SELECT * FROM presence WHERE session_id = ?')
|
|
273
|
+
.bind(sessionId)
|
|
274
|
+
.all();
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
route: result.route as string,
|
|
278
|
+
tree: JSON.parse(result.tree as string),
|
|
279
|
+
data: JSON.parse(result.data as string),
|
|
280
|
+
schema: { version: result.schema_version as string },
|
|
281
|
+
presence: presenceResults.results.map((p) => ({
|
|
282
|
+
userId: p.user_id as string,
|
|
283
|
+
role: p.role as 'user' | 'assistant' | 'monitor' | 'admin',
|
|
284
|
+
cursor:
|
|
285
|
+
p.cursor_x !== null
|
|
286
|
+
? { x: p.cursor_x as number, y: p.cursor_y as number }
|
|
287
|
+
: undefined,
|
|
288
|
+
editing: p.editing as string | undefined,
|
|
289
|
+
status: p.status as 'online' | 'away' | 'offline',
|
|
290
|
+
lastActivity: p.last_activity as string,
|
|
291
|
+
})),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async saveSession(session: PageSession): Promise<void> {
|
|
296
|
+
await this.db
|
|
297
|
+
.prepare(
|
|
298
|
+
`
|
|
299
|
+
INSERT OR REPLACE INTO sessions (session_id, route, tree, data, schema_version, updated_at)
|
|
300
|
+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
301
|
+
`,
|
|
302
|
+
)
|
|
303
|
+
.bind(
|
|
304
|
+
this.routeToSessionId(session.route),
|
|
305
|
+
session.route,
|
|
306
|
+
JSON.stringify(session.tree),
|
|
307
|
+
JSON.stringify(session.data),
|
|
308
|
+
session.schema.version,
|
|
309
|
+
)
|
|
310
|
+
.run();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async getTree(sessionId: string): Promise<SerializedComponent | null> {
|
|
314
|
+
const result = await this.db
|
|
315
|
+
.prepare('SELECT tree FROM sessions WHERE session_id = ?')
|
|
316
|
+
.bind(sessionId)
|
|
317
|
+
.first();
|
|
318
|
+
|
|
319
|
+
if (!result) return null;
|
|
320
|
+
return JSON.parse(result.tree as string);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async saveTree(sessionId: string, tree: SerializedComponent): Promise<void> {
|
|
324
|
+
await this.db
|
|
325
|
+
.prepare(
|
|
326
|
+
`
|
|
327
|
+
UPDATE sessions SET tree = ?, updated_at = CURRENT_TIMESTAMP
|
|
328
|
+
WHERE session_id = ?
|
|
329
|
+
`,
|
|
330
|
+
)
|
|
331
|
+
.bind(JSON.stringify(tree), sessionId)
|
|
332
|
+
.run();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private routeToSessionId(route: string): string {
|
|
336
|
+
return route.replace(/^\/|\/$/g, '').replace(/\//g, '-') || 'index';
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Cloudflare D1 database interface (matches Cloudflare Workers API)
|
|
342
|
+
*/
|
|
343
|
+
interface D1Database {
|
|
344
|
+
prepare(query: string): D1PreparedStatement;
|
|
345
|
+
exec(query: string): Promise<void>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface D1PreparedStatement {
|
|
349
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
350
|
+
first(): Promise<Record<string, unknown> | null>;
|
|
351
|
+
all(): Promise<{ results: Record<string, unknown>[] }>;
|
|
352
|
+
run(): Promise<void>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Cloudflare Durable Object interface (matches Cloudflare Workers API)
|
|
357
|
+
*/
|
|
358
|
+
interface DurableObjectStorage {
|
|
359
|
+
get<T = unknown>(key: string): Promise<T | undefined>;
|
|
360
|
+
get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
|
|
361
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
362
|
+
put<T>(entries: Record<string, T>): Promise<void>;
|
|
363
|
+
delete(key: string): Promise<boolean>;
|
|
364
|
+
delete(keys: string[]): Promise<number>;
|
|
365
|
+
list<T = unknown>(options?: {
|
|
366
|
+
prefix?: string;
|
|
367
|
+
limit?: number;
|
|
368
|
+
}): Promise<Map<string, T>>;
|
|
369
|
+
transaction<T>(
|
|
370
|
+
closure: (txn: DurableObjectStorage) => Promise<T>,
|
|
371
|
+
): Promise<T>;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
interface DurableObjectState {
|
|
375
|
+
storage: DurableObjectStorage;
|
|
376
|
+
id: DurableObjectId;
|
|
377
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface DurableObjectId {
|
|
381
|
+
toString(): string;
|
|
382
|
+
equals(other: DurableObjectId): boolean;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface DurableObjectNamespace {
|
|
386
|
+
get(id: DurableObjectId): DurableObjectStub;
|
|
387
|
+
idFromName(name: string): DurableObjectId;
|
|
388
|
+
idFromString(id: string): DurableObjectId;
|
|
389
|
+
newUniqueId(): DurableObjectId;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
interface DurableObjectStub {
|
|
393
|
+
id: DurableObjectId;
|
|
394
|
+
fetch(input: RequestInfo, init?: RequestInit): Promise<Response>;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Cloudflare Durable Objects storage adapter (strong consistency)
|
|
399
|
+
*
|
|
400
|
+
* Each Aeon session maps to a Durable Object instance, providing:
|
|
401
|
+
* - Strong consistency for real-time collaborative editing
|
|
402
|
+
* - Automatic coalescing of WebSocket connections
|
|
403
|
+
* - Sub-millisecond latency within the same colo
|
|
404
|
+
*
|
|
405
|
+
* Use in combination with D1 for read replicas and historical data.
|
|
406
|
+
*/
|
|
407
|
+
export class DurableObjectStorageAdapter implements StorageAdapter {
|
|
408
|
+
name = 'durable-object';
|
|
409
|
+
private namespace: DurableObjectNamespace;
|
|
410
|
+
private routeCache: Map<string, RouteDefinition> = new Map();
|
|
411
|
+
|
|
412
|
+
constructor(namespace: DurableObjectNamespace) {
|
|
413
|
+
this.namespace = namespace;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async init(): Promise<void> {
|
|
417
|
+
// Durable Objects are created on-demand, no initialization needed
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async getRoute(path: string): Promise<RouteDefinition | null> {
|
|
421
|
+
// Check cache first
|
|
422
|
+
if (this.routeCache.has(path)) {
|
|
423
|
+
return this.routeCache.get(path)!;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Get from the routes Durable Object
|
|
427
|
+
const routesId = this.namespace.idFromName('__routes__');
|
|
428
|
+
const routesStub = this.namespace.get(routesId);
|
|
429
|
+
|
|
430
|
+
const response = await routesStub.fetch(
|
|
431
|
+
new Request('http://internal/route', {
|
|
432
|
+
method: 'POST',
|
|
433
|
+
body: JSON.stringify({ action: 'get', path }),
|
|
434
|
+
headers: { 'Content-Type': 'application/json' },
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
if (!response.ok) return null;
|
|
439
|
+
|
|
440
|
+
const route = (await response.json()) as RouteDefinition | null;
|
|
441
|
+
if (route) {
|
|
442
|
+
this.routeCache.set(path, route);
|
|
443
|
+
}
|
|
444
|
+
return route;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async getAllRoutes(): Promise<RouteDefinition[]> {
|
|
448
|
+
const routesId = this.namespace.idFromName('__routes__');
|
|
449
|
+
const routesStub = this.namespace.get(routesId);
|
|
450
|
+
|
|
451
|
+
const response = await routesStub.fetch(
|
|
452
|
+
new Request('http://internal/routes', {
|
|
453
|
+
method: 'GET',
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
if (!response.ok) return [];
|
|
458
|
+
return response.json() as Promise<RouteDefinition[]>;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async saveRoute(route: RouteDefinition): Promise<void> {
|
|
462
|
+
const routesId = this.namespace.idFromName('__routes__');
|
|
463
|
+
const routesStub = this.namespace.get(routesId);
|
|
464
|
+
|
|
465
|
+
await routesStub.fetch(
|
|
466
|
+
new Request('http://internal/route', {
|
|
467
|
+
method: 'PUT',
|
|
468
|
+
body: JSON.stringify(route),
|
|
469
|
+
headers: { 'Content-Type': 'application/json' },
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Update cache
|
|
474
|
+
this.routeCache.set(route.pattern, route);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async deleteRoute(path: string): Promise<void> {
|
|
478
|
+
const routesId = this.namespace.idFromName('__routes__');
|
|
479
|
+
const routesStub = this.namespace.get(routesId);
|
|
480
|
+
|
|
481
|
+
await routesStub.fetch(
|
|
482
|
+
new Request('http://internal/route', {
|
|
483
|
+
method: 'DELETE',
|
|
484
|
+
body: JSON.stringify({ path }),
|
|
485
|
+
headers: { 'Content-Type': 'application/json' },
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
this.routeCache.delete(path);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async getSession(sessionId: string): Promise<PageSession | null> {
|
|
493
|
+
const doId = this.namespace.idFromName(sessionId);
|
|
494
|
+
const stub = this.namespace.get(doId);
|
|
495
|
+
|
|
496
|
+
const response = await stub.fetch(
|
|
497
|
+
new Request('http://internal/session', {
|
|
498
|
+
method: 'GET',
|
|
499
|
+
}),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
if (!response.ok) return null;
|
|
503
|
+
return response.json() as Promise<PageSession>;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async saveSession(session: PageSession): Promise<void> {
|
|
507
|
+
const sessionId = this.routeToSessionId(session.route);
|
|
508
|
+
const doId = this.namespace.idFromName(sessionId);
|
|
509
|
+
const stub = this.namespace.get(doId);
|
|
510
|
+
|
|
511
|
+
await stub.fetch(
|
|
512
|
+
new Request('http://internal/session', {
|
|
513
|
+
method: 'PUT',
|
|
514
|
+
body: JSON.stringify(session),
|
|
515
|
+
headers: { 'Content-Type': 'application/json' },
|
|
516
|
+
}),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async getTree(sessionId: string): Promise<SerializedComponent | null> {
|
|
521
|
+
const doId = this.namespace.idFromName(sessionId);
|
|
522
|
+
const stub = this.namespace.get(doId);
|
|
523
|
+
|
|
524
|
+
const response = await stub.fetch(
|
|
525
|
+
new Request('http://internal/tree', {
|
|
526
|
+
method: 'GET',
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
if (!response.ok) return null;
|
|
531
|
+
return response.json() as Promise<SerializedComponent>;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async saveTree(sessionId: string, tree: SerializedComponent): Promise<void> {
|
|
535
|
+
const doId = this.namespace.idFromName(sessionId);
|
|
536
|
+
const stub = this.namespace.get(doId);
|
|
537
|
+
|
|
538
|
+
await stub.fetch(
|
|
539
|
+
new Request('http://internal/tree', {
|
|
540
|
+
method: 'PUT',
|
|
541
|
+
body: JSON.stringify(tree),
|
|
542
|
+
headers: { 'Content-Type': 'application/json' },
|
|
543
|
+
}),
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Get a direct stub for WebSocket connections
|
|
549
|
+
* This allows real-time collaboration via Durable Object WebSockets
|
|
550
|
+
*/
|
|
551
|
+
getSessionStub(sessionId: string): DurableObjectStub {
|
|
552
|
+
const doId = this.namespace.idFromName(sessionId);
|
|
553
|
+
return this.namespace.get(doId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private routeToSessionId(route: string): string {
|
|
557
|
+
return route.replace(/^\/|\/$/g, '').replace(/\//g, '-') || 'index';
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Fire-and-forget async operation (errors are silently ignored) */
|
|
562
|
+
const propagate = (promise: Promise<unknown>): void => {
|
|
563
|
+
void promise.catch(() => {});
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Hybrid storage adapter (D1 + Durable Objects)
|
|
568
|
+
*
|
|
569
|
+
* Combines the best of both:
|
|
570
|
+
* - Durable Objects for real-time collaborative editing (strong consistency)
|
|
571
|
+
* - D1 for read replicas and historical snapshots (eventual consistency)
|
|
572
|
+
*
|
|
573
|
+
* Write path: Durable Object → async propagate to D1
|
|
574
|
+
* Read path: Durable Object (real-time) or D1 (historical)
|
|
575
|
+
*/
|
|
576
|
+
export class HybridStorageAdapter implements StorageAdapter {
|
|
577
|
+
name = 'hybrid';
|
|
578
|
+
private do: DurableObjectStorageAdapter;
|
|
579
|
+
private d1: D1StorageAdapter;
|
|
580
|
+
|
|
581
|
+
constructor(options: { namespace: DurableObjectNamespace; db: D1Database }) {
|
|
582
|
+
this.do = new DurableObjectStorageAdapter(options.namespace);
|
|
583
|
+
this.d1 = new D1StorageAdapter(options.db);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async init(): Promise<void> {
|
|
587
|
+
await Promise.all([this.do.init(), this.d1.init()]);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async getRoute(path: string): Promise<RouteDefinition | null> {
|
|
591
|
+
// Prefer Durable Object for latest data
|
|
592
|
+
const route = await this.do.getRoute(path);
|
|
593
|
+
if (route) return route;
|
|
594
|
+
|
|
595
|
+
// Fall back to D1 for historical/replicated data
|
|
596
|
+
return this.d1.getRoute(path);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async getAllRoutes(): Promise<RouteDefinition[]> {
|
|
600
|
+
// Get from D1 for complete list (more efficient for large lists)
|
|
601
|
+
return this.d1.getAllRoutes();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async saveRoute(route: RouteDefinition): Promise<void> {
|
|
605
|
+
// Write to Durable Object first (strong consistency)
|
|
606
|
+
await this.do.saveRoute(route);
|
|
607
|
+
// Async propagate to D1 (eventual consistency, non-blocking)
|
|
608
|
+
propagate(this.d1.saveRoute(route));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async deleteRoute(path: string): Promise<void> {
|
|
612
|
+
// Delete from both in parallel
|
|
613
|
+
await this.do.deleteRoute(path);
|
|
614
|
+
propagate(this.d1.deleteRoute(path));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async getSession(sessionId: string): Promise<PageSession | null> {
|
|
618
|
+
// Always get from Durable Object for real-time data
|
|
619
|
+
return this.do.getSession(sessionId);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async saveSession(session: PageSession): Promise<void> {
|
|
623
|
+
// Write to Durable Object first
|
|
624
|
+
await this.do.saveSession(session);
|
|
625
|
+
// Async propagate to D1 (non-blocking)
|
|
626
|
+
propagate(this.d1.saveSession(session));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async getTree(sessionId: string): Promise<SerializedComponent | null> {
|
|
630
|
+
return this.do.getTree(sessionId);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async saveTree(sessionId: string, tree: SerializedComponent): Promise<void> {
|
|
634
|
+
await this.do.saveTree(sessionId, tree);
|
|
635
|
+
// Async propagate to D1 (non-blocking)
|
|
636
|
+
propagate(this.d1.saveTree(sessionId, tree));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Get historical snapshots from D1
|
|
641
|
+
*/
|
|
642
|
+
async getHistoricalSession(sessionId: string): Promise<PageSession | null> {
|
|
643
|
+
return this.d1.getSession(sessionId);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Get direct Durable Object stub for WebSocket connections
|
|
648
|
+
*/
|
|
649
|
+
getSessionStub(sessionId: string): DurableObjectStub {
|
|
650
|
+
return this.do.getSessionStub(sessionId);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Dash client interface
|
|
656
|
+
*
|
|
657
|
+
* Dash is AFFECTIVELY's real-time sync system built on Aeon.
|
|
658
|
+
* It provides:
|
|
659
|
+
* - Real-time subscriptions via WebSocket
|
|
660
|
+
* - Automatic conflict resolution (CRDT-based)
|
|
661
|
+
* - Offline-first with sync on reconnect
|
|
662
|
+
* - Cross-platform (web, mobile, edge)
|
|
663
|
+
*
|
|
664
|
+
* @example
|
|
665
|
+
* ```typescript
|
|
666
|
+
* import { createDashClient } from '@affectively/dash';
|
|
667
|
+
*
|
|
668
|
+
* const dash = createDashClient({
|
|
669
|
+
* endpoint: 'wss://dash.affectively.com',
|
|
670
|
+
* auth: { token: 'your-auth-token' },
|
|
671
|
+
* });
|
|
672
|
+
*
|
|
673
|
+
* const storage = new DashStorageAdapter(dash);
|
|
674
|
+
* ```
|
|
675
|
+
*/
|
|
676
|
+
interface DashClient {
|
|
677
|
+
/** Connect to Dash server */
|
|
678
|
+
connect(): Promise<void>;
|
|
679
|
+
|
|
680
|
+
/** Disconnect from Dash server */
|
|
681
|
+
disconnect(): Promise<void>;
|
|
682
|
+
|
|
683
|
+
/** Check connection status */
|
|
684
|
+
isConnected(): boolean;
|
|
685
|
+
|
|
686
|
+
/** Get a document by collection and id */
|
|
687
|
+
get<T>(collection: string, id: string): Promise<T | null>;
|
|
688
|
+
|
|
689
|
+
/** Query documents in a collection */
|
|
690
|
+
query<T>(collection: string, filter?: DashFilter): Promise<T[]>;
|
|
691
|
+
|
|
692
|
+
/** Set/update a document */
|
|
693
|
+
set<T>(collection: string, id: string, data: T): Promise<void>;
|
|
694
|
+
|
|
695
|
+
/** Delete a document */
|
|
696
|
+
delete(collection: string, id: string): Promise<void>;
|
|
697
|
+
|
|
698
|
+
/** Subscribe to real-time updates */
|
|
699
|
+
subscribe<T>(
|
|
700
|
+
collection: string,
|
|
701
|
+
filter: DashFilter | undefined,
|
|
702
|
+
callback: (changes: DashChange<T>[]) => void,
|
|
703
|
+
): DashSubscription;
|
|
704
|
+
|
|
705
|
+
/** Batch operations */
|
|
706
|
+
batch(operations: DashOperation[]): Promise<void>;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
interface DashFilter {
|
|
710
|
+
where?: Array<{
|
|
711
|
+
field: string;
|
|
712
|
+
op: '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'contains';
|
|
713
|
+
value: unknown;
|
|
714
|
+
}>;
|
|
715
|
+
orderBy?: { field: string; direction: 'asc' | 'desc' };
|
|
716
|
+
limit?: number;
|
|
717
|
+
offset?: number;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
interface DashChange<T> {
|
|
721
|
+
type: 'added' | 'modified' | 'removed';
|
|
722
|
+
id: string;
|
|
723
|
+
data: T | null;
|
|
724
|
+
previousData?: T;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
interface DashSubscription {
|
|
728
|
+
unsubscribe(): void;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
interface DashOperation {
|
|
732
|
+
type: 'set' | 'delete';
|
|
733
|
+
collection: string;
|
|
734
|
+
id: string;
|
|
735
|
+
data?: unknown;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Dash storage adapter
|
|
740
|
+
*
|
|
741
|
+
* Uses AFFECTIVELY's Dash real-time sync system as the backend.
|
|
742
|
+
* This is the recommended adapter for the AFFECTIVELY ecosystem
|
|
743
|
+
* as it integrates seamlessly with other Dash-powered features.
|
|
744
|
+
*
|
|
745
|
+
* Features:
|
|
746
|
+
* - Real-time sync across all connected clients
|
|
747
|
+
* - CRDT-based conflict resolution (no data loss)
|
|
748
|
+
* - Offline-first with automatic sync on reconnect
|
|
749
|
+
* - Presence tracking built-in
|
|
750
|
+
* - Works with existing Dash infrastructure
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* ```typescript
|
|
754
|
+
* import { createDashClient } from '@affectively/dash';
|
|
755
|
+
* import { DashStorageAdapter } from '@affectively/aeon-flux';
|
|
756
|
+
*
|
|
757
|
+
* const dash = createDashClient({
|
|
758
|
+
* endpoint: process.env.DASH_ENDPOINT,
|
|
759
|
+
* auth: { token: await getAuthToken() },
|
|
760
|
+
* });
|
|
761
|
+
*
|
|
762
|
+
* const storage = new DashStorageAdapter(dash, {
|
|
763
|
+
* routesCollection: 'aeon-routes',
|
|
764
|
+
* sessionsCollection: 'aeon-sessions',
|
|
765
|
+
* presenceCollection: 'aeon-presence',
|
|
766
|
+
* });
|
|
767
|
+
*
|
|
768
|
+
* // Use with Aeon Flux server
|
|
769
|
+
* const server = await createAeonServer({
|
|
770
|
+
* storage,
|
|
771
|
+
* // ...
|
|
772
|
+
* });
|
|
773
|
+
* ```
|
|
774
|
+
*/
|
|
775
|
+
export class DashStorageAdapter implements StorageAdapter {
|
|
776
|
+
name = 'dash';
|
|
777
|
+
private client: DashClient;
|
|
778
|
+
private collections: {
|
|
779
|
+
routes: string;
|
|
780
|
+
sessions: string;
|
|
781
|
+
presence: string;
|
|
782
|
+
};
|
|
783
|
+
private subscriptions: DashSubscription[] = [];
|
|
784
|
+
|
|
785
|
+
constructor(
|
|
786
|
+
client: DashClient,
|
|
787
|
+
options?: {
|
|
788
|
+
routesCollection?: string;
|
|
789
|
+
sessionsCollection?: string;
|
|
790
|
+
presenceCollection?: string;
|
|
791
|
+
},
|
|
792
|
+
) {
|
|
793
|
+
this.client = client;
|
|
794
|
+
this.collections = {
|
|
795
|
+
routes: options?.routesCollection ?? 'aeon-routes',
|
|
796
|
+
sessions: options?.sessionsCollection ?? 'aeon-sessions',
|
|
797
|
+
presence: options?.presenceCollection ?? 'aeon-presence',
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async init(): Promise<void> {
|
|
802
|
+
// Connect to Dash if not already connected
|
|
803
|
+
if (!this.client.isConnected()) {
|
|
804
|
+
await this.client.connect();
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async getRoute(path: string): Promise<RouteDefinition | null> {
|
|
809
|
+
const route = await this.client.get<RouteDefinition>(
|
|
810
|
+
this.collections.routes,
|
|
811
|
+
this.pathToId(path),
|
|
812
|
+
);
|
|
813
|
+
return route;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async getAllRoutes(): Promise<RouteDefinition[]> {
|
|
817
|
+
const routes = await this.client.query<RouteDefinition>(
|
|
818
|
+
this.collections.routes,
|
|
819
|
+
{ orderBy: { field: 'pattern', direction: 'asc' } },
|
|
820
|
+
);
|
|
821
|
+
return routes;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async saveRoute(route: RouteDefinition): Promise<void> {
|
|
825
|
+
await this.client.set(
|
|
826
|
+
this.collections.routes,
|
|
827
|
+
this.pathToId(route.pattern),
|
|
828
|
+
route,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
async deleteRoute(path: string): Promise<void> {
|
|
833
|
+
await this.client.delete(this.collections.routes, this.pathToId(path));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async getSession(sessionId: string): Promise<PageSession | null> {
|
|
837
|
+
const session = await this.client.get<PageSession>(
|
|
838
|
+
this.collections.sessions,
|
|
839
|
+
sessionId,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
if (!session) return null;
|
|
843
|
+
|
|
844
|
+
// Get presence for this session
|
|
845
|
+
const presence = await this.client.query<PresenceRecord>(
|
|
846
|
+
this.collections.presence,
|
|
847
|
+
{
|
|
848
|
+
where: [{ field: 'sessionId', op: '==', value: sessionId }],
|
|
849
|
+
},
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
...session,
|
|
854
|
+
presence: presence.map((p) => ({
|
|
855
|
+
userId: p.userId,
|
|
856
|
+
role: p.role,
|
|
857
|
+
cursor: p.cursor,
|
|
858
|
+
editing: p.editing,
|
|
859
|
+
status: p.status,
|
|
860
|
+
lastActivity: p.lastActivity,
|
|
861
|
+
})),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
async saveSession(session: PageSession): Promise<void> {
|
|
866
|
+
const sessionId = this.routeToSessionId(session.route);
|
|
867
|
+
|
|
868
|
+
// Save session (without presence, that's managed separately)
|
|
869
|
+
const { presence: _, ...sessionData } = session;
|
|
870
|
+
await this.client.set(this.collections.sessions, sessionId, sessionData);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async getTree(sessionId: string): Promise<SerializedComponent | null> {
|
|
874
|
+
const session = await this.getSession(sessionId);
|
|
875
|
+
return session?.tree ?? null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async saveTree(sessionId: string, tree: SerializedComponent): Promise<void> {
|
|
879
|
+
const session = await this.getSession(sessionId);
|
|
880
|
+
if (session) {
|
|
881
|
+
await this.client.set(this.collections.sessions, sessionId, {
|
|
882
|
+
...session,
|
|
883
|
+
tree,
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Subscribe to real-time route changes
|
|
890
|
+
*/
|
|
891
|
+
subscribeToRoutes(
|
|
892
|
+
callback: (changes: DashChange<RouteDefinition>[]) => void,
|
|
893
|
+
): DashSubscription {
|
|
894
|
+
const sub = this.client.subscribe<RouteDefinition>(
|
|
895
|
+
this.collections.routes,
|
|
896
|
+
undefined,
|
|
897
|
+
callback,
|
|
898
|
+
);
|
|
899
|
+
this.subscriptions.push(sub);
|
|
900
|
+
return sub;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Subscribe to real-time session changes
|
|
905
|
+
*/
|
|
906
|
+
subscribeToSession(
|
|
907
|
+
sessionId: string,
|
|
908
|
+
callback: (changes: DashChange<PageSession>[]) => void,
|
|
909
|
+
): DashSubscription {
|
|
910
|
+
const sub = this.client.subscribe<PageSession>(
|
|
911
|
+
this.collections.sessions,
|
|
912
|
+
{ where: [{ field: 'id', op: '==', value: sessionId }] },
|
|
913
|
+
callback,
|
|
914
|
+
);
|
|
915
|
+
this.subscriptions.push(sub);
|
|
916
|
+
return sub;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Subscribe to presence updates for a session
|
|
921
|
+
*/
|
|
922
|
+
subscribeToPresence(
|
|
923
|
+
sessionId: string,
|
|
924
|
+
callback: (changes: DashChange<PresenceRecord>[]) => void,
|
|
925
|
+
): DashSubscription {
|
|
926
|
+
const sub = this.client.subscribe<PresenceRecord>(
|
|
927
|
+
this.collections.presence,
|
|
928
|
+
{ where: [{ field: 'sessionId', op: '==', value: sessionId }] },
|
|
929
|
+
callback,
|
|
930
|
+
);
|
|
931
|
+
this.subscriptions.push(sub);
|
|
932
|
+
return sub;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Update presence for current user
|
|
937
|
+
*/
|
|
938
|
+
async updatePresence(
|
|
939
|
+
sessionId: string,
|
|
940
|
+
userId: string,
|
|
941
|
+
presence: Partial<PresenceRecord>,
|
|
942
|
+
): Promise<void> {
|
|
943
|
+
await this.client.set(this.collections.presence, `${sessionId}:${userId}`, {
|
|
944
|
+
sessionId,
|
|
945
|
+
userId,
|
|
946
|
+
...presence,
|
|
947
|
+
lastActivity: new Date().toISOString(),
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Clean up subscriptions
|
|
953
|
+
*/
|
|
954
|
+
destroy(): void {
|
|
955
|
+
for (const sub of this.subscriptions) {
|
|
956
|
+
sub.unsubscribe();
|
|
957
|
+
}
|
|
958
|
+
this.subscriptions = [];
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
private pathToId(path: string): string {
|
|
962
|
+
return path.replace(/\//g, '_').replace(/^_/, '') || 'index';
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
private routeToSessionId(route: string): string {
|
|
966
|
+
return route.replace(/^\/|\/$/g, '').replace(/\//g, '-') || 'index';
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
interface PresenceRecord {
|
|
971
|
+
sessionId: string;
|
|
972
|
+
userId: string;
|
|
973
|
+
role: 'user' | 'assistant' | 'monitor' | 'admin';
|
|
974
|
+
cursor?: { x: number; y: number };
|
|
975
|
+
editing?: string;
|
|
976
|
+
status: 'online' | 'away' | 'offline';
|
|
977
|
+
lastActivity: string;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Create a storage adapter based on configuration
|
|
982
|
+
*/
|
|
983
|
+
export function createStorageAdapter(config: {
|
|
984
|
+
type: 'file' | 'd1' | 'durable-object' | 'hybrid' | 'dash' | 'custom';
|
|
985
|
+
pagesDir?: string;
|
|
986
|
+
dataDir?: string;
|
|
987
|
+
d1?: D1Database;
|
|
988
|
+
durableObjectNamespace?: DurableObjectNamespace;
|
|
989
|
+
dash?: DashClient;
|
|
990
|
+
dashCollections?: {
|
|
991
|
+
routes?: string;
|
|
992
|
+
sessions?: string;
|
|
993
|
+
presence?: string;
|
|
994
|
+
};
|
|
995
|
+
custom?: StorageAdapter;
|
|
996
|
+
}): StorageAdapter {
|
|
997
|
+
switch (config.type) {
|
|
998
|
+
case 'd1':
|
|
999
|
+
if (!config.d1) {
|
|
1000
|
+
throw new Error('D1 database required for d1 storage adapter');
|
|
1001
|
+
}
|
|
1002
|
+
return new D1StorageAdapter(config.d1);
|
|
1003
|
+
|
|
1004
|
+
case 'durable-object':
|
|
1005
|
+
if (!config.durableObjectNamespace) {
|
|
1006
|
+
throw new Error(
|
|
1007
|
+
'Durable Object namespace required for durable-object storage adapter',
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
return new DurableObjectStorageAdapter(config.durableObjectNamespace);
|
|
1011
|
+
|
|
1012
|
+
case 'hybrid':
|
|
1013
|
+
if (!config.durableObjectNamespace || !config.d1) {
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
'Both Durable Object namespace and D1 database required for hybrid storage adapter',
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
return new HybridStorageAdapter({
|
|
1019
|
+
namespace: config.durableObjectNamespace,
|
|
1020
|
+
db: config.d1,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
case 'dash':
|
|
1024
|
+
if (!config.dash) {
|
|
1025
|
+
throw new Error('Dash client required for dash storage adapter');
|
|
1026
|
+
}
|
|
1027
|
+
return new DashStorageAdapter(config.dash, {
|
|
1028
|
+
routesCollection: config.dashCollections?.routes,
|
|
1029
|
+
sessionsCollection: config.dashCollections?.sessions,
|
|
1030
|
+
presenceCollection: config.dashCollections?.presence,
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
case 'custom':
|
|
1034
|
+
if (!config.custom) {
|
|
1035
|
+
throw new Error('Custom adapter required for custom storage');
|
|
1036
|
+
}
|
|
1037
|
+
return config.custom;
|
|
1038
|
+
|
|
1039
|
+
case 'file':
|
|
1040
|
+
default:
|
|
1041
|
+
return new FileStorageAdapter({
|
|
1042
|
+
pagesDir: config.pagesDir ?? './pages',
|
|
1043
|
+
dataDir: config.dataDir ?? '.aeon/data',
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// All adapters are exported inline with their class declarations
|