@korajs/cli 0.6.0 → 1.0.0-beta.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin.cjs +2582 -202
  3. package/dist/bin.cjs.map +1 -1
  4. package/dist/bin.js +152 -96
  5. package/dist/bin.js.map +1 -1
  6. package/dist/chunk-5NI2FEQL.js +92 -0
  7. package/dist/chunk-5NI2FEQL.js.map +1 -0
  8. package/dist/chunk-6C4BHSRA.js +82 -0
  9. package/dist/chunk-6C4BHSRA.js.map +1 -0
  10. package/dist/{chunk-VLTPEATY.js → chunk-B5YS4STN.js} +9 -96
  11. package/dist/chunk-B5YS4STN.js.map +1 -0
  12. package/dist/{chunk-EEZNRI5W.js → chunk-BWTKRKNJ.js} +13 -5
  13. package/dist/{chunk-EEZNRI5W.js.map → chunk-BWTKRKNJ.js.map} +1 -1
  14. package/dist/{chunk-Q2FBCOQD.js → chunk-EOWLAAIV.js} +5 -3
  15. package/dist/{chunk-Q2FBCOQD.js.map → chunk-EOWLAAIV.js.map} +1 -1
  16. package/dist/create.cjs +8 -2
  17. package/dist/create.cjs.map +1 -1
  18. package/dist/create.js +3 -2
  19. package/dist/create.js.map +1 -1
  20. package/dist/index.js +3 -2
  21. package/dist/lab-manager-KUDII6BT.js +280 -0
  22. package/dist/lab-manager-KUDII6BT.js.map +1 -0
  23. package/dist/schema-loader-GGHECMTM.js +9 -0
  24. package/dist/schema-loader-GGHECMTM.js.map +1 -0
  25. package/dist/spectator-manager-E2LH2P54.js +142 -0
  26. package/dist/spectator-manager-E2LH2P54.js.map +1 -0
  27. package/dist/studio-server-NIFKZI4F.js +1738 -0
  28. package/dist/studio-server-NIFKZI4F.js.map +1 -0
  29. package/package.json +8 -6
  30. package/templates/react-basic/AGENTS.md +87 -0
  31. package/templates/react-sync/AGENTS.md +87 -0
  32. package/templates/react-sync/src/App.tsx +8 -0
  33. package/templates/react-sync/src/index.css +11 -0
  34. package/templates/react-tailwind/AGENTS.md +87 -0
  35. package/templates/react-tailwind-sync/AGENTS.md +87 -0
  36. package/templates/react-tailwind-sync/src/App.tsx +28 -0
  37. package/templates/svelte-basic/AGENTS.md +76 -0
  38. package/templates/svelte-basic/src/App.svelte +27 -31
  39. package/templates/svelte-basic/src/Root.svelte +4 -4
  40. package/templates/svelte-basic/src/modules/todos/useTodos.ts +1 -1
  41. package/templates/svelte-basic/vite.config.ts +1 -1
  42. package/templates/svelte-sync/AGENTS.md +76 -0
  43. package/templates/svelte-sync/src/App.svelte +31 -35
  44. package/templates/svelte-sync/src/Root.svelte +6 -6
  45. package/templates/svelte-sync/src/modules/todos/useTodos.ts +1 -1
  46. package/templates/svelte-tailwind/AGENTS.md +76 -0
  47. package/templates/svelte-tailwind/src/App.svelte +17 -21
  48. package/templates/svelte-tailwind/src/Root.svelte +4 -4
  49. package/templates/svelte-tailwind/src/modules/todos/useTodos.ts +1 -1
  50. package/templates/svelte-tailwind/vite.config.ts +2 -2
  51. package/templates/svelte-tailwind-sync/AGENTS.md +76 -0
  52. package/templates/svelte-tailwind-sync/src/App.svelte +52 -56
  53. package/templates/svelte-tailwind-sync/src/Root.svelte +6 -6
  54. package/templates/svelte-tailwind-sync/src/modules/todos/useTodos.ts +1 -1
  55. package/templates/svelte-tailwind-sync/vite.config.ts +1 -1
  56. package/templates/tauri-react/AGENTS.md +91 -0
  57. package/templates/vue-basic/AGENTS.md +84 -0
  58. package/templates/vue-basic/src/modules/todos/useTodos.ts +1 -1
  59. package/templates/vue-sync/AGENTS.md +84 -0
  60. package/templates/vue-sync/src/main.ts +5 -1
  61. package/templates/vue-sync/src/modules/todos/useTodos.ts +1 -1
  62. package/templates/vue-tailwind/AGENTS.md +84 -0
  63. package/templates/vue-tailwind/src/App.vue +1 -8
  64. package/templates/vue-tailwind/src/modules/todos/useTodos.ts +1 -1
  65. package/templates/vue-tailwind-sync/AGENTS.md +84 -0
  66. package/templates/vue-tailwind-sync/src/modules/todos/useTodos.ts +1 -1
  67. package/dist/chunk-VLTPEATY.js.map +0 -1
