@spooky-sync/core 0.0.1-canary.290 → 0.0.1-canary.292

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/dist/index.js CHANGED
@@ -1290,350 +1290,705 @@ var WebPushModule = class {
1290
1290
  };
1291
1291
 
1292
1292
  //#endregion
1293
- //#region src/events/index.ts
1293
+ //#region src/mutation/rows.ts
1294
+ const PENDING_TABLE = "_00_pending_mutations";
1295
+ const FAILED_TABLE = "_00_failed_mutations";
1296
+ /** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
1297
+ const PENDING_WRITES_TABLE = "_00_pending_writes";
1298
+ const PENDING_ROW_VERSION = 2;
1294
1299
  /**
1295
- * A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
1296
- * @template E The EventTypeMap defining all supported events.
1300
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
1301
+ * escaping (outbox ids contain `_`, so they come back escaped).
1297
1302
  */
1298
- var EventSystem = class {
1299
- subscriberId = 0;
1300
- isProcessing = false;
1301
- buffer;
1302
- subscribers;
1303
- subscribersTypeMap;
1304
- lastEvents;
1305
- debouncedEvents;
1306
- constructor(_eventTypes) {
1307
- this._eventTypes = _eventTypes;
1308
- this.buffer = [];
1309
- this.subscribers = this._eventTypes.reduce((acc, key) => {
1310
- return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
1311
- }, {});
1312
- this.lastEvents = {};
1313
- this.subscribersTypeMap = /* @__PURE__ */ new Map();
1314
- this.debouncedEvents = /* @__PURE__ */ new Map();
1315
- }
1316
- get eventTypes() {
1317
- return this._eventTypes;
1318
- }
1319
- /**
1320
- * Subscribes a handler to a specific event type.
1321
- * @param type The event type to subscribe to.
1322
- * @param handler The function to call when the event occurs.
1323
- * @param options Subscription options (once, immediately).
1324
- * @returns A subscription ID that can be used to unsubscribe.
1325
- */
1326
- subscribe(type, handler, options) {
1327
- const id = this.subscriberId++;
1328
- this.subscribers[type].set(id, {
1329
- id,
1330
- handler,
1331
- once: options?.once ?? false
1332
- });
1333
- this.subscribersTypeMap.set(id, type);
1334
- if (options?.immediately) {
1335
- const lastEvent = this.lastEvents[type];
1336
- if (lastEvent) handler(lastEvent);
1337
- }
1338
- return id;
1339
- }
1340
- /**
1341
- * Subscribes a handler to multiple event types.
1342
- * @param types An array of event types to subscribe to.
1343
- * @param handler The function to call when any of the events occur.
1344
- * @param options Subscription options.
1345
- * @returns An array of subscription IDs.
1346
- */
1347
- subscribeMany(types, handler, options) {
1348
- return types.map((type) => this.subscribe(type, handler, options));
1349
- }
1350
- /**
1351
- * Unsubscribes a specific subscription by ID.
1352
- * @param id The subscription ID returned by subscribe().
1353
- * @returns True if the subscription was found and removed, false otherwise.
1354
- */
1355
- unsubscribe(id) {
1356
- const type = this.subscribersTypeMap.get(id);
1357
- if (type) {
1358
- this.subscribers[type].delete(id);
1359
- this.subscribersTypeMap.delete(id);
1360
- return true;
1361
- }
1362
- return false;
1363
- }
1364
- /**
1365
- * Emits an event with the given type and payload.
1366
- * @param type The type of event to emit.
1367
- * @param payload The data associated with the event.
1368
- */
1369
- emit(type, payload) {
1370
- const event = {
1371
- type,
1372
- payload
1373
- };
1374
- this.addEvent(event);
1375
- }
1376
- /**
1377
- * Adds a fully constructed event object to the system.
1378
- * Similar to emit, but takes the full event object directly.
1379
- * Supports debouncing if options are provided.
1380
- * @param event The event object.
1381
- * @param options Options for the event push (e.g., debouncing).
1382
- */
1383
- addEvent(event, options) {
1384
- if (options?.debounced) {
1385
- this.handleDebouncedEvent(event, options.debounced.key, options.debounced.delay);
1386
- return;
1387
- }
1388
- this.buffer.push(event);
1389
- this.scheduleProcessing();
1390
- }
1391
- handleDebouncedEvent(event, key, delay) {
1392
- if (this.debouncedEvents.has(key)) clearTimeout(this.debouncedEvents.get(key)?.timer);
1393
- const timer = setTimeout(() => {
1394
- this.debouncedEvents.delete(key);
1395
- this.buffer.push(event);
1396
- this.scheduleProcessing();
1397
- }, delay);
1398
- this.debouncedEvents.set(key, {
1399
- timer,
1400
- resolve: () => {}
1401
- });
1402
- }
1403
- scheduleProcessing() {
1404
- if (!this.isProcessing) queueMicrotask(() => this.processEvents());
1405
- }
1406
- async processEvents() {
1407
- if (this.isProcessing) return;
1408
- this.isProcessing = true;
1409
- try {
1410
- while (this.dequeue());
1411
- } finally {
1412
- this.isProcessing = false;
1413
- }
1414
- }
1415
- dequeue() {
1416
- const event = this.buffer.shift();
1417
- if (!event) return false;
1418
- this.setLastEvent(event.type, event);
1419
- this.broadcastEvent(event.type, event);
1420
- return true;
1421
- }
1422
- setLastEvent(type, event) {
1423
- this.lastEvents[type] = event;
1424
- }
1425
- broadcastEvent(type, event) {
1426
- const subscribers = this.subscribers[type].values();
1427
- for (const subscriber of subscribers) {
1428
- subscriber.handler(event);
1429
- if (subscriber.once) this.unsubscribe(subscriber.id);
1430
- }
1431
- }
1432
- };
1433
- function createEventSystem(eventTypes) {
1434
- return new EventSystem(eventTypes);
1303
+ function parseStoredRecordId(id) {
1304
+ const [table, ...rest] = id.split(":");
1305
+ let raw = rest.join(":");
1306
+ if (raw.startsWith("⟨") && raw.endsWith("⟩")) raw = raw.slice(1, -1);
1307
+ return new RecordId$1(table, raw);
1435
1308
  }
1436
-
1437
- //#endregion
1438
- //#region src/modules/auth/events/index.ts
1439
- const AuthEventTypes = {
1440
- AuthStateChanged: "AUTH_STATE_CHANGED",
1441
- ImpersonationChanged: "IMPERSONATION_CHANGED"
1309
+ /** Stable `table:id` string for an id read from the store, escaping removed. */
1310
+ const storedIdString = (id) => {
1311
+ if (id instanceof RecordId$1) return `${id.table}:${String(id.id)}`;
1312
+ const rid = parseStoredRecordId(String(id));
1313
+ return `${rid.table}:${rid.id}`;
1442
1314
  };
1443
- function createAuthEventSystem() {
1444
- return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
1445
- }
1446
-
1447
- //#endregion
1448
- //#region src/modules/devtools/versions.ts
1449
- const UNAVAILABLE = "unavailable";
1450
- function emptyBackendVersions() {
1451
- return {
1452
- ssp: UNAVAILABLE,
1453
- scheduler: UNAVAILABLE,
1454
- surrealdb: UNAVAILABLE
1455
- };
1456
- }
1457
- function emptyBackendInfo() {
1458
- return {
1459
- versions: emptyBackendVersions(),
1460
- entities: []
1461
- };
1462
- }
1463
- /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
1464
- function normalizeServerVersion(v) {
1465
- return String(v).replace(/^surrealdb-/i, "").trim();
1466
- }
1467
- /**
1468
- * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
1469
- * array. The SurrealQL function returns the parsed `/info` array; depending on
1470
- * how the result is unwrapped it may arrive as the array itself, a single
1471
- * object, or `null`. Tolerant of all three.
1472
- */
1473
- function toEntityArray(raw) {
1474
- if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
1475
- if (raw && typeof raw === "object") return [raw];
1476
- return [];
1315
+ /** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
1316
+ function createdAtFromId(id) {
1317
+ const raw = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id;
1318
+ const m = /^⟨?(\d{13})/.exec(raw);
1319
+ return m ? Number(m[1]) : null;
1477
1320
  }
1478
1321
  /**
1479
- * Derive component versions + the full entity list from a `/info` entity array.
1480
- * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
1481
- * scheduler). Never throws; missing pieces stay `'unavailable'`.
1322
+ * Read any row shape this table ever had. Legacy rows (`v` missing) lack
1323
+ * `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
1324
+ * replayable and returns `null`.
1482
1325
  */
1483
- function parseBackendInfo(raw) {
1484
- const entities = toEntityArray(raw);
1485
- const versions = emptyBackendVersions();
1486
- for (const entity of entities) {
1487
- const version = entity.version ? String(entity.version) : void 0;
1488
- if (entity.entity === "ssp" && version) versions.ssp = version;
1489
- else if (entity.entity === "scheduler" && version) versions.scheduler = version;
1490
- if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
1491
- }
1326
+ function parsePendingRow(row) {
1327
+ if (!row || typeof row !== "object") return null;
1328
+ const r = row;
1329
+ const type = r.mutationType;
1330
+ if (type !== "create" && type !== "update" && type !== "delete") return null;
1331
+ if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
1332
+ const id = storedIdString(r.id);
1333
+ const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
1334
+ const data = r.data && typeof r.data === "object" ? r.data : void 0;
1335
+ if (type === "create" && !data) return null;
1492
1336
  return {
1493
- versions,
1494
- entities
1337
+ id,
1338
+ mutationType: type,
1339
+ recordId,
1340
+ tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
1341
+ data,
1342
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : void 0,
1343
+ createdAt: typeof r.createdAt === "number" ? r.createdAt : createdAtFromId(id) ?? 0,
1344
+ v: typeof r.v === "number" ? r.v : 1
1495
1345
  };
1496
1346
  }
1497
-
1498
- //#endregion
1499
- //#region src/modules/devtools/storage-info.ts
1500
1347
  /**
1501
- * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
1502
- * which throws for a file another context holds an exclusive sync access
1503
- * handle on — SAHPool does exactly that for its whole pool, so a locked file
1504
- * (size omitted) is a live "who has the pool" signal, not a failure.
1348
+ * Re-type a stored row's payload from the schema before it is pushed. The
1349
+ * store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
1350
+ * datetime as a string; the server coerces neither. Unknown keys stay as they
1351
+ * are, and a payload the schema rejects is sent raw so the server's answer,
1352
+ * not a local throw, decides its fate.
1505
1353
  */
1506
- async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
1507
- const nav = typeof navigator !== "undefined" ? navigator : void 0;
1508
- if (!nav?.storage?.getDirectory) return {
1509
- supported: false,
1510
- entries: [],
1511
- totalBytes: 0,
1512
- truncated: false
1513
- };
1514
- const entries = [];
1515
- let totalBytes = 0;
1516
- let truncated = false;
1354
+ function hydrateRowData(row, columns) {
1355
+ if (!row.data || !columns) return row;
1517
1356
  try {
1518
- const root = await nav.storage.getDirectory();
1519
- const walk = async (dir, prefix, depth) => {
1520
- if (depth > maxDepth) return;
1521
- for await (const [name, handle] of dir.entries()) {
1522
- if (entries.length >= maxEntries) {
1523
- truncated = true;
1524
- return;
1525
- }
1526
- const path = prefix ? `${prefix}/${name}` : name;
1527
- if (handle.kind === "directory") {
1528
- entries.push({
1529
- path,
1530
- kind: "directory"
1531
- });
1532
- await walk(handle, path, depth + 1);
1533
- } else {
1534
- let size;
1535
- try {
1536
- size = (await handle.getFile()).size;
1537
- totalBytes += size;
1538
- } catch {}
1539
- const entry = {
1540
- path,
1541
- kind: "file"
1542
- };
1543
- if (size !== void 0) entry.size = size;
1544
- entries.push(entry);
1545
- }
1546
- }
1547
- };
1548
- await walk(root, "", 0);
1549
- entries.sort((a, b) => a.path.localeCompare(b.path));
1550
- return {
1551
- supported: true,
1552
- entries,
1553
- totalBytes,
1554
- truncated
1555
- };
1556
- } catch (e) {
1557
1357
  return {
1558
- supported: true,
1559
- entries,
1560
- totalBytes,
1561
- truncated,
1562
- error: e instanceof Error ? e.message : String(e)
1358
+ ...row,
1359
+ data: parseQueryParams(columns, row.data)
1563
1360
  };
1361
+ } catch {
1362
+ return row;
1564
1363
  }
1565
1364
  }
1566
-
1567
- //#endregion
1568
- //#region src/modules/devtools/flags.ts
1569
- /**
1570
- * SurrealDB's SDK returns one entry per statement; each entry is the rows for
1571
- * that statement, but older/local shapes wrap them as `{ status, result }`.
1572
- * `getTableData` in `index.ts` unwraps the same three shapes inline — this is
1573
- * that logic, reusable.
1574
- */
1575
- function statementRows(result, index = 0) {
1576
- if (!Array.isArray(result)) return [];
1577
- const entry = result[index];
1578
- if (Array.isArray(entry)) return entry;
1579
- if (entry && typeof entry === "object" && "result" in entry) {
1580
- const inner = entry.result;
1581
- return Array.isArray(inner) ? inner : inner === void 0 || inner === null ? [] : [inner];
1582
- }
1583
- return entry === void 0 || entry === null ? [] : [entry];
1584
- }
1585
- function message(err) {
1586
- return err instanceof Error ? err.message : String(err);
1365
+ const toOutboxItem = (row) => ({
1366
+ id: row.id,
1367
+ type: row.mutationType,
1368
+ recordId: row.recordId,
1369
+ table: row.tableName,
1370
+ status: "pending",
1371
+ ackedAt: null,
1372
+ attempts: 0
1373
+ });
1374
+ /** The `_00_pending_writes` id for a minted mutation id (same suffix). */
1375
+ const pendingWriteId = (mutationId) => `${PENDING_WRITES_TABLE}:${mutationId.includes(":") ? mutationId.slice(mutationId.indexOf(":") + 1) : mutationId}`;
1376
+ function parsePendingWriteRow(row) {
1377
+ if (!row || typeof row !== "object") return null;
1378
+ const r = row;
1379
+ if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
1380
+ if (!r.data || typeof r.data !== "object") return null;
1381
+ const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
1382
+ return {
1383
+ id: storedIdString(r.id),
1384
+ recordId,
1385
+ tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
1386
+ data: r.data,
1387
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
1388
+ flushBy: typeof r.flushBy === "number" ? r.flushBy : 0
1389
+ };
1587
1390
  }
1588
- /** Row shape guards. `key` is the only field the panel truly can't do without. */
1589
- function hasStringKey(row) {
1590
- return !!row && typeof row === "object" && typeof row.key === "string";
1391
+ const loadPendingRows = () => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
1392
+ const loadPendingWriteRows = () => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
1393
+ const loadFailedRows = () => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
1394
+ const pendingInsert = (type, withData, withBefore) => {
1395
+ const fields = [
1396
+ `mutationType = '${type}'`,
1397
+ "recordId = $id",
1398
+ "tableName = $table",
1399
+ "createdAt = $createdAt",
1400
+ `v = ${PENDING_ROW_VERSION}`
1401
+ ];
1402
+ if (withData) fields.push("data = $data");
1403
+ if (withBefore) fields.push("beforeRecord = $before");
1404
+ return `CREATE ONLY $mid SET ${fields.join(", ")}`;
1405
+ };
1406
+ /** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
1407
+ function planCreateTx(input) {
1408
+ const data = input.data ?? {};
1409
+ const keys = Object.keys(data).map((key) => ({
1410
+ key,
1411
+ variable: `data_${key}`
1412
+ }));
1413
+ const vars = {
1414
+ id: input.recordId,
1415
+ mid: input.mutationId,
1416
+ table: input.table,
1417
+ createdAt: input.now,
1418
+ data
1419
+ };
1420
+ for (const { key, variable } of keys) vars[variable] = data[key];
1421
+ return {
1422
+ query: surql.seal(surql.tx([surql.createSet("id", keys), pendingInsert("create", true, false)]), { resultIndex: 0 }),
1423
+ vars
1424
+ };
1591
1425
  }
1592
- function isAssignment(row) {
1593
- return hasStringKey(row);
1426
+ /** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
1427
+ function planUpdateTx(input) {
1428
+ return {
1429
+ query: surql.seal(surql.tx([
1430
+ surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
1431
+ surql.let("updated", surql.updateMerge("id", "data")),
1432
+ pendingInsert("update", true, true),
1433
+ surql.returnObject([{
1434
+ key: "target",
1435
+ variable: "updated"
1436
+ }])
1437
+ ])),
1438
+ vars: {
1439
+ id: input.recordId,
1440
+ mid: input.mutationId,
1441
+ table: input.table,
1442
+ createdAt: input.now,
1443
+ data: input.data ?? {},
1444
+ before: input.before ?? null
1445
+ }
1446
+ };
1594
1447
  }
1595
- function isFlagRow(row) {
1596
- return hasStringKey(row);
1448
+ /** DELETE the row and write the outbox entry (with `beforeRecord`). */
1449
+ function planDeleteTx(input) {
1450
+ return {
1451
+ query: surql.seal(surql.tx([surql.delete("id"), pendingInsert("delete", false, true)])),
1452
+ vars: {
1453
+ id: input.recordId,
1454
+ mid: input.mutationId,
1455
+ table: input.table,
1456
+ createdAt: input.now,
1457
+ before: input.before ?? null
1458
+ }
1459
+ };
1597
1460
  }
1598
- var FlagsAdminService = class {
1599
- constructor(deps) {
1600
- this.deps = deps;
1601
- }
1602
- /**
1603
- * Everything the Access tab renders, in one round trip per source.
1604
- *
1605
- * Each section fails independently: a remote read that throws downgrades to
1606
- * `isAdmin: false` plus an `error`, while local assignments and overrides
1607
- * still render. Signing out must not blank the whole tab.
1608
- */
1609
- async getFlags() {
1610
- const userId = this.deps.currentUserId();
1611
- const snapshot = {
1612
- at: Date.now(),
1613
- userId,
1614
- isAdmin: false,
1615
- flags: [],
1616
- assignments: [],
1617
- overrides: this.deps.overrides()?.getLocalOverrides() ?? {}
1618
- };
1619
- try {
1620
- snapshot.assignments = statementRows(await this.deps.local.query("SELECT key, variant, payload FROM _00_user_feature")).filter(isAssignment).map((r) => ({
1621
- key: r.key,
1622
- variant: r.variant,
1623
- payload: r.payload
1624
- }));
1625
- } catch (err) {
1626
- this.deps.logger.debug({
1627
- err,
1628
- Category: "sp00ky-client::FlagsAdminService::getFlags"
1629
- }, "Local feature assignments unavailable");
1461
+ /**
1462
+ * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
1463
+ * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
1464
+ * in the same transaction, so the row never changes without a durable copy of
1465
+ * what still has to reach the server.
1466
+ */
1467
+ function planLocalOnlyUpdateTx(input) {
1468
+ const statements = [surql.updateSet("id", [{ statement: "_00_rv += 1" }]), surql.let("updated", surql.updateMerge("id", "data"))];
1469
+ if (input.mirror) statements.push(surql.upsert("wid", "wrow"));
1470
+ statements.push(surql.returnObject([{
1471
+ key: "target",
1472
+ variable: "updated"
1473
+ }]));
1474
+ return {
1475
+ query: surql.seal(surql.tx(statements)),
1476
+ vars: input.mirror ? {
1477
+ id: input.recordId,
1478
+ data: input.data,
1479
+ wid: input.mirror.id,
1480
+ wrow: input.mirror.row
1481
+ } : {
1482
+ id: input.recordId,
1483
+ data: input.data
1630
1484
  }
1631
- if (!userId) return snapshot;
1632
- try {
1633
- snapshot.isAdmin = statementRows(await this.deps.remote.query("SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1")).length > 0;
1634
- } catch (err) {
1635
- snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
1636
- return snapshot;
1485
+ };
1486
+ }
1487
+ /**
1488
+ * The outbox row of a flushed debounced update. With `mirrorId` the
1489
+ * `_00_pending_writes` row goes in the same transaction: the patch moves
1490
+ * from one table to the other, it is never in both or in neither.
1491
+ */
1492
+ function planDeferredOutboxRowTx(input) {
1493
+ const statements = [pendingInsert("update", true, true)];
1494
+ if (input.mirrorId) statements.push(surql.delete("wid"));
1495
+ return {
1496
+ query: surql.seal(surql.tx(statements)),
1497
+ vars: {
1498
+ id: input.recordId,
1499
+ mid: input.mutationId,
1500
+ table: input.table,
1501
+ createdAt: input.now,
1502
+ data: input.data ?? {},
1503
+ before: input.before ?? null,
1504
+ ...input.mirrorId ? { wid: input.mirrorId } : {}
1505
+ }
1506
+ };
1507
+ }
1508
+ /**
1509
+ * Many outbox rows as ONE multi-statement request. Statement i answers row i.
1510
+ * Each statement is its own transaction on the server, as before.
1511
+ */
1512
+ function remoteBatch(rows) {
1513
+ const vars = {};
1514
+ return {
1515
+ sql: rows.map((row, i) => {
1516
+ const rid = parseRecordIdString(row.recordId);
1517
+ vars[`id${i}`] = rid;
1518
+ switch (row.mutationType) {
1519
+ case "create": {
1520
+ const data = row.data ?? {};
1521
+ return `CREATE ONLY $id${i} SET ${Object.keys(data).map((key) => {
1522
+ vars[`d${i}_${key}`] = data[key];
1523
+ return `${key} = $d${i}_${key}`;
1524
+ }).join(", ")}`;
1525
+ }
1526
+ case "update":
1527
+ vars[`data${i}`] = row.data ?? {};
1528
+ return `UPDATE $id${i} MERGE $data${i}`;
1529
+ case "delete": return `DELETE $id${i}`;
1530
+ }
1531
+ }).join(";\n"),
1532
+ vars
1533
+ };
1534
+ }
1535
+ /**
1536
+ * How to undo a rejected mutation locally. A create is deleted; an update or
1537
+ * delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
1538
+ * comes back). Without a `beforeRecord` nothing can be restored.
1539
+ */
1540
+ function planRevert(row, before) {
1541
+ const rid = parseRecordIdString(row.recordId);
1542
+ if (row.mutationType === "create") return {
1543
+ tx: {
1544
+ query: surql.seal(surql.tx([surql.delete("id")])),
1545
+ vars: { id: rid }
1546
+ },
1547
+ circuit: {
1548
+ table: row.tableName,
1549
+ op: "DELETE",
1550
+ id: row.recordId,
1551
+ record: row.data ?? {}
1552
+ },
1553
+ revert: "full"
1554
+ };
1555
+ if (!before) return {
1556
+ tx: null,
1557
+ circuit: {
1558
+ table: row.tableName,
1559
+ op: "UPDATE",
1560
+ id: row.recordId,
1561
+ record: {}
1562
+ },
1563
+ revert: "partial"
1564
+ };
1565
+ const { id: _id, ...content } = before;
1566
+ return {
1567
+ tx: {
1568
+ query: surql.seal(surql.tx([surql.upsert("id", "content")])),
1569
+ vars: {
1570
+ id: rid,
1571
+ content
1572
+ }
1573
+ },
1574
+ circuit: {
1575
+ table: row.tableName,
1576
+ op: row.mutationType === "delete" ? "CREATE" : "UPDATE",
1577
+ id: row.recordId,
1578
+ record: {
1579
+ ...before,
1580
+ id: rid
1581
+ }
1582
+ },
1583
+ revert: "full"
1584
+ };
1585
+ }
1586
+ const failedRecordId = (mutationId) => new RecordId$1(FAILED_TABLE, mutationId.slice(mutationId.indexOf(":") + 1));
1587
+ function buildFailedRow(row, error, before, attempts, now, revert) {
1588
+ return {
1589
+ id: row.id,
1590
+ mutationType: row.mutationType,
1591
+ recordId: row.recordId,
1592
+ tableName: row.tableName,
1593
+ data: row.data,
1594
+ beforeRecord: before,
1595
+ error,
1596
+ attempts,
1597
+ createdAt: row.createdAt,
1598
+ failedAt: now,
1599
+ revert
1600
+ };
1601
+ }
1602
+ /** ONE transaction: write the tray row, delete the pending row. */
1603
+ function moveToFailedTx(failed) {
1604
+ const { id: _id, ...content } = failed;
1605
+ return {
1606
+ query: surql.seal(surql.tx([surql.create("fid", "failed"), surql.delete("mid")])),
1607
+ vars: {
1608
+ fid: failedRecordId(failed.id),
1609
+ failed: content,
1610
+ mid: parseStoredRecordId(failed.id)
1611
+ }
1612
+ };
1613
+ }
1614
+ const deletePendingRow = (mutationId) => ({
1615
+ sql: "DELETE $mid",
1616
+ vars: { mid: parseStoredRecordId(mutationId) }
1617
+ });
1618
+ const deleteFailedRow = (mutationId) => ({
1619
+ sql: "DELETE $fid",
1620
+ vars: { fid: failedRecordId(mutationId) }
1621
+ });
1622
+ function parseFailedRow(row) {
1623
+ if (!row || typeof row !== "object") return null;
1624
+ const r = row;
1625
+ const type = r.mutationType;
1626
+ if (type !== "create" && type !== "update" && type !== "delete") return null;
1627
+ if (typeof r.recordId !== "string") return null;
1628
+ const err = r.error ?? {};
1629
+ return {
1630
+ id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
1631
+ mutationType: type,
1632
+ recordId: r.recordId,
1633
+ tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(r.recordId),
1634
+ data: r.data && typeof r.data === "object" ? r.data : void 0,
1635
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
1636
+ error: {
1637
+ message: typeof err.message === "string" ? err.message : "unknown",
1638
+ kind: err.kind === "unreplayable" ? "unreplayable" : "application"
1639
+ },
1640
+ attempts: typeof r.attempts === "number" ? r.attempts : 0,
1641
+ createdAt: typeof r.createdAt === "number" ? r.createdAt : 0,
1642
+ failedAt: typeof r.failedAt === "number" ? r.failedAt : 0,
1643
+ revert: r.revert === "partial" || r.revert === "none" ? r.revert : "full"
1644
+ };
1645
+ }
1646
+
1647
+ //#endregion
1648
+ //#region src/events/index.ts
1649
+ /**
1650
+ * A type-safe event system that handles subscription, emission (including debouncing), and buffering of events.
1651
+ * @template E The EventTypeMap defining all supported events.
1652
+ */
1653
+ var EventSystem = class {
1654
+ subscriberId = 0;
1655
+ isProcessing = false;
1656
+ buffer;
1657
+ subscribers;
1658
+ subscribersTypeMap;
1659
+ lastEvents;
1660
+ debouncedEvents;
1661
+ constructor(_eventTypes) {
1662
+ this._eventTypes = _eventTypes;
1663
+ this.buffer = [];
1664
+ this.subscribers = this._eventTypes.reduce((acc, key) => {
1665
+ return Object.assign(acc, { [key]: /* @__PURE__ */ new Map() });
1666
+ }, {});
1667
+ this.lastEvents = {};
1668
+ this.subscribersTypeMap = /* @__PURE__ */ new Map();
1669
+ this.debouncedEvents = /* @__PURE__ */ new Map();
1670
+ }
1671
+ get eventTypes() {
1672
+ return this._eventTypes;
1673
+ }
1674
+ /**
1675
+ * Subscribes a handler to a specific event type.
1676
+ * @param type The event type to subscribe to.
1677
+ * @param handler The function to call when the event occurs.
1678
+ * @param options Subscription options (once, immediately).
1679
+ * @returns A subscription ID that can be used to unsubscribe.
1680
+ */
1681
+ subscribe(type, handler, options) {
1682
+ const id = this.subscriberId++;
1683
+ this.subscribers[type].set(id, {
1684
+ id,
1685
+ handler,
1686
+ once: options?.once ?? false
1687
+ });
1688
+ this.subscribersTypeMap.set(id, type);
1689
+ if (options?.immediately) {
1690
+ const lastEvent = this.lastEvents[type];
1691
+ if (lastEvent) handler(lastEvent);
1692
+ }
1693
+ return id;
1694
+ }
1695
+ /**
1696
+ * Subscribes a handler to multiple event types.
1697
+ * @param types An array of event types to subscribe to.
1698
+ * @param handler The function to call when any of the events occur.
1699
+ * @param options Subscription options.
1700
+ * @returns An array of subscription IDs.
1701
+ */
1702
+ subscribeMany(types, handler, options) {
1703
+ return types.map((type) => this.subscribe(type, handler, options));
1704
+ }
1705
+ /**
1706
+ * Unsubscribes a specific subscription by ID.
1707
+ * @param id The subscription ID returned by subscribe().
1708
+ * @returns True if the subscription was found and removed, false otherwise.
1709
+ */
1710
+ unsubscribe(id) {
1711
+ const type = this.subscribersTypeMap.get(id);
1712
+ if (type) {
1713
+ this.subscribers[type].delete(id);
1714
+ this.subscribersTypeMap.delete(id);
1715
+ return true;
1716
+ }
1717
+ return false;
1718
+ }
1719
+ /**
1720
+ * Emits an event with the given type and payload.
1721
+ * @param type The type of event to emit.
1722
+ * @param payload The data associated with the event.
1723
+ */
1724
+ emit(type, payload) {
1725
+ const event = {
1726
+ type,
1727
+ payload
1728
+ };
1729
+ this.addEvent(event);
1730
+ }
1731
+ /**
1732
+ * Adds a fully constructed event object to the system.
1733
+ * Similar to emit, but takes the full event object directly.
1734
+ * Supports debouncing if options are provided.
1735
+ * @param event The event object.
1736
+ * @param options Options for the event push (e.g., debouncing).
1737
+ */
1738
+ addEvent(event, options) {
1739
+ if (options?.debounced) {
1740
+ this.handleDebouncedEvent(event, options.debounced.key, options.debounced.delay);
1741
+ return;
1742
+ }
1743
+ this.buffer.push(event);
1744
+ this.scheduleProcessing();
1745
+ }
1746
+ handleDebouncedEvent(event, key, delay) {
1747
+ if (this.debouncedEvents.has(key)) clearTimeout(this.debouncedEvents.get(key)?.timer);
1748
+ const timer = setTimeout(() => {
1749
+ this.debouncedEvents.delete(key);
1750
+ this.buffer.push(event);
1751
+ this.scheduleProcessing();
1752
+ }, delay);
1753
+ this.debouncedEvents.set(key, {
1754
+ timer,
1755
+ resolve: () => {}
1756
+ });
1757
+ }
1758
+ scheduleProcessing() {
1759
+ if (!this.isProcessing) queueMicrotask(() => this.processEvents());
1760
+ }
1761
+ async processEvents() {
1762
+ if (this.isProcessing) return;
1763
+ this.isProcessing = true;
1764
+ try {
1765
+ while (this.dequeue());
1766
+ } finally {
1767
+ this.isProcessing = false;
1768
+ }
1769
+ }
1770
+ dequeue() {
1771
+ const event = this.buffer.shift();
1772
+ if (!event) return false;
1773
+ this.setLastEvent(event.type, event);
1774
+ this.broadcastEvent(event.type, event);
1775
+ return true;
1776
+ }
1777
+ setLastEvent(type, event) {
1778
+ this.lastEvents[type] = event;
1779
+ }
1780
+ broadcastEvent(type, event) {
1781
+ const subscribers = this.subscribers[type].values();
1782
+ for (const subscriber of subscribers) {
1783
+ subscriber.handler(event);
1784
+ if (subscriber.once) this.unsubscribe(subscriber.id);
1785
+ }
1786
+ }
1787
+ };
1788
+ function createEventSystem(eventTypes) {
1789
+ return new EventSystem(eventTypes);
1790
+ }
1791
+
1792
+ //#endregion
1793
+ //#region src/modules/auth/events/index.ts
1794
+ const AuthEventTypes = {
1795
+ AuthStateChanged: "AUTH_STATE_CHANGED",
1796
+ ImpersonationChanged: "IMPERSONATION_CHANGED"
1797
+ };
1798
+ function createAuthEventSystem() {
1799
+ return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
1800
+ }
1801
+
1802
+ //#endregion
1803
+ //#region src/modules/devtools/versions.ts
1804
+ const UNAVAILABLE = "unavailable";
1805
+ function emptyBackendVersions() {
1806
+ return {
1807
+ ssp: UNAVAILABLE,
1808
+ scheduler: UNAVAILABLE,
1809
+ surrealdb: UNAVAILABLE
1810
+ };
1811
+ }
1812
+ function emptyBackendInfo() {
1813
+ return {
1814
+ versions: emptyBackendVersions(),
1815
+ entities: []
1816
+ };
1817
+ }
1818
+ /** Strip a leading `surrealdb-` so versions read as bare semver (e.g. `2.0.3`). */
1819
+ function normalizeServerVersion(v) {
1820
+ return String(v).replace(/^surrealdb-/i, "").trim();
1821
+ }
1822
+ /**
1823
+ * Normalize whatever `RETURN fn::spooky::info()` resolves to into the entity
1824
+ * array. The SurrealQL function returns the parsed `/info` array; depending on
1825
+ * how the result is unwrapped it may arrive as the array itself, a single
1826
+ * object, or `null`. Tolerant of all three.
1827
+ */
1828
+ function toEntityArray(raw) {
1829
+ if (Array.isArray(raw)) return raw.filter((e) => !!e && typeof e === "object");
1830
+ if (raw && typeof raw === "object") return [raw];
1831
+ return [];
1832
+ }
1833
+ /**
1834
+ * Derive component versions + the full entity list from a `/info` entity array.
1835
+ * `surrealdb` is taken from whichever entity reports `surrealdb_version` (ssp or
1836
+ * scheduler). Never throws; missing pieces stay `'unavailable'`.
1837
+ */
1838
+ function parseBackendInfo(raw) {
1839
+ const entities = toEntityArray(raw);
1840
+ const versions = emptyBackendVersions();
1841
+ for (const entity of entities) {
1842
+ const version = entity.version ? String(entity.version) : void 0;
1843
+ if (entity.entity === "ssp" && version) versions.ssp = version;
1844
+ else if (entity.entity === "scheduler" && version) versions.scheduler = version;
1845
+ if (versions.surrealdb === UNAVAILABLE && entity.surrealdb_version) versions.surrealdb = normalizeServerVersion(String(entity.surrealdb_version));
1846
+ }
1847
+ return {
1848
+ versions,
1849
+ entities
1850
+ };
1851
+ }
1852
+
1853
+ //#endregion
1854
+ //#region src/modules/devtools/storage-info.ts
1855
+ /**
1856
+ * Recursively list the origin's OPFS. Sizes come from `handle.getFile()`,
1857
+ * which throws for a file another context holds an exclusive sync access
1858
+ * handle on — SAHPool does exactly that for its whole pool, so a locked file
1859
+ * (size omitted) is a live "who has the pool" signal, not a failure.
1860
+ */
1861
+ async function walkOpfs(maxEntries = 2e3, maxDepth = 8) {
1862
+ const nav = typeof navigator !== "undefined" ? navigator : void 0;
1863
+ if (!nav?.storage?.getDirectory) return {
1864
+ supported: false,
1865
+ entries: [],
1866
+ totalBytes: 0,
1867
+ truncated: false
1868
+ };
1869
+ const entries = [];
1870
+ let totalBytes = 0;
1871
+ let truncated = false;
1872
+ try {
1873
+ const root = await nav.storage.getDirectory();
1874
+ const walk = async (dir, prefix, depth) => {
1875
+ if (depth > maxDepth) return;
1876
+ for await (const [name, handle] of dir.entries()) {
1877
+ if (entries.length >= maxEntries) {
1878
+ truncated = true;
1879
+ return;
1880
+ }
1881
+ const path = prefix ? `${prefix}/${name}` : name;
1882
+ if (handle.kind === "directory") {
1883
+ entries.push({
1884
+ path,
1885
+ kind: "directory"
1886
+ });
1887
+ await walk(handle, path, depth + 1);
1888
+ } else {
1889
+ let size;
1890
+ try {
1891
+ size = (await handle.getFile()).size;
1892
+ totalBytes += size;
1893
+ } catch {}
1894
+ const entry = {
1895
+ path,
1896
+ kind: "file"
1897
+ };
1898
+ if (size !== void 0) entry.size = size;
1899
+ entries.push(entry);
1900
+ }
1901
+ }
1902
+ };
1903
+ await walk(root, "", 0);
1904
+ entries.sort((a, b) => a.path.localeCompare(b.path));
1905
+ return {
1906
+ supported: true,
1907
+ entries,
1908
+ totalBytes,
1909
+ truncated
1910
+ };
1911
+ } catch (e) {
1912
+ return {
1913
+ supported: true,
1914
+ entries,
1915
+ totalBytes,
1916
+ truncated,
1917
+ error: e instanceof Error ? e.message : String(e)
1918
+ };
1919
+ }
1920
+ }
1921
+
1922
+ //#endregion
1923
+ //#region src/modules/devtools/flags.ts
1924
+ /**
1925
+ * SurrealDB's SDK returns one entry per statement; each entry is the rows for
1926
+ * that statement, but older/local shapes wrap them as `{ status, result }`.
1927
+ * `getTableData` in `index.ts` unwraps the same three shapes inline — this is
1928
+ * that logic, reusable.
1929
+ */
1930
+ function statementRows(result, index = 0) {
1931
+ if (!Array.isArray(result)) return [];
1932
+ const entry = result[index];
1933
+ if (Array.isArray(entry)) return entry;
1934
+ if (entry && typeof entry === "object" && "result" in entry) {
1935
+ const inner = entry.result;
1936
+ return Array.isArray(inner) ? inner : inner === void 0 || inner === null ? [] : [inner];
1937
+ }
1938
+ return entry === void 0 || entry === null ? [] : [entry];
1939
+ }
1940
+ function message(err) {
1941
+ return err instanceof Error ? err.message : String(err);
1942
+ }
1943
+ /** Row shape guards. `key` is the only field the panel truly can't do without. */
1944
+ function hasStringKey(row) {
1945
+ return !!row && typeof row === "object" && typeof row.key === "string";
1946
+ }
1947
+ function isAssignment(row) {
1948
+ return hasStringKey(row);
1949
+ }
1950
+ function isFlagRow(row) {
1951
+ return hasStringKey(row);
1952
+ }
1953
+ var FlagsAdminService = class {
1954
+ constructor(deps) {
1955
+ this.deps = deps;
1956
+ }
1957
+ /**
1958
+ * Everything the Access tab renders, in one round trip per source.
1959
+ *
1960
+ * Each section fails independently: a remote read that throws downgrades to
1961
+ * `isAdmin: false` plus an `error`, while local assignments and overrides
1962
+ * still render. Signing out must not blank the whole tab.
1963
+ */
1964
+ async getFlags() {
1965
+ const userId = this.deps.currentUserId();
1966
+ const snapshot = {
1967
+ at: Date.now(),
1968
+ userId,
1969
+ isAdmin: false,
1970
+ flags: [],
1971
+ assignments: [],
1972
+ overrides: this.deps.overrides()?.getLocalOverrides() ?? {}
1973
+ };
1974
+ try {
1975
+ snapshot.assignments = statementRows(await this.deps.local.query("SELECT key, variant, payload FROM _00_user_feature")).filter(isAssignment).map((r) => ({
1976
+ key: r.key,
1977
+ variant: r.variant,
1978
+ payload: r.payload
1979
+ }));
1980
+ } catch (err) {
1981
+ this.deps.logger.debug({
1982
+ err,
1983
+ Category: "sp00ky-client::FlagsAdminService::getFlags"
1984
+ }, "Local feature assignments unavailable");
1985
+ }
1986
+ if (!userId) return snapshot;
1987
+ try {
1988
+ snapshot.isAdmin = statementRows(await this.deps.remote.query("SELECT VALUE id FROM _00_admin WHERE user = $auth.id LIMIT 1")).length > 0;
1989
+ } catch (err) {
1990
+ snapshot.error = `Could not check admin status: ${message(err)}. If this deployment predates the Access tab, run \`spky migrate\` (or redeploy) to apply the internal schema.`;
1991
+ return snapshot;
1637
1992
  }
1638
1993
  if (!snapshot.isAdmin) return snapshot;
1639
1994
  try {
@@ -1750,14 +2105,83 @@ function selfAllowlistedVariant(flag, userId) {
1750
2105
  return (Array.isArray(flag.rules) ? flag.rules : []).find((rule) => rule?.kind === "allowlist" && Array.isArray(rule.users) && rule.users.some((u) => String(u) === userId))?.variant;
1751
2106
  }
1752
2107
 
2108
+ //#endregion
2109
+ //#region src/modules/devtools/mutations.ts
2110
+ /** When a mutation was queued, from the timestamp its id starts with (0 for a legacy id). */
2111
+ const queuedAtOf = (id) => createdAtFromId(id) ?? 0;
2112
+ /** The tab's mutation picture: the live outbox over what DevTools saw leave it. */
2113
+ function buildMutationsState(input) {
2114
+ const byId = /* @__PURE__ */ new Map();
2115
+ for (const h of input.history) byId.set(h.id, {
2116
+ id: h.id,
2117
+ op: h.op,
2118
+ recordId: h.recordId,
2119
+ table: h.table,
2120
+ fields: h.fields,
2121
+ status: h.outcome ? h.outcome.status : "dropped",
2122
+ attempts: 0,
2123
+ queuedAt: h.queuedAt,
2124
+ settledAt: h.outcome?.at,
2125
+ error: h.outcome?.error
2126
+ });
2127
+ for (const item of input.outbox) {
2128
+ const seen = byId.get(item.id);
2129
+ const status = item.status === "acked" ? "synced" : item.attempts > 0 ? "retrying" : "pending";
2130
+ byId.set(item.id, {
2131
+ id: item.id,
2132
+ op: item.type,
2133
+ recordId: item.recordId,
2134
+ table: item.table,
2135
+ fields: seen?.fields,
2136
+ status,
2137
+ attempts: item.attempts,
2138
+ queuedAt: seen?.queuedAt || queuedAtOf(item.id),
2139
+ settledAt: item.ackedAt ?? seen?.settledAt
2140
+ });
2141
+ }
2142
+ const all = [...byId.values()].toSorted((a, b) => b.queuedAt - a.queuedAt || (a.id < b.id ? 1 : -1));
2143
+ const count = (s) => all.filter((m) => m.status === s).length;
2144
+ const debounced = [...input.pendingWrites].map((w) => ({
2145
+ key: w.key,
2146
+ recordId: w.recordId,
2147
+ table: w.table,
2148
+ fields: Object.keys(w.data),
2149
+ since: w.firstAt,
2150
+ durable: w.mirrorId !== null
2151
+ }));
2152
+ return {
2153
+ role: input.tabRole,
2154
+ connection: input.health.connection,
2155
+ health: input.health.status,
2156
+ consecutiveFailures: input.health.consecutiveFailures,
2157
+ lastError: input.health.error,
2158
+ counts: {
2159
+ pending: count("pending"),
2160
+ retrying: count("retrying"),
2161
+ debounced: debounced.length,
2162
+ failed: input.failedCount,
2163
+ synced: count("synced"),
2164
+ rolledBack: count("rolled-back")
2165
+ },
2166
+ entries: all.slice(0, input.cap),
2167
+ total: all.length,
2168
+ debounced
2169
+ };
2170
+ }
2171
+ /** Keep the newest `cap` entries of an insertion-ordered history map. */
2172
+ function trimHistory(history, cap) {
2173
+ for (const id of history.keys()) {
2174
+ if (history.size <= cap) return;
2175
+ history.delete(id);
2176
+ }
2177
+ }
2178
+
1753
2179
  //#endregion
1754
2180
  //#region src/modules/devtools/index.ts
1755
- const CORE_VERSION = "0.0.1-canary.290";
1756
- const WASM_VERSION = "0.0.1-canary.290";
2181
+ const CORE_VERSION = "0.0.1-canary.292";
2182
+ const WASM_VERSION = "0.0.1-canary.292";
1757
2183
  const SURREAL_VERSION = "3.0.3";
1758
2184
  var DevToolsService = class DevToolsService {
1759
- eventsHistory = [];
1760
- eventIdCounter = 0;
1761
2185
  version = CORE_VERSION;
1762
2186
  backendInfo = emptyBackendInfo();
1763
2187
  enabled = false;
@@ -1776,6 +2200,34 @@ var DevToolsService = class DevToolsService {
1776
2200
  }
1777
2201
  /** Blob cache counters for the panel, wired by Sp00kyClient. */
1778
2202
  blobInfoProvider = null;
2203
+ /** Outbox, debounced writes and the failed tray, wired by Sp00kyClient. */
2204
+ mutationSource = null;
2205
+ /**
2206
+ * Mutations seen since a consumer attached, by id, oldest first. The outbox
2207
+ * only knows what is still queued; this is what lets the panel show a write
2208
+ * as synced or rolled back after it left.
2209
+ */
2210
+ mutationHistory = /* @__PURE__ */ new Map();
2211
+ static MUTATIONS_CAP = 200;
2212
+ setMutationSource(source) {
2213
+ this.mutationSource = source;
2214
+ }
2215
+ /**
2216
+ * The page's log buffer. It records whether or not anybody is watching (so
2217
+ * a panel opened late still sees the boot); while a consumer is attached,
2218
+ * new lines go out as small `SP00KY_LOGS` deltas, never inside the state
2219
+ * push, which would re-serialize the whole buffer every time.
2220
+ */
2221
+ logTap = null;
2222
+ /** `seq` of the newest line already pushed. */
2223
+ logCursor = 0;
2224
+ logTimer = null;
2225
+ lastLogPushAt = 0;
2226
+ setLogTap(tap) {
2227
+ this.logTap = tap;
2228
+ this.logCursor = tap.head;
2229
+ tap.subscribe(() => this.scheduleLogPush());
2230
+ }
1779
2231
  setBlobInfoProvider(provider) {
1780
2232
  this.blobInfoProvider = provider;
1781
2233
  }
@@ -1808,6 +2260,7 @@ var DevToolsService = class DevToolsService {
1808
2260
  const type = e.data?.type;
1809
2261
  if (type === "SP00KY_DEVTOOLS_CONNECT") {
1810
2262
  this.enabled = true;
2263
+ this.logCursor = this.logTap?.head ?? 0;
1811
2264
  this.refreshLocalTables();
1812
2265
  this.notifyDevTools();
1813
2266
  } else if (type === "SP00KY_DEVTOOLS_DISCONNECT") this.enabled = false;
@@ -1873,57 +2326,193 @@ var DevToolsService = class DevToolsService {
1873
2326
  });
1874
2327
  return result;
1875
2328
  }
1876
- onQueryInitialized(payload) {
2329
+ onStreamUpdate(update) {
2330
+ if (update.synthetic) return;
1877
2331
  this.logger.debug({
1878
- payload,
1879
- Category: "sp00ky-client::DevToolsService::onQueryInitialized"
1880
- }, "QueryInitialized");
1881
- const queryHash = this.hashString(payload.queryId.toString());
1882
- this.addEvent("QUERY_REQUEST_INIT", {
1883
- queryHash,
1884
- query: payload.sql,
1885
- variables: {}
2332
+ queryHash: update.queryHash,
2333
+ op: update.op,
2334
+ localCount: update.localArray?.length ?? 0,
2335
+ materializationTimeMs: update.materializationTimeMs,
2336
+ storeApplyMs: update.storeApplyMs,
2337
+ circuitStepMs: update.circuitStepMs,
2338
+ transformMs: update.transformMs,
2339
+ Category: "sp00ky-client::DevToolsService::onStreamUpdate"
2340
+ }, "StreamUpdate");
2341
+ this.notifyDevTools();
2342
+ }
2343
+ onMutation(payload) {
2344
+ for (const p of payload) this.recordQueued(p);
2345
+ this.notifyDevTools();
2346
+ }
2347
+ /** A write entered the outbox. */
2348
+ recordQueued(p) {
2349
+ if (!this.enabled || !p?.mutation_id) return;
2350
+ const id = storedIdString(p.mutation_id);
2351
+ const recordId = p.record_id ? encodeRecordId(p.record_id) : "";
2352
+ this.mutationHistory.set(id, {
2353
+ id,
2354
+ op: p.type ?? "create",
2355
+ recordId,
2356
+ table: typeof p.tableName === "string" ? p.tableName : recordId.split(":")[0],
2357
+ fields: p.data && typeof p.data === "object" ? Object.keys(p.data) : [],
2358
+ queuedAt: queuedAtOf(id) || Date.now()
2359
+ });
2360
+ trimHistory(this.mutationHistory, DevToolsService.MUTATIONS_CAP);
2361
+ }
2362
+ /** The server answered a write: accepted (`synced`) or rejected (`rolled-back`). */
2363
+ onMutationOutcome(e) {
2364
+ if (!this.enabled) return;
2365
+ const prev = this.mutationHistory.get(e.mutationId);
2366
+ this.mutationHistory.set(e.mutationId, {
2367
+ ...prev ?? {
2368
+ id: e.mutationId,
2369
+ op: e.eventType,
2370
+ recordId: e.recordId,
2371
+ table: e.recordId.split(":")[0],
2372
+ queuedAt: queuedAtOf(e.mutationId)
2373
+ },
2374
+ outcome: {
2375
+ status: e.status,
2376
+ at: Date.now(),
2377
+ error: e.error
2378
+ }
1886
2379
  });
2380
+ trimHistory(this.mutationHistory, DevToolsService.MUTATIONS_CAP);
2381
+ this.notifyDevTools();
2382
+ }
2383
+ /** Something the panel shows moved (a query's status, the outbox, sync health): re-push. */
2384
+ onStateChanged() {
1887
2385
  this.notifyDevTools();
1888
2386
  }
1889
- onQueryUpdated(payload) {
1890
- this.logger.debug({
1891
- id: payload.queryId?.toString(),
1892
- Category: "sp00ky-client::DevToolsService::onQueryUpdated"
1893
- }, "QueryUpdated");
1894
- const queryHash = this.hashString(payload.queryId.toString());
1895
- this.addEvent("QUERY_UPDATED", {
1896
- queryHash,
1897
- recordCount: Array.isArray(payload.records) ? payload.records.length : 0
1898
- });
1899
- this.notifyDevTools();
2387
+ mutationsState() {
2388
+ const source = this.mutationSource;
2389
+ if (!source) return null;
2390
+ const s = source.state();
2391
+ return buildMutationsState({
2392
+ outbox: s.outbox,
2393
+ pendingWrites: s.pendingWrites.values(),
2394
+ failedCount: s.failedCount,
2395
+ tabRole: s.tabRole,
2396
+ health: s.sync.health,
2397
+ history: this.mutationHistory.values(),
2398
+ cap: DevToolsService.MUTATIONS_CAP
2399
+ });
2400
+ }
2401
+ /**
2402
+ * The Mutations tab's on-demand half: the failed tray (a local read), one
2403
+ * queued row's payload, and the tray's retry / discard.
2404
+ */
2405
+ async mutationOp(op, args) {
2406
+ const source = this.mutationSource;
2407
+ if (!source) return {
2408
+ success: false,
2409
+ error: "this client does not expose its mutations"
2410
+ };
2411
+ const id = String(args.id ?? "");
2412
+ try {
2413
+ switch (op) {
2414
+ case "listFailed": return this.serializeForDevTools({
2415
+ success: true,
2416
+ failed: await source.listFailed()
2417
+ });
2418
+ case "get": {
2419
+ const res = await this.databaseService.query("SELECT * FROM $ids", { ids: [parseStoredRecordId(id)] });
2420
+ const raw = Array.isArray(res?.[0]) ? res[0][0] : void 0;
2421
+ return this.serializeForDevTools({
2422
+ success: true,
2423
+ row: raw ? parsePendingRow(raw) : null
2424
+ });
2425
+ }
2426
+ case "retry": return await source.retryFailed(id) ? { success: true } : {
2427
+ success: false,
2428
+ error: `${id} is not in the failed tray`
2429
+ };
2430
+ case "discard": return await source.discardFailed(id) ? { success: true } : {
2431
+ success: false,
2432
+ error: `${id} is not in the failed tray`
2433
+ };
2434
+ case "clearHistory":
2435
+ this.mutationHistory.clear();
2436
+ this.notifyDevTools();
2437
+ return { success: true };
2438
+ default: return {
2439
+ success: false,
2440
+ error: `Unknown mutation op: ${String(op)}`
2441
+ };
2442
+ }
2443
+ } catch (e) {
2444
+ return {
2445
+ success: false,
2446
+ error: e instanceof Error ? e.message : String(e)
2447
+ };
2448
+ }
2449
+ }
2450
+ /** The Logs tab: read the buffer, change what it records, empty it. */
2451
+ logOp(op, args) {
2452
+ const tap = this.logTap;
2453
+ if (!tap) return {
2454
+ success: false,
2455
+ error: "this client does not capture its logs"
2456
+ };
2457
+ try {
2458
+ switch (op) {
2459
+ case "read": {
2460
+ const limit = typeof args.limit === "number" ? args.limit : void 0;
2461
+ return {
2462
+ success: true,
2463
+ ...tap.read(Number(args.after ?? 0), limit)
2464
+ };
2465
+ }
2466
+ case "setCaptureLevel":
2467
+ tap.setCaptureLevel(String(args.level));
2468
+ this.notifyDevTools();
2469
+ return {
2470
+ success: true,
2471
+ captureLevel: tap.captureLevel,
2472
+ consoleLevel: tap.consoleLevel
2473
+ };
2474
+ case "clear":
2475
+ tap.clear();
2476
+ return {
2477
+ success: true,
2478
+ head: tap.head
2479
+ };
2480
+ default: return {
2481
+ success: false,
2482
+ error: `Unknown log op: ${String(op)}`
2483
+ };
2484
+ }
2485
+ } catch (e) {
2486
+ return {
2487
+ success: false,
2488
+ error: e instanceof Error ? e.message : String(e)
2489
+ };
2490
+ }
1900
2491
  }
1901
- onStreamUpdate(update) {
1902
- if (update.synthetic) return;
1903
- this.logger.debug({
1904
- queryHash: update.queryHash,
1905
- Category: "sp00ky-client::DevToolsService::onStreamUpdate"
1906
- }, "StreamUpdate");
1907
- this.addEvent("STREAM_UPDATE", {
1908
- queryHash: update.queryHash,
1909
- op: update.op,
1910
- localCount: update.localArray?.length ?? 0,
1911
- materializationTimeMs: update.materializationTimeMs,
1912
- storeApplyMs: update.storeApplyMs,
1913
- circuitStepMs: update.circuitStepMs,
1914
- transformMs: update.transformMs
1915
- });
1916
- this.notifyDevTools();
2492
+ /** Same coalescing as {@link notifyDevTools}, for the log delta channel. */
2493
+ scheduleLogPush() {
2494
+ if (!this.enabled || typeof window === "undefined" || this.logTimer !== null) return;
2495
+ const waited = Date.now() - this.lastLogPushAt;
2496
+ const delay = Math.max(0, DevToolsService.NOTIFY_MIN_INTERVAL_MS - waited);
2497
+ this.logTimer = setTimeout(() => {
2498
+ this.logTimer = null;
2499
+ if (this.enabled) this.flushLogs();
2500
+ }, delay);
1917
2501
  }
1918
- onMutation(payload) {
1919
- payload.forEach((p) => {
1920
- this.addEvent("MUTATION_REQUEST_EXECUTION", { mutation: {
1921
- type: p.type ?? "create",
1922
- fields: "data" in p && p.data && typeof p.data === "object" ? Object.keys(p.data) : [],
1923
- selector: encodeRecordId(p.record_id)
1924
- } });
1925
- });
1926
- this.notifyDevTools();
2502
+ flushLogs() {
2503
+ const tap = this.logTap;
2504
+ if (!tap) return;
2505
+ this.lastLogPushAt = Date.now();
2506
+ const delta = tap.read(this.logCursor);
2507
+ this.logCursor = delta.head;
2508
+ if (delta.entries.length === 0) return;
2509
+ window.postMessage({
2510
+ type: "SP00KY_LOGS",
2511
+ source: "sp00ky-devtools-page",
2512
+ entries: delta.entries,
2513
+ head: delta.head,
2514
+ dropped: delta.dropped
2515
+ }, "*");
1927
2516
  }
1928
2517
  hashString(str) {
1929
2518
  let hash = 0;
@@ -1935,20 +2524,6 @@ var DevToolsService = class DevToolsService {
1935
2524
  }
1936
2525
  return hash;
1937
2526
  }
1938
- logEvent(eventType, payload) {
1939
- this.addEvent(eventType, payload);
1940
- this.notifyDevTools();
1941
- }
1942
- addEvent(eventType, payload) {
1943
- if (!this.enabled) return;
1944
- this.eventsHistory.push({
1945
- id: this.eventIdCounter++,
1946
- timestamp: Date.now(),
1947
- eventType,
1948
- payload: this.serializeForDevTools(payload)
1949
- });
1950
- if (this.eventsHistory.length > 100) this.eventsHistory.shift();
1951
- }
1952
2527
  /** Unwrap a SurrealDB `INFO FOR DB` result to its `{ tables, ... }` object. */
1953
2528
  unwrapInfo(res) {
1954
2529
  if (!Array.isArray(res) || !res[0]) return null;
@@ -1982,7 +2557,7 @@ var DevToolsService = class DevToolsService {
1982
2557
  getState(opts = {}) {
1983
2558
  if (opts.refreshTables) this.refreshLocalTables();
1984
2559
  return this.serializeForDevTools({
1985
- eventsHistory: [...this.eventsHistory],
2560
+ eventsHistory: [],
1986
2561
  activeQueries: Object.fromEntries(this.getActiveQueries()),
1987
2562
  auth: {
1988
2563
  authenticated: this.authService.isAuthenticated,
@@ -2008,7 +2583,13 @@ var DevToolsService = class DevToolsService {
2008
2583
  fallback: false
2009
2584
  },
2010
2585
  tabs: this.tabsInfoProvider?.() ?? null
2011
- }
2586
+ },
2587
+ mutations: this.mutationsState(),
2588
+ logs: this.logTap ? {
2589
+ head: this.logTap.head,
2590
+ consoleLevel: this.logTap.consoleLevel,
2591
+ captureLevel: this.logTap.captureLevel
2592
+ } : null
2012
2593
  });
2013
2594
  }
2014
2595
  /**
@@ -2208,14 +2789,12 @@ var DevToolsService = class DevToolsService {
2208
2789
  },
2209
2790
  getFlags: () => this.flagsAdmin.getFlags(),
2210
2791
  impersonationOp: (op, args) => this.impersonationOp(op, args ?? {}),
2792
+ mutationOp: (op, args) => this.mutationOp(op, args ?? {}),
2793
+ logOp: (op, args) => this.logOp(op, args ?? {}),
2211
2794
  setFlagEnabled: (key, enabled) => this.flagsAdmin.setFlagEnabled(key, enabled),
2212
2795
  setFlagUserVariant: (key, variant, remove, userId) => this.flagsAdmin.setFlagUserVariant(key, variant, remove, userId),
2213
2796
  setLocalFlagOverride: (key, variant, payload) => this.flagsAdmin.setLocalFlagOverride(key, variant, payload),
2214
2797
  clearLocalFlagOverrides: () => this.flagsAdmin.clearLocalFlagOverrides(),
2215
- clearHistory: () => {
2216
- this.eventsHistory = [];
2217
- this.notifyDevTools();
2218
- },
2219
2798
  refreshVersions: () => this.refreshBackendVersions(),
2220
2799
  getStorageInfo: (opts) => this.getStorageInfo(opts),
2221
2800
  requestPersistentStorage: () => this.requestPersistentStorage(),
@@ -4139,674 +4718,319 @@ function* ensureRegistered(env, opts = {}) {
4139
4718
  });
4140
4719
  return;
4141
4720
  }
4142
- yield fx.timer.set("ensure-registered", AUTH_READY_RETRY_MS, {
4143
- type: "EnsureRegistered",
4144
- requireAuth: true,
4145
- attempt
4146
- });
4147
- return;
4148
- }
4149
- }
4150
- for (const hash of hashes) yield fx.dispatch({
4151
- type: "RegisterRemote",
4152
- hash
4153
- });
4154
- }
4155
- const stmt = (results, i) => results[i];
4156
- /**
4157
- * The SSP answers a registration whose shape is not on the query allowlist
4158
- * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
4159
- * `fn::query::register` statement error carrying SurrealDB's http error text
4160
- * (status reason and/or body), and no retry can change the answer.
4161
- */
4162
- const isNotAllowlisted = (error) => /not_allowlisted|\b403\b|forbidden/i.test(String(error?.message ?? error));
4163
- /**
4164
- * One remote registration: `fn::query::register` plus the edge/meta/children
4165
- * read in ONE request, then membership application. Concurrent with every
4166
- * other registration; retried on its own backoff; stops when the entry is
4167
- * gone.
4168
- */
4169
- function* registerRemote(env, hash, retry = false) {
4170
- const state = yield fx.state.read((s) => s);
4171
- const entry = state.queries.get(hash);
4172
- if (!entry) return;
4173
- const { remote } = entry.lifecycle;
4174
- if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
4175
- yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, { type: "fetch-begin" })));
4176
- try {
4177
- const table = listRefTable(env, state);
4178
- const results = yield fx.remote.query(registerSelect(table), registerVars({
4179
- id: entry.def.id,
4180
- surql: entry.def.surql,
4181
- params: { ...entry.def.params },
4182
- ttl: entry.def.ttl
4183
- }), env.remoteTimeoutMs);
4184
- if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4185
- const register = stmt(results, 0);
4186
- if (!register || register.status === "ERR") throw new Error(register?.status === "ERR" ? register.error : "register returned nothing");
4187
- const answer = (r) => r?.status === "OK" ? r.result : FAILED;
4188
- const snap = snapshotFromSingle(answer(stmt(results, 1)), answer(stmt(results, 2)), answer(stmt(results, 3)));
4189
- if (!snap) {
4190
- yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registered" }), resetRegisterAttempts(hash)));
4191
- yield fx.emit({
4192
- type: "log",
4193
- level: "debug",
4194
- message: "registration read-back did not answer; re-reading membership",
4195
- data: { hash }
4196
- });
4197
- yield* markMembershipDirty([hash]);
4198
- yield fx.dispatch({
4199
- type: "SyncOutcome",
4200
- ok: false,
4201
- error: "registration read-back failed"
4202
- });
4203
- return;
4204
- }
4205
- if ((yield* applyMembership(hash, snap.primary, snap.meta)) === "ignored" && snap.meta.present && snap.meta.state === "materializing") {
4206
- yield fx.state.update(compose(markMembershipDirty$1([hash]), setMembershipReread(hash, 1)));
4207
- yield fx.timer.set("membership", 150, { type: "ReadDirtyMembership" });
4208
- }
4209
- yield* applySubqueryChildren(hash, snap.subquery);
4210
- yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registered" }), resetRegisterAttempts(hash)));
4211
- yield fx.dispatch({
4212
- type: "SyncOutcome",
4213
- ok: true
4214
- });
4215
- } catch (error) {
4216
- const current = yield fx.state.read((s) => s.queries.get(hash));
4217
- if (!current) return;
4218
- if (isNotAllowlisted(error)) {
4219
- yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
4220
- yield fx.emit({
4221
- type: "log",
4222
- level: "error",
4223
- message: "query refused: not allowlisted",
4224
- data: {
4225
- hash,
4226
- table: entry.def.tableName,
4227
- surql: entry.def.surql,
4228
- params: Object.keys(entry.def.params),
4229
- hint: "Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev)."
4230
- }
4231
- });
4232
- yield fx.dispatch({
4233
- type: "SyncOutcome",
4234
- ok: false,
4235
- error
4236
- });
4237
- return;
4238
- }
4239
- const attempts = current.registerAttempts + 1;
4240
- yield fx.state.update(bumpRegisterAttempts(hash));
4241
- yield fx.emit({
4242
- type: "log",
4243
- level: "warn",
4244
- message: "remote registration failed",
4245
- data: {
4246
- hash,
4247
- attempts,
4248
- error
4249
- }
4250
- });
4251
- yield fx.dispatch({
4252
- type: "SyncOutcome",
4253
- ok: false,
4254
- error
4255
- });
4256
- if (attempts >= REGISTER_MAX_RETRIES) yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
4257
- else yield fx.timer.set(`register:${hash}`, backoffMs(attempts), {
4258
- type: "RegisterRemote",
4259
- hash,
4260
- retry: true
4261
- });
4262
- } finally {
4263
- if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, { type: "fetch-end" }));
4264
- }
4265
- }
4266
-
4267
- //#endregion
4268
- //#region src/query/lifecycle.saga.ts
4269
- /**
4270
- * One tick for every query in state: evict the ones nobody has watched for a
4271
- * ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
4272
- * itself at half the shortest ttl.
4273
- */
4274
- function* lifecycleTick(env) {
4275
- const now = yield fx.now();
4276
- const state = yield fx.state.read((s) => s);
4277
- for (const hash of evictable(state, now)) yield* evictQuery(hash);
4278
- const remaining = yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered"));
4279
- if (remaining.length > 0) {
4280
- const beat = heartbeatBatch(remaining.map((e) => e.def.id));
4281
- try {
4282
- const results = yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs);
4283
- let reclaimed = 0;
4284
- for (let i = 0; i < remaining.length; i++) {
4285
- const r = results[i];
4286
- if (r?.status === "OK" && heartbeatRowGone(r.result)) {
4287
- reclaimed++;
4288
- yield fx.state.update(applyLifecycle(remaining[i].def.hash, { type: "remote-dropped" }));
4289
- }
4290
- }
4291
- yield fx.state.update(stampHeartbeat(remaining.map((e) => e.def.hash), now));
4292
- if (reclaimed > 0) {
4293
- yield fx.emit({
4294
- type: "log",
4295
- level: "warn",
4296
- message: "query rows reclaimed by the server; re-registering",
4297
- data: { reclaimed }
4298
- });
4299
- yield fx.dispatch({ type: "EnsureRegistered" });
4300
- }
4301
- yield fx.dispatch({
4302
- type: "SyncOutcome",
4303
- ok: true
4304
- });
4305
- } catch (error) {
4306
- yield fx.dispatch({
4307
- type: "SyncOutcome",
4308
- ok: false,
4309
- error
4310
- });
4311
- }
4312
- }
4313
- const ttl = yield fx.state.read((s) => shortestTtlMs(s));
4314
- yield fx.timer.set("lifecycle", Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: "LifecycleTick" });
4315
- }
4316
- /** Free a query's local view and forget it. The server row expires by TTL. */
4317
- function* evictQuery(hash) {
4318
- if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4319
- try {
4320
- yield fx.ssp.unregister(hash);
4321
- } catch (error) {
4322
- yield fx.emit({
4323
- type: "log",
4324
- level: "debug",
4325
- message: "unregister failed",
4326
- data: {
4327
- hash,
4328
- error
4329
- }
4330
- });
4721
+ yield fx.timer.set("ensure-registered", AUTH_READY_RETRY_MS, {
4722
+ type: "EnsureRegistered",
4723
+ requireAuth: true,
4724
+ attempt
4725
+ });
4726
+ return;
4727
+ }
4331
4728
  }
4332
- yield fx.state.update(removeQuery(hash));
4333
- yield fx.emit({
4334
- type: "query:evicted",
4729
+ for (const hash of hashes) yield fx.dispatch({
4730
+ type: "RegisterRemote",
4335
4731
  hash
4336
4732
  });
4337
4733
  }
4338
- /** Drop acked outbox items membership never named within the grace window. */
4339
- function* ackPrune() {
4340
- const now = yield fx.now();
4341
- yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
4342
- if (yield fx.state.read((s) => s.outbox.some((i) => i.status === "acked"))) yield fx.timer.set("ack-prune", ACK_GRACE_MS, { type: "AckPrune" });
4343
- }
4344
- /** Every `_00_view` row, parsed. Throws when the read fails. */
4345
- function* readViews() {
4346
- const res = yield fx.local.query(readViewRows());
4347
- return parseViewIndex(Array.isArray(res) ? res[0] : void 0);
4348
- }
4349
- const indexOf = (rows) => new Map(rows.map((r) => [r.key, r.ids]));
4734
+ const stmt = (results, i) => results[i];
4350
4735
  /**
4351
- * Load the durable view index (boot, bucket switch). A failed read leaves it
4352
- * empty, which fails closed: cold scans paint nothing until the server answers.
4736
+ * The SSP answers a registration whose shape is not on the query allowlist
4737
+ * with HTTP 403 `{"error":"not_allowlisted"}`. It reaches the client as the
4738
+ * `fn::query::register` statement error carrying SurrealDB's http error text
4739
+ * (status reason and/or body), and no retry can change the answer.
4353
4740
  */
4354
- function* loadViews() {
4355
- try {
4356
- yield fx.state.update(reloadViews(indexOf(yield* readViews())));
4357
- } catch (error) {
4358
- yield fx.emit({
4359
- type: "log",
4360
- level: "warn",
4361
- message: "view index read failed",
4362
- data: { error }
4363
- });
4364
- }
4365
- }
4366
- const tableOf$1 = (id) => id.slice(0, id.indexOf(":"));
4741
+ const isNotAllowlisted = (error) => /not_allowlisted|\b403\b|forbidden/i.test(String(error?.message ?? error));
4367
4742
  /**
4368
- * Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
4369
- * `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
4370
- * rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
4371
- * nothing retains (see `retained`) from the store and the circuit, a chunk at
4372
- * a time. Only a leader or solo tab deletes, and a leader relays the deletes
4373
- * so followers drop the rows too; a follower only refreshes its index.
4743
+ * One remote registration: `fn::query::register` plus the edge/meta/children
4744
+ * read in ONE request, then membership application. Concurrent with every
4745
+ * other registration; retried on its own backoff; stops when the entry is
4746
+ * gone.
4374
4747
  */
4375
- function* gcTick() {
4376
- yield fx.state.wait((s) => s.primed);
4748
+ function* registerRemote(env, hash, retry = false) {
4749
+ const state = yield fx.state.read((s) => s);
4750
+ const entry = state.queries.get(hash);
4751
+ if (!entry) return;
4752
+ const { remote } = entry.lifecycle;
4753
+ if (remote === "registered" || remote === "failed" || remote === "registering" && !retry) return;
4754
+ yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registering" }), applyLifecycle(hash, { type: "fetch-begin" })));
4377
4755
  try {
4378
- const rows = yield* readViews();
4379
- const [role, bucket, held] = yield fx.state.read((s) => [
4380
- s.tabRole,
4381
- s.bucketId,
4382
- new Set([...s.queries.values()].map((e) => e.def.viewKey))
4383
- ]);
4384
- const now = yield fx.now();
4385
- const expired = role === "follower" ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
4386
- const retired = /* @__PURE__ */ new Set();
4387
- for (let i = 0; i < expired.length; i += GC_CHUNK) {
4388
- const chunk = expired.slice(i, i + GC_CHUNK);
4389
- const settled = yield fx.all(chunk.map((r) => fx.local.delete(VIEW_TABLE, viewRecordId(r.key))));
4390
- chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
4756
+ const table = listRefTable(env, state);
4757
+ const results = yield fx.remote.query(registerSelect(table), registerVars({
4758
+ id: entry.def.id,
4759
+ surql: entry.def.surql,
4760
+ params: { ...entry.def.params },
4761
+ ttl: entry.def.ttl
4762
+ }), env.remoteTimeoutMs);
4763
+ if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4764
+ const register = stmt(results, 0);
4765
+ if (!register || register.status === "ERR") throw new Error(register?.status === "ERR" ? register.error : "register returned nothing");
4766
+ const answer = (r) => r?.status === "OK" ? r.result : FAILED;
4767
+ const snap = snapshotFromSingle(answer(stmt(results, 1)), answer(stmt(results, 2)), answer(stmt(results, 3)));
4768
+ if (!snap) {
4769
+ yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registered" }), resetRegisterAttempts(hash)));
4770
+ yield fx.emit({
4771
+ type: "log",
4772
+ level: "debug",
4773
+ message: "registration read-back did not answer; re-reading membership",
4774
+ data: { hash }
4775
+ });
4776
+ yield* markMembershipDirty([hash]);
4777
+ yield fx.dispatch({
4778
+ type: "SyncOutcome",
4779
+ ok: false,
4780
+ error: "registration read-back failed"
4781
+ });
4782
+ return;
4391
4783
  }
4392
- yield fx.state.update(reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
4393
- if (role === "follower") return;
4394
- const removed = yield* collectOrphans(bucket, role === "leader");
4395
- yield fx.emit({
4396
- type: "log",
4397
- level: "info",
4398
- message: "orphan gc done",
4399
- data: {
4400
- removed,
4401
- retiredViews: retired.size
4402
- }
4784
+ if ((yield* applyMembership(hash, snap.primary, snap.meta)) === "ignored" && snap.meta.present && snap.meta.state === "materializing") {
4785
+ yield fx.state.update(compose(markMembershipDirty$1([hash]), setMembershipReread(hash, 1)));
4786
+ yield fx.timer.set("membership", 150, { type: "ReadDirtyMembership" });
4787
+ }
4788
+ yield* applySubqueryChildren(hash, snap.subquery);
4789
+ yield fx.state.update(compose(applyLifecycle(hash, { type: "remote-registered" }), resetRegisterAttempts(hash)));
4790
+ yield fx.dispatch({
4791
+ type: "SyncOutcome",
4792
+ ok: true
4403
4793
  });
4404
4794
  } catch (error) {
4795
+ const current = yield fx.state.read((s) => s.queries.get(hash));
4796
+ if (!current) return;
4797
+ if (isNotAllowlisted(error)) {
4798
+ yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
4799
+ yield fx.emit({
4800
+ type: "log",
4801
+ level: "error",
4802
+ message: "query refused: not allowlisted",
4803
+ data: {
4804
+ hash,
4805
+ table: entry.def.tableName,
4806
+ surql: entry.def.surql,
4807
+ params: Object.keys(entry.def.params),
4808
+ hint: "Add or regenerate the query allowlist: spky generate, then spky deploy (or restart spky dev)."
4809
+ }
4810
+ });
4811
+ yield fx.dispatch({
4812
+ type: "SyncOutcome",
4813
+ ok: false,
4814
+ error
4815
+ });
4816
+ return;
4817
+ }
4818
+ const attempts = current.registerAttempts + 1;
4819
+ yield fx.state.update(bumpRegisterAttempts(hash));
4405
4820
  yield fx.emit({
4406
4821
  type: "log",
4407
4822
  level: "warn",
4408
- message: "orphan gc failed",
4409
- data: { error }
4410
- });
4411
- } finally {
4412
- yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
4413
- }
4414
- }
4415
- /**
4416
- * Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
4417
- * re-checked against fresh state (a membership committed meanwhile may name
4418
- * an id again) and the sweep stops if the bucket moved under it.
4419
- */
4420
- function* collectOrphans(bucket, relay) {
4421
- const candidates = yield fx.state.read((s) => {
4422
- const keep = retained(s);
4423
- return [...s.versions.keys()].filter((id) => !keep(id));
4424
- });
4425
- let removed = 0;
4426
- for (let i = 0; i < candidates.length; i += GC_CHUNK) {
4427
- const chunk = yield fx.state.read((s) => {
4428
- if (!s.primed || s.bucketId !== bucket) return null;
4429
- const keep = retained(s);
4430
- return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
4431
- });
4432
- if (chunk === null) break;
4433
- const settled = yield fx.all(chunk.map((id) => fx.local.delete(tableOf$1(id), id)));
4434
- const done = chunk.filter((_, j) => settled[j].ok);
4435
- if (done.length === 0) continue;
4436
- const records = done.map((id) => ({
4437
- table: tableOf$1(id),
4438
- op: "DELETE",
4439
- id,
4440
- record: {}
4441
- }));
4442
- yield fx.state.update(deleteVersions(done));
4443
- yield fx.ssp.ingest(records);
4444
- if (relay) yield fx.emit({
4445
- type: "tabs:broadcast",
4446
- message: {
4447
- type: "ingest",
4448
- records
4449
- }
4450
- });
4451
- removed += done.length;
4452
- }
4453
- if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
4454
- return removed;
4455
- }
4456
-
4457
- //#endregion
4458
- //#region src/mutation/rows.ts
4459
- const PENDING_TABLE = "_00_pending_mutations";
4460
- const FAILED_TABLE = "_00_failed_mutations";
4461
- /** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
4462
- const PENDING_WRITES_TABLE = "_00_pending_writes";
4463
- const PENDING_ROW_VERSION = 2;
4464
- /**
4465
- * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
4466
- * escaping (outbox ids contain `_`, so they come back escaped).
4467
- */
4468
- function parseStoredRecordId(id) {
4469
- const [table, ...rest] = id.split(":");
4470
- let raw = rest.join(":");
4471
- if (raw.startsWith("⟨") && raw.endsWith("⟩")) raw = raw.slice(1, -1);
4472
- return new RecordId$1(table, raw);
4473
- }
4474
- /** Stable `table:id` string for an id read from the store, escaping removed. */
4475
- const storedIdString = (id) => {
4476
- if (id instanceof RecordId$1) return `${id.table}:${String(id.id)}`;
4477
- const rid = parseStoredRecordId(String(id));
4478
- return `${rid.table}:${rid.id}`;
4479
- };
4480
- /** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
4481
- function createdAtFromId(id) {
4482
- const raw = id.includes(":") ? id.slice(id.indexOf(":") + 1) : id;
4483
- const m = /^⟨?(\d{13})/.exec(raw);
4484
- return m ? Number(m[1]) : null;
4485
- }
4486
- /**
4487
- * Read any row shape this table ever had. Legacy rows (`v` missing) lack
4488
- * `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
4489
- * replayable and returns `null`.
4490
- */
4491
- function parsePendingRow(row) {
4492
- if (!row || typeof row !== "object") return null;
4493
- const r = row;
4494
- const type = r.mutationType;
4495
- if (type !== "create" && type !== "update" && type !== "delete") return null;
4496
- if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
4497
- const id = storedIdString(r.id);
4498
- const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
4499
- const data = r.data && typeof r.data === "object" ? r.data : void 0;
4500
- if (type === "create" && !data) return null;
4501
- return {
4502
- id,
4503
- mutationType: type,
4504
- recordId,
4505
- tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
4506
- data,
4507
- beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : void 0,
4508
- createdAt: typeof r.createdAt === "number" ? r.createdAt : createdAtFromId(id) ?? 0,
4509
- v: typeof r.v === "number" ? r.v : 1
4510
- };
4823
+ message: "remote registration failed",
4824
+ data: {
4825
+ hash,
4826
+ attempts,
4827
+ error
4828
+ }
4829
+ });
4830
+ yield fx.dispatch({
4831
+ type: "SyncOutcome",
4832
+ ok: false,
4833
+ error
4834
+ });
4835
+ if (attempts >= REGISTER_MAX_RETRIES) yield fx.state.update(applyLifecycle(hash, { type: "remote-failed" }));
4836
+ else yield fx.timer.set(`register:${hash}`, backoffMs(attempts), {
4837
+ type: "RegisterRemote",
4838
+ hash,
4839
+ retry: true
4840
+ });
4841
+ } finally {
4842
+ if (yield fx.state.read((s) => s.queries.has(hash))) yield fx.state.update(applyLifecycle(hash, { type: "fetch-end" }));
4843
+ }
4511
4844
  }
4845
+
4846
+ //#endregion
4847
+ //#region src/query/lifecycle.saga.ts
4512
4848
  /**
4513
- * Re-type a stored row's payload from the schema before it is pushed. The
4514
- * store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
4515
- * datetime as a string; the server coerces neither. Unknown keys stay as they
4516
- * are, and a payload the schema rejects is sent raw so the server's answer,
4517
- * not a local throw, decides its fate.
4849
+ * One tick for every query in state: evict the ones nobody has watched for a
4850
+ * ttl, heartbeat the rest in one request, notice reclaimed rows. Reschedules
4851
+ * itself at half the shortest ttl.
4518
4852
  */
4519
- function hydrateRowData(row, columns) {
4520
- if (!row.data || !columns) return row;
4521
- try {
4522
- return {
4523
- ...row,
4524
- data: parseQueryParams(columns, row.data)
4525
- };
4526
- } catch {
4527
- return row;
4853
+ function* lifecycleTick(env) {
4854
+ const now = yield fx.now();
4855
+ const state = yield fx.state.read((s) => s);
4856
+ for (const hash of evictable(state, now)) yield* evictQuery(hash);
4857
+ const remaining = yield fx.state.read((s) => [...s.queries.values()].filter((e) => e.lifecycle.remote === "registered"));
4858
+ if (remaining.length > 0) {
4859
+ const beat = heartbeatBatch(remaining.map((e) => e.def.id));
4860
+ try {
4861
+ const results = yield fx.remote.query(beat.sql, beat.vars, env.remoteTimeoutMs);
4862
+ let reclaimed = 0;
4863
+ for (let i = 0; i < remaining.length; i++) {
4864
+ const r = results[i];
4865
+ if (r?.status === "OK" && heartbeatRowGone(r.result)) {
4866
+ reclaimed++;
4867
+ yield fx.state.update(applyLifecycle(remaining[i].def.hash, { type: "remote-dropped" }));
4868
+ }
4869
+ }
4870
+ yield fx.state.update(stampHeartbeat(remaining.map((e) => e.def.hash), now));
4871
+ if (reclaimed > 0) {
4872
+ yield fx.emit({
4873
+ type: "log",
4874
+ level: "warn",
4875
+ message: "query rows reclaimed by the server; re-registering",
4876
+ data: { reclaimed }
4877
+ });
4878
+ yield fx.dispatch({ type: "EnsureRegistered" });
4879
+ }
4880
+ yield fx.dispatch({
4881
+ type: "SyncOutcome",
4882
+ ok: true
4883
+ });
4884
+ } catch (error) {
4885
+ yield fx.dispatch({
4886
+ type: "SyncOutcome",
4887
+ ok: false,
4888
+ error
4889
+ });
4890
+ }
4528
4891
  }
4892
+ const ttl = yield fx.state.read((s) => shortestTtlMs(s));
4893
+ yield fx.timer.set("lifecycle", Math.floor((ttl ?? env.defaultTtlMs) * TTL_HEARTBEAT_FRACTION), { type: "LifecycleTick" });
4529
4894
  }
4530
- const toOutboxItem = (row) => ({
4531
- id: row.id,
4532
- type: row.mutationType,
4533
- recordId: row.recordId,
4534
- table: row.tableName,
4535
- status: "pending",
4536
- ackedAt: null,
4537
- attempts: 0
4538
- });
4539
- /** The `_00_pending_writes` id for a minted mutation id (same suffix). */
4540
- const pendingWriteId = (mutationId) => `${PENDING_WRITES_TABLE}:${mutationId.includes(":") ? mutationId.slice(mutationId.indexOf(":") + 1) : mutationId}`;
4541
- function parsePendingWriteRow(row) {
4542
- if (!row || typeof row !== "object") return null;
4543
- const r = row;
4544
- if (typeof r.recordId !== "string" && !(r.recordId instanceof RecordId$1)) return null;
4545
- if (!r.data || typeof r.data !== "object") return null;
4546
- const recordId = typeof r.recordId === "string" ? r.recordId : storedIdString(r.recordId);
4547
- return {
4548
- id: storedIdString(r.id),
4549
- recordId,
4550
- tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(recordId),
4551
- data: r.data,
4552
- beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
4553
- flushBy: typeof r.flushBy === "number" ? r.flushBy : 0
4554
- };
4555
- }
4556
- const loadPendingRows = () => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
4557
- const loadPendingWriteRows = () => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
4558
- const loadFailedRows = () => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
4559
- const pendingInsert = (type, withData, withBefore) => {
4560
- const fields = [
4561
- `mutationType = '${type}'`,
4562
- "recordId = $id",
4563
- "tableName = $table",
4564
- "createdAt = $createdAt",
4565
- `v = ${PENDING_ROW_VERSION}`
4566
- ];
4567
- if (withData) fields.push("data = $data");
4568
- if (withBefore) fields.push("beforeRecord = $before");
4569
- return `CREATE ONLY $mid SET ${fields.join(", ")}`;
4570
- };
4571
- /** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
4572
- function planCreateTx(input) {
4573
- const data = input.data ?? {};
4574
- const keys = Object.keys(data).map((key) => ({
4575
- key,
4576
- variable: `data_${key}`
4577
- }));
4578
- const vars = {
4579
- id: input.recordId,
4580
- mid: input.mutationId,
4581
- table: input.table,
4582
- createdAt: input.now,
4583
- data
4584
- };
4585
- for (const { key, variable } of keys) vars[variable] = data[key];
4586
- return {
4587
- query: surql.seal(surql.tx([surql.createSet("id", keys), pendingInsert("create", true, false)]), { resultIndex: 0 }),
4588
- vars
4589
- };
4590
- }
4591
- /** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
4592
- function planUpdateTx(input) {
4593
- return {
4594
- query: surql.seal(surql.tx([
4595
- surql.updateSet("id", [{ statement: "_00_rv += 1" }]),
4596
- surql.let("updated", surql.updateMerge("id", "data")),
4597
- pendingInsert("update", true, true),
4598
- surql.returnObject([{
4599
- key: "target",
4600
- variable: "updated"
4601
- }])
4602
- ])),
4603
- vars: {
4604
- id: input.recordId,
4605
- mid: input.mutationId,
4606
- table: input.table,
4607
- createdAt: input.now,
4608
- data: input.data ?? {},
4609
- before: input.before ?? null
4610
- }
4611
- };
4895
+ /** Free a query's local view and forget it. The server row expires by TTL. */
4896
+ function* evictQuery(hash) {
4897
+ if (!(yield fx.state.read((s) => s.queries.has(hash)))) return;
4898
+ try {
4899
+ yield fx.ssp.unregister(hash);
4900
+ } catch (error) {
4901
+ yield fx.emit({
4902
+ type: "log",
4903
+ level: "debug",
4904
+ message: "unregister failed",
4905
+ data: {
4906
+ hash,
4907
+ error
4908
+ }
4909
+ });
4910
+ }
4911
+ yield fx.state.update(removeQuery(hash));
4912
+ yield fx.emit({
4913
+ type: "query:evicted",
4914
+ hash
4915
+ });
4612
4916
  }
4613
- /** DELETE the row and write the outbox entry (with `beforeRecord`). */
4614
- function planDeleteTx(input) {
4615
- return {
4616
- query: surql.seal(surql.tx([surql.delete("id"), pendingInsert("delete", false, true)])),
4617
- vars: {
4618
- id: input.recordId,
4619
- mid: input.mutationId,
4620
- table: input.table,
4621
- createdAt: input.now,
4622
- before: input.before ?? null
4623
- }
4624
- };
4917
+ /** Drop acked outbox items membership never named within the grace window. */
4918
+ function* ackPrune() {
4919
+ const now = yield fx.now();
4920
+ yield fx.state.update(outboxPruneAcked(now, ACK_GRACE_MS));
4921
+ if (yield fx.state.read((s) => s.outbox.some((i) => i.status === "acked"))) yield fx.timer.set("ack-prune", ACK_GRACE_MS, { type: "AckPrune" });
4625
4922
  }
4626
- /**
4627
- * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
4628
- * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
4629
- * in the same transaction, so the row never changes without a durable copy of
4630
- * what still has to reach the server.
4631
- */
4632
- function planLocalOnlyUpdateTx(input) {
4633
- const statements = [surql.updateSet("id", [{ statement: "_00_rv += 1" }]), surql.let("updated", surql.updateMerge("id", "data"))];
4634
- if (input.mirror) statements.push(surql.upsert("wid", "wrow"));
4635
- statements.push(surql.returnObject([{
4636
- key: "target",
4637
- variable: "updated"
4638
- }]));
4639
- return {
4640
- query: surql.seal(surql.tx(statements)),
4641
- vars: input.mirror ? {
4642
- id: input.recordId,
4643
- data: input.data,
4644
- wid: input.mirror.id,
4645
- wrow: input.mirror.row
4646
- } : {
4647
- id: input.recordId,
4648
- data: input.data
4649
- }
4650
- };
4923
+ /** Every `_00_view` row, parsed. Throws when the read fails. */
4924
+ function* readViews() {
4925
+ const res = yield fx.local.query(readViewRows());
4926
+ return parseViewIndex(Array.isArray(res) ? res[0] : void 0);
4651
4927
  }
4928
+ const indexOf = (rows) => new Map(rows.map((r) => [r.key, r.ids]));
4652
4929
  /**
4653
- * The outbox row of a flushed debounced update. With `mirrorId` the
4654
- * `_00_pending_writes` row goes in the same transaction: the patch moves
4655
- * from one table to the other, it is never in both or in neither.
4930
+ * Load the durable view index (boot, bucket switch). A failed read leaves it
4931
+ * empty, which fails closed: cold scans paint nothing until the server answers.
4656
4932
  */
4657
- function planDeferredOutboxRowTx(input) {
4658
- const statements = [pendingInsert("update", true, true)];
4659
- if (input.mirrorId) statements.push(surql.delete("wid"));
4660
- return {
4661
- query: surql.seal(surql.tx(statements)),
4662
- vars: {
4663
- id: input.recordId,
4664
- mid: input.mutationId,
4665
- table: input.table,
4666
- createdAt: input.now,
4667
- data: input.data ?? {},
4668
- before: input.before ?? null,
4669
- ...input.mirrorId ? { wid: input.mirrorId } : {}
4670
- }
4671
- };
4933
+ function* loadViews() {
4934
+ try {
4935
+ yield fx.state.update(reloadViews(indexOf(yield* readViews())));
4936
+ } catch (error) {
4937
+ yield fx.emit({
4938
+ type: "log",
4939
+ level: "warn",
4940
+ message: "view index read failed",
4941
+ data: { error }
4942
+ });
4943
+ }
4672
4944
  }
4945
+ const tableOf$1 = (id) => id.slice(0, id.indexOf(":"));
4673
4946
  /**
4674
- * Many outbox rows as ONE multi-statement request. Statement i answers row i.
4675
- * Each statement is its own transaction on the server, as before.
4947
+ * Orphan collection, `GC_BOOT_DELAY_MS` after boot and then every
4948
+ * `GC_INTERVAL_MS`. Re-reads every `_00_view` row into the index, retires the
4949
+ * rows no query has resolved for `VIEW_RETENTION_MS`, then deletes the bodies
4950
+ * nothing retains (see `retained`) from the store and the circuit, a chunk at
4951
+ * a time. Only a leader or solo tab deletes, and a leader relays the deletes
4952
+ * so followers drop the rows too; a follower only refreshes its index.
4676
4953
  */
4677
- function remoteBatch(rows) {
4678
- const vars = {};
4679
- return {
4680
- sql: rows.map((row, i) => {
4681
- const rid = parseRecordIdString(row.recordId);
4682
- vars[`id${i}`] = rid;
4683
- switch (row.mutationType) {
4684
- case "create": {
4685
- const data = row.data ?? {};
4686
- return `CREATE ONLY $id${i} SET ${Object.keys(data).map((key) => {
4687
- vars[`d${i}_${key}`] = data[key];
4688
- return `${key} = $d${i}_${key}`;
4689
- }).join(", ")}`;
4690
- }
4691
- case "update":
4692
- vars[`data${i}`] = row.data ?? {};
4693
- return `UPDATE $id${i} MERGE $data${i}`;
4694
- case "delete": return `DELETE $id${i}`;
4954
+ function* gcTick() {
4955
+ yield fx.state.wait((s) => s.primed);
4956
+ try {
4957
+ const rows = yield* readViews();
4958
+ const [role, bucket, held] = yield fx.state.read((s) => [
4959
+ s.tabRole,
4960
+ s.bucketId,
4961
+ new Set([...s.queries.values()].map((e) => e.def.viewKey))
4962
+ ]);
4963
+ const now = yield fx.now();
4964
+ const expired = role === "follower" ? [] : rows.filter((r) => !held.has(r.key) && now - r.updatedAt >= VIEW_RETENTION_MS);
4965
+ const retired = /* @__PURE__ */ new Set();
4966
+ for (let i = 0; i < expired.length; i += GC_CHUNK) {
4967
+ const chunk = expired.slice(i, i + GC_CHUNK);
4968
+ const settled = yield fx.all(chunk.map((r) => fx.local.delete(VIEW_TABLE, viewRecordId(r.key))));
4969
+ chunk.forEach((r, j) => settled[j].ok && retired.add(r.key));
4970
+ }
4971
+ yield fx.state.update(reloadViews(indexOf(rows.filter((r) => !retired.has(r.key)))));
4972
+ if (role === "follower") return;
4973
+ const removed = yield* collectOrphans(bucket, role === "leader");
4974
+ yield fx.emit({
4975
+ type: "log",
4976
+ level: "info",
4977
+ message: "orphan gc done",
4978
+ data: {
4979
+ removed,
4980
+ retiredViews: retired.size
4695
4981
  }
4696
- }).join(";\n"),
4697
- vars
4698
- };
4982
+ });
4983
+ } catch (error) {
4984
+ yield fx.emit({
4985
+ type: "log",
4986
+ level: "warn",
4987
+ message: "orphan gc failed",
4988
+ data: { error }
4989
+ });
4990
+ } finally {
4991
+ yield fx.timer.set("gc", GC_INTERVAL_MS, { type: "GcTick" });
4992
+ }
4699
4993
  }
4700
4994
  /**
4701
- * How to undo a rejected mutation locally. A create is deleted; an update or
4702
- * delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
4703
- * comes back). Without a `beforeRecord` nothing can be restored.
4995
+ * Delete every body nothing retains, `GC_CHUNK` at a time. Each chunk is
4996
+ * re-checked against fresh state (a membership committed meanwhile may name
4997
+ * an id again) and the sweep stops if the bucket moved under it.
4704
4998
  */
4705
- function planRevert(row, before) {
4706
- const rid = parseRecordIdString(row.recordId);
4707
- if (row.mutationType === "create") return {
4708
- tx: {
4709
- query: surql.seal(surql.tx([surql.delete("id")])),
4710
- vars: { id: rid }
4711
- },
4712
- circuit: {
4713
- table: row.tableName,
4999
+ function* collectOrphans(bucket, relay) {
5000
+ const candidates = yield fx.state.read((s) => {
5001
+ const keep = retained(s);
5002
+ return [...s.versions.keys()].filter((id) => !keep(id));
5003
+ });
5004
+ let removed = 0;
5005
+ for (let i = 0; i < candidates.length; i += GC_CHUNK) {
5006
+ const chunk = yield fx.state.read((s) => {
5007
+ if (!s.primed || s.bucketId !== bucket) return null;
5008
+ const keep = retained(s);
5009
+ return candidates.slice(i, i + GC_CHUNK).filter((id) => s.versions.has(id) && !keep(id));
5010
+ });
5011
+ if (chunk === null) break;
5012
+ const settled = yield fx.all(chunk.map((id) => fx.local.delete(tableOf$1(id), id)));
5013
+ const done = chunk.filter((_, j) => settled[j].ok);
5014
+ if (done.length === 0) continue;
5015
+ const records = done.map((id) => ({
5016
+ table: tableOf$1(id),
4714
5017
  op: "DELETE",
4715
- id: row.recordId,
4716
- record: row.data ?? {}
4717
- },
4718
- revert: "full"
4719
- };
4720
- if (!before) return {
4721
- tx: null,
4722
- circuit: {
4723
- table: row.tableName,
4724
- op: "UPDATE",
4725
- id: row.recordId,
5018
+ id,
4726
5019
  record: {}
4727
- },
4728
- revert: "partial"
4729
- };
4730
- const { id: _id, ...content } = before;
4731
- return {
4732
- tx: {
4733
- query: surql.seal(surql.tx([surql.upsert("id", "content")])),
4734
- vars: {
4735
- id: rid,
4736
- content
4737
- }
4738
- },
4739
- circuit: {
4740
- table: row.tableName,
4741
- op: row.mutationType === "delete" ? "CREATE" : "UPDATE",
4742
- id: row.recordId,
4743
- record: {
4744
- ...before,
4745
- id: rid
5020
+ }));
5021
+ yield fx.state.update(deleteVersions(done));
5022
+ yield fx.ssp.ingest(records);
5023
+ if (relay) yield fx.emit({
5024
+ type: "tabs:broadcast",
5025
+ message: {
5026
+ type: "ingest",
5027
+ records
4746
5028
  }
4747
- },
4748
- revert: "full"
4749
- };
4750
- }
4751
- const failedRecordId = (mutationId) => new RecordId$1(FAILED_TABLE, mutationId.slice(mutationId.indexOf(":") + 1));
4752
- function buildFailedRow(row, error, before, attempts, now, revert) {
4753
- return {
4754
- id: row.id,
4755
- mutationType: row.mutationType,
4756
- recordId: row.recordId,
4757
- tableName: row.tableName,
4758
- data: row.data,
4759
- beforeRecord: before,
4760
- error,
4761
- attempts,
4762
- createdAt: row.createdAt,
4763
- failedAt: now,
4764
- revert
4765
- };
4766
- }
4767
- /** ONE transaction: write the tray row, delete the pending row. */
4768
- function moveToFailedTx(failed) {
4769
- const { id: _id, ...content } = failed;
4770
- return {
4771
- query: surql.seal(surql.tx([surql.create("fid", "failed"), surql.delete("mid")])),
4772
- vars: {
4773
- fid: failedRecordId(failed.id),
4774
- failed: content,
4775
- mid: parseStoredRecordId(failed.id)
4776
- }
4777
- };
4778
- }
4779
- const deletePendingRow = (mutationId) => ({
4780
- sql: "DELETE $mid",
4781
- vars: { mid: parseStoredRecordId(mutationId) }
4782
- });
4783
- const deleteFailedRow = (mutationId) => ({
4784
- sql: "DELETE $fid",
4785
- vars: { fid: failedRecordId(mutationId) }
4786
- });
4787
- function parseFailedRow(row) {
4788
- if (!row || typeof row !== "object") return null;
4789
- const r = row;
4790
- const type = r.mutationType;
4791
- if (type !== "create" && type !== "update" && type !== "delete") return null;
4792
- if (typeof r.recordId !== "string") return null;
4793
- const err = r.error ?? {};
4794
- return {
4795
- id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
4796
- mutationType: type,
4797
- recordId: r.recordId,
4798
- tableName: typeof r.tableName === "string" ? r.tableName : extractTablePart(r.recordId),
4799
- data: r.data && typeof r.data === "object" ? r.data : void 0,
4800
- beforeRecord: r.beforeRecord && typeof r.beforeRecord === "object" ? r.beforeRecord : null,
4801
- error: {
4802
- message: typeof err.message === "string" ? err.message : "unknown",
4803
- kind: err.kind === "unreplayable" ? "unreplayable" : "application"
4804
- },
4805
- attempts: typeof r.attempts === "number" ? r.attempts : 0,
4806
- createdAt: typeof r.createdAt === "number" ? r.createdAt : 0,
4807
- failedAt: typeof r.failedAt === "number" ? r.failedAt : 0,
4808
- revert: r.revert === "partial" || r.revert === "none" ? r.revert : "full"
4809
- };
5029
+ });
5030
+ removed += done.length;
5031
+ }
5032
+ if (removed > 0) yield fx.dispatch({ type: "FetchRows" });
5033
+ return removed;
4810
5034
  }
4811
5035
 
4812
5036
  //#endregion
@@ -7189,18 +7413,155 @@ var Runtime = class {
7189
7413
 
7190
7414
  //#endregion
7191
7415
  //#region src/services/logger/index.ts
7192
- function createLogger(level = "info", transmit) {
7416
+ const LEVELS = pino.levels.values;
7417
+ const CAPTURE_KEY = "sp00ky:devtools:logCapture";
7418
+ function isLevel(value) {
7419
+ return typeof value === "string" && value in LEVELS;
7420
+ }
7421
+ /**
7422
+ * JSON for a log object that can never throw into the call site: Errors keep
7423
+ * their message and stack (plain `JSON.stringify` prints `{}`), bigints become
7424
+ * strings, and a cycle prints `[Circular]` instead of throwing.
7425
+ */
7426
+ function stringifyLog(o) {
7427
+ const ancestors = [];
7428
+ try {
7429
+ return JSON.stringify(o, function(_key, value) {
7430
+ if (typeof value === "bigint") return value.toString();
7431
+ if (value instanceof Error) return {
7432
+ type: value.name,
7433
+ message: value.message,
7434
+ stack: value.stack
7435
+ };
7436
+ if (typeof value !== "object" || value === null) return value;
7437
+ while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop();
7438
+ if (ancestors.includes(value)) return "[Circular]";
7439
+ ancestors.push(value);
7440
+ return value;
7441
+ });
7442
+ } catch {
7443
+ const msg = o?.msg;
7444
+ return JSON.stringify({ msg: typeof msg === "string" ? msg : "[unserializable log object]" });
7445
+ }
7446
+ }
7447
+ /**
7448
+ * The page-side log buffer behind the DevTools Logs tab. Always on and
7449
+ * bounded: a line costs the JSON the console write produces anyway, and a
7450
+ * panel opened after boot still sees the boot.
7451
+ *
7452
+ * It also owns the logger's level. The console prints at the configured level
7453
+ * only; `setCaptureLevel` changes what is RECORDED (debug without flooding the
7454
+ * console), live on every child logger, and keeps it for this tab's session so
7455
+ * a reload captures its boot at that level too.
7456
+ */
7457
+ var LogTap = class LogTap {
7458
+ static CAPACITY = 500;
7459
+ static LINE_MAX = 4e3;
7460
+ ring = [];
7461
+ seq = 0;
7462
+ /** `seq` at the last `clear()`: lines up to here are gone on purpose, not dropped. */
7463
+ clearedThrough = 0;
7464
+ listeners = /* @__PURE__ */ new Set();
7465
+ loggers = [];
7466
+ captureLevelValue;
7467
+ constructor(consoleLevel) {
7468
+ this.consoleLevel = consoleLevel;
7469
+ this.captureLevelValue = consoleLevel;
7470
+ try {
7471
+ const saved = typeof sessionStorage !== "undefined" ? sessionStorage.getItem(CAPTURE_KEY) : null;
7472
+ if (isLevel(saved)) this.captureLevelValue = saved;
7473
+ } catch {}
7474
+ }
7475
+ /** `seq` of the newest captured line. */
7476
+ get head() {
7477
+ return this.seq;
7478
+ }
7479
+ get captureLevel() {
7480
+ return this.captureLevelValue;
7481
+ }
7482
+ /** What pino has to produce for both the console and the buffer. */
7483
+ get effectiveLevel() {
7484
+ return LEVELS[this.captureLevelValue] < LEVELS[this.consoleLevel] ? this.captureLevelValue : this.consoleLevel;
7485
+ }
7486
+ /** Whether the console prints a line at this numeric level. */
7487
+ printsToConsole(level) {
7488
+ return level >= LEVELS[this.consoleLevel];
7489
+ }
7490
+ /** Remember a logger (the root and every child) so a level change reaches it. */
7491
+ track(logger) {
7492
+ this.loggers.push(new WeakRef(logger));
7493
+ }
7494
+ setCaptureLevel(level) {
7495
+ if (!isLevel(level)) throw new Error(`unknown log level: ${String(level)}`);
7496
+ this.captureLevelValue = level;
7497
+ try {
7498
+ if (typeof sessionStorage !== "undefined") if (level === this.consoleLevel) sessionStorage.removeItem(CAPTURE_KEY);
7499
+ else sessionStorage.setItem(CAPTURE_KEY, level);
7500
+ } catch {}
7501
+ const effective = this.effectiveLevel;
7502
+ for (let i = this.loggers.length - 1; i >= 0; i--) {
7503
+ const logger = this.loggers[i].deref();
7504
+ if (logger) logger.level = effective;
7505
+ else this.loggers.splice(i, 1);
7506
+ }
7507
+ }
7508
+ record(o, line) {
7509
+ const level = typeof o.level === "number" ? o.level : LEVELS.info;
7510
+ if (level < LEVELS[this.captureLevelValue]) return;
7511
+ this.ring.push({
7512
+ seq: ++this.seq,
7513
+ time: typeof o.time === "number" ? o.time : Date.now(),
7514
+ level,
7515
+ msg: typeof o.msg === "string" ? o.msg : "",
7516
+ line: line.length > LogTap.LINE_MAX ? `${line.slice(0, LogTap.LINE_MAX)}…` : line
7517
+ });
7518
+ if (this.ring.length > LogTap.CAPACITY) this.ring.shift();
7519
+ for (const cb of this.listeners) try {
7520
+ cb();
7521
+ } catch {}
7522
+ }
7523
+ /** Lines after `after` (0 = everything still buffered), oldest first. */
7524
+ read(after = 0, limit = LogTap.CAPACITY) {
7525
+ const first = this.ring[0]?.seq ?? this.seq + 1;
7526
+ const fresh = this.ring.filter((e) => e.seq > after);
7527
+ return {
7528
+ entries: fresh.slice(Math.max(0, fresh.length - limit)),
7529
+ head: this.seq,
7530
+ dropped: Math.max(0, first - Math.max(after, this.clearedThrough) - 1),
7531
+ consoleLevel: this.consoleLevel,
7532
+ captureLevel: this.captureLevelValue
7533
+ };
7534
+ }
7535
+ clear() {
7536
+ this.ring.length = 0;
7537
+ this.clearedThrough = this.seq;
7538
+ }
7539
+ /** Called synchronously inside the log call: keep it cheap, never log from it. */
7540
+ subscribe(cb) {
7541
+ this.listeners.add(cb);
7542
+ return () => void this.listeners.delete(cb);
7543
+ }
7544
+ };
7545
+ function createLogger(level = "info", transmit, tap) {
7193
7546
  const browserConfig = {
7194
7547
  asObject: true,
7195
7548
  write: (o) => {
7196
- console.log(JSON.stringify(o));
7549
+ const line = stringifyLog(o);
7550
+ if (!tap || tap.printsToConsole(o?.level)) console.log(line);
7551
+ tap?.record(o, line);
7197
7552
  }
7198
7553
  };
7199
- if (transmit) browserConfig.transmit = transmit;
7200
- return pino({
7201
- level,
7202
- browser: browserConfig
7554
+ if (transmit) browserConfig.transmit = {
7555
+ ...transmit,
7556
+ level: transmit.level ?? level
7557
+ };
7558
+ const logger = pino({
7559
+ level: tap ? tap.effectiveLevel : level,
7560
+ browser: browserConfig,
7561
+ onChild: (child) => tap?.track(child)
7203
7562
  });
7563
+ tap?.track(logger);
7564
+ return logger;
7204
7565
  }
7205
7566
 
7206
7567
  //#endregion
@@ -7313,7 +7674,7 @@ var AbstractDatabaseService = class {
7313
7674
  await this.beforeQuery();
7314
7675
  const startTime = performance.now();
7315
7676
  try {
7316
- this.logger.debug({
7677
+ this.logger.trace({
7317
7678
  query,
7318
7679
  vars,
7319
7680
  Category: "sp00ky-client::Database::query"
@@ -14172,7 +14533,8 @@ function mintSalt() {
14172
14533
  return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
14173
14534
  }
14174
14535
  function createServices(config) {
14175
- const logger = createLogger(config.logLevel ?? "info", config.otelTransmit);
14536
+ const logTap = new LogTap(config.logLevel ?? "info");
14537
+ const logger = createLogger(config.logLevel ?? "info", config.otelTransmit, logTap);
14176
14538
  if (config.crdt) preloadLoro();
14177
14539
  const tabsSupport = detectSharedTabsSupport(config);
14178
14540
  const local = createLocalEngine(config.localEngine, config.database, logger, { shared: tabsSupport.supported });
@@ -14205,6 +14567,7 @@ function createServices(config) {
14205
14567
  const tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `tab_${Math.random().toString(36).slice(2)}`;
14206
14568
  return {
14207
14569
  logger,
14570
+ logTap,
14208
14571
  local,
14209
14572
  remote,
14210
14573
  connectionSupervisor,
@@ -14225,7 +14588,7 @@ function createTabsCoordinator(config, s, host) {
14225
14588
  return new TabsCoordinator({
14226
14589
  tabId: s.tabId,
14227
14590
  fingerprint: computeTabsFingerprint({
14228
- coreVersion: "0.0.1-canary.290",
14591
+ coreVersion: "0.0.1-canary.292",
14229
14592
  schemaHash: hash53(config.schemaSurql),
14230
14593
  endpoint: config.database.endpoint ?? "",
14231
14594
  namespace: config.database.namespace,
@@ -14811,6 +15174,8 @@ const UNKNOWN_STORAGE_HEALTH = Object.freeze({
14811
15174
  status: "unknown",
14812
15175
  fallback: false
14813
15176
  });
15177
+ /** Longest SurrealQL a statement log line shows as its message. */
15178
+ const STATEMENT_MSG_MAX = 160;
14814
15179
  /**
14815
15180
  * The public client. Every method is a thin translation into a saga run, a
14816
15181
  * state read, or a subscription on the runtime; the engine's behaviour lives
@@ -14915,6 +15280,13 @@ var Sp00kyClient = class {
14915
15280
  this.devTools.setFeatureFlagOverrides(this.featureFlags);
14916
15281
  s.streamProcessor.addReceiver(this.devTools);
14917
15282
  this.devTools.setBlobInfoProvider(() => s.blobs.stats());
15283
+ this.devTools.setMutationSource({
15284
+ state: () => this.runtime.state,
15285
+ listFailed: () => this.listFailedMutations(),
15286
+ retryFailed: (id) => this.retryFailedMutation(id),
15287
+ discardFailed: (id) => this.discardFailedMutation(id)
15288
+ });
15289
+ if (s.logTap) this.devTools.setLogTap(s.logTap);
14918
15290
  if (config.sharedTabs) this.devTools.setTabsInfoProvider(() => {
14919
15291
  const c = s.tabs;
14920
15292
  if (!this.sharedActive || !c) return {
@@ -14967,31 +15339,75 @@ var Sp00kyClient = class {
14967
15339
  if (e.type === "tabs:sendTo" && this.hub) this.hub.sendTo(e.tabId, e.message);
14968
15340
  });
14969
15341
  this.runtime.on("*", (e) => this.forwardToDevTools(e));
14970
- s.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", (event) => this.devTools.logEvent("LOCAL_QUERY", event.payload));
14971
- s.remote.getEvents().subscribe("DATABASE_REMOTE_QUERY", (event) => this.devTools.logEvent("REMOTE_QUERY", event.payload));
15342
+ const logStatement = (store) => (event) => {
15343
+ if (!s.logger.isLevelEnabled("debug")) return;
15344
+ const q = event.payload ?? {};
15345
+ const sql = String(q.query ?? "").replace(/\s+/g, " ").trim();
15346
+ const cut = sql.length > STATEMENT_MSG_MAX;
15347
+ s.logger.debug({
15348
+ store,
15349
+ ...cut ? { query: q.query } : {},
15350
+ vars: q.vars,
15351
+ ms: Math.round((q.duration ?? 0) * 10) / 10,
15352
+ ok: q.success !== false,
15353
+ error: q.error,
15354
+ Category: `sp00ky-client::${store}`
15355
+ }, cut ? `${sql.slice(0, STATEMENT_MSG_MAX)}…` : sql);
15356
+ };
15357
+ s.local.getEvents().subscribe("DATABASE_LOCAL_QUERY", logStatement("local"));
15358
+ s.remote.getEvents().subscribe("DATABASE_REMOTE_QUERY", logStatement("remote"));
14972
15359
  }
15360
+ /** Runtime events the DevTools panel follows; query transitions also go to the log. */
14973
15361
  forwardToDevTools(e) {
15362
+ const log = this.services.logger;
14974
15363
  switch (e.type) {
14975
15364
  case "query:status":
14976
- this.devTools.logEvent("QUERY_STATUS_CHANGED", {
14977
- queryHash: e.hash,
14978
- status: e.status
14979
- });
15365
+ log.debug({
15366
+ hash: e.hash,
15367
+ status: e.status,
15368
+ Category: "sp00ky-client::query"
15369
+ }, "Query status");
15370
+ this.devTools.onStateChanged();
14980
15371
  break;
14981
15372
  case "query:authority":
14982
- this.devTools.logEvent("QUERY_AUTHORITY_CHANGED", {
14983
- queryHash: e.hash,
14984
- known: e.known
14985
- });
15373
+ log.debug({
15374
+ hash: e.hash,
15375
+ known: e.known,
15376
+ Category: "sp00ky-client::query"
15377
+ }, e.known ? "Query membership known" : "Query membership unknown");
15378
+ this.devTools.onStateChanged();
14986
15379
  break;
14987
15380
  case "query:view-lost":
14988
- this.devTools.logEvent("QUERY_VIEW_LOST", { queryHash: e.hash });
15381
+ log.info({
15382
+ hash: e.hash,
15383
+ Category: "sp00ky-client::query"
15384
+ }, "Query view lost on the server; re-registering");
15385
+ this.devTools.onStateChanged();
14989
15386
  break;
14990
15387
  case "mutation:event":
14991
15388
  this.devTools.onMutation([e.event]);
14992
15389
  break;
14993
- case "devtools":
14994
- this.devTools.logEvent(e.name, e.data);
15390
+ case "mutation:settled":
15391
+ this.devTools.onMutationOutcome({
15392
+ mutationId: e.mutationId,
15393
+ recordId: e.recordId,
15394
+ eventType: e.eventType,
15395
+ status: "synced"
15396
+ });
15397
+ break;
15398
+ case "mutation:rolled-back":
15399
+ this.devTools.onMutationOutcome({
15400
+ mutationId: e.mutationId,
15401
+ recordId: e.recordId,
15402
+ eventType: e.eventType,
15403
+ status: "rolled-back",
15404
+ error: e.error
15405
+ });
15406
+ break;
15407
+ case "tray:changed":
15408
+ case "health:changed":
15409
+ case "activity:changed":
15410
+ this.devTools.onStateChanged();
14995
15411
  break;
14996
15412
  default: break;
14997
15413
  }