@spooky-sync/core 0.0.1-canary.333 → 0.0.1-canary.335
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/AGENTS.md +8 -2
- package/README.md +4 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.js +191 -47
- package/dist/types.d.ts +12 -3
- package/package.json +3 -3
- package/skills/sp00ky-core/SKILL.md +6 -1
- package/src/boot/bucket-switch.saga.test.ts +9 -7
- package/src/boot/bucket-switch.saga.ts +6 -3
- package/src/client/runtime.test.ts +40 -0
- package/src/client/runtime.ts +2 -2
- package/src/modules/devtools/index.ts +3 -1
- package/src/mutation/ingest-outage.integration.test.ts +400 -0
- package/src/mutation/push.saga.test.ts +74 -14
- package/src/mutation/push.saga.ts +25 -5
- package/src/mutation/rows.test.ts +49 -2
- package/src/mutation/rows.ts +93 -21
- package/src/query/materialize.saga.test.ts +73 -0
- package/src/query/materialize.saga.ts +17 -5
- package/src/query/membership.saga.test.ts +5 -2
- package/src/query/membership.saga.ts +2 -3
- package/src/query/register.saga.test.ts +2 -1
- package/src/state/client-state.ts +8 -0
- package/src/state/lifecycle.test.ts +46 -3
- package/src/state/lifecycle.ts +32 -4
- package/src/state/reducers.test.ts +15 -2
- package/src/state/reducers.ts +23 -4
- package/src/state/selectors.ts +1 -0
- package/src/types.ts +12 -3
- package/src/utils/error-classification.test.ts +33 -0
- package/src/utils/error-classification.ts +27 -0
package/AGENTS.md
CHANGED
|
@@ -27,7 +27,13 @@ Sp00kyClient<S> ── facade: every method = one saga run, selector or subscri
|
|
|
27
27
|
|
|
28
28
|
Local writes are applied optimistically (row + outbox entry in one local
|
|
29
29
|
transaction), rendered through the outbox overlay, and drained to the remote in
|
|
30
|
-
batches.
|
|
30
|
+
FIFO batches. Each statement of a batch commits on its own, but the server stops
|
|
31
|
+
the batch at its first failed statement (`remoteBatch` chains them), so no write
|
|
32
|
+
reaches the server ahead of an earlier one that failed. A batch whose answer is
|
|
33
|
+
lost (it threw a transport error, or its rows were loaded from the store after a
|
|
34
|
+
restart) is in doubt (`OutboxItem.inDoubt`): re-sent, an in-doubt CREATE checks
|
|
35
|
+
the row already on the server and is a no-op when it holds what the CREATE
|
|
36
|
+
writes. Server membership arrives through one batched edge read (registration,
|
|
31
37
|
poll, LIVE dirt) and bodies are fetched once across all queries. Rows are always
|
|
32
38
|
re-materialized from state, never patched in place.
|
|
33
39
|
|
|
@@ -59,7 +65,7 @@ re-materialized from state, never patched in place.
|
|
|
59
65
|
- **A never-resolved query paints only vouched rows.** Before the server answers (`cold`), the local predicate scan shows a cached body only if some `_00_view` row names it (as a member or subquery child) or a write for it is in flight. A body no view names is an orphan (deleted upstream while nothing watched it): it stays hidden, and the GC deletes it 30 s after boot and then hourly. A `_00_view` row no query has resolved for 14 days is retired by the same sweep, so that query paints cold again.
|
|
60
66
|
- **`database.store` defaults to `'memory'`.** `'indexeddb'` keeps one IndexedDB database per user bucket (`sp00ky-<bucket>`) and needs `@surrealdb/wasm` >= 3.1.0: the 3.0.x engine opened the store but failed every write (`An IndexedDB error occured: idb error`), so each session fell back to memory. 3.1.0 ships the engine as `@surrealdb/wasm-native`; a Vite app lists it in `optimizeDeps.exclude` next to `@surrealdb/wasm`. A store that still cannot open after a drop runs the session in memory (error log `falling back to in-memory`). Under `localEngine: 'sqlite'`, `'indexeddb'` means OPFS.
|
|
61
67
|
- **Boot owns the first identity.** The facade subscribes to auth with `immediate: false`: boot opens the bucket the hint names and `auth.restoreSession` announces the user. `LocalDatabaseService.switchStore` also waits for an in-flight `connect`, and lets statements already past the gate finish before it closes a client: the 3.1 engine streams results from its worker, and a statement whose client closed under it stayed pending until its 30 s deadline with every later local statement queued behind it.
|
|
62
|
-
- **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued.
|
|
68
|
+
- **A rejected write is not lost.** Application errors roll the local row back (create/update/delete) and move the mutation to `_00_failed_mutations`; the tray API above lists, retries (as a fresh write) or discards it. Network errors keep the row queued, and so does a write SurrealDB refused only because sp00ky's own `_00_` ingest event could not post to the scheduler/SSP (refused, past its TIMEOUT, DNS, 5xx/408/429; `SP00KY_EVENT_POST_FAILED` in `utils/error-classification.ts`).
|
|
63
69
|
|
|
64
70
|
## Module map (under `src/`)
|
|
65
71
|
|
package/README.md
CHANGED
|
@@ -49,7 +49,10 @@ back.
|
|
|
49
49
|
acked (kept in the overlay until membership names them), rejected rows are
|
|
50
50
|
rolled back and moved to `_00_failed_mutations` in the same transaction that
|
|
51
51
|
deletes the pending row, a transport cut leaves the tail queued behind a
|
|
52
|
-
backoff.
|
|
52
|
+
backoff. A push whose answer never came (timeout, dropped socket, a session
|
|
53
|
+
that died mid-push) may have committed, so its rows are in doubt: re-sent, an
|
|
54
|
+
in-doubt CREATE that finds a matching row of its own on the server is a no-op
|
|
55
|
+
instead of an "already exists" rollback. The tray is exposed as `failedMutationCount`,
|
|
53
56
|
`subscribeToFailedMutations`, `listFailedMutations`, `retryFailedMutation`
|
|
54
57
|
and `discardFailedMutation`.
|
|
55
58
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1055,6 +1055,17 @@ type OutEvent = {
|
|
|
1055
1055
|
* behind `status: 'fetching'`, `notified` says whether subscribers received
|
|
1056
1056
|
* at least one materialization this registration.
|
|
1057
1057
|
*
|
|
1058
|
+
* `answered` says subscribers have been handed the rows of a known membership
|
|
1059
|
+
* (the server's, or the durable one of an earlier session) at least once:
|
|
1060
|
+
* a materialization ran past `cold`, with no body of a server set still to
|
|
1061
|
+
* download, and the membership it drew was still the one in state when it
|
|
1062
|
+
* committed. Only a re-seed (a new entry, a bucket switch) clears it, so a
|
|
1063
|
+
* reconnect or a lost view does not send a painted query back to loading.
|
|
1064
|
+
* It is what the bindings' `isAuthoritative()` mirrors (see `isAnswered`):
|
|
1065
|
+
* a `cached` seed or a freshly applied server set alone is not enough,
|
|
1066
|
+
* because `records` is still empty (or stale) until `materialize` paints, and
|
|
1067
|
+
* "known and empty" read in that beat made apps create rows that existed.
|
|
1068
|
+
*
|
|
1058
1069
|
* `backgroundDepth` is the part of `fetchDepth` that re-verifies rows the
|
|
1059
1070
|
* query already shows: a registration of a query that is not `cold`. It still
|
|
1060
1071
|
* holds `status: 'fetching'` (results are not yet confirmed), but it is not
|
|
@@ -1068,6 +1079,7 @@ interface QueryLifecycle {
|
|
|
1068
1079
|
readonly fetchDepth: number;
|
|
1069
1080
|
readonly backgroundDepth: number;
|
|
1070
1081
|
readonly notified: boolean;
|
|
1082
|
+
readonly answered: boolean;
|
|
1071
1083
|
}
|
|
1072
1084
|
//#endregion
|
|
1073
1085
|
//#region src/kernel/effects.d.ts
|
|
@@ -1327,6 +1339,14 @@ interface OutboxItem {
|
|
|
1327
1339
|
readonly status: OutboxStatus;
|
|
1328
1340
|
readonly ackedAt: number | null;
|
|
1329
1341
|
readonly attempts: number;
|
|
1342
|
+
/**
|
|
1343
|
+
* A request carrying this row may have committed without the answer
|
|
1344
|
+
* reaching this client: it threw a transport error, or the row was loaded
|
|
1345
|
+
* from the store, so a session that died mid-push may have sent it. Its
|
|
1346
|
+
* CREATE then accepts the row already on the server when that row holds
|
|
1347
|
+
* what it would write (see `remoteBatch`). Absent means false.
|
|
1348
|
+
*/
|
|
1349
|
+
readonly inDoubt?: boolean;
|
|
1330
1350
|
}
|
|
1331
1351
|
type TabRole = 'solo' | 'leader' | 'follower';
|
|
1332
1352
|
/** A debounced update accumulating locally until its flush writes the outbox row. */
|
package/dist/index.js
CHANGED
|
@@ -546,8 +546,32 @@ const NETWORK_ERROR_PATTERNS = [
|
|
|
546
546
|
"specify a namespace",
|
|
547
547
|
"specify a database"
|
|
548
548
|
];
|
|
549
|
+
/**
|
|
550
|
+
* A write SurrealDB refused because sp00ky's OWN ingest post failed: every
|
|
551
|
+
* synced table's `_00_<table>_mutation` / `_00_<table>_delete` event posts to
|
|
552
|
+
* the scheduler or SSP inside the write's transaction (`sp00ky.rs::ingest_post`),
|
|
553
|
+
* so a scheduler that is down, slow past the event TIMEOUT, or answering 5xx
|
|
554
|
+
* aborts the write. That is an outage, not the mutation's fault, and it clears
|
|
555
|
+
* on its own; classified `application` it was rolled back and discarded, so
|
|
556
|
+
* every write queued during a scheduler restart or a cold SSP was lost.
|
|
557
|
+
*
|
|
558
|
+
* Wording on SurrealDB 3.1.5 and 3.3.4 (the event name may be nested inside an
|
|
559
|
+
* app event that wrote to a synced table):
|
|
560
|
+
* "Error while processing event _00_item_mutation: There was an error
|
|
561
|
+
* processing a remote HTTP request: error sending request for url (...)"
|
|
562
|
+
* (refused connection or TIMEOUT), "...: 503 Service Unavailable" (5xx),
|
|
563
|
+
* "Error while processing event _00_item_mutation: failed to lookup address
|
|
564
|
+
* information: ..." (DNS).
|
|
565
|
+
*
|
|
566
|
+
* Only `_00_` events count: an app's own event posting to its own endpoint
|
|
567
|
+
* stays `application`, as does a 4xx from `/ingest` other than 408/429 (a
|
|
568
|
+
* malformed or oversized payload, a secret mismatch), which no retry fixes and
|
|
569
|
+
* which would otherwise hold the outbox's serial lane forever.
|
|
570
|
+
*/
|
|
571
|
+
const SP00KY_EVENT_POST_FAILED = /error while processing event _00_[^:\s]*: (?:there was an error processing a remote http request: (?:error sending request|5\d\d\b|408\b|429\b)|failed to lookup address information)/;
|
|
549
572
|
function classifySyncError(error) {
|
|
550
573
|
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
574
|
+
if (SP00KY_EVENT_POST_FAILED.test(message)) return "network";
|
|
551
575
|
for (const pattern of NETWORK_ERROR_PATTERNS) if (message.includes(pattern)) return "network";
|
|
552
576
|
return "application";
|
|
553
577
|
}
|
|
@@ -1382,6 +1406,11 @@ function hydrateRowData(row, columns) {
|
|
|
1382
1406
|
return row;
|
|
1383
1407
|
}
|
|
1384
1408
|
}
|
|
1409
|
+
/**
|
|
1410
|
+
* A row read back from the store at boot, bucket switch or leader takeover.
|
|
1411
|
+
* The session that queued it may have sent it and died before the answer (a
|
|
1412
|
+
* closed tab, a killed app, a lost leader), so its outcome is in doubt.
|
|
1413
|
+
*/
|
|
1385
1414
|
const toOutboxItem = (row) => ({
|
|
1386
1415
|
id: row.id,
|
|
1387
1416
|
type: row.mutationType,
|
|
@@ -1389,7 +1418,8 @@ const toOutboxItem = (row) => ({
|
|
|
1389
1418
|
table: row.tableName,
|
|
1390
1419
|
status: "pending",
|
|
1391
1420
|
ackedAt: null,
|
|
1392
|
-
attempts: 0
|
|
1421
|
+
attempts: 0,
|
|
1422
|
+
inDoubt: true
|
|
1393
1423
|
});
|
|
1394
1424
|
/** The `_00_pending_writes` id for a minted mutation id (same suffix). */
|
|
1395
1425
|
const pendingWriteId = (mutationId) => `${PENDING_WRITES_TABLE}:${mutationId.includes(":") ? mutationId.slice(mutationId.indexOf(":") + 1) : mutationId}`;
|
|
@@ -1526,28 +1556,86 @@ function planDeferredOutboxRowTx(input) {
|
|
|
1526
1556
|
};
|
|
1527
1557
|
}
|
|
1528
1558
|
/**
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1559
|
+
* What a statement of a `remoteBatch` throws instead of running, because an
|
|
1560
|
+
* earlier statement in the same request failed.
|
|
1531
1561
|
*/
|
|
1532
|
-
|
|
1562
|
+
const BATCH_SKIPPED = "sp00ky: not run, an earlier statement in this batch failed";
|
|
1563
|
+
/** A CRDT edit-log row (`<table>__crdt_log`): written once under a fresh id, never changed. */
|
|
1564
|
+
const isLogTable = (table) => table.endsWith("__crdt_log");
|
|
1565
|
+
/**
|
|
1566
|
+
* Which fields of the row already on the server must equal the payload of the
|
|
1567
|
+
* CREATE at `i` for that row to count as this CREATE's own earlier commit, or
|
|
1568
|
+
* `null` when the CREATE must not tolerate an existing row at all.
|
|
1569
|
+
*
|
|
1570
|
+
* Only a CREATE whose outcome is in doubt tolerates one: elsewhere "already
|
|
1571
|
+
* exists" means someone else holds the id, a rejection as before. The check
|
|
1572
|
+
* is every payload field except those a later in-doubt row of the same
|
|
1573
|
+
* record, which may have committed in the same lost round, could have changed
|
|
1574
|
+
* since; those rows run again after it and set them anyway. A later in-doubt
|
|
1575
|
+
* DELETE or CREATE of the record may have replaced the row, so then existence
|
|
1576
|
+
* is all that is checked. Rows that are not in doubt never ran.
|
|
1577
|
+
*
|
|
1578
|
+
* A CRDT log row's id is fresh and the row immutable, so its existence is
|
|
1579
|
+
* proof enough, in doubt or not.
|
|
1580
|
+
*/
|
|
1581
|
+
function matchFields(rows, i, inDoubt) {
|
|
1582
|
+
const row = rows[i];
|
|
1583
|
+
if (isLogTable(row.tableName)) return [];
|
|
1584
|
+
if (!inDoubt.has(row.id)) return null;
|
|
1585
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1586
|
+
for (const later of rows.slice(i + 1)) {
|
|
1587
|
+
if (later.recordId !== row.recordId || !inDoubt.has(later.id)) continue;
|
|
1588
|
+
if (later.mutationType !== "update") return [];
|
|
1589
|
+
for (const key of Object.keys(later.data ?? {})) changed.add(key);
|
|
1590
|
+
}
|
|
1591
|
+
return Object.keys(row.data ?? {}).filter((key) => !changed.has(key));
|
|
1592
|
+
}
|
|
1593
|
+
function remoteStatement(rows, i, vars, inDoubt) {
|
|
1594
|
+
const row = rows[i];
|
|
1595
|
+
vars[`id${i}`] = parseRecordIdString(row.recordId);
|
|
1596
|
+
switch (row.mutationType) {
|
|
1597
|
+
case "create": {
|
|
1598
|
+
const data = row.data ?? {};
|
|
1599
|
+
const create = `CREATE ONLY $id${i} SET ${Object.keys(data).map((key) => {
|
|
1600
|
+
vars[`d${i}_${key}`] = data[key];
|
|
1601
|
+
return `${key} = $d${i}_${key}`;
|
|
1602
|
+
}).join(", ")}`;
|
|
1603
|
+
const match = matchFields(rows, i, inDoubt);
|
|
1604
|
+
if (!match) return create;
|
|
1605
|
+
return `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${[`($was${i} = NONE)`, ...match.map((key) => `($was${i}.${key} != $d${i}_${key})`)].join(" OR ")} { ${create} }`;
|
|
1606
|
+
}
|
|
1607
|
+
case "update":
|
|
1608
|
+
vars[`data${i}`] = row.data ?? {};
|
|
1609
|
+
return `UPDATE $id${i} MERGE $data${i}`;
|
|
1610
|
+
case "delete": return `DELETE $id${i}`;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Many outbox rows as ONE multi-statement request. Statement i answers row i
|
|
1615
|
+
* and is its own transaction on the server, as before, but the statements are
|
|
1616
|
+
* chained: statement i binds `$ok<i>` only when it commits, and statement i+1
|
|
1617
|
+
* throws `BATCH_SKIPPED` without touching anything unless `$ok<i>` is bound
|
|
1618
|
+
* (a LET lives for one request only). So the server stops at the first
|
|
1619
|
+
* failure: everything before it is committed, nothing after it ran.
|
|
1620
|
+
*
|
|
1621
|
+
* Without the chain, statements after a failed one ran anyway. A retryable
|
|
1622
|
+
* failure at i left the rows after i queued although they had committed; the
|
|
1623
|
+
* re-sent CREATE then read "already exists", was taken for a rejection and
|
|
1624
|
+
* rolled back locally while the server kept the row. Acking them instead
|
|
1625
|
+
* would have let a later write land before an earlier one it may depend on.
|
|
1626
|
+
*
|
|
1627
|
+
* The chain cannot help when the answer itself is lost: the request may have
|
|
1628
|
+
* committed any prefix of the batch. Rows named in `inDoubt` (mutation ids)
|
|
1629
|
+
* are such rows. An UPDATE MERGE or a DELETE is safe to run twice, a CREATE
|
|
1630
|
+
* is not, so an in-doubt CREATE checks the row already there first (see
|
|
1631
|
+
* `matchFields`) and is a no-op when it is its own: the batch goes on in
|
|
1632
|
+
* order, with no "already exists" to cut it.
|
|
1633
|
+
*/
|
|
1634
|
+
function remoteBatch(rows, inDoubt = /* @__PURE__ */ new Set()) {
|
|
1533
1635
|
const vars = {};
|
|
1534
1636
|
return {
|
|
1535
|
-
sql: rows.map((
|
|
1536
|
-
|
|
1537
|
-
vars[`id${i}`] = rid;
|
|
1538
|
-
switch (row.mutationType) {
|
|
1539
|
-
case "create": {
|
|
1540
|
-
const data = row.data ?? {};
|
|
1541
|
-
return `CREATE ONLY $id${i} SET ${Object.keys(data).map((key) => {
|
|
1542
|
-
vars[`d${i}_${key}`] = data[key];
|
|
1543
|
-
return `${key} = $d${i}_${key}`;
|
|
1544
|
-
}).join(", ")}`;
|
|
1545
|
-
}
|
|
1546
|
-
case "update":
|
|
1547
|
-
vars[`data${i}`] = row.data ?? {};
|
|
1548
|
-
return `UPDATE $id${i} MERGE $data${i}`;
|
|
1549
|
-
case "delete": return `DELETE $id${i}`;
|
|
1550
|
-
}
|
|
1637
|
+
sql: rows.map((_, i) => {
|
|
1638
|
+
return `LET $ok${i} = { ${i === 0 ? "" : `IF !$ok${i - 1} { THROW "${BATCH_SKIPPED}" }; `}${remoteStatement(rows, i, vars, inDoubt)}; true }`;
|
|
1551
1639
|
}).join(";\n"),
|
|
1552
1640
|
vars
|
|
1553
1641
|
};
|
|
@@ -2198,8 +2286,8 @@ function trimHistory(history, cap) {
|
|
|
2198
2286
|
|
|
2199
2287
|
//#endregion
|
|
2200
2288
|
//#region src/modules/devtools/index.ts
|
|
2201
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
2202
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
2289
|
+
const CORE_VERSION = "0.0.1-canary.335";
|
|
2290
|
+
const WASM_VERSION = "0.0.1-canary.335";
|
|
2203
2291
|
const SURREAL_VERSION = "3.1.0";
|
|
2204
2292
|
var DevToolsService = class DevToolsService {
|
|
2205
2293
|
version = CORE_VERSION;
|
|
@@ -2338,7 +2426,7 @@ var DevToolsService = class DevToolsService {
|
|
|
2338
2426
|
remoteIds: remoteArray.slice(0, DevToolsService.STATE_IDS_CAP).map(([id]) => id),
|
|
2339
2427
|
idsTruncated: localArray.length > DevToolsService.STATE_IDS_CAP || remoteArray.length > DevToolsService.STATE_IDS_CAP,
|
|
2340
2428
|
membershipKnown: q.config.membershipKnown === true,
|
|
2341
|
-
authoritative: q.
|
|
2429
|
+
authoritative: q.answered === true,
|
|
2342
2430
|
viewLost: q.viewLost === true,
|
|
2343
2431
|
serverState: q.serverState ?? null,
|
|
2344
2432
|
timings: this.dataManager.phaseTimings(q)
|
|
@@ -2931,7 +3019,8 @@ function seedLifecycle(resolvedBefore) {
|
|
|
2931
3019
|
remote: "unregistered",
|
|
2932
3020
|
fetchDepth: 0,
|
|
2933
3021
|
backgroundDepth: 0,
|
|
2934
|
-
notified: false
|
|
3022
|
+
notified: false,
|
|
3023
|
+
answered: false
|
|
2935
3024
|
};
|
|
2936
3025
|
}
|
|
2937
3026
|
/** Total on every (lifecycle, event) pair the sagas can produce; throws on the rest. */
|
|
@@ -2980,17 +3069,27 @@ function transition(l, ev) {
|
|
|
2980
3069
|
backgroundDepth
|
|
2981
3070
|
};
|
|
2982
3071
|
}
|
|
2983
|
-
case "notified":
|
|
2984
|
-
|
|
2985
|
-
notified:
|
|
2986
|
-
|
|
3072
|
+
case "notified": {
|
|
3073
|
+
const answered = l.answered || ev.answered === true && l.phase !== "cold";
|
|
3074
|
+
return l.notified && answered === l.answered ? l : {
|
|
3075
|
+
...l,
|
|
3076
|
+
notified: true,
|
|
3077
|
+
answered
|
|
3078
|
+
};
|
|
3079
|
+
}
|
|
2987
3080
|
case "bucket-switch": return {
|
|
2988
3081
|
...seedLifecycle(ev.resolvedBefore),
|
|
2989
3082
|
fetchDepth: 0
|
|
2990
3083
|
};
|
|
2991
3084
|
}
|
|
2992
3085
|
}
|
|
3086
|
+
/** Membership is known (server set or durable seed); its rows may not be painted yet. */
|
|
2993
3087
|
const isAuthoritative = (l) => l.phase !== "cold";
|
|
3088
|
+
/**
|
|
3089
|
+
* Subscribers hold the rows of a known membership: what the authority channel
|
|
3090
|
+
* (`subscribeQueryAuthority`, the bindings' `isAuthoritative()`) reports.
|
|
3091
|
+
*/
|
|
3092
|
+
const isAnswered = (l) => l.answered;
|
|
2994
3093
|
const hasServerMembership = (l) => l.phase === "live" || l.phase === "view-lost";
|
|
2995
3094
|
const deriveStatus = (l) => l.fetchDepth > 0 ? "fetching" : "idle";
|
|
2996
3095
|
/** A fetch cycle the user is waiting on: rows to download, or a first registration. */
|
|
@@ -3208,7 +3307,12 @@ const setSubqueryRemoteArray = (hash, subqueryRemoteArray) => (s) => {
|
|
|
3208
3307
|
if (!entry || entry.lifecycle.phase === "cold") return next;
|
|
3209
3308
|
return withView(next, entry.def.viewKey, vouchedIds(entry.remoteArray, subqueryRemoteArray));
|
|
3210
3309
|
};
|
|
3211
|
-
|
|
3310
|
+
/**
|
|
3311
|
+
* Commit a materialization. `answered`: it drew a known membership with no
|
|
3312
|
+
* body still to download, and that membership is still current (see
|
|
3313
|
+
* `QueryLifecycle`).
|
|
3314
|
+
*/
|
|
3315
|
+
const setRecords = (hash, records, changed, materializeMs, answered = false) => (s) => {
|
|
3212
3316
|
const next = withEntry(s, hash, (e) => {
|
|
3213
3317
|
const prev = e.telemetry.phaseSamples.localFetch ?? [];
|
|
3214
3318
|
const telemetry = {
|
|
@@ -3227,7 +3331,10 @@ const setRecords = (hash, records, changed, materializeMs) => (s) => {
|
|
|
3227
3331
|
...e,
|
|
3228
3332
|
records: changed ? records : e.records,
|
|
3229
3333
|
telemetry,
|
|
3230
|
-
lifecycle: transition(e.lifecycle, {
|
|
3334
|
+
lifecycle: transition(e.lifecycle, {
|
|
3335
|
+
type: "notified",
|
|
3336
|
+
answered
|
|
3337
|
+
})
|
|
3231
3338
|
};
|
|
3232
3339
|
});
|
|
3233
3340
|
return next === s ? s : {
|
|
@@ -3439,6 +3546,18 @@ const outboxBumpAttempts = (id) => (s) => {
|
|
|
3439
3546
|
outbox
|
|
3440
3547
|
};
|
|
3441
3548
|
};
|
|
3549
|
+
/** Mark items whose last push may have committed although no answer came back. */
|
|
3550
|
+
const outboxMarkInDoubt = (ids) => (s) => {
|
|
3551
|
+
const marked = new Set(ids);
|
|
3552
|
+
if (!s.outbox.some((i) => marked.has(i.id) && !i.inDoubt)) return s;
|
|
3553
|
+
return {
|
|
3554
|
+
...s,
|
|
3555
|
+
outbox: s.outbox.map((i) => marked.has(i.id) && !i.inDoubt ? {
|
|
3556
|
+
...i,
|
|
3557
|
+
inDoubt: true
|
|
3558
|
+
} : i)
|
|
3559
|
+
};
|
|
3560
|
+
};
|
|
3442
3561
|
/** Drop acked items older than `graceMs` (membership never named them). */
|
|
3443
3562
|
const outboxPruneAcked = (now, graceMs) => (s) => {
|
|
3444
3563
|
const stale = s.outbox.filter((i) => i.status === "acked" && i.ackedAt !== null && now - i.ackedAt >= graceMs);
|
|
@@ -3484,7 +3603,13 @@ const setIdentity = (patch) => (s) => ({
|
|
|
3484
3603
|
...s,
|
|
3485
3604
|
...patch
|
|
3486
3605
|
});
|
|
3487
|
-
/**
|
|
3606
|
+
/**
|
|
3607
|
+
* Re-home a query after a bucket switch: new store, same hash, fresh sync state.
|
|
3608
|
+
* `records` is kept: it is what subscribers were last handed, and the next
|
|
3609
|
+
* render diffs against it. Blanking it here (without telling anyone) let a
|
|
3610
|
+
* new store that renders `[]` read as unchanged, so the previous principal's
|
|
3611
|
+
* rows stayed on screen under an authority flip that vouched for them.
|
|
3612
|
+
*/
|
|
3488
3613
|
const rebindQuery = (hash, next) => (s) => {
|
|
3489
3614
|
const rebound = withEntry(s, hash, (e) => ({
|
|
3490
3615
|
...e,
|
|
@@ -3496,7 +3621,6 @@ const rebindQuery = (hash, next) => (s) => {
|
|
|
3496
3621
|
remoteArray: next.remoteArray,
|
|
3497
3622
|
localArray: next.localArray,
|
|
3498
3623
|
subqueryRemoteArray: [],
|
|
3499
|
-
records: [],
|
|
3500
3624
|
serverState: null,
|
|
3501
3625
|
lastHeartbeatAt: null,
|
|
3502
3626
|
lastPolledAt: null,
|
|
@@ -3729,6 +3853,7 @@ function toQueryState(e) {
|
|
|
3729
3853
|
viewLost: l.phase === "view-lost",
|
|
3730
3854
|
serverState: e.serverState,
|
|
3731
3855
|
syncNotified: l.notified,
|
|
3856
|
+
answered: l.answered,
|
|
3732
3857
|
ttlTimer: null,
|
|
3733
3858
|
ttlDurationMs: e.def.ttlMs,
|
|
3734
3859
|
lastHeartbeatAt: e.lastHeartbeatAt ?? void 0,
|
|
@@ -4820,14 +4945,8 @@ function* applyMembership(hash, remoteArray, meta, verifiedRemoval) {
|
|
|
4820
4945
|
});
|
|
4821
4946
|
return outcome;
|
|
4822
4947
|
}
|
|
4823
|
-
const wasAuthoritative = isAuthoritative(entry.lifecycle);
|
|
4824
4948
|
yield fx.state.update(commitMembership(hash, remoteArray, meta?.present ?? true));
|
|
4825
4949
|
yield* forgetDeparted(hash, departedIds(entry.remoteArray, remoteArray));
|
|
4826
|
-
if (!wasAuthoritative) yield fx.emit({
|
|
4827
|
-
type: "query:authority",
|
|
4828
|
-
hash,
|
|
4829
|
-
known: true
|
|
4830
|
-
});
|
|
4831
4950
|
const now = yield fx.now();
|
|
4832
4951
|
try {
|
|
4833
4952
|
yield fx.local.upsert(VIEW_TABLE, viewRecordId(entry.def.viewKey), viewRow(remoteArray, entry.subqueryRemoteArray, true, now), "replace");
|
|
@@ -5790,6 +5909,8 @@ const ERR = (r) => r && r.status === "ERR" ? r.error : null;
|
|
|
5790
5909
|
* A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
|
|
5791
5910
|
* so a CREATE retried after its first attempt committed (and only the answer
|
|
5792
5911
|
* was lost) finds it already there: that is a success, not a rejection.
|
|
5912
|
+
* `remoteBatch` already makes such a CREATE a no-op on the server; this covers
|
|
5913
|
+
* a row the user may create but not read back, where that check cannot see it.
|
|
5793
5914
|
*/
|
|
5794
5915
|
const isRepeatedLogCreate = (row, err) => row.mutationType === "create" && row.tableName.endsWith("__crdt_log") && /already exists/i.test(err);
|
|
5795
5916
|
/**
|
|
@@ -5797,6 +5918,17 @@ const isRepeatedLogCreate = (row, err) => row.mutationType === "create" && row.t
|
|
|
5797
5918
|
* per request, per-statement outcome. Accepted rows are acked (they stay in
|
|
5798
5919
|
* the overlay until membership names them), rejected rows are rolled back,
|
|
5799
5920
|
* a transport failure leaves the unsent tail queued behind a backoff timer.
|
|
5921
|
+
* The server stops a batch at its first failed statement (`remoteBatch`), so
|
|
5922
|
+
* the rows after a rejection are sent again at once, and a re-sent row behind
|
|
5923
|
+
* a retryable failure never ran before, so it cannot read "already exists".
|
|
5924
|
+
*
|
|
5925
|
+
* A request that throws a transport error (the push timeout, a dropped
|
|
5926
|
+
* socket) may still have committed on the server, and so may one sent by a
|
|
5927
|
+
* session that died before its answer. Those rows are marked in doubt
|
|
5928
|
+
* (`OutboxItem.inDoubt`, every row loaded from the store is), and their
|
|
5929
|
+
* re-sent CREATE accepts the row already on the server when it holds what the
|
|
5930
|
+
* CREATE would write, instead of failing "already exists" and rolling back a
|
|
5931
|
+
* write the server kept.
|
|
5800
5932
|
*/
|
|
5801
5933
|
function* drain(env) {
|
|
5802
5934
|
for (;;) {
|
|
@@ -5818,7 +5950,7 @@ function* drain(env) {
|
|
|
5818
5950
|
else yield fx.state.update(outboxRemove(item.id));
|
|
5819
5951
|
}
|
|
5820
5952
|
if (rows.length === 0) continue;
|
|
5821
|
-
const req = remoteBatch(rows);
|
|
5953
|
+
const req = remoteBatch(rows, new Set(batch.filter((i) => i.inDoubt).map((i) => i.id)));
|
|
5822
5954
|
let results;
|
|
5823
5955
|
try {
|
|
5824
5956
|
const raw = yield fx.remote.query(req.sql, req.vars, env.pushTimeoutMs);
|
|
@@ -5837,7 +5969,7 @@ function* drain(env) {
|
|
|
5837
5969
|
continue;
|
|
5838
5970
|
}
|
|
5839
5971
|
const attempts = batch.find((i) => i.id === rows[0].id).attempts;
|
|
5840
|
-
yield fx.state.update(outboxBumpAttempts(rows[0].id));
|
|
5972
|
+
yield fx.state.update(compose(outboxMarkInDoubt(rows.map((row) => row.id)), outboxBumpAttempts(rows[0].id)));
|
|
5841
5973
|
yield fx.dispatch({
|
|
5842
5974
|
type: "SyncOutcome",
|
|
5843
5975
|
ok: false,
|
|
@@ -5852,6 +5984,7 @@ function* drain(env) {
|
|
|
5852
5984
|
const row = rows[i];
|
|
5853
5985
|
const r = results[i];
|
|
5854
5986
|
if (!r) {
|
|
5987
|
+
yield fx.state.update(outboxMarkInDoubt(rows.slice(i).map((unanswered) => unanswered.id)));
|
|
5855
5988
|
cutAt = i;
|
|
5856
5989
|
break;
|
|
5857
5990
|
}
|
|
@@ -5889,6 +6022,7 @@ function* drain(env) {
|
|
|
5889
6022
|
});
|
|
5890
6023
|
continue;
|
|
5891
6024
|
}
|
|
6025
|
+
if (err.includes(BATCH_SKIPPED)) break;
|
|
5892
6026
|
if (classifySyncError(new Error(err)) === "network") {
|
|
5893
6027
|
cutAt = i;
|
|
5894
6028
|
break;
|
|
@@ -6671,14 +6805,18 @@ const NONE = /* @__PURE__ */ new Set();
|
|
|
6671
6805
|
* only writer of `records`. Runs on the `mat:<hash>` dedupe lane whenever the
|
|
6672
6806
|
* query is dirty. Before the server has answered (`cold`) only bodies some
|
|
6673
6807
|
* durable view vouches for paint (see `paintsCold`).
|
|
6808
|
+
*
|
|
6809
|
+
* Also the only place a query becomes answered (see `QueryLifecycle`): the
|
|
6810
|
+
* authority flip is emitted here, after the rows it vouches for.
|
|
6674
6811
|
*/
|
|
6675
6812
|
function* materialize(hash) {
|
|
6676
|
-
const [entry, ov, known] = yield fx.state.read((s) => {
|
|
6813
|
+
const [entry, ov, known, complete] = yield fx.state.read((s) => {
|
|
6677
6814
|
const e = s.queries.get(hash);
|
|
6678
6815
|
return [
|
|
6679
6816
|
e,
|
|
6680
6817
|
overlay(s),
|
|
6681
|
-
e?.lifecycle.phase === "cold" ? vouched(s) : NONE
|
|
6818
|
+
e?.lifecycle.phase === "cold" ? vouched(s) : NONE,
|
|
6819
|
+
needed(s, hash).length === 0
|
|
6682
6820
|
];
|
|
6683
6821
|
});
|
|
6684
6822
|
if (!entry) return;
|
|
@@ -6718,12 +6856,18 @@ function* materialize(hash) {
|
|
|
6718
6856
|
if (!current) return;
|
|
6719
6857
|
const t1 = yield fx.now();
|
|
6720
6858
|
const changed = !rowsEqual(rows, current.records);
|
|
6721
|
-
|
|
6859
|
+
const answered = complete && entry.lifecycle.phase !== "cold" && current.lifecycle.phase !== "cold" && current.remoteArray === entry.remoteArray;
|
|
6860
|
+
yield fx.state.update(compose(setRecords(hash, rows, changed, t1 - t0, answered), changed ? stampUpdated(hash, t1) : (s) => s));
|
|
6722
6861
|
if (changed) yield fx.emit({
|
|
6723
6862
|
type: "query:records",
|
|
6724
6863
|
hash,
|
|
6725
6864
|
records: rows
|
|
6726
6865
|
});
|
|
6866
|
+
if (answered && !isAnswered(current.lifecycle)) yield fx.emit({
|
|
6867
|
+
type: "query:authority",
|
|
6868
|
+
hash,
|
|
6869
|
+
known: true
|
|
6870
|
+
});
|
|
6727
6871
|
}
|
|
6728
6872
|
/**
|
|
6729
6873
|
* A view update from the in-browser SSP: the query's local id-set moved.
|
|
@@ -7206,10 +7350,10 @@ function* rebindQueries() {
|
|
|
7206
7350
|
remoteArray: view?.ids ?? [],
|
|
7207
7351
|
localArray
|
|
7208
7352
|
}));
|
|
7209
|
-
if (
|
|
7353
|
+
if (isAnswered(entry.lifecycle)) yield fx.emit({
|
|
7210
7354
|
type: "query:authority",
|
|
7211
7355
|
hash: entry.def.hash,
|
|
7212
|
-
known:
|
|
7356
|
+
known: false
|
|
7213
7357
|
});
|
|
7214
7358
|
}
|
|
7215
7359
|
}
|
|
@@ -7688,7 +7832,7 @@ var Runtime = class {
|
|
|
7688
7832
|
this.authoritySubs.set(hash, set);
|
|
7689
7833
|
if (options.immediate) {
|
|
7690
7834
|
const entry = this.stateValue.queries.get(hash);
|
|
7691
|
-
if (entry) this.safely(() => cb(
|
|
7835
|
+
if (entry) this.safely(() => cb(isAnswered(entry.lifecycle)));
|
|
7692
7836
|
}
|
|
7693
7837
|
return () => {
|
|
7694
7838
|
set.delete(cb);
|
|
@@ -15745,7 +15889,7 @@ function createTabsCoordinator(config, s, host) {
|
|
|
15745
15889
|
return new TabsCoordinator({
|
|
15746
15890
|
tabId: s.tabId,
|
|
15747
15891
|
fingerprint: computeTabsFingerprint({
|
|
15748
|
-
coreVersion: "0.0.1-canary.
|
|
15892
|
+
coreVersion: "0.0.1-canary.335",
|
|
15749
15893
|
schemaHash: hash53(config.schemaSurql),
|
|
15750
15894
|
endpoint: config.database.endpoint ?? "",
|
|
15751
15895
|
namespace: config.database.namespace,
|
package/dist/types.d.ts
CHANGED
|
@@ -2069,6 +2069,12 @@ interface QueryState {
|
|
|
2069
2069
|
* always emits at least once even when its records are unchanged — otherwise
|
|
2070
2070
|
* an empty re-registered window would never notify and stay "loading". */
|
|
2071
2071
|
syncNotified?: boolean;
|
|
2072
|
+
/**
|
|
2073
|
+
* Subscribers hold the rows of a known membership (see `QueryLifecycle`'s
|
|
2074
|
+
* `answered`): what the authority channel and the bindings'
|
|
2075
|
+
* `isAuthoritative()` report. Ephemeral.
|
|
2076
|
+
*/
|
|
2077
|
+
answered?: boolean;
|
|
2072
2078
|
/** Timer for TTL expiration. */
|
|
2073
2079
|
ttlTimer: NodeJS.Timeout | null;
|
|
2074
2080
|
/** TTL duration in milliseconds. */
|
|
@@ -2147,9 +2153,12 @@ interface QueryTimings {
|
|
|
2147
2153
|
type QueryUpdateCallback = (records: Record<string, any>[]) => void;
|
|
2148
2154
|
type QueryStatusCallback = (status: QueryStatus) => void;
|
|
2149
2155
|
/**
|
|
2150
|
-
* Observer for a query's authority flips: `true` once
|
|
2151
|
-
*
|
|
2152
|
-
*
|
|
2156
|
+
* Observer for a query's authority flips: `true` once subscribers hold the
|
|
2157
|
+
* rendered rows of a known membership (a server answer whose bodies are
|
|
2158
|
+
* local, or the durable membership of an earlier session), delivered after
|
|
2159
|
+
* the rows that vouch for it; `false` when it is reset (bucket switch). Knowing the set is
|
|
2160
|
+
* not enough: a `cached` re-seed or a just-applied server set has no rows
|
|
2161
|
+
* painted yet. What the bindings' `isAuthoritative()` mirrors.
|
|
2153
2162
|
*/
|
|
2154
2163
|
type QueryAuthorityCallback = (known: boolean) => void;
|
|
2155
2164
|
type MutationCallback = (mutations: MutationEvent[]) => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.335",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -75,8 +75,8 @@
|
|
|
75
75
|
}
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
79
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
78
|
+
"@spooky-sync/query-builder": "0.0.1-canary.335",
|
|
79
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.335",
|
|
80
80
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
81
81
|
"@surrealdb/wasm": "^3.1.0",
|
|
82
82
|
"@surrealdb/wasm-native": "^3.3.4",
|
|
@@ -129,7 +129,12 @@ Use `debounced` option for frequent updates (e.g., live typing):
|
|
|
129
129
|
|
|
130
130
|
A push the server rejects (permission, validation) is rolled back locally
|
|
131
131
|
(create, update and delete) and moved to the `_00_failed_mutations` tray in the
|
|
132
|
-
same local transaction. Network errors never do this; they retry
|
|
132
|
+
same local transaction. Network errors never do this; they retry, and so does a
|
|
133
|
+
write the server refused only because sp00ky's own ingest post to the scheduler
|
|
134
|
+
failed (down, past its timeout, or answering 5xx). A create whose push answer
|
|
135
|
+
was lost (timeout, dropped socket, a tab or app closed mid-push) is not rolled
|
|
136
|
+
back when its retry finds the row it wrote already on the server; a row there
|
|
137
|
+
with other content is still "already exists". Inspect and
|
|
133
138
|
act on the tray with `db.failedMutationCount`, `db.subscribeToFailedMutations(cb)`,
|
|
134
139
|
`db.listFailedMutations()`, `db.retryFailedMutation(id)` (a fresh optimistic
|
|
135
140
|
write) and `db.discardFailedMutation(id)`.
|
|
@@ -158,9 +158,9 @@ describe('dropImpersonatedBucket', () => {
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
describe('rebindQueries', () => {
|
|
161
|
-
it('re-seeds from the new store, rebuilds the local view (tolerating failures),
|
|
161
|
+
it('re-seeds from the new store, rebuilds the local view (tolerating failures), withdraws authority it held', async () => {
|
|
162
162
|
const s = R.compose(
|
|
163
|
-
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live' } })),
|
|
163
|
+
R.putQuery(buildEntry({ def: { hash: 'a' }, lifecycle: { phase: 'live', answered: true }, records: [{ id: 't:9' }] })),
|
|
164
164
|
R.putQuery(buildEntry({ def: { hash: 'b' }, lifecycle: { phase: 'cold' } }))
|
|
165
165
|
)({ ...buildState(), sessionId: 's' });
|
|
166
166
|
const out = await runPure(rebindQueries(), {
|
|
@@ -177,11 +177,13 @@ describe('rebindQueries', () => {
|
|
|
177
177
|
},
|
|
178
178
|
});
|
|
179
179
|
expect(out.state.queries.get('a')!.lifecycle.phase).toBe('cold');
|
|
180
|
-
expect(out.state.queries.get('b')!.lifecycle
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
]);
|
|
180
|
+
expect(out.state.queries.get('b')!.lifecycle).toMatchObject({ phase: 'cached', answered: false });
|
|
181
|
+
// `b` found a durable view, but its rows are not painted until the
|
|
182
|
+
// render: announcing "known" here is what let an app read the empty
|
|
183
|
+
// `records` of a re-seed as "the user has none" and create a duplicate.
|
|
184
|
+
expect(out.emitted.filter((e) => e.type === 'query:authority')).toEqual([{ type: 'query:authority', hash: 'a', known: false }]);
|
|
185
|
+
expect(out.state.queries.get('a')!.records).toEqual([{ id: 't:9' }]);
|
|
186
|
+
expect([...out.state.dirty].sort()).toEqual(['a', 'b']);
|
|
185
187
|
expect(out.emitted.some((e) => e.type === 'log')).toBe(true);
|
|
186
188
|
});
|
|
187
189
|
});
|
|
@@ -3,7 +3,7 @@ import type { Saga } from '../kernel/saga';
|
|
|
3
3
|
import type { RegisterResult } from '../kernel/effects';
|
|
4
4
|
import { fx } from '../kernel/effects';
|
|
5
5
|
import type { ClientState, QueryEntry } from '../state/client-state';
|
|
6
|
-
import { seedLifecycle,
|
|
6
|
+
import { seedLifecycle, isAnswered } from '../state/lifecycle';
|
|
7
7
|
import * as R from '../state/reducers';
|
|
8
8
|
import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
|
|
9
9
|
import type { SagaEnv } from '../query/env';
|
|
@@ -126,8 +126,11 @@ export function* rebindQueries(): Saga<void> {
|
|
|
126
126
|
}
|
|
127
127
|
const lifecycle = seedLifecycle(isResolvedBefore(view));
|
|
128
128
|
yield fx.state.update(R.rebindQuery(entry.def.hash, { id: new RecordId('_00_query', hash), lifecycle, remoteArray: view?.ids ?? [], localArray }));
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
// A re-seed is never answered, not even from a durable view: subscribers
|
|
130
|
+
// still hold the previous store's rows until `materialize` paints the new
|
|
131
|
+
// store's, and only that render flips authority back on.
|
|
132
|
+
if (isAnswered(entry.lifecycle)) {
|
|
133
|
+
yield fx.emit({ type: 'query:authority', hash: entry.def.hash, known: false });
|
|
131
134
|
}
|
|
132
135
|
}
|
|
133
136
|
}
|