@spooky-sync/core 0.0.1-canary.334 → 0.0.1-canary.336

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 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. Server membership arrives through one batched edge read (registration,
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. The tray is exposed as `failedMutationCount`,
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
@@ -1339,6 +1339,14 @@ interface OutboxItem {
1339
1339
  readonly status: OutboxStatus;
1340
1340
  readonly ackedAt: number | null;
1341
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;
1342
1350
  }
1343
1351
  type TabRole = 'solo' | 'leader' | 'follower';
1344
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
- * Many outbox rows as ONE multi-statement request. Statement i answers row i.
1530
- * Each statement is its own transaction on the server, as before.
1559
+ * What a statement of a `remoteBatch` throws instead of running, because an
1560
+ * earlier statement in the same request failed.
1561
+ */
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.
1531
1633
  */
1532
- function remoteBatch(rows) {
1634
+ function remoteBatch(rows, inDoubt = /* @__PURE__ */ new Set()) {
1533
1635
  const vars = {};
1534
1636
  return {
1535
- sql: rows.map((row, i) => {
1536
- const rid = parseRecordIdString(row.recordId);
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.334";
2202
- const WASM_VERSION = "0.0.1-canary.334";
2289
+ const CORE_VERSION = "0.0.1-canary.336";
2290
+ const WASM_VERSION = "0.0.1-canary.336";
2203
2291
  const SURREAL_VERSION = "3.1.0";
2204
2292
  var DevToolsService = class DevToolsService {
2205
2293
  version = CORE_VERSION;
@@ -3458,6 +3546,18 @@ const outboxBumpAttempts = (id) => (s) => {
3458
3546
  outbox
3459
3547
  };
3460
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
+ };
3461
3561
  /** Drop acked items older than `graceMs` (membership never named them). */
3462
3562
  const outboxPruneAcked = (now, graceMs) => (s) => {
3463
3563
  const stale = s.outbox.filter((i) => i.status === "acked" && i.ackedAt !== null && now - i.ackedAt >= graceMs);
@@ -5809,6 +5909,8 @@ const ERR = (r) => r && r.status === "ERR" ? r.error : null;
5809
5909
  * A CRDT edit-log row (`<table>__crdt_log`) is written once and never changes,
5810
5910
  * so a CREATE retried after its first attempt committed (and only the answer
5811
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.
5812
5914
  */
5813
5915
  const isRepeatedLogCreate = (row, err) => row.mutationType === "create" && row.tableName.endsWith("__crdt_log") && /already exists/i.test(err);
5814
5916
  /**
@@ -5816,6 +5918,17 @@ const isRepeatedLogCreate = (row, err) => row.mutationType === "create" && row.t
5816
5918
  * per request, per-statement outcome. Accepted rows are acked (they stay in
5817
5919
  * the overlay until membership names them), rejected rows are rolled back,
5818
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.
5819
5932
  */
5820
5933
  function* drain(env) {
5821
5934
  for (;;) {
@@ -5837,7 +5950,7 @@ function* drain(env) {
5837
5950
  else yield fx.state.update(outboxRemove(item.id));
5838
5951
  }
5839
5952
  if (rows.length === 0) continue;
5840
- const req = remoteBatch(rows);
5953
+ const req = remoteBatch(rows, new Set(batch.filter((i) => i.inDoubt).map((i) => i.id)));
5841
5954
  let results;
5842
5955
  try {
5843
5956
  const raw = yield fx.remote.query(req.sql, req.vars, env.pushTimeoutMs);
@@ -5856,7 +5969,7 @@ function* drain(env) {
5856
5969
  continue;
5857
5970
  }
5858
5971
  const attempts = batch.find((i) => i.id === rows[0].id).attempts;
5859
- yield fx.state.update(outboxBumpAttempts(rows[0].id));
5972
+ yield fx.state.update(compose(outboxMarkInDoubt(rows.map((row) => row.id)), outboxBumpAttempts(rows[0].id)));
5860
5973
  yield fx.dispatch({
5861
5974
  type: "SyncOutcome",
5862
5975
  ok: false,
@@ -5871,6 +5984,7 @@ function* drain(env) {
5871
5984
  const row = rows[i];
5872
5985
  const r = results[i];
5873
5986
  if (!r) {
5987
+ yield fx.state.update(outboxMarkInDoubt(rows.slice(i).map((unanswered) => unanswered.id)));
5874
5988
  cutAt = i;
5875
5989
  break;
5876
5990
  }
@@ -5908,6 +6022,7 @@ function* drain(env) {
5908
6022
  });
5909
6023
  continue;
5910
6024
  }
6025
+ if (err.includes(BATCH_SKIPPED)) break;
5911
6026
  if (classifySyncError(new Error(err)) === "network") {
5912
6027
  cutAt = i;
5913
6028
  break;
@@ -15774,7 +15889,7 @@ function createTabsCoordinator(config, s, host) {
15774
15889
  return new TabsCoordinator({
15775
15890
  tabId: s.tabId,
15776
15891
  fingerprint: computeTabsFingerprint({
15777
- coreVersion: "0.0.1-canary.334",
15892
+ coreVersion: "0.0.1-canary.336",
15778
15893
  schemaHash: hash53(config.schemaSurql),
15779
15894
  endpoint: config.database.endpoint ?? "",
15780
15895
  namespace: config.database.namespace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.334",
3
+ "version": "0.0.1-canary.336",
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.334",
79
- "@spooky-sync/ssp-wasm": "0.0.1-canary.334",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.336",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.336",
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. Inspect and
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)`.