@@ -0,0 +1,1738 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/commands/studio/studio-server.ts
4
+ import { createServer } from "http";
5
+
6
+ // src/commands/studio/db-reader.ts
7
+ var KORA_TABLE_PREFIX = "_kora_";
8
+ var OPS_TABLE_PREFIX = "_kora_ops_";
9
+ var META_COLUMNS = /* @__PURE__ */ new Set([
10
+ "id",
11
+ "_created_at",
12
+ "_updated_at",
13
+ "_version",
14
+ "_field_versions",
15
+ "_deleted"
16
+ ]);
17
+ function parseVersionStamp(raw) {
18
+ if (typeof raw !== "string" || raw.length === 0) {
19
+ return null;
20
+ }
21
+ const parts = raw.split(":");
22
+ if (parts.length < 3) {
23
+ return null;
24
+ }
25
+ const wallTime = Number.parseInt(parts[0] ?? "", 10);
26
+ const logical = Number.parseInt(parts[1] ?? "", 10);
27
+ if (Number.isNaN(wallTime) || Number.isNaN(logical)) {
28
+ return null;
29
+ }
30
+ return { wallTime, logical, nodeId: parts.slice(2).join(":") };
31
+ }
32
+ function parseFieldVersionsColumn(raw) {
33
+ if (typeof raw !== "string" || raw.length === 0) {
34
+ return {};
35
+ }
36
+ try {
37
+ const parsed = JSON.parse(raw);
38
+ const result = {};
39
+ for (const [field, value] of Object.entries(parsed)) {
40
+ const version = parseVersionStamp(value);
41
+ if (version) {
42
+ result[field] = version;
43
+ }
44
+ }
45
+ return result;
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+ function safeIdentifier(name) {
51
+ if (!/^[a-zA-Z0-9_]+$/.test(name)) {
52
+ throw new Error(`Unsafe SQL identifier: "${name}"`);
53
+ }
54
+ return name;
55
+ }
56
+ function parseJsonColumn(raw) {
57
+ if (typeof raw !== "string" || raw.length === 0) {
58
+ return null;
59
+ }
60
+ try {
61
+ return JSON.parse(raw);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ function parseJsonArrayColumn(raw) {
67
+ if (typeof raw !== "string" || raw.length === 0) {
68
+ return [];
69
+ }
70
+ try {
71
+ const parsed = JSON.parse(raw);
72
+ return Array.isArray(parsed) ? parsed.map(String) : [];
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+ var StudioDbReader = class _StudioDbReader {
78
+ constructor(db, dbPath) {
79
+ this.db = db;
80
+ this.dbPath = dbPath;
81
+ }
82
+ db;
83
+ dbPath;
84
+ /**
85
+ * Open a Kora database strictly read-only.
86
+ * @throws when the file does not exist or is not a Kora database
87
+ */
88
+ static async open(dbPath) {
89
+ let Database;
90
+ try {
91
+ Database = (await import("better-sqlite3")).default;
92
+ } catch {
93
+ throw new Error(
94
+ 'Kora Studio needs the "better-sqlite3" package to open database files. Install it in your project: pnpm add -D better-sqlite3'
95
+ );
96
+ }
97
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
98
+ const reader = new _StudioDbReader(db, dbPath);
99
+ if (reader.listCollections().length === 0 && !reader.hasKoraTables()) {
100
+ db.close();
101
+ throw new Error(`"${dbPath}" does not look like a Kora database (no _kora_* tables found).`);
102
+ }
103
+ return reader;
104
+ }
105
+ close() {
106
+ this.db.close();
107
+ }
108
+ hasKoraTables() {
109
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name LIKE ?").get(`${KORA_TABLE_PREFIX}%`);
110
+ return row.count > 0;
111
+ }
112
+ /** Collections = tables that have a companion _kora_ops_<name> table. */
113
+ listCollections() {
114
+ const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all();
115
+ const names = new Set(tables.map((t) => t.name));
116
+ const collections = [];
117
+ for (const { name } of tables) {
118
+ if (name.startsWith(OPS_TABLE_PREFIX)) {
119
+ const collection = name.slice(OPS_TABLE_PREFIX.length);
120
+ if (names.has(collection)) {
121
+ collections.push(collection);
122
+ }
123
+ }
124
+ }
125
+ return collections.sort();
126
+ }
127
+ overview() {
128
+ const collections = this.listCollections().map((name) => {
129
+ const table = safeIdentifier(name);
130
+ const live = this.db.prepare(`SELECT COUNT(*) as count FROM ${table} WHERE _deleted = 0`).get();
131
+ const tombstones = this.db.prepare(`SELECT COUNT(*) as count FROM ${table} WHERE _deleted = 1`).get();
132
+ const operations = this.db.prepare(`SELECT COUNT(*) as count FROM ${OPS_TABLE_PREFIX}${table}`).get();
133
+ const columnRows = this.db.prepare(`PRAGMA table_info(${table})`).all();
134
+ return {
135
+ name,
136
+ liveRecords: live.count,
137
+ tombstones: tombstones.count,
138
+ operations: operations.count,
139
+ columns: columnRows.map((c) => c.name).filter((c) => !META_COLUMNS.has(c))
140
+ };
141
+ });
142
+ const versionVector = this.tableExists("_kora_version_vector") ? this.db.prepare(
143
+ "SELECT node_id as nodeId, sequence_number as sequenceNumber FROM _kora_version_vector ORDER BY node_id"
144
+ ).all() : [];
145
+ const meta = this.tableExists("_kora_meta") ? this.db.prepare("SELECT key, value FROM _kora_meta ORDER BY key").all() : [];
146
+ const pendingSyncOps = this.tableExists("_kora_sync_queue") ? this.db.prepare("SELECT COUNT(*) as count FROM _kora_sync_queue").get().count : 0;
147
+ const auditTraces = this.tableExists("_kora_audit_traces") ? this.db.prepare("SELECT COUNT(*) as count FROM _kora_audit_traces").get().count : 0;
148
+ return { dbPath: this.dbPath, collections, versionVector, meta, pendingSyncOps, auditTraces };
149
+ }
150
+ records(collection, options = {}) {
151
+ const table = safeIdentifier(collection);
152
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 500);
153
+ const offset = Math.max(options.offset ?? 0, 0);
154
+ const clauses = [];
155
+ const params = [];
156
+ if (!options.includeDeleted) {
157
+ clauses.push("_deleted = 0");
158
+ }
159
+ if (options.search && options.search.trim().length > 0) {
160
+ const textColumns = this.db.prepare(`PRAGMA table_info(${table})`).all().filter((c) => c.type.toUpperCase().includes("TEXT") || c.name === "id");
161
+ const term = `%${options.search.trim()}%`;
162
+ const likes = textColumns.map((c) => {
163
+ params.push(term);
164
+ return `${safeIdentifier(c.name)} LIKE ?`;
165
+ });
166
+ if (likes.length > 0) {
167
+ clauses.push(`(${likes.join(" OR ")})`);
168
+ }
169
+ }
170
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "";
171
+ const total = this.db.prepare(`SELECT COUNT(*) as count FROM ${table} ${where}`).get(...params).count;
172
+ const rows = this.db.prepare(`SELECT * FROM ${table} ${where} ORDER BY _updated_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
173
+ return { records: rows.map((row) => this.toStudioRecord(row)), total };
174
+ }
175
+ /** Full operation log for one collection (capped), for replay and the DAG. */
176
+ allOperations(collection, cap = 5e3) {
177
+ const table = safeIdentifier(collection);
178
+ const rows = this.db.prepare(`SELECT * FROM ${OPS_TABLE_PREFIX}${table} ORDER BY timestamp ASC LIMIT ?`).all(Math.min(Math.max(cap, 1), 2e4));
179
+ return rows.map((row) => this.toStudioOperation(row));
180
+ }
181
+ /**
182
+ * Cheap change fingerprint for live updates. SQLite's `data_version` PRAGMA
183
+ * increments whenever ANOTHER connection commits — exactly what a read-only
184
+ * watcher needs to know "something changed, refetch".
185
+ */
186
+ fingerprint() {
187
+ const row = this.db.prepare("PRAGMA data_version").get();
188
+ return String(row.data_version);
189
+ }
190
+ /** Raw value of a single column on a record (for richtext preview decode). */
191
+ rawFieldValue(collection, recordId, field) {
192
+ const table = safeIdentifier(collection);
193
+ const column = safeIdentifier(field);
194
+ const row = this.db.prepare(`SELECT ${column} as value FROM ${table} WHERE id = ?`).get(recordId);
195
+ return row?.value;
196
+ }
197
+ record(collection, recordId) {
198
+ const table = safeIdentifier(collection);
199
+ const row = this.db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(recordId);
200
+ return row ? this.toStudioRecord(row) : null;
201
+ }
202
+ /** Full operation history for one record, newest first. */
203
+ recordOperations(collection, recordId) {
204
+ const table = safeIdentifier(collection);
205
+ const rows = this.db.prepare(
206
+ `SELECT * FROM ${OPS_TABLE_PREFIX}${table} WHERE record_id = ? ORDER BY timestamp DESC`
207
+ ).all(recordId);
208
+ return rows.map((row) => this.toStudioOperation(row));
209
+ }
210
+ operations(collection, options = {}) {
211
+ const table = safeIdentifier(collection);
212
+ const limit = Math.min(Math.max(options.limit ?? 50, 1), 500);
213
+ const offset = Math.max(options.offset ?? 0, 0);
214
+ const total = this.db.prepare(`SELECT COUNT(*) as count FROM ${OPS_TABLE_PREFIX}${table}`).get().count;
215
+ const rows = this.db.prepare(`SELECT * FROM ${OPS_TABLE_PREFIX}${table} ORDER BY timestamp DESC LIMIT ? OFFSET ?`).all(limit, offset);
216
+ return { operations: rows.map((row) => this.toStudioOperation(row)), total };
217
+ }
218
+ auditTraces(limit = 100) {
219
+ if (!this.tableExists("_kora_audit_traces")) {
220
+ return [];
221
+ }
222
+ const rows = this.db.prepare(
223
+ `SELECT id, recorded_at, event_type, collection, record_id, field, strategy, tier, constraint_name
224
+ FROM _kora_audit_traces ORDER BY recorded_at DESC LIMIT ?`
225
+ ).all(Math.min(Math.max(limit, 1), 500));
226
+ return rows.map((row) => ({
227
+ id: String(row.id),
228
+ recordedAt: Number(row.recorded_at),
229
+ eventType: String(row.event_type),
230
+ collection: String(row.collection),
231
+ recordId: String(row.record_id),
232
+ field: String(row.field),
233
+ strategy: String(row.strategy),
234
+ tier: Number(row.tier),
235
+ constraintName: row.constraint_name === null ? null : String(row.constraint_name)
236
+ }));
237
+ }
238
+ tableExists(name) {
239
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name = ?").get(name);
240
+ return row.count > 0;
241
+ }
242
+ toStudioRecord(row) {
243
+ const fields = {};
244
+ for (const [key, value] of Object.entries(row)) {
245
+ if (!META_COLUMNS.has(key)) {
246
+ fields[key] = value instanceof Uint8Array || Buffer.isBuffer(value) ? `<binary ${value.byteLength} bytes>` : value;
247
+ }
248
+ }
249
+ return {
250
+ id: String(row.id),
251
+ fields,
252
+ createdAt: Number(row._created_at),
253
+ updatedAt: Number(row._updated_at),
254
+ deleted: row._deleted === 1,
255
+ version: parseVersionStamp(row._version),
256
+ fieldVersions: parseFieldVersionsColumn(row._field_versions)
257
+ };
258
+ }
259
+ toStudioOperation(row) {
260
+ return {
261
+ id: String(row.id),
262
+ nodeId: String(row.node_id),
263
+ type: String(row.type),
264
+ recordId: String(row.record_id),
265
+ data: parseJsonColumn(row.data),
266
+ previousData: parseJsonColumn(row.previous_data),
267
+ timestamp: parseVersionStamp(row.timestamp),
268
+ sequenceNumber: Number(row.sequence_number),
269
+ causalDeps: parseJsonArrayColumn(row.causal_deps),
270
+ schemaVersion: Number(row.schema_version)
271
+ };
272
+ }
273
+ };
274
+
275
+ // src/commands/studio/studio-replay.ts
276
+ function compareVersion(a, b) {
277
+ if (!a || !b) {
278
+ return a === b ? 0 : a ? 1 : -1;
279
+ }
280
+ if (a.wallTime !== b.wallTime) {
281
+ return a.wallTime - b.wallTime;
282
+ }
283
+ if (a.logical !== b.logical) {
284
+ return a.logical - b.logical;
285
+ }
286
+ return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0;
287
+ }
288
+ function sortByHlc(ops) {
289
+ return [...ops].sort((a, b) => compareVersion(a.timestamp, b.timestamp));
290
+ }
291
+ function replayToOperation(ops, upToOpId) {
292
+ const ordered = sortByHlc(ops);
293
+ let cutIndex = ordered.length - 1;
294
+ if (upToOpId !== null) {
295
+ const found = ordered.findIndex((o) => o.id === upToOpId);
296
+ if (found === -1) {
297
+ throw new Error(`Operation "${upToOpId}" not found in the log`);
298
+ }
299
+ cutIndex = found;
300
+ }
301
+ const records = /* @__PURE__ */ new Map();
302
+ for (let i = 0; i <= cutIndex; i++) {
303
+ const operation = ordered[i];
304
+ if (!operation) {
305
+ continue;
306
+ }
307
+ const writer = operation.timestamp ? shortNode(operation.timestamp.nodeId) : "?";
308
+ if (operation.type === "insert" && operation.data) {
309
+ const writers = {};
310
+ for (const field of Object.keys(operation.data)) {
311
+ writers[field] = writer;
312
+ }
313
+ records.set(operation.recordId, {
314
+ fields: { ...operation.data },
315
+ deleted: false,
316
+ writers
317
+ });
318
+ } else if (operation.type === "update" && operation.data) {
319
+ const existing = records.get(operation.recordId);
320
+ if (existing) {
321
+ for (const [field, value] of Object.entries(operation.data)) {
322
+ existing.fields[field] = value;
323
+ existing.writers[field] = writer;
324
+ }
325
+ } else {
326
+ const writers = {};
327
+ for (const field of Object.keys(operation.data)) {
328
+ writers[field] = writer;
329
+ }
330
+ records.set(operation.recordId, {
331
+ fields: { ...operation.data },
332
+ deleted: false,
333
+ writers
334
+ });
335
+ }
336
+ } else if (operation.type === "delete") {
337
+ const existing = records.get(operation.recordId);
338
+ if (existing) {
339
+ existing.deleted = true;
340
+ } else {
341
+ records.set(operation.recordId, { fields: {}, deleted: true, writers: {} });
342
+ }
343
+ }
344
+ }
345
+ return {
346
+ records: [...records.entries()].map(([id, r]) => ({
347
+ id,
348
+ fields: r.fields,
349
+ deleted: r.deleted,
350
+ lastWriterByField: r.writers
351
+ })),
352
+ appliedCount: cutIndex + 1,
353
+ totalCount: ordered.length,
354
+ cutOperation: ordered[cutIndex] ?? null
355
+ };
356
+ }
357
+ function buildCausalDag(ops) {
358
+ const ordered = sortByHlc(ops);
359
+ const laneByNode = /* @__PURE__ */ new Map();
360
+ const lanes = [];
361
+ for (const operation of ordered) {
362
+ if (!laneByNode.has(operation.nodeId)) {
363
+ laneByNode.set(operation.nodeId, lanes.length);
364
+ lanes.push({ nodeId: operation.nodeId, shortNodeId: shortNode(operation.nodeId) });
365
+ }
366
+ }
367
+ const idSet = new Set(ordered.map((o) => o.id));
368
+ const nodes = ordered.map((operation, index) => ({
369
+ id: operation.id,
370
+ type: operation.type,
371
+ recordId: operation.recordId,
372
+ nodeId: operation.nodeId,
373
+ shortNodeId: shortNode(operation.nodeId),
374
+ sequenceNumber: operation.sequenceNumber,
375
+ wallTime: operation.timestamp?.wallTime ?? 0,
376
+ x: index,
377
+ lane: laneByNode.get(operation.nodeId) ?? 0,
378
+ dataPreview: operation.data ? JSON.stringify(operation.data).slice(0, 80) : ""
379
+ }));
380
+ const edges = [];
381
+ for (const operation of ordered) {
382
+ for (const dep of operation.causalDeps) {
383
+ if (idSet.has(dep)) {
384
+ edges.push({ from: dep, to: operation.id });
385
+ }
386
+ }
387
+ }
388
+ return { nodes, edges, lanes };
389
+ }
390
+ function shortNode(nodeId) {
391
+ return nodeId.length > 8 ? nodeId.slice(-8) : nodeId;
392
+ }
393
+
394
+ // src/commands/studio/studio-ui.ts
395
+ var STUDIO_HTML = `<!DOCTYPE html>
396
+ <html lang="en">
397
+ <head>
398
+ <meta charset="utf-8" />
399
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
400
+ <title>Kora Studio</title>
401
+ <link rel="stylesheet" href="/style.css" />
402
+ </head>
403
+ <body>
404
+ <header>
405
+ <div class="logo">kora <span>studio</span></div>
406
+ <div class="modebadge" id="modebadge"></div>
407
+ <div class="dbpath mono" id="dbpath"></div>
408
+ <div class="live" id="live"><div class="dot"></div><span>live</span></div>
409
+ <button id="refresh">Refresh</button>
410
+ </header>
411
+ <div class="tabs" id="tabs"></div>
412
+ <div class="layout">
413
+ <nav class="sidebar" id="sidebar"></nav>
414
+ <main id="main"><div class="empty">Loading\u2026</div></main>
415
+ </div>
416
+ <div class="drawer" id="drawer"></div>
417
+ <div class="dag-tip" id="dagtip"></div>
418
+ <script src="/app.js"></script>
419
+ </body>
420
+ </html>
421
+ `;
422
+
423
+ // src/commands/studio/studio-ui-app.ts
424
+ var STUDIO_APP_JS = `
425
+ (function () {
426
+ 'use strict';
427
+
428
+ // \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
429
+ var state = {
430
+ mode: 'file', // 'file' | 'lab'
431
+ tab: 'data', // data | ops | timetravel | merges | sync | lab
432
+ device: null, // selected lab device for data-ish tabs
433
+ devices: [],
434
+ collection: null,
435
+ page: 0,
436
+ includeDeleted: false,
437
+ search: '',
438
+ overview: null,
439
+ ttPosition: null, // time-travel slider position
440
+ ttPlaying: null,
441
+ labState: null,
442
+ feed: [],
443
+ deviceColors: {},
444
+ live: false
445
+ };
446
+ var PAGE = 50;
447
+ var refreshTimer = null;
448
+
449
+ // \u2500\u2500 Utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
450
+ function qs(sel) { return document.querySelector(sel); }
451
+ function api(path) {
452
+ var sep = path.indexOf('?') === -1 ? '?' : '&';
453
+ var url = state.device ? path + sep + 'device=' + encodeURIComponent(state.device) : path;
454
+ return fetch(url).then(function (r) {
455
+ if (!r.ok) { return r.json().then(function (b) { throw new Error(b.error || r.statusText); }); }
456
+ return r.json();
457
+ });
458
+ }
459
+ function post(path, body) {
460
+ return fetch(path, {
461
+ method: 'POST',
462
+ headers: { 'content-type': 'application/json' },
463
+ body: JSON.stringify(body || {})
464
+ }).then(function (r) {
465
+ if (!r.ok) { return r.json().then(function (b) { throw new Error(b.error || r.statusText); }); }
466
+ return r.json();
467
+ });
468
+ }
469
+ function el(tag, attrs, children) {
470
+ var node = document.createElement(tag);
471
+ if (attrs) { Object.keys(attrs).forEach(function (k) {
472
+ if (k === 'class') node.className = attrs[k];
473
+ else if (k === 'text') node.textContent = attrs[k];
474
+ else if (k === 'html') node.innerHTML = attrs[k];
475
+ else if (k.slice(0, 2) === 'on') node.addEventListener(k.slice(2), attrs[k]);
476
+ else node.setAttribute(k, attrs[k]);
477
+ }); }
478
+ (children || []).forEach(function (c) { if (c) node.appendChild(c); });
479
+ return node;
480
+ }
481
+ function fmtTime(ms) {
482
+ if (!ms) return '\xB7';
483
+ var d = new Date(ms);
484
+ return d.toLocaleTimeString() + '.' + ('00' + d.getMilliseconds()).slice(-3);
485
+ }
486
+ function fmtDateTime(ms) {
487
+ if (!ms) return '\xB7';
488
+ var d = new Date(ms);
489
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
490
+ }
491
+ function shortNode(id) { return id && id.length > 8 ? id.slice(-8) : (id || '\xB7'); }
492
+ function shortId(id) { return id && id.length > 13 ? id.slice(0, 13) + '\u2026' : (id || ''); }
493
+ function fmtVal(v) {
494
+ if (v === null || v === undefined) return 'null';
495
+ if (typeof v === 'object') return JSON.stringify(v);
496
+ return String(v);
497
+ }
498
+ function deviceColor(name) {
499
+ if (!(name in state.deviceColors)) {
500
+ state.deviceColors[name] = 'dev-c' + (Object.keys(state.deviceColors).length % 6);
501
+ }
502
+ return state.deviceColors[name];
503
+ }
504
+ function debounceRefresh() {
505
+ if (refreshTimer) clearTimeout(refreshTimer);
506
+ refreshTimer = setTimeout(function () { render(false); }, 250);
507
+ }
508
+
509
+ // \u2500\u2500 Boot + live events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
510
+ fetch('/api/mode').then(function (r) { return r.json(); }).then(function (info) {
511
+ state.mode = info.mode;
512
+ state.devices = info.devices || [];
513
+ if (state.mode === 'lab') { state.tab = 'lab'; state.device = state.devices[0] || null; }
514
+ qs('#modebadge').textContent = state.mode;
515
+ if (info.spectator) {
516
+ qs('#dbpath').textContent = '\u21C4 ' + info.spectator.url +
517
+ (info.spectator.connected ? ' \xB7 connected' : ' \xB7 connecting\u2026') +
518
+ ' \xB7 ' + info.spectator.operationsReceived + ' ops received';
519
+ }
520
+ connectEvents();
521
+ render(true);
522
+ });
523
+
524
+ function connectEvents() {
525
+ var source = new EventSource('/api/events');
526
+ source.addEventListener('hello', function () {
527
+ state.live = true; qs('#live').className = 'live on';
528
+ });
529
+ source.addEventListener('change', function () {
530
+ if (state.tab !== 'timetravel') debounceRefresh();
531
+ });
532
+ source.addEventListener('lab', function (event) {
533
+ var data = JSON.parse(event.data);
534
+ state.feed.push(data);
535
+ if (state.feed.length > 400) state.feed.shift();
536
+ appendFeedLine(data);
537
+ });
538
+ source.addEventListener('spectator', function (event) {
539
+ var data = JSON.parse(event.data);
540
+ data.device = 'server';
541
+ state.feed.push(data);
542
+ if (state.feed.length > 400) state.feed.shift();
543
+ appendFeedLine(data);
544
+ // Keep the header status fresh as ops stream in.
545
+ fetch('/api/spectator/status').then(function (r) { return r.json(); }).then(function (s) {
546
+ qs('#dbpath').textContent = '\u21C4 ' + s.url +
547
+ (s.connected ? ' \xB7 connected' : ' \xB7 reconnecting\u2026') +
548
+ ' \xB7 ' + s.operationsReceived + ' ops received';
549
+ }).catch(function () {});
550
+ });
551
+ source.onerror = function () {
552
+ state.live = false; qs('#live').className = 'live';
553
+ };
554
+ }
555
+
556
+ // \u2500\u2500 Tabs + chrome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
557
+ function renderTabs() {
558
+ var tabs = qs('#tabs');
559
+ tabs.textContent = '';
560
+ var defs = [];
561
+ if (state.mode === 'lab') defs.push(['lab', 'Lab']);
562
+ defs.push(['data', 'Data'], ['ops', 'Operations'], ['timetravel', 'Time Travel'], ['merges', 'Merges'], ['sync', 'Sync']);
563
+ defs.forEach(function (d) {
564
+ tabs.appendChild(el('div', {
565
+ class: 'tab' + (state.tab === d[0] ? ' active' : ''),
566
+ text: d[1],
567
+ onclick: function () { state.tab = d[0]; state.page = 0; closeDrawer(); render(true); }
568
+ }));
569
+ });
570
+ tabs.appendChild(el('div', { class: 'spacer' }));
571
+ if (state.mode === 'lab' && state.tab !== 'lab') {
572
+ var pick = el('select', { onchange: function () { state.device = pick.value; state.page = 0; render(true); } },
573
+ state.devices.map(function (d) {
574
+ var o = el('option', { value: d, text: d });
575
+ if (d === state.device) o.selected = true;
576
+ return o;
577
+ }));
578
+ tabs.appendChild(el('div', { class: 'devicepick' }, [el('span', { text: 'device:' }), pick]));
579
+ }
580
+ }
581
+
582
+ function renderSidebar() {
583
+ var nav = qs('#sidebar');
584
+ nav.textContent = '';
585
+ if (state.tab === 'lab' || state.tab === 'sync' || state.tab === 'merges') {
586
+ nav.style.display = 'none';
587
+ return;
588
+ }
589
+ nav.style.display = '';
590
+ nav.appendChild(el('h3', { text: 'Collections' }));
591
+ (state.overview ? state.overview.collections : []).forEach(function (c) {
592
+ nav.appendChild(el('div', {
593
+ class: 'item' + (state.collection === c.name ? ' active' : ''),
594
+ onclick: function () { state.collection = c.name; state.page = 0; closeDrawer(); render(false); }
595
+ }, [
596
+ el('span', { text: c.name }),
597
+ el('span', { class: 'count', text: String(c.liveRecords) + (c.tombstones ? ' +' + c.tombstones + '\u2020' : '') })
598
+ ]));
599
+ });
600
+ }
601
+
602
+ // \u2500\u2500 Main render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
603
+ function render(full) {
604
+ renderTabs();
605
+ if (state.tab === 'lab') { renderSidebar(); renderLab(full); return; }
606
+ api('/api/overview').then(function (overview) {
607
+ state.overview = overview;
608
+ qs('#dbpath').textContent = overview.dbPath || '';
609
+ if (!state.collection || !overview.collections.some(function (c) { return c.name === state.collection; })) {
610
+ state.collection = overview.collections.length ? overview.collections[0].name : null;
611
+ }
612
+ renderSidebar();
613
+ var main = qs('#main');
614
+ main.textContent = '';
615
+ if (state.tab === 'data') return renderData(main);
616
+ if (state.tab === 'ops') return renderOps(main);
617
+ if (state.tab === 'timetravel') return renderTimeTravel(main);
618
+ if (state.tab === 'merges') return renderMerges(main);
619
+ if (state.tab === 'sync') return renderSync(main);
620
+ }).catch(function (e) {
621
+ qs('#main').innerHTML = '<div class="error">' + e.message + '</div>';
622
+ });
623
+ }
624
+
625
+ function toolbar(main, title, extra) {
626
+ main.appendChild(el('div', { class: 'toolbar' }, [el('h2', { text: title })].concat(extra || [])));
627
+ }
628
+ function pager(main, total) {
629
+ var pages = Math.max(1, Math.ceil(total / PAGE));
630
+ main.appendChild(el('div', { class: 'pager' }, [
631
+ el('button', { text: '\u2039 Prev', onclick: function () { if (state.page > 0) { state.page--; render(false); } } }),
632
+ el('span', { text: 'page ' + (state.page + 1) + ' / ' + pages + ' \xB7 ' + total + ' total' }),
633
+ el('button', { text: 'Next \u203A', onclick: function () { if (state.page < pages - 1) { state.page++; render(false); } } })
634
+ ]));
635
+ }
636
+
637
+ // \u2500\u2500 Data tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
638
+ function renderData(main) {
639
+ if (!state.collection) { main.appendChild(el('div', { class: 'empty', text: 'No collections.' })); return; }
640
+ var searchBox = el('input', { type: 'text', placeholder: 'search\u2026', value: state.search });
641
+ searchBox.oninput = function () { state.search = searchBox.value; state.page = 0; debounceRefresh(); };
642
+ var deletedToggle = el('label', {}, [
643
+ (function () { var cb = el('input', { type: 'checkbox' }); cb.checked = state.includeDeleted;
644
+ cb.onchange = function () { state.includeDeleted = cb.checked; state.page = 0; render(false); }; return cb; })(),
645
+ el('span', { text: 'tombstones' })
646
+ ]);
647
+ toolbar(main, state.collection, [searchBox, deletedToggle]);
648
+
649
+ var q = '/api/collections/' + state.collection + '/records?limit=' + PAGE +
650
+ '&offset=' + (state.page * PAGE) + '&includeDeleted=' + state.includeDeleted +
651
+ (state.search ? '&search=' + encodeURIComponent(state.search) : '');
652
+ api(q).then(function (data) {
653
+ var info = state.overview.collections.filter(function (c) { return c.name === state.collection; })[0];
654
+ var columns = info ? info.columns : [];
655
+ if (!data.records.length) { main.appendChild(el('div', { class: 'empty', text: 'No records.' })); return; }
656
+ var thead = el('tr', {}, [el('th', { text: 'id' })].concat(
657
+ columns.map(function (c) { return el('th', { text: c }); }),
658
+ [el('th', { text: 'updated' })]
659
+ ));
660
+ var tbody = el('tbody', {}, data.records.map(function (r) {
661
+ return el('tr', { class: 'clickable' + (r.deleted ? ' tombstone' : ''), onclick: function () { openRecord(r.id); } },
662
+ [el('td', { class: 'mono', text: shortId(r.id) })].concat(
663
+ columns.map(function (c) { return el('td', { text: fmtVal(r.fields[c]).slice(0, 60) }); }),
664
+ [el('td', { class: 'mono', text: fmtDateTime(r.updatedAt) })]
665
+ ));
666
+ }));
667
+ main.appendChild(el('table', { class: 'grid' }, [el('thead', {}, [thead]), tbody]));
668
+ pager(main, data.total);
669
+ }).catch(showError(main));
670
+ }
671
+
672
+ // \u2500\u2500 Record drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
673
+ function openRecord(id) {
674
+ api('/api/collections/' + state.collection + '/records/' + encodeURIComponent(id)).then(function (data) {
675
+ var drawer = qs('#drawer');
676
+ drawer.textContent = '';
677
+ var r = data.record;
678
+ drawer.appendChild(el('h3', {}, [
679
+ el('span', { class: 'mono', text: r.id }),
680
+ el('span', { class: 'close', text: '\u2715', onclick: closeDrawer })
681
+ ]));
682
+ if (r.deleted) drawer.appendChild(el('span', { class: 'badge delete', text: 'tombstone' }));
683
+
684
+ drawer.appendChild(el('h4', { text: 'Fields \u2014 with last writer per field' }));
685
+ Object.keys(r.fields).forEach(function (f) {
686
+ var fv = r.fieldVersions[f];
687
+ var val = fmtVal(r.fields[f]);
688
+ var preview = data.richtextPreviews && data.richtextPreviews[f];
689
+ drawer.appendChild(el('div', { class: 'fieldrow' }, [
690
+ el('div', { class: 'fname mono', text: f }),
691
+ el('div', { class: 'fval', text: val }),
692
+ preview ? el('div', { class: 'preview', text: '\u201C' + preview + '\u201D' }) : null,
693
+ fv ? el('span', { class: 'chip' }, [
694
+ el('span', { text: 'last writer' }),
695
+ el('b', { class: 'mono', text: shortNode(fv.nodeId) }),
696
+ el('span', { class: 'mono', text: fmtDateTime(fv.wallTime) })
697
+ ]) : el('span', { class: 'chip', text: 'no field version' })
698
+ ]));
699
+ });
700
+
701
+ drawer.appendChild(el('h4', { text: 'Causal graph for this record' }));
702
+ var dagBox = el('div', { class: 'dagwrap' });
703
+ drawer.appendChild(dagBox);
704
+ api('/api/collections/' + state.collection + '/dag?record=' + encodeURIComponent(id)).then(function (dag) {
705
+ renderDag(dagBox, dag, 500);
706
+ });
707
+
708
+ drawer.appendChild(el('h4', { text: 'Operation history (newest first)' }));
709
+ if (!data.operations.length) drawer.appendChild(el('div', { class: 'empty', text: 'No operations (compacted?).' }));
710
+ data.operations.forEach(function (o) {
711
+ var body = el('div', { class: 'op ' + o.type }, [
712
+ el('div', {}, [
713
+ el('span', { class: 'badge ' + o.type, text: o.type }),
714
+ el('span', { class: 'meta', text: ' ' + shortNode(o.nodeId) + ' \xB7 seq ' + o.sequenceNumber + ' \xB7 ' + (o.timestamp ? fmtDateTime(o.timestamp.wallTime) : '\xB7') })
715
+ ])
716
+ ]);
717
+ if (o.data) {
718
+ var pre = el('pre', { class: 'mono' });
719
+ pre.textContent = JSON.stringify(o.data, null, 1);
720
+ body.appendChild(pre);
721
+ }
722
+ drawer.appendChild(body);
723
+ });
724
+ drawer.className = 'drawer open';
725
+ }).catch(function (e) { alertError(e); });
726
+ }
727
+ function closeDrawer() { qs('#drawer').className = 'drawer'; }
728
+
729
+ // \u2500\u2500 Operations tab (DAG + table) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
730
+ function renderOps(main) {
731
+ if (!state.collection) { main.appendChild(el('div', { class: 'empty', text: 'No collections.' })); return; }
732
+ toolbar(main, state.collection + ' \u2014 operation log');
733
+ var dagBox = el('div', { class: 'dagwrap' });
734
+ main.appendChild(dagBox);
735
+ api('/api/collections/' + state.collection + '/dag?limit=120').then(function (dag) {
736
+ renderDag(dagBox, dag, null);
737
+ });
738
+
739
+ api('/api/collections/' + state.collection + '/ops?limit=' + PAGE + '&offset=' + (state.page * PAGE)).then(function (data) {
740
+ if (!data.operations.length) { main.appendChild(el('div', { class: 'empty', text: 'Empty (compacted?).' })); return; }
741
+ var thead = el('tr', {}, ['type', 'record', 'node', 'seq', 'time', 'data'].map(function (h) { return el('th', { text: h }); }));
742
+ var tbody = el('tbody', {}, data.operations.map(function (o) {
743
+ return el('tr', { class: 'clickable', onclick: function () { openRecord(o.recordId); } }, [
744
+ el('td', {}, [el('span', { class: 'badge ' + o.type, text: o.type })]),
745
+ el('td', { class: 'mono', text: shortId(o.recordId) }),
746
+ el('td', { class: 'mono', text: shortNode(o.nodeId) }),
747
+ el('td', { class: 'mono', text: String(o.sequenceNumber) }),
748
+ el('td', { class: 'mono', text: o.timestamp ? fmtDateTime(o.timestamp.wallTime) : '\xB7' }),
749
+ el('td', { class: 'mono', text: o.data ? JSON.stringify(o.data).slice(0, 70) : '\xB7' })
750
+ ]);
751
+ }));
752
+ main.appendChild(el('table', { class: 'grid' }, [el('thead', {}, [thead]), tbody]));
753
+ pager(main, data.total);
754
+ }).catch(showError(main));
755
+ }
756
+
757
+ // \u2500\u2500 Causal DAG renderer (SVG) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
758
+ function renderDag(container, dag, maxWidth) {
759
+ container.textContent = '';
760
+ if (!dag.nodes.length) { container.appendChild(el('div', { class: 'empty', text: 'No operations to graph.' })); return; }
761
+ var XSTEP = 46, YSTEP = 44, PADX = 90, PADY = 26, R = 7;
762
+ var width = PADX + dag.nodes.length * XSTEP + 30;
763
+ var height = PADY + dag.lanes.length * YSTEP + 16;
764
+ var svgns = 'http://www.w3.org/2000/svg';
765
+ var svg = document.createElementNS(svgns, 'svg');
766
+ svg.setAttribute('width', String(width));
767
+ svg.setAttribute('height', String(height));
768
+
769
+ var defs = document.createElementNS(svgns, 'defs');
770
+ defs.innerHTML = '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#3a4356"/></marker>';
771
+ svg.appendChild(defs);
772
+
773
+ var colors = { insert: '#48bb78', update: '#4299e1', delete: '#f56565' };
774
+ var pos = {};
775
+ dag.nodes.forEach(function (n) {
776
+ pos[n.id] = { x: PADX + n.x * XSTEP, y: PADY + n.lane * YSTEP + YSTEP / 2 };
777
+ });
778
+
779
+ dag.lanes.forEach(function (lane, i) {
780
+ var y = PADY + i * YSTEP + YSTEP / 2;
781
+ var line = document.createElementNS(svgns, 'line');
782
+ line.setAttribute('x1', '10'); line.setAttribute('x2', String(width - 10));
783
+ line.setAttribute('y1', String(y)); line.setAttribute('y2', String(y));
784
+ line.setAttribute('class', 'dag-lane-line');
785
+ svg.appendChild(line);
786
+ var label = document.createElementNS(svgns, 'text');
787
+ label.setAttribute('x', '10'); label.setAttribute('y', String(y - 8));
788
+ label.setAttribute('class', 'dag-lane-label');
789
+ label.textContent = lane.shortNodeId;
790
+ svg.appendChild(label);
791
+ });
792
+
793
+ dag.edges.forEach(function (e) {
794
+ var a = pos[e.from], b = pos[e.to];
795
+ if (!a || !b) return;
796
+ var path = document.createElementNS(svgns, 'path');
797
+ var midX = (a.x + b.x) / 2;
798
+ path.setAttribute('d', 'M ' + a.x + ' ' + a.y + ' C ' + midX + ' ' + a.y + ', ' + midX + ' ' + b.y + ', ' + (b.x - R - 2) + ' ' + b.y);
799
+ path.setAttribute('class', 'dag-edge');
800
+ svg.appendChild(path);
801
+ });
802
+
803
+ var tip = qs('#dagtip');
804
+ dag.nodes.forEach(function (n) {
805
+ var g = document.createElementNS(svgns, 'g');
806
+ g.setAttribute('class', 'dag-node');
807
+ var c = document.createElementNS(svgns, 'circle');
808
+ var p = pos[n.id];
809
+ c.setAttribute('cx', String(p.x)); c.setAttribute('cy', String(p.y)); c.setAttribute('r', String(R));
810
+ c.setAttribute('fill', '#161a22');
811
+ c.setAttribute('stroke', colors[n.type] || '#8b93a7');
812
+ g.appendChild(c);
813
+ g.addEventListener('mousemove', function (ev) {
814
+ tip.style.display = 'block';
815
+ tip.style.left = (ev.clientX + 14) + 'px';
816
+ tip.style.top = (ev.clientY + 14) + 'px';
817
+ tip.innerHTML = '<b>' + n.type + '</b> by ' + n.shortNodeId + ' \xB7 seq ' + n.sequenceNumber +
818
+ '<br/>record ' + shortId(n.recordId) +
819
+ (n.dataPreview ? '<br/><span class="mono">' + escapeHtml(n.dataPreview) + '</span>' : '');
820
+ });
821
+ g.addEventListener('mouseleave', function () { tip.style.display = 'none'; });
822
+ g.addEventListener('click', function () { openRecord(n.recordId); });
823
+ svg.appendChild(g);
824
+ });
825
+
826
+ if (maxWidth) container.style.maxWidth = maxWidth + 'px';
827
+ container.appendChild(svg);
828
+ container.scrollLeft = width;
829
+ }
830
+ function escapeHtml(s) {
831
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
832
+ }
833
+
834
+ // \u2500\u2500 Time travel tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
835
+ function renderTimeTravel(main) {
836
+ if (!state.collection) { main.appendChild(el('div', { class: 'empty', text: 'No collections.' })); return; }
837
+ toolbar(main, state.collection + ' \u2014 time travel');
838
+ api('/api/collections/' + state.collection + '/replay').then(function (full) {
839
+ var total = full.totalCount;
840
+ if (!total) { main.appendChild(el('div', { class: 'empty', text: 'No operations to replay.' })); return; }
841
+ if (state.ttPosition === null || state.ttPosition > total) state.ttPosition = total;
842
+
843
+ var posLabel = el('span', { class: 'mono', text: '' });
844
+ var slider = el('input', { type: 'range', min: '1', max: String(total), value: String(state.ttPosition) });
845
+ var playBtn = el('button', { text: '\u25B6 replay' });
846
+ var cutBox = el('div', { class: 'tt-cut mono' });
847
+ var stateBox = el('div', {});
848
+
849
+ function show(position) {
850
+ state.ttPosition = position;
851
+ posLabel.textContent = position + ' / ' + total + ' ops';
852
+ api('/api/collections/' + state.collection + '/replay?upTo=' + encodeURIComponent(opIdAt(position))).then(function (cut) {
853
+ var o = cut.cutOperation;
854
+ cutBox.innerHTML = o
855
+ ? 'cut at <b>' + o.type + '</b> by ' + shortNode(o.nodeId) + ' \xB7 ' + (o.timestamp ? fmtDateTime(o.timestamp.wallTime) : '') +
856
+ (o.data ? ' \xB7 ' + escapeHtml(JSON.stringify(o.data).slice(0, 90)) : '')
857
+ : '';
858
+ renderReplayState(stateBox, cut);
859
+ });
860
+ }
861
+ // The full replay result orders ops by HLC; fetch the ordered id list once.
862
+ var orderedIds = [];
863
+ api('/api/collections/' + state.collection + '/dag?limit=20000').then(function (dag) {
864
+ orderedIds = dag.nodes.map(function (n) { return n.id; });
865
+ show(Math.min(state.ttPosition, orderedIds.length));
866
+ });
867
+ function opIdAt(position) { return orderedIds[position - 1]; }
868
+
869
+ slider.oninput = function () { show(Number(slider.value)); };
870
+ playBtn.onclick = function () {
871
+ if (state.ttPlaying) { clearInterval(state.ttPlaying); state.ttPlaying = null; playBtn.textContent = '\u25B6 replay'; return; }
872
+ var pos = 1;
873
+ playBtn.textContent = '\u23F8 pause';
874
+ state.ttPlaying = setInterval(function () {
875
+ if (pos > orderedIds.length) { clearInterval(state.ttPlaying); state.ttPlaying = null; playBtn.textContent = '\u25B6 replay'; return; }
876
+ slider.value = String(pos);
877
+ show(pos);
878
+ pos++;
879
+ }, 350);
880
+ };
881
+
882
+ main.appendChild(el('div', { class: 'tt-controls' }, [playBtn, slider, posLabel]));
883
+ main.appendChild(cutBox);
884
+ main.appendChild(stateBox);
885
+ }).catch(showError(main));
886
+ }
887
+
888
+ function renderReplayState(box, cut) {
889
+ box.textContent = '';
890
+ if (!cut.records.length) { box.appendChild(el('div', { class: 'empty', text: 'No records at this point.' })); return; }
891
+ var fieldSet = {};
892
+ cut.records.forEach(function (r) { Object.keys(r.fields).forEach(function (f) { fieldSet[f] = true; }); });
893
+ var fields = Object.keys(fieldSet);
894
+ var thead = el('tr', {}, [el('th', { text: 'id' })].concat(fields.map(function (f) { return el('th', { text: f }); })));
895
+ var tbody = el('tbody', {}, cut.records.map(function (r) {
896
+ return el('tr', { class: r.deleted ? 'tombstone' : '' },
897
+ [el('td', { class: 'mono', text: shortId(r.id) })].concat(fields.map(function (f) {
898
+ return el('td', { text: r.fields[f] === undefined ? '' : fmtVal(r.fields[f]).slice(0, 40) });
899
+ })));
900
+ }));
901
+ box.appendChild(el('table', { class: 'grid' }, [el('thead', {}, [thead]), tbody]));
902
+ }
903
+
904
+ // \u2500\u2500 Merges tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
905
+ function renderMerges(main) {
906
+ toolbar(main, 'Merge audit trail');
907
+ api('/api/audit?limit=200').then(function (data) {
908
+ if (!data.traces.length) { main.appendChild(el('div', { class: 'empty', text: 'No merge audit traces recorded.' })); return; }
909
+ var thead = el('tr', {}, ['when', 'event', 'collection', 'record', 'field', 'strategy', 'tier'].map(function (h) { return el('th', { text: h }); }));
910
+ var tbody = el('tbody', {}, data.traces.map(function (t) {
911
+ return el('tr', {}, [
912
+ el('td', { class: 'mono', text: fmtDateTime(t.recordedAt) }),
913
+ el('td', { text: t.eventType }),
914
+ el('td', { text: t.collection }),
915
+ el('td', { class: 'mono', text: shortId(t.recordId) }),
916
+ el('td', { text: t.field }),
917
+ el('td', { text: t.strategy }),
918
+ el('td', { text: String(t.tier) })
919
+ ]);
920
+ }));
921
+ main.appendChild(el('table', { class: 'grid' }, [el('thead', {}, [thead]), tbody]));
922
+ }).catch(showError(main));
923
+ }
924
+
925
+ // \u2500\u2500 Sync tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
926
+ function renderSync(main) {
927
+ toolbar(main, 'Sync state' + (state.device ? ' \u2014 ' + state.device : ''));
928
+ var o = state.overview;
929
+ main.appendChild(el('div', { class: 'panel' }, [
930
+ el('h3', { text: 'Version vector \u2014 what this store has seen from each node' }),
931
+ el('table', { class: 'grid' }, [
932
+ el('thead', {}, [el('tr', {}, [el('th', { text: 'node' }), el('th', { text: 'max sequence' })])]),
933
+ el('tbody', {}, o.versionVector.map(function (v) {
934
+ return el('tr', {}, [el('td', { class: 'mono', text: v.nodeId }), el('td', { class: 'mono', text: String(v.sequenceNumber) })]);
935
+ }))
936
+ ])
937
+ ]));
938
+ main.appendChild(el('div', { class: 'panel' }, [
939
+ el('h3', { text: 'Outbound queue' }),
940
+ el('div', { text: o.pendingSyncOps + ' operation(s) waiting to sync' })
941
+ ]));
942
+ main.appendChild(el('div', { class: 'panel' }, [
943
+ el('h3', { text: 'Store meta' }),
944
+ el('table', { class: 'grid' }, [
945
+ el('tbody', {}, o.meta.map(function (m) {
946
+ return el('tr', {}, [el('td', { class: 'mono', text: m.key }), el('td', { class: 'mono', text: m.value })]);
947
+ }))
948
+ ])
949
+ ]));
950
+ if (state.mode === 'spectator') {
951
+ var feedBox = el('div', { class: 'lab-feed', style: 'width:100%;max-height:340px' }, [
952
+ el('h3', { text: 'Live events from the server' }),
953
+ el('div', { class: 'feed', id: 'feed' })
954
+ ]);
955
+ main.appendChild(feedBox);
956
+ state.feed.slice(-200).forEach(appendFeedLine);
957
+ }
958
+ }
959
+
960
+ // \u2500\u2500 Lab tab \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
961
+ function renderLab(full) {
962
+ var main = qs('#main');
963
+ qs('#sidebar').style.display = 'none';
964
+ Promise.all([fetch('/api/lab/state').then(function (r) { return r.json(); }),
965
+ fetch('/api/lab/convergence').then(function (r) { return r.json(); })])
966
+ .then(function (results) {
967
+ state.labState = results[0];
968
+ state.devices = results[0].devices.map(function (d) { return d.name; });
969
+ var convergence = results[1];
970
+ main.textContent = '';
971
+ qs('#dbpath').textContent = 'sync laboratory \xB7 ' + results[0].serverOperations + ' ops on server';
972
+
973
+ var conv = el('div', { class: 'convergence ' + (convergence.converged ? 'ok' : 'bad') }, [
974
+ el('div', { text: convergence.converged
975
+ ? '\u2713 ALL ' + convergence.deviceCount + ' DEVICES CONVERGED'
976
+ : '\u2717 DEVICES DIVERGED (' + convergence.differences.length + ' difference(s)) \u2014 sync to converge' })
977
+ ]);
978
+ if (!convergence.converged) {
979
+ conv.appendChild(el('div', { class: 'diffs', text: convergence.differences.slice(0, 4).join(' \xB7 ') }));
980
+ }
981
+ var addBtn = el('button', { class: 'primary', text: '+ add device', onclick: function () {
982
+ post('/api/lab/devices', {}).then(function () { render(true); }).catch(alertError);
983
+ } });
984
+ var syncAllBtn = el('button', { text: 'sync all', onclick: function () {
985
+ var chain = Promise.resolve();
986
+ state.devices.forEach(function (d) { chain = chain.then(function () { return post('/api/lab/devices/' + d + '/sync', {}); }); });
987
+ chain.then(function () { return post('/api/lab/devices/' + state.devices[0] + '/sync', {}); })
988
+ .then(function () { render(true); }).catch(alertError);
989
+ } });
990
+ main.appendChild(el('div', { class: 'lab-top' }, [conv, addBtn, syncAllBtn]));
991
+
992
+ var devicesBox = el('div', { class: 'lab-devices' });
993
+ results[0].devices.forEach(function (d) { devicesBox.appendChild(deviceCard(d, results[0].collections)); });
994
+
995
+ var feedBox = el('div', { class: 'lab-feed' }, [
996
+ el('h3', { text: 'Live events' }),
997
+ el('div', { class: 'feed', id: 'feed' })
998
+ ]);
999
+ main.appendChild(el('div', { class: 'lab-grid' }, [devicesBox, feedBox]));
1000
+ state.feed.slice(-200).forEach(appendFeedLine);
1001
+ })
1002
+ .catch(showError(qs('#main')));
1003
+ }
1004
+
1005
+ function deviceCard(d, collections) {
1006
+ var collection = collections[0];
1007
+ var card = el('div', { class: 'device-card' + (d.connected ? '' : ' offline') });
1008
+ card.appendChild(el('div', { class: 'device-head' }, [
1009
+ el('span', { class: 'name ' + deviceColor(d.name), text: d.name }),
1010
+ el('span', { class: 'node mono', text: shortNode(d.nodeId) }),
1011
+ el('span', { class: 'badge ' + (d.connected ? 'ok' : 'bad'), text: d.connected ? 'online' : 'offline' }),
1012
+ d.pendingOperations ? el('span', { class: 'badge neutral', text: d.pendingOperations + ' queued' }) : null,
1013
+ el('span', { class: 'spacer' }),
1014
+ el('button', { text: d.connected ? 'disconnect' : 'connect', onclick: function () {
1015
+ post('/api/lab/devices/' + d.name + '/' + (d.connected ? 'disconnect' : 'connect'), {})
1016
+ .then(function () { render(true); }).catch(alertError);
1017
+ } }),
1018
+ el('button', { text: 'sync', onclick: function () {
1019
+ post('/api/lab/devices/' + d.name + '/sync', {}).then(function () { render(true); }).catch(alertError);
1020
+ } })
1021
+ ]));
1022
+
1023
+ var body = el('div', { class: 'device-body' });
1024
+ card.appendChild(body);
1025
+
1026
+ // Chaos controls
1027
+ body.appendChild(el('h5', { text: 'Network chaos (applies on next connect)' }));
1028
+ var chaosDefs = [['dropRate', 'drop'], ['duplicateRate', 'duplicate'], ['reorderRate', 'reorder']];
1029
+ chaosDefs.forEach(function (cd) {
1030
+ var key = cd[0];
1031
+ var valueLabel = el('span', { class: 'mono', text: String(d.chaos[key]) });
1032
+ var range = el('input', { type: 'range', min: '0', max: '0.5', step: '0.05', value: String(d.chaos[key]) });
1033
+ range.oninput = function () { valueLabel.textContent = range.value; };
1034
+ range.onchange = function () {
1035
+ var body2 = {}; body2[key] = Number(range.value);
1036
+ post('/api/lab/devices/' + d.name + '/chaos', body2).catch(alertError);
1037
+ };
1038
+ body.appendChild(el('div', { class: 'chaos-row' }, [el('span', { text: cd[1] }), range, valueLabel]));
1039
+ });
1040
+
1041
+ // Records with inline editing
1042
+ body.appendChild(el('h5', { text: 'records (' + collection.name + ') \u2014 edits happen ON this device' }));
1043
+ var recBox = el('div', {});
1044
+ body.appendChild(recBox);
1045
+ api('/api/collections/' + collection.name + '/records?limit=20&device=' + encodeURIComponent(d.name)).then(function (data) {
1046
+ data.records.forEach(function (r) {
1047
+ var titleInput = el('input', { type: 'text', value: String(r.fields.title || '') });
1048
+ titleInput.onchange = function () {
1049
+ post('/api/lab/devices/' + d.name + '/update', { collection: collection.name, id: r.id, data: { title: titleInput.value } }).catch(alertError);
1050
+ };
1051
+ var doneCb = el('input', { type: 'checkbox', title: 'done' });
1052
+ doneCb.checked = !!r.fields.done;
1053
+ doneCb.onchange = function () {
1054
+ post('/api/lab/devices/' + d.name + '/update', { collection: collection.name, id: r.id, data: { done: doneCb.checked } }).catch(alertError);
1055
+ };
1056
+ var incBtn = el('button', { text: '+1', title: 'atomic increment \u2014 composes across devices', onclick: function () {
1057
+ post('/api/lab/devices/' + d.name + '/update', { collection: collection.name, id: r.id, data: {}, increments: { points: 1 } })
1058
+ .then(function () { debounceRefresh(); }).catch(alertError);
1059
+ } });
1060
+ var delBtn = el('button', { class: 'danger', text: '\u2715', title: 'delete', onclick: function () {
1061
+ post('/api/lab/devices/' + d.name + '/delete', { collection: collection.name, id: r.id })
1062
+ .then(function () { debounceRefresh(); }).catch(alertError);
1063
+ } });
1064
+ recBox.appendChild(el('div', { class: 'rec-line' }, [
1065
+ doneCb, titleInput,
1066
+ el('span', { class: 'pts mono', text: String(r.fields.points === undefined ? '' : r.fields.points) }),
1067
+ incBtn, delBtn,
1068
+ el('button', { text: '\u2315', title: 'inspect', onclick: function () { state.device = d.name; state.collection = collection.name; openRecord(r.id); } })
1069
+ ]));
1070
+ });
1071
+ if (!data.records.length) recBox.appendChild(el('div', { class: 'empty', text: 'no records yet' }));
1072
+ });
1073
+
1074
+ // Insert form
1075
+ var newTitle = el('input', { type: 'text', placeholder: 'new task title\u2026' });
1076
+ var insertBtn = el('button', { class: 'primary', text: 'insert', onclick: function () {
1077
+ if (!newTitle.value.trim()) return;
1078
+ post('/api/lab/devices/' + d.name + '/insert', { collection: collection.name, data: { title: newTitle.value.trim() } })
1079
+ .then(function () { newTitle.value = ''; debounceRefresh(); }).catch(alertError);
1080
+ } });
1081
+ newTitle.addEventListener('keydown', function (e) { if (e.key === 'Enter') insertBtn.click(); });
1082
+ body.appendChild(el('div', { class: 'insert-form' }, [newTitle, insertBtn]));
1083
+ return card;
1084
+ }
1085
+
1086
+ function appendFeedLine(event) {
1087
+ var feed = qs('#feed');
1088
+ if (!feed) return;
1089
+ var cls = 'feed-line';
1090
+ if (event.type === 'operation:created') cls += ' evt-created';
1091
+ if (event.type === 'operation:applied') cls += ' evt-applied';
1092
+ if (event.type === 'merge:conflict') cls += ' evt-conflict';
1093
+ if (event.type === 'sync:apply-failed') cls += ' evt-failed';
1094
+ feed.appendChild(el('div', { class: cls }, [
1095
+ el('span', { class: 'mono', text: fmtTime(event.at) }),
1096
+ el('span', { class: 'dev ' + deviceColor(event.device), text: event.device }),
1097
+ el('span', { class: 'what', text: event.type.replace('operation:', '').replace('sync:', '') + ' \xB7 ' + event.summary })
1098
+ ]));
1099
+ feed.scrollTop = feed.scrollHeight;
1100
+ }
1101
+
1102
+ // \u2500\u2500 Misc \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1103
+ function showError(main) {
1104
+ return function (e) { main.appendChild(el('div', { class: 'error', text: e.message })); };
1105
+ }
1106
+ function alertError(e) { console.error(e); qs('#dbpath').textContent = 'error: ' + e.message; }
1107
+
1108
+ qs('#refresh').onclick = function () { render(true); };
1109
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeDrawer(); });
1110
+ })();
1111
+ `;
1112
+
1113
+ // src/commands/studio/studio-ui-css.ts
1114
+ var STUDIO_CSS = `
1115
+ :root {
1116
+ --bg: #0e1015; --panel: #161a22; --panel-2: #1c212c; --panel-3: #232936;
1117
+ --border: #262c3a; --text: #e6e9f0; --muted: #8b93a7;
1118
+ --accent: #4fd1c5; --accent-2: #9f7aea;
1119
+ --insert: #48bb78; --update: #4299e1; --delete: #f56565; --warn: #ed8936;
1120
+ --ok-bg: rgba(72,187,120,.12); --bad-bg: rgba(245,101,101,.12);
1121
+ }
1122
+ * { box-sizing: border-box; margin: 0; padding: 0; }
1123
+ html, body { height: 100%; }
1124
+ body {
1125
+ background: var(--bg); color: var(--text); overflow: hidden;
1126
+ font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
1127
+ }
1128
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
1129
+ button {
1130
+ background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
1131
+ border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px;
1132
+ }
1133
+ button:hover { border-color: var(--accent); }
1134
+ button.primary { background: rgba(79,209,197,.12); border-color: var(--accent); color: var(--accent); }
1135
+ button.danger { color: var(--delete); }
1136
+ button:disabled { opacity: .4; cursor: default; }
1137
+ input, select {
1138
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
1139
+ border-radius: 6px; padding: 4px 8px; font-size: 13px;
1140
+ }
1141
+ input:focus, select:focus { outline: none; border-color: var(--accent); }
1142
+ input[type="checkbox"] { width: auto; }
1143
+ input[type="range"] { padding: 0; }
1144
+
1145
+ header {
1146
+ display: flex; align-items: center; gap: 14px; padding: 10px 16px;
1147
+ background: var(--panel); border-bottom: 1px solid var(--border); height: 48px;
1148
+ }
1149
+ header .logo { font-weight: 700; letter-spacing: .3px; white-space: nowrap; }
1150
+ header .logo span { color: var(--accent); }
1151
+ header .modebadge {
1152
+ font-size: 10px; text-transform: uppercase; letter-spacing: 1px; padding: 2px 8px;
1153
+ border-radius: 10px; background: rgba(159,122,234,.15); color: var(--accent-2);
1154
+ }
1155
+ header .dbpath { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
1156
+ header .live { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
1157
+ header .live .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
1158
+ header .live.on .dot { background: var(--insert); box-shadow: 0 0 8px rgba(72,187,120,.8); }
1159
+
1160
+ .tabs { display: flex; gap: 2px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--border); }
1161
+ .tabs .tab {
1162
+ padding: 8px 16px; cursor: pointer; color: var(--muted); font-size: 13px;
1163
+ border-bottom: 2px solid transparent; user-select: none;
1164
+ }
1165
+ .tabs .tab:hover { color: var(--text); }
1166
+ .tabs .tab.active { color: var(--accent); border-bottom-color: var(--accent); }
1167
+ .tabs .spacer { flex: 1; }
1168
+ .tabs .devicepick { display: flex; align-items: center; gap: 6px; padding: 6px 0; color: var(--muted); font-size: 12px; }
1169
+
1170
+ .layout { display: flex; height: calc(100vh - 85px); }
1171
+ nav.sidebar {
1172
+ width: 220px; background: var(--panel); border-right: 1px solid var(--border);
1173
+ padding: 12px 0; overflow-y: auto; flex-shrink: 0;
1174
+ }
1175
+ nav.sidebar h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .8px; color: var(--muted); padding: 8px 16px 4px; }
1176
+ nav.sidebar .item {
1177
+ display: flex; justify-content: space-between; align-items: center;
1178
+ padding: 6px 16px; cursor: pointer; border-left: 2px solid transparent; font-size: 13px;
1179
+ }
1180
+ nav.sidebar .item:hover { background: var(--panel-2); }
1181
+ nav.sidebar .item.active { border-left-color: var(--accent); background: var(--panel-2); }
1182
+ nav.sidebar .item .count { color: var(--muted); font-size: 11px; }
1183
+ main { flex: 1; overflow: auto; padding: 16px 20px; }
1184
+
1185
+ .toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
1186
+ .toolbar h2 { font-size: 16px; margin-right: auto; }
1187
+ .toolbar label { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 5px; cursor: pointer; }
1188
+
1189
+ table.grid { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
1190
+ table.grid th, table.grid td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
1191
+ table.grid th { background: var(--panel-2); color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; position: sticky; top: 0; z-index: 1; }
1192
+ table.grid tbody tr.clickable { cursor: pointer; }
1193
+ table.grid tbody tr.clickable:hover { background: var(--panel-2); }
1194
+ tr.tombstone td { color: var(--muted); text-decoration: line-through; }
1195
+
1196
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
1197
+ .badge.insert { background: rgba(72,187,120,.15); color: var(--insert); }
1198
+ .badge.update { background: rgba(66,153,225,.15); color: var(--update); }
1199
+ .badge.delete { background: rgba(245,101,101,.15); color: var(--delete); }
1200
+ .badge.neutral { background: var(--panel-3); color: var(--muted); }
1201
+ .badge.ok { background: var(--ok-bg); color: var(--insert); }
1202
+ .badge.bad { background: var(--bad-bg); color: var(--delete); }
1203
+
1204
+ .chip {
1205
+ display: inline-flex; gap: 6px; align-items: center; background: var(--panel-2);
1206
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; font-size: 11px; color: var(--muted);
1207
+ }
1208
+ .chip b { color: var(--accent-2); font-weight: 600; }
1209
+
1210
+ .pager { display: flex; gap: 8px; align-items: center; margin-top: 10px; color: var(--muted); font-size: 12px; }
1211
+ .empty { color: var(--muted); padding: 40px; text-align: center; }
1212
+ .error { color: var(--delete); padding: 20px; }
1213
+
1214
+ .drawer {
1215
+ position: fixed; top: 85px; right: 0; bottom: 0; width: 560px; max-width: 92vw;
1216
+ background: var(--panel); border-left: 1px solid var(--border); overflow-y: auto;
1217
+ padding: 16px; box-shadow: -12px 0 30px rgba(0,0,0,.45); display: none; z-index: 20;
1218
+ }
1219
+ .drawer.open { display: block; }
1220
+ .drawer h3 { font-size: 14px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
1221
+ .drawer h4 { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; margin: 16px 0 8px; }
1222
+ .drawer .close { cursor: pointer; color: var(--muted); padding: 4px 8px; }
1223
+ .fieldrow { padding: 8px 10px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 6px; }
1224
+ .fieldrow .fname { color: var(--accent); font-size: 12px; }
1225
+ .fieldrow .fval { word-break: break-word; margin: 2px 0 4px; }
1226
+ .fieldrow .preview { color: var(--muted); font-style: italic; }
1227
+
1228
+ .op { border-left: 2px solid var(--border); margin-left: 6px; padding: 6px 0 6px 14px; position: relative; }
1229
+ .op::before { content: ""; position: absolute; left: -5px; top: 13px; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
1230
+ .op.insert::before { background: var(--insert); } .op.update::before { background: var(--update); } .op.delete::before { background: var(--delete); }
1231
+ .op .meta { color: var(--muted); font-size: 11px; }
1232
+ .op pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; margin-top: 4px; overflow-x: auto; }
1233
+
1234
+ .panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 14px; }
1235
+ .panel h3 { font-size: 13px; margin-bottom: 8px; color: var(--accent); }
1236
+
1237
+ /* DAG */
1238
+ .dagwrap { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; overflow-x: auto; margin-bottom: 14px; }
1239
+ .dagwrap svg { display: block; }
1240
+ .dag-node { cursor: pointer; }
1241
+ .dag-node circle { stroke-width: 2; }
1242
+ .dag-node text { fill: var(--muted); font-size: 10px; font-family: ui-monospace, Menlo, monospace; }
1243
+ .dag-edge { stroke: #3a4356; stroke-width: 1.4; fill: none; marker-end: url(#arrow); }
1244
+ .dag-lane-label { fill: var(--accent-2); font-size: 11px; font-family: ui-monospace, Menlo, monospace; }
1245
+ .dag-lane-line { stroke: #1d2330; stroke-width: 1; }
1246
+ .dag-tip {
1247
+ position: fixed; background: var(--panel-3); border: 1px solid var(--border); border-radius: 6px;
1248
+ padding: 6px 10px; font-size: 12px; pointer-events: none; z-index: 50; max-width: 380px; display: none;
1249
+ }
1250
+
1251
+ /* Time travel */
1252
+ .tt-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
1253
+ .tt-controls input[type="range"] { flex: 1; }
1254
+ .tt-cut { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-size: 12px; }
1255
+
1256
+ /* Lab */
1257
+ .lab-top { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
1258
+ .convergence { padding: 8px 14px; border-radius: 8px; font-weight: 600; font-size: 13px; }
1259
+ .convergence.ok { background: var(--ok-bg); color: var(--insert); border: 1px solid rgba(72,187,120,.4); }
1260
+ .convergence.bad { background: var(--bad-bg); color: var(--delete); border: 1px solid rgba(245,101,101,.4); }
1261
+ .convergence .diffs { font-weight: 400; font-size: 11px; margin-top: 4px; color: var(--muted); }
1262
+ .lab-grid { display: flex; gap: 14px; align-items: flex-start; }
1263
+ .lab-devices { flex: 1; display: grid; grid-template-columns: repeat(auto-fill, minmax(390px, 1fr)); gap: 14px; }
1264
+ .device-card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
1265
+ .device-card.offline { border-color: rgba(245,101,101,.35); }
1266
+ .device-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--panel-2); }
1267
+ .device-head .name { font-weight: 700; }
1268
+ .device-head .node { color: var(--muted); font-size: 11px; }
1269
+ .device-head .spacer { flex: 1; }
1270
+ .device-body { padding: 10px 12px; }
1271
+ .device-body h5 { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; margin: 10px 0 6px; }
1272
+ .chaos-row { display: grid; grid-template-columns: 78px 1fr 44px; gap: 6px; align-items: center; font-size: 11px; color: var(--muted); margin-bottom: 3px; }
1273
+ .rec-line { display: flex; align-items: center; gap: 6px; padding: 5px 6px; border-bottom: 1px solid var(--border); font-size: 12px; flex-wrap: wrap; }
1274
+ .rec-line:hover { background: var(--panel-2); }
1275
+ .rec-line .title { flex: 1; min-width: 120px; }
1276
+ .rec-line input[type="text"] { font-size: 12px; padding: 2px 6px; width: 130px; }
1277
+ .rec-line .pts { color: var(--accent); min-width: 30px; text-align: right; }
1278
+ .insert-form { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
1279
+ .insert-form input { flex: 1; min-width: 120px; }
1280
+ .lab-feed { width: 360px; flex-shrink: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; display: flex; flex-direction: column; max-height: calc(100vh - 200px); }
1281
+ .lab-feed h3 { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .6px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
1282
+ .lab-feed .feed { overflow-y: auto; padding: 6px 0; flex: 1; }
1283
+ .feed-line { display: flex; gap: 8px; padding: 3px 12px; font-size: 11px; align-items: baseline; }
1284
+ .feed-line .dev { min-width: 66px; font-weight: 600; }
1285
+ .feed-line .what { color: var(--muted); }
1286
+ .feed-line.evt-created .what, .feed-line.evt-applied .what { color: var(--text); }
1287
+ .feed-line.evt-conflict .what { color: var(--warn); }
1288
+ .feed-line.evt-failed .what { color: var(--delete); font-weight: 700; }
1289
+ .dev-c0 { color: #4fd1c5; } .dev-c1 { color: #9f7aea; } .dev-c2 { color: #f6ad55; }
1290
+ .dev-c3 { color: #63b3ed; } .dev-c4 { color: #f687b3; } .dev-c5 { color: #68d391; }
1291
+ `;
1292
+
1293
+ // src/commands/studio/studio-server.ts
1294
+ var SSE_POLL_MS = 700;
1295
+ async function startStudioServer(options) {
1296
+ const host = options.host ?? "127.0.0.1";
1297
+ const dbPath = options.spectator ? options.spectator.dbPath : options.dbPath;
1298
+ const context = {
1299
+ mode: options.spectator ? "spectator" : options.lab ? "lab" : "file",
1300
+ lab: options.lab ?? null,
1301
+ spectator: options.spectator ?? null,
1302
+ mainReader: dbPath ? await StudioDbReader.open(dbPath) : null,
1303
+ deviceReaders: /* @__PURE__ */ new Map()
1304
+ };
1305
+ const sseClients = /* @__PURE__ */ new Set();
1306
+ const broadcast = (event, data) => {
1307
+ const payload = `event: ${event}
1308
+ data: ${JSON.stringify(data)}
1309
+
1310
+ `;
1311
+ for (const client of sseClients) {
1312
+ client.write(payload);
1313
+ }
1314
+ };
1315
+ let unsubscribeSpectator = null;
1316
+ if (context.spectator) {
1317
+ unsubscribeSpectator = context.spectator.onEvent((event) => {
1318
+ broadcast("spectator", event);
1319
+ broadcast("change", { at: event.at });
1320
+ });
1321
+ }
1322
+ let pollTimer = null;
1323
+ if (context.mode === "file" && context.mainReader) {
1324
+ let lastFingerprint = context.mainReader.fingerprint();
1325
+ pollTimer = setInterval(() => {
1326
+ try {
1327
+ const current = context.mainReader?.fingerprint();
1328
+ if (current !== void 0 && current !== lastFingerprint) {
1329
+ lastFingerprint = current;
1330
+ broadcast("change", { at: Date.now() });
1331
+ }
1332
+ } catch {
1333
+ }
1334
+ }, SSE_POLL_MS);
1335
+ }
1336
+ let unsubscribeLab = null;
1337
+ if (context.lab) {
1338
+ unsubscribeLab = context.lab.onEvent((event) => {
1339
+ broadcast("lab", event);
1340
+ broadcast("change", { at: event.at });
1341
+ });
1342
+ }
1343
+ const server = createServer((req, res) => {
1344
+ handleRequest(context, sseClients, req, res).catch((error) => {
1345
+ const message = error instanceof Error ? error.message : "Internal error";
1346
+ if (!res.headersSent) {
1347
+ sendJson(res, 500, { error: message });
1348
+ } else {
1349
+ res.end();
1350
+ }
1351
+ });
1352
+ });
1353
+ await new Promise((resolve, reject) => {
1354
+ server.once("error", reject);
1355
+ server.listen(options.port, host, () => {
1356
+ server.removeListener("error", reject);
1357
+ resolve();
1358
+ });
1359
+ });
1360
+ const address = server.address();
1361
+ const port = typeof address === "object" && address !== null ? address.port : options.port;
1362
+ return {
1363
+ server,
1364
+ port,
1365
+ url: `http://${host}:${port}`,
1366
+ close: async () => {
1367
+ if (pollTimer) {
1368
+ clearInterval(pollTimer);
1369
+ }
1370
+ unsubscribeSpectator?.();
1371
+ unsubscribeLab?.();
1372
+ for (const client of sseClients) {
1373
+ client.end();
1374
+ }
1375
+ sseClients.clear();
1376
+ context.mainReader?.close();
1377
+ for (const [, reader] of context.deviceReaders) {
1378
+ reader.close();
1379
+ }
1380
+ context.deviceReaders.clear();
1381
+ await new Promise((resolve, reject) => {
1382
+ server.close((error) => error ? reject(error) : resolve());
1383
+ });
1384
+ }
1385
+ };
1386
+ }
1387
+ async function resolveReader(context, url) {
1388
+ const device = url.searchParams.get("device");
1389
+ if (device && context.lab) {
1390
+ const cached = context.deviceReaders.get(device);
1391
+ if (cached) {
1392
+ return cached;
1393
+ }
1394
+ const state = context.lab.deviceState(device);
1395
+ const reader = await StudioDbReader.open(state.dbPath);
1396
+ context.deviceReaders.set(device, reader);
1397
+ return reader;
1398
+ }
1399
+ if (context.mainReader) {
1400
+ return context.mainReader;
1401
+ }
1402
+ if (context.lab) {
1403
+ const first = context.lab.listDevices()[0];
1404
+ if (first) {
1405
+ return resolveReader(context, new URL(`${url.origin}${url.pathname}?device=${first.name}`));
1406
+ }
1407
+ }
1408
+ throw new Error("No database available");
1409
+ }
1410
+ async function handleRequest(context, sseClients, req, res) {
1411
+ const url = new URL(req.url ?? "/", "http://studio.local");
1412
+ const path = url.pathname;
1413
+ const method = req.method ?? "GET";
1414
+ if (method === "GET" && (path === "/" || path === "/index.html")) {
1415
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1416
+ res.end(STUDIO_HTML);
1417
+ return;
1418
+ }
1419
+ if (method === "GET" && path === "/app.js") {
1420
+ res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
1421
+ res.end(STUDIO_APP_JS);
1422
+ return;
1423
+ }
1424
+ if (method === "GET" && path === "/style.css") {
1425
+ res.writeHead(200, { "content-type": "text/css; charset=utf-8" });
1426
+ res.end(STUDIO_CSS);
1427
+ return;
1428
+ }
1429
+ if (method === "GET" && path === "/api/events") {
1430
+ res.writeHead(200, {
1431
+ "content-type": "text/event-stream",
1432
+ "cache-control": "no-store",
1433
+ connection: "keep-alive"
1434
+ });
1435
+ res.write(`event: hello
1436
+ data: ${JSON.stringify({ mode: context.mode })}
1437
+
1438
+ `);
1439
+ if (context.lab) {
1440
+ for (const event of context.lab.recentEvents().slice(-100)) {
1441
+ res.write(`event: lab
1442
+ data: ${JSON.stringify(event)}
1443
+
1444
+ `);
1445
+ }
1446
+ }
1447
+ if (context.spectator) {
1448
+ for (const event of context.spectator.recentEvents().slice(-100)) {
1449
+ res.write(`event: spectator
1450
+ data: ${JSON.stringify(event)}
1451
+
1452
+ `);
1453
+ }
1454
+ }
1455
+ sseClients.add(res);
1456
+ req.on("close", () => {
1457
+ sseClients.delete(res);
1458
+ });
1459
+ return;
1460
+ }
1461
+ if (path.startsWith("/api/lab")) {
1462
+ if (!context.lab) {
1463
+ sendJson(res, 404, {
1464
+ error: "Studio is not running in lab mode. Start with: kora studio --lab"
1465
+ });
1466
+ return;
1467
+ }
1468
+ await handleLabRoute(context, context.lab, req, res, url);
1469
+ return;
1470
+ }
1471
+ if (method !== "GET") {
1472
+ sendJson(res, 405, { error: "Studio data routes are read-only: GET only." });
1473
+ return;
1474
+ }
1475
+ if (path === "/api/mode") {
1476
+ sendJson(res, 200, {
1477
+ mode: context.mode,
1478
+ devices: context.lab?.listDevices().map((d) => d.name) ?? [],
1479
+ ...context.spectator ? { spectator: context.spectator.status() } : {}
1480
+ });
1481
+ return;
1482
+ }
1483
+ if (path === "/api/spectator/status") {
1484
+ if (!context.spectator) {
1485
+ sendJson(res, 404, { error: "Not in spectator mode" });
1486
+ return;
1487
+ }
1488
+ sendJson(res, 200, context.spectator.status());
1489
+ return;
1490
+ }
1491
+ if (path === "/api/overview") {
1492
+ const reader = await resolveReader(context, url);
1493
+ sendJson(res, 200, reader.overview());
1494
+ return;
1495
+ }
1496
+ if (path === "/api/audit") {
1497
+ const reader = await resolveReader(context, url);
1498
+ sendJson(res, 200, { traces: reader.auditTraces(intParam(url, "limit") ?? 100) });
1499
+ return;
1500
+ }
1501
+ const collectionMatch = path.match(
1502
+ /^\/api\/collections\/([a-zA-Z0-9_]+)\/(records|ops|replay|dag)(?:\/([^/]+))?(?:\/(ops))?$/
1503
+ );
1504
+ if (collectionMatch) {
1505
+ const [, collection, kind, recordId, sub] = collectionMatch;
1506
+ if (!collection || !kind) {
1507
+ sendJson(res, 400, { error: "Bad request" });
1508
+ return;
1509
+ }
1510
+ const reader = await resolveReader(context, url);
1511
+ if (!reader.listCollections().includes(collection)) {
1512
+ sendJson(res, 404, { error: `Unknown collection "${collection}"` });
1513
+ return;
1514
+ }
1515
+ if (kind === "replay") {
1516
+ const ops = reader.allOperations(collection);
1517
+ const upTo = url.searchParams.get("upTo");
1518
+ sendJson(res, 200, replayToOperation(ops, upTo));
1519
+ return;
1520
+ }
1521
+ if (kind === "dag") {
1522
+ const record = url.searchParams.get("record");
1523
+ const limit = intParam(url, "limit") ?? 200;
1524
+ let ops = reader.allOperations(collection);
1525
+ if (record) {
1526
+ ops = ops.filter((o) => o.recordId === record);
1527
+ } else {
1528
+ ops = ops.slice(-limit);
1529
+ }
1530
+ sendJson(res, 200, buildCausalDag(ops));
1531
+ return;
1532
+ }
1533
+ if (kind === "ops") {
1534
+ sendJson(
1535
+ res,
1536
+ 200,
1537
+ reader.operations(collection, {
1538
+ limit: intParam(url, "limit"),
1539
+ offset: intParam(url, "offset")
1540
+ })
1541
+ );
1542
+ return;
1543
+ }
1544
+ if (recordId && sub === "ops") {
1545
+ sendJson(res, 200, { operations: reader.recordOperations(collection, recordId) });
1546
+ return;
1547
+ }
1548
+ if (recordId) {
1549
+ const record = reader.record(collection, recordId);
1550
+ if (!record) {
1551
+ sendJson(res, 404, { error: `Record "${recordId}" not found` });
1552
+ return;
1553
+ }
1554
+ const richtextPreviews = await decodeRichtextFields(
1555
+ reader,
1556
+ collection,
1557
+ recordId,
1558
+ record.fields
1559
+ );
1560
+ sendJson(res, 200, {
1561
+ record,
1562
+ richtextPreviews,
1563
+ operations: reader.recordOperations(collection, recordId)
1564
+ });
1565
+ return;
1566
+ }
1567
+ sendJson(
1568
+ res,
1569
+ 200,
1570
+ reader.records(collection, {
1571
+ limit: intParam(url, "limit"),
1572
+ offset: intParam(url, "offset"),
1573
+ includeDeleted: url.searchParams.get("includeDeleted") === "true",
1574
+ search: url.searchParams.get("search") ?? void 0
1575
+ })
1576
+ );
1577
+ return;
1578
+ }
1579
+ sendJson(res, 404, { error: `No route for ${path}` });
1580
+ }
1581
+ async function handleLabRoute(context, lab, req, res, url) {
1582
+ const path = url.pathname;
1583
+ const method = req.method ?? "GET";
1584
+ if (method === "GET" && path === "/api/lab/state") {
1585
+ const schema = lab.getSchema();
1586
+ sendJson(res, 200, {
1587
+ devices: lab.listDevices(),
1588
+ serverOperations: lab.serverOperationCount(),
1589
+ collections: Object.entries(schema.collections).map(([name, def]) => ({
1590
+ name,
1591
+ fields: Object.entries(def.fields).map(([fieldName, descriptor]) => ({
1592
+ name: fieldName,
1593
+ kind: descriptor.kind,
1594
+ optional: !descriptor.required,
1595
+ enumValues: descriptor.enumValues ?? null,
1596
+ defaultValue: descriptor.defaultValue ?? null
1597
+ }))
1598
+ }))
1599
+ });
1600
+ return;
1601
+ }
1602
+ if (method === "GET" && path === "/api/lab/convergence") {
1603
+ sendJson(res, 200, await lab.convergence());
1604
+ return;
1605
+ }
1606
+ if (method !== "POST") {
1607
+ sendJson(res, 405, { error: "POST required" });
1608
+ return;
1609
+ }
1610
+ const body = await readJsonBody(req);
1611
+ if (path === "/api/lab/devices") {
1612
+ const state = await lab.addDevice(typeof body.name === "string" ? body.name : void 0);
1613
+ sendJson(res, 200, state);
1614
+ return;
1615
+ }
1616
+ const deviceMatch = path.match(
1617
+ /^\/api\/lab\/devices\/([a-zA-Z0-9_-]+)\/(connect|disconnect|sync|chaos|insert|update|delete)$/
1618
+ );
1619
+ if (!deviceMatch) {
1620
+ sendJson(res, 404, { error: `No lab route for ${path}` });
1621
+ return;
1622
+ }
1623
+ const [, device, action] = deviceMatch;
1624
+ if (!device || !action) {
1625
+ sendJson(res, 400, { error: "Bad request" });
1626
+ return;
1627
+ }
1628
+ switch (action) {
1629
+ case "connect":
1630
+ await lab.connect(device);
1631
+ break;
1632
+ case "disconnect": {
1633
+ await lab.disconnect(device);
1634
+ break;
1635
+ }
1636
+ case "sync":
1637
+ await lab.sync(device);
1638
+ break;
1639
+ case "chaos":
1640
+ sendJson(
1641
+ res,
1642
+ 200,
1643
+ lab.setChaos(device, body)
1644
+ );
1645
+ return;
1646
+ case "insert": {
1647
+ const record = await lab.insert(
1648
+ device,
1649
+ String(body.collection ?? ""),
1650
+ body.data ?? {}
1651
+ );
1652
+ sendJson(res, 200, { record });
1653
+ return;
1654
+ }
1655
+ case "update": {
1656
+ const record = await lab.update(
1657
+ device,
1658
+ String(body.collection ?? ""),
1659
+ String(body.id ?? ""),
1660
+ body.data ?? {},
1661
+ body.increments ?? void 0
1662
+ );
1663
+ sendJson(res, 200, { record });
1664
+ return;
1665
+ }
1666
+ case "delete":
1667
+ await lab.delete(device, String(body.collection ?? ""), String(body.id ?? ""));
1668
+ break;
1669
+ }
1670
+ sendJson(res, 200, { ok: true, device: lab.deviceState(device) });
1671
+ }
1672
+ async function decodeRichtextFields(reader, collection, recordId, fields) {
1673
+ const previews = {};
1674
+ const binaryFields = Object.entries(fields).filter(
1675
+ ([, value]) => typeof value === "string" && value.startsWith("<binary ")
1676
+ );
1677
+ if (binaryFields.length === 0) {
1678
+ return previews;
1679
+ }
1680
+ let toPlainText = null;
1681
+ try {
1682
+ const storePkg = await import("@korajs/store");
1683
+ toPlainText = storePkg.richtextToPlainText ?? null;
1684
+ } catch {
1685
+ return previews;
1686
+ }
1687
+ if (!toPlainText) {
1688
+ return previews;
1689
+ }
1690
+ for (const [field] of binaryFields) {
1691
+ try {
1692
+ const raw = reader.rawFieldValue(collection, recordId, field);
1693
+ if (raw instanceof Uint8Array || Buffer.isBuffer(raw)) {
1694
+ previews[field] = toPlainText(new Uint8Array(raw));
1695
+ }
1696
+ } catch {
1697
+ }
1698
+ }
1699
+ return previews;
1700
+ }
1701
+ async function readJsonBody(req) {
1702
+ const chunks = [];
1703
+ let size = 0;
1704
+ for await (const chunk of req) {
1705
+ size += chunk.length;
1706
+ if (size > 256 * 1024) {
1707
+ throw new Error("Request body too large");
1708
+ }
1709
+ chunks.push(chunk);
1710
+ }
1711
+ if (chunks.length === 0) {
1712
+ return {};
1713
+ }
1714
+ try {
1715
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
1716
+ } catch {
1717
+ throw new Error("Invalid JSON body");
1718
+ }
1719
+ }
1720
+ function intParam(url, name) {
1721
+ const raw = url.searchParams.get(name);
1722
+ if (raw === null) {
1723
+ return void 0;
1724
+ }
1725
+ const parsed = Number.parseInt(raw, 10);
1726
+ return Number.isNaN(parsed) ? void 0 : parsed;
1727
+ }
1728
+ function sendJson(res, status, body) {
1729
+ res.writeHead(status, {
1730
+ "content-type": "application/json; charset=utf-8",
1731
+ "cache-control": "no-store"
1732
+ });
1733
+ res.end(JSON.stringify(body));
1734
+ }
1735
+ export {
1736
+ startStudioServer
1737
+ };
1738
+ //# sourceMappingURL=studio-server-NIFKZI4F.js.map