@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.d.ts +4 -5
- package/dist/index.js +1462 -1046
- package/dist/types.d.ts +66 -1
- package/package.json +3 -3
- package/src/client/services.ts +6 -2
- package/src/client/sp00ky-client.test.ts +33 -1
- package/src/client/sp00ky-client.ts +52 -7
- package/src/kernel/events.ts +0 -1
- package/src/modules/devtools/index.ts +264 -92
- package/src/modules/devtools/mutations.test.ts +92 -0
- package/src/modules/devtools/mutations.ts +160 -0
- package/src/modules/devtools/notify-throttle.test.ts +34 -22
- package/src/modules/devtools/state-shape.test.ts +129 -15
- package/src/services/database/database.ts +3 -1
- package/src/services/logger/index.test.ts +132 -0
- package/src/services/logger/index.ts +187 -6
- package/src/testing/fake-services.ts +17 -3
package/dist/index.js
CHANGED
|
@@ -1290,350 +1290,705 @@ var WebPushModule = class {
|
|
|
1290
1290
|
};
|
|
1291
1291
|
|
|
1292
1292
|
//#endregion
|
|
1293
|
-
//#region src/
|
|
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
|
-
*
|
|
1296
|
-
*
|
|
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
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
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
|
-
|
|
1438
|
-
|
|
1439
|
-
const
|
|
1440
|
-
|
|
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
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
*
|
|
1480
|
-
* `
|
|
1481
|
-
*
|
|
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
|
|
1484
|
-
|
|
1485
|
-
const
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
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
|
-
*
|
|
1502
|
-
*
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
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
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1559
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
if (
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
return
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
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
|
-
|
|
1596
|
-
|
|
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
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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.
|
|
1756
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
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
|
-
|
|
2329
|
+
onStreamUpdate(update) {
|
|
2330
|
+
if (update.synthetic) return;
|
|
1877
2331
|
this.logger.debug({
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
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
|
-
|
|
1890
|
-
this.
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
-
|
|
1902
|
-
|
|
1903
|
-
this.
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
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
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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: [
|
|
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.
|
|
4333
|
-
|
|
4334
|
-
type: "query:evicted",
|
|
4729
|
+
for (const hash of hashes) yield fx.dispatch({
|
|
4730
|
+
type: "RegisterRemote",
|
|
4335
4731
|
hash
|
|
4336
4732
|
});
|
|
4337
4733
|
}
|
|
4338
|
-
|
|
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
|
-
*
|
|
4352
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
4369
|
-
*
|
|
4370
|
-
*
|
|
4371
|
-
*
|
|
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*
|
|
4376
|
-
yield fx.state.
|
|
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
|
|
4379
|
-
const
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
const
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
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
|
-
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
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: "
|
|
4409
|
-
data: {
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
}
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
4426
|
-
|
|
4427
|
-
|
|
4428
|
-
|
|
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
|
-
*
|
|
4514
|
-
*
|
|
4515
|
-
*
|
|
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
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
|
|
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
|
-
|
|
4531
|
-
|
|
4532
|
-
|
|
4533
|
-
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
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
|
-
/**
|
|
4614
|
-
function
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
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
|
-
*
|
|
4628
|
-
|
|
4629
|
-
|
|
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
|
-
*
|
|
4654
|
-
*
|
|
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
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
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
|
-
*
|
|
4675
|
-
*
|
|
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
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
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
|
-
})
|
|
4697
|
-
|
|
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
|
-
*
|
|
4702
|
-
*
|
|
4703
|
-
*
|
|
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
|
|
4706
|
-
const
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
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
|
|
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
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
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
|
-
|
|
4749
|
-
}
|
|
4750
|
-
}
|
|
4751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
7200
|
-
|
|
7201
|
-
level
|
|
7202
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
14971
|
-
|
|
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
|
-
|
|
14977
|
-
|
|
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
|
-
|
|
14983
|
-
|
|
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
|
-
|
|
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 "
|
|
14994
|
-
this.devTools.
|
|
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
|
}
|