@spooky-sync/core 0.0.1-canary.38 → 0.0.1-canary.381

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/AGENTS.md +91 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2238 -144
  4. package/dist/index.js +18149 -2475
  5. package/dist/live.d.ts +149 -0
  6. package/dist/live.js +739 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/pure.d.ts +401 -0
  9. package/dist/pure.js +24 -0
  10. package/dist/sqlite-open.js +392 -0
  11. package/dist/sqlite-worker.d.ts +1 -0
  12. package/dist/sqlite-worker.js +439 -0
  13. package/dist/sw.d.ts +207 -0
  14. package/dist/sw.js +393 -0
  15. package/dist/tabs-broker-worker.d.ts +8 -0
  16. package/dist/tabs-broker-worker.js +477 -0
  17. package/dist/types.d.ts +2082 -39
  18. package/dist/worker-edges.js +498 -0
  19. package/dist/worker-impersonation.js +60 -0
  20. package/dist/worker-ref-tables.d.ts +48 -0
  21. package/dist/worker-types.d.ts +112 -0
  22. package/dist/worker-types.js +73 -0
  23. package/package.json +28 -7
  24. package/scripts/check-broker-bundle.mjs +33 -0
  25. package/scripts/check-worker-entries.mjs +173 -0
  26. package/skills/sp00ky-core/SKILL.md +42 -8
  27. package/skills/sp00ky-core/references/config.md +8 -2
  28. package/src/boot/auth-flip.saga.test.ts +174 -0
  29. package/src/boot/auth-flip.saga.ts +77 -0
  30. package/src/boot/boot.saga.test.ts +203 -0
  31. package/src/boot/boot.saga.ts +146 -0
  32. package/src/boot/bucket-switch.saga.test.ts +191 -0
  33. package/src/boot/bucket-switch.saga.ts +151 -0
  34. package/src/boot/preload.saga.test.ts +96 -0
  35. package/src/boot/preload.saga.ts +45 -0
  36. package/src/bucket-blurhash.test.ts +148 -0
  37. package/src/build-globals.d.ts +12 -0
  38. package/src/client/bucket-handle.test.ts +195 -0
  39. package/src/client/bucket-handle.ts +352 -0
  40. package/src/client/router.test.ts +59 -0
  41. package/src/client/router.ts +104 -0
  42. package/src/client/runtime.test.ts +319 -0
  43. package/src/client/runtime.ts +307 -0
  44. package/src/client/services.test.ts +74 -0
  45. package/src/client/services.ts +437 -0
  46. package/src/client/sp00ky-client.test.ts +428 -0
  47. package/src/client/sp00ky-client.ts +737 -0
  48. package/src/events/index.ts +3 -0
  49. package/src/index.ts +86 -1
  50. package/src/kernel/constants.test.ts +19 -0
  51. package/src/kernel/constants.ts +94 -0
  52. package/src/kernel/effects.test.ts +40 -0
  53. package/src/kernel/effects.ts +171 -0
  54. package/src/kernel/events.ts +60 -0
  55. package/src/kernel/interpreter.test.ts +116 -0
  56. package/src/kernel/interpreter.ts +148 -0
  57. package/src/kernel/saga.test.ts +89 -0
  58. package/src/kernel/saga.ts +73 -0
  59. package/src/live/index.test.ts +512 -0
  60. package/src/live/index.ts +1059 -0
  61. package/src/modules/app-release/index.test.ts +125 -0
  62. package/src/modules/app-release/index.ts +209 -0
  63. package/src/modules/auth/auth.local-first.test.ts +228 -0
  64. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  65. package/src/modules/auth/events/index.ts +7 -1
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.integration.test.ts +209 -0
  68. package/src/modules/auth/impersonation.test.ts +377 -0
  69. package/src/modules/auth/impersonation.ts +179 -0
  70. package/src/modules/auth/index.ts +538 -14
  71. package/src/modules/channel/channel.test.ts +752 -0
  72. package/src/modules/channel/index.ts +1153 -0
  73. package/src/modules/channel/webrtc.ts +472 -0
  74. package/src/modules/crdt/acceptance.ts +75 -0
  75. package/src/modules/crdt/bytes.ts +50 -0
  76. package/src/modules/crdt/crdt-field.ts +125 -0
  77. package/src/modules/crdt/crdt.test.ts +546 -0
  78. package/src/modules/crdt/hash.ts +103 -0
  79. package/src/modules/crdt/index.ts +821 -0
  80. package/src/modules/crdt/loro-loader.ts +25 -0
  81. package/src/modules/crdt/mirror.ts +37 -0
  82. package/src/modules/devtools/flags.ts +349 -0
  83. package/src/modules/devtools/index.ts +746 -93
  84. package/src/modules/devtools/mutations.test.ts +92 -0
  85. package/src/modules/devtools/mutations.ts +160 -0
  86. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  87. package/src/modules/devtools/state-shape.test.ts +263 -0
  88. package/src/modules/devtools/storage-info.test.ts +79 -0
  89. package/src/modules/devtools/storage-info.ts +168 -0
  90. package/src/modules/devtools/versions.test.ts +74 -0
  91. package/src/modules/devtools/versions.ts +110 -0
  92. package/src/modules/feature-flag/index.test.ts +298 -0
  93. package/src/modules/feature-flag/index.ts +316 -0
  94. package/src/modules/ref-tables.test.ts +93 -0
  95. package/src/modules/ref-tables.ts +91 -0
  96. package/src/modules/web-push/index.test.ts +824 -0
  97. package/src/modules/web-push/index.ts +1027 -0
  98. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  99. package/src/mutation/jobs.test.ts +25 -0
  100. package/src/mutation/jobs.ts +41 -0
  101. package/src/mutation/mutation-id.test.ts +37 -0
  102. package/src/mutation/mutation-id.ts +35 -0
  103. package/src/mutation/pending-writes.integration.test.ts +175 -0
  104. package/src/mutation/push.saga.test.ts +361 -0
  105. package/src/mutation/push.saga.ts +245 -0
  106. package/src/mutation/rows.test.ts +278 -0
  107. package/src/mutation/rows.ts +515 -0
  108. package/src/mutation/tray.saga.test.ts +63 -0
  109. package/src/mutation/tray.saga.ts +48 -0
  110. package/src/mutation/write.saga.test.ts +436 -0
  111. package/src/mutation/write.saga.ts +384 -0
  112. package/src/pure.ts +66 -0
  113. package/src/push/types.ts +174 -0
  114. package/src/query/anon-query-isolation.integration.test.ts +531 -0
  115. package/src/query/env.test.ts +23 -0
  116. package/src/query/env.ts +65 -0
  117. package/src/query/fetch.saga.test.ts +199 -0
  118. package/src/query/fetch.saga.ts +120 -0
  119. package/src/query/hash.test.ts +14 -0
  120. package/src/query/hash.ts +18 -0
  121. package/src/query/id-set-plan.test.ts +132 -0
  122. package/src/query/lifecycle.saga.test.ts +274 -0
  123. package/src/query/lifecycle.saga.ts +186 -0
  124. package/src/query/list-ref-live.integration.test.ts +203 -0
  125. package/src/query/materialize.saga.test.ts +200 -0
  126. package/src/query/materialize.saga.ts +102 -0
  127. package/src/query/materialize.test.ts +58 -0
  128. package/src/query/materialize.ts +57 -0
  129. package/src/query/membership.saga.test.ts +514 -0
  130. package/src/query/membership.saga.ts +339 -0
  131. package/src/query/membership.test.ts +151 -0
  132. package/src/query/membership.ts +234 -0
  133. package/src/query/register.saga.test.ts +369 -0
  134. package/src/query/register.saga.ts +293 -0
  135. package/src/query/render-set.test.ts +47 -0
  136. package/src/query/render-set.ts +71 -0
  137. package/src/query/session-guard.integration.test.ts +304 -0
  138. package/src/query/sql.test.ts +118 -0
  139. package/src/query/sql.ts +174 -0
  140. package/src/query/window-query.test.ts +70 -0
  141. package/src/query/window-query.ts +208 -0
  142. package/src/services/blobs/blob-cache.test.ts +359 -0
  143. package/src/services/blobs/blob-cache.ts +603 -0
  144. package/src/services/blobs/blob-manifest.ts +227 -0
  145. package/src/services/blobs/blob-store.test.ts +77 -0
  146. package/src/services/blobs/blob-store.ts +359 -0
  147. package/src/services/blobs/blob.fixture.ts +90 -0
  148. package/src/services/blobs/index.ts +70 -0
  149. package/src/services/database/cache-engine.ts +209 -0
  150. package/src/services/database/connection-supervisor.test.ts +318 -0
  151. package/src/services/database/connection-supervisor.ts +422 -0
  152. package/src/services/database/database.query-timeout.test.ts +140 -0
  153. package/src/services/database/database.responses.test.ts +43 -0
  154. package/src/services/database/database.ts +151 -56
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/index.ts +7 -0
  158. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  159. package/src/services/database/local-migrator.integration.test.ts +110 -0
  160. package/src/services/database/local-migrator.ts +54 -9
  161. package/src/services/database/local.test.ts +150 -0
  162. package/src/services/database/local.ts +503 -59
  163. package/src/services/database/plan-render.test.ts +159 -0
  164. package/src/services/database/plan-render.ts +108 -0
  165. package/src/services/database/relation-resolver.test.ts +484 -0
  166. package/src/services/database/relation-resolver.ts +0 -0
  167. package/src/services/database/remote.ts +283 -4
  168. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  169. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  170. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  171. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  172. package/src/services/database/sqlite-column-reset.ts +91 -0
  173. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  174. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  175. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  176. package/src/services/database/sqlite-lock-verify.ts +45 -0
  177. package/src/services/database/sqlite-open.test.ts +150 -0
  178. package/src/services/database/sqlite-open.ts +164 -0
  179. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.ts +196 -0
  181. package/src/services/database/sqlite-projection.test.ts +121 -0
  182. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  183. package/src/services/database/sqlite-select.test.ts +261 -0
  184. package/src/services/database/sqlite-select.ts +131 -0
  185. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  186. package/src/services/database/sqlite-transport.ts +224 -0
  187. package/src/services/database/sqlite-worker.ts +437 -0
  188. package/src/services/database/surql-translate.ts +417 -0
  189. package/src/services/database/surreal-cache-engine.ts +163 -0
  190. package/src/services/logger/index.test.ts +132 -0
  191. package/src/services/logger/index.ts +187 -6
  192. package/src/services/persistence/resilient.ts +8 -1
  193. package/src/services/persistence/surrealdb.ts +2 -2
  194. package/src/services/stream-processor/index.ts +801 -63
  195. package/src/services/stream-processor/permissions.test.ts +47 -0
  196. package/src/services/stream-processor/permissions.ts +53 -0
  197. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  198. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  199. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  200. package/src/services/stream-processor/wasm-types.ts +57 -1
  201. package/src/services/tabs/broker-client.ts +284 -0
  202. package/src/services/tabs/broker.test.ts +381 -0
  203. package/src/services/tabs/coordinator.test.ts +386 -0
  204. package/src/services/tabs/coordinator.ts +646 -0
  205. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  206. package/src/services/tabs/leader-locks.ts +75 -0
  207. package/src/services/tabs/protocol.ts +262 -0
  208. package/src/services/tabs/support.ts +36 -0
  209. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  210. package/src/sp00ky.ts +5 -394
  211. package/src/state/client-state.ts +231 -0
  212. package/src/state/lifecycle.test.ts +184 -0
  213. package/src/state/lifecycle.ts +127 -0
  214. package/src/state/reducers.test.ts +337 -0
  215. package/src/state/reducers.ts +571 -0
  216. package/src/state/selectors.test.ts +203 -0
  217. package/src/state/selectors.ts +264 -0
  218. package/src/sw/index.test.ts +582 -0
  219. package/src/sw/index.ts +607 -0
  220. package/src/sw/store.ts +96 -0
  221. package/src/sync/connection.saga.test.ts +97 -0
  222. package/src/sync/connection.saga.ts +89 -0
  223. package/src/sync/live.saga.test.ts +229 -0
  224. package/src/sync/live.saga.ts +116 -0
  225. package/src/sync/policy.health.test.ts +56 -0
  226. package/src/sync/policy.test.ts +663 -0
  227. package/src/sync/policy.ts +515 -0
  228. package/src/sync/poll.saga.test.ts +51 -0
  229. package/src/sync/poll.saga.ts +36 -0
  230. package/src/sync/tabs.saga.test.ts +78 -0
  231. package/src/sync/tabs.saga.ts +96 -0
  232. package/src/testing/adapters.ts +137 -0
  233. package/src/testing/build.ts +63 -0
  234. package/src/testing/fake-services.ts +63 -0
  235. package/src/testing/run-pure.test.ts +92 -0
  236. package/src/testing/run-pure.ts +128 -0
  237. package/src/testing/services.ts +17 -0
  238. package/src/types.ts +694 -7
  239. package/src/utils/blurhash.ts +90 -0
  240. package/src/utils/edges.ts +57 -0
  241. package/src/utils/error-classification.test.ts +116 -0
  242. package/src/utils/error-classification.ts +74 -0
  243. package/src/utils/index.ts +83 -6
  244. package/src/utils/parser.test.ts +67 -102
  245. package/src/utils/parser.ts +63 -2
  246. package/src/utils/semver.test.ts +32 -0
  247. package/src/utils/semver.ts +30 -0
  248. package/src/utils/sha256.ts +15 -0
  249. package/src/utils/surql.ts +22 -11
  250. package/src/utils/utils.test.ts +3 -2
  251. package/tsdown.config.ts +117 -3
  252. package/vitest.config.ts +48 -0
  253. package/src/modules/cache/index.ts +0 -241
  254. package/src/modules/cache/types.ts +0 -19
  255. package/src/modules/data/data.test.ts +0 -58
  256. package/src/modules/data/index.ts +0 -882
  257. package/src/modules/sync/engine.ts +0 -173
  258. package/src/modules/sync/events/index.ts +0 -78
  259. package/src/modules/sync/index.ts +0 -3
  260. package/src/modules/sync/queue/index.ts +0 -2
  261. package/src/modules/sync/queue/queue-down.ts +0 -90
  262. package/src/modules/sync/queue/queue-up.ts +0 -224
  263. package/src/modules/sync/scheduler.ts +0 -84
  264. package/src/modules/sync/sync.ts +0 -422
  265. package/src/modules/sync/utils.test.ts +0 -311
  266. package/src/modules/sync/utils.ts +0 -169
@@ -0,0 +1,515 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { MutationEventType } from '../types';
3
+ import type { Vars } from '../kernel/effects';
4
+ import type { IngestRecord } from '../services/stream-processor/index';
5
+ import type { OutboxItem } from '../state/client-state';
6
+ import { surql } from '../utils/surql';
7
+ import type { SealedQuery } from '../utils/surql';
8
+ import { extractTablePart, parseRecordIdString } from '../utils/index';
9
+ import { parseQueryParams } from '../utils/parser';
10
+
11
+ export const PENDING_TABLE = '_00_pending_mutations';
12
+ export const FAILED_TABLE = '_00_failed_mutations';
13
+ /** Debounced patches still inside their delay (see `DebounceOptions.flushOnHide`). */
14
+ export const PENDING_WRITES_TABLE = '_00_pending_writes';
15
+ export const PENDING_ROW_VERSION = 2;
16
+
17
+ /** One `_00_pending_mutations` row as this client writes it (v2). */
18
+ export interface PendingMutationRow {
19
+ id: string;
20
+ mutationType: MutationEventType;
21
+ recordId: string;
22
+ tableName: string;
23
+ data?: Record<string, unknown>;
24
+ beforeRecord?: Record<string, unknown> | null;
25
+ createdAt: number;
26
+ v: number;
27
+ }
28
+
29
+ export interface FailedMutationRow {
30
+ id: string;
31
+ mutationType: MutationEventType;
32
+ recordId: string;
33
+ tableName: string;
34
+ data?: Record<string, unknown>;
35
+ beforeRecord?: Record<string, unknown> | null;
36
+ error: { message: string; kind: 'application' | 'unreplayable' };
37
+ attempts: number;
38
+ createdAt: number;
39
+ failedAt: number;
40
+ revert: 'full' | 'partial' | 'none';
41
+ }
42
+
43
+ /**
44
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
45
+ * escaping (outbox ids contain `_`, so they come back escaped).
46
+ */
47
+ export function parseStoredRecordId(id: string): RecordId<string> {
48
+ const [table, ...rest] = id.split(':');
49
+ let raw = rest.join(':');
50
+ if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
51
+ return new RecordId(table, raw);
52
+ }
53
+
54
+ /** Stable `table:id` string for an id read from the store, escaping removed. */
55
+ export const storedIdString = (id: unknown): string => {
56
+ if (id instanceof RecordId) return `${id.table.name}:${String(id.id)}`;
57
+ const rid = parseStoredRecordId(String(id));
58
+ return `${rid.table.name}:${rid.id}`;
59
+ };
60
+
61
+ /** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
62
+ export function createdAtFromId(id: string): number | null {
63
+ const raw = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
64
+ const m = /^⟨?(\d{13})/.exec(raw);
65
+ return m ? Number(m[1]) : null;
66
+ }
67
+
68
+ /**
69
+ * Read any row shape this table ever had. Legacy rows (`v` missing) lack
70
+ * `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
71
+ * replayable and returns `null`.
72
+ */
73
+ export function parsePendingRow(row: unknown): PendingMutationRow | null {
74
+ if (!row || typeof row !== 'object') return null;
75
+ const r = row as Record<string, unknown>;
76
+ const type = r.mutationType;
77
+ if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
78
+ if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
79
+ const id = storedIdString(r.id);
80
+ const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
81
+ const data = r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined;
82
+ if (type === 'create' && !data) return null;
83
+ return {
84
+ id,
85
+ mutationType: type,
86
+ recordId,
87
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
88
+ data,
89
+ beforeRecord:
90
+ r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : undefined,
91
+ createdAt: typeof r.createdAt === 'number' ? r.createdAt : (createdAtFromId(id) ?? 0),
92
+ v: typeof r.v === 'number' ? r.v : 1,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Re-type a stored row's payload from the schema before it is pushed. The
98
+ * store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
99
+ * datetime as a string; the server coerces neither. Unknown keys stay as they
100
+ * are, and a payload the schema rejects is sent raw so the server's answer,
101
+ * not a local throw, decides its fate.
102
+ */
103
+ export function hydrateRowData(row: PendingMutationRow, columns: Record<string, unknown> | null): PendingMutationRow {
104
+ if (!row.data || !columns) return row;
105
+ try {
106
+ return { ...row, data: parseQueryParams(columns as never, row.data) };
107
+ } catch {
108
+ return row;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * A row read back from the store at boot, bucket switch or leader takeover.
114
+ * The session that queued it may have sent it and died before the answer (a
115
+ * closed tab, a killed app, a lost leader), so its outcome is in doubt.
116
+ */
117
+ export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
118
+ id: row.id,
119
+ type: row.mutationType,
120
+ recordId: row.recordId,
121
+ table: row.tableName,
122
+ status: 'pending',
123
+ ackedAt: null,
124
+ attempts: 0,
125
+ inDoubt: true,
126
+ });
127
+
128
+ /**
129
+ * One `_00_pending_writes` row: the merged patch of a debounced update that
130
+ * has not reached the outbox yet. `flushBy` is when the writing tab's timer
131
+ * is due to move it there.
132
+ */
133
+ export interface PendingWriteRow {
134
+ id: string;
135
+ recordId: string;
136
+ tableName: string;
137
+ data: Record<string, unknown>;
138
+ beforeRecord: Record<string, unknown> | null;
139
+ flushBy: number;
140
+ }
141
+
142
+ /** The `_00_pending_writes` id for a minted mutation id (same suffix). */
143
+ export const pendingWriteId = (mutationId: string): string =>
144
+ `${PENDING_WRITES_TABLE}:${mutationId.includes(':') ? mutationId.slice(mutationId.indexOf(':') + 1) : mutationId}`;
145
+
146
+ export function parsePendingWriteRow(row: unknown): PendingWriteRow | null {
147
+ if (!row || typeof row !== 'object') return null;
148
+ const r = row as Record<string, unknown>;
149
+ if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
150
+ if (!r.data || typeof r.data !== 'object') return null;
151
+ const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
152
+ return {
153
+ id: storedIdString(r.id),
154
+ recordId,
155
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
156
+ data: r.data as Record<string, unknown>,
157
+ beforeRecord: r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
158
+ flushBy: typeof r.flushBy === 'number' ? r.flushBy : 0,
159
+ };
160
+ }
161
+
162
+ export const loadPendingRows = (): string => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
163
+ export const loadPendingWriteRows = (): string => `SELECT * FROM ${PENDING_WRITES_TABLE}`;
164
+ export const loadFailedRows = (): string => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
165
+
166
+ // ---- local write transactions ------------------------------------------------
167
+
168
+ export interface LocalTx {
169
+ query: SealedQuery<unknown>;
170
+ vars: Vars;
171
+ }
172
+
173
+ const pendingInsert = (type: MutationEventType, withData: boolean, withBefore: boolean): string => {
174
+ const fields = [
175
+ `mutationType = '${type}'`,
176
+ 'recordId = $id',
177
+ 'tableName = $table',
178
+ 'createdAt = $createdAt',
179
+ `v = ${PENDING_ROW_VERSION}`,
180
+ ];
181
+ if (withData) fields.push('data = $data');
182
+ if (withBefore) fields.push('beforeRecord = $before');
183
+ return `CREATE ONLY $mid SET ${fields.join(', ')}`;
184
+ };
185
+
186
+ export interface WritePlanInput {
187
+ recordId: RecordId<string>;
188
+ mutationId: RecordId<string>;
189
+ table: string;
190
+ data?: Record<string, unknown>;
191
+ before?: Record<string, unknown> | null;
192
+ now: number;
193
+ }
194
+
195
+ /** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
196
+ export function planCreateTx(input: WritePlanInput): LocalTx {
197
+ const data = input.data ?? {};
198
+ const keys = Object.keys(data).map((key) => ({ key, variable: `data_${key}` }));
199
+ const vars: Vars = { id: input.recordId, mid: input.mutationId, table: input.table, createdAt: input.now, data };
200
+ for (const { key, variable } of keys) vars[variable] = data[key];
201
+ return {
202
+ query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys), pendingInsert('create', true, false)]), {
203
+ resultIndex: 0,
204
+ }),
205
+ vars,
206
+ };
207
+ }
208
+
209
+ /** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
210
+ export function planUpdateTx(input: WritePlanInput): LocalTx {
211
+ return {
212
+ query: surql.seal<unknown>(
213
+ surql.tx([
214
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
215
+ surql.let('updated', surql.updateMerge('id', 'data')),
216
+ pendingInsert('update', true, true),
217
+ surql.returnObject([{ key: 'target', variable: 'updated' }]),
218
+ ])
219
+ ),
220
+ vars: {
221
+ id: input.recordId,
222
+ mid: input.mutationId,
223
+ table: input.table,
224
+ createdAt: input.now,
225
+ data: input.data ?? {},
226
+ before: input.before ?? null,
227
+ },
228
+ };
229
+ }
230
+
231
+ /** DELETE the row and write the outbox entry (with `beforeRecord`). */
232
+ export function planDeleteTx(input: WritePlanInput): LocalTx {
233
+ return {
234
+ query: surql.seal<unknown>(surql.tx([surql.delete('id'), pendingInsert('delete', false, true)])),
235
+ vars: {
236
+ id: input.recordId,
237
+ mid: input.mutationId,
238
+ table: input.table,
239
+ createdAt: input.now,
240
+ before: input.before ?? null,
241
+ },
242
+ };
243
+ }
244
+
245
+ /**
246
+ * A channel peer's create: the row alone, no outbox entry (the peer's own
247
+ * client pushes it). Result index 0 is the row.
248
+ */
249
+ export function planPeerCreateTx(input: { recordId: RecordId<string>; data: Record<string, unknown> }): LocalTx {
250
+ const keys = Object.keys(input.data).map((key) => ({ key, variable: `data_${key}` }));
251
+ const vars: Vars = { id: input.recordId };
252
+ for (const { key, variable } of keys) vars[variable] = input.data[key];
253
+ return { query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys)]), { resultIndex: 0 }), vars };
254
+ }
255
+
256
+ /** Undo a peer's create the server never confirmed: the row alone, no outbox entry. */
257
+ export function planPeerUndoTx(recordId: RecordId<string>): LocalTx {
258
+ return { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: recordId } };
259
+ }
260
+
261
+ /**
262
+ * Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later.
263
+ * With a `mirror`, the merged patch so far is written to `_00_pending_writes`
264
+ * in the same transaction, so the row never changes without a durable copy of
265
+ * what still has to reach the server.
266
+ */
267
+ export function planLocalOnlyUpdateTx(input: {
268
+ recordId: RecordId<string>;
269
+ data: Record<string, unknown>;
270
+ mirror?: { id: RecordId<string>; row: Omit<PendingWriteRow, 'id' | 'recordId'> & { recordId: RecordId<string> } };
271
+ }): LocalTx {
272
+ const statements = [surql.updateSet('id', [{ statement: '_00_rv += 1' }]), surql.let('updated', surql.updateMerge('id', 'data'))];
273
+ if (input.mirror) statements.push(surql.upsert('wid', 'wrow'));
274
+ statements.push(surql.returnObject([{ key: 'target', variable: 'updated' }]));
275
+ return {
276
+ query: surql.seal<unknown>(surql.tx(statements)),
277
+ vars: input.mirror ? { id: input.recordId, data: input.data, wid: input.mirror.id, wrow: input.mirror.row } : { id: input.recordId, data: input.data },
278
+ };
279
+ }
280
+
281
+ /**
282
+ * The outbox row of a flushed debounced update. With `mirrorId` the
283
+ * `_00_pending_writes` row goes in the same transaction: the patch moves
284
+ * from one table to the other, it is never in both or in neither.
285
+ */
286
+ export function planDeferredOutboxRowTx(input: WritePlanInput & { mirrorId?: RecordId<string> }): LocalTx {
287
+ const statements = [pendingInsert('update', true, true)];
288
+ if (input.mirrorId) statements.push(surql.delete('wid'));
289
+ return {
290
+ query: surql.seal<unknown>(surql.tx(statements)),
291
+ vars: {
292
+ id: input.recordId,
293
+ mid: input.mutationId,
294
+ table: input.table,
295
+ createdAt: input.now,
296
+ data: input.data ?? {},
297
+ before: input.before ?? null,
298
+ ...(input.mirrorId ? { wid: input.mirrorId } : {}),
299
+ },
300
+ };
301
+ }
302
+
303
+ // ---- remote push -------------------------------------------------------------
304
+
305
+ export interface RemoteBatch {
306
+ sql: string;
307
+ vars: Vars;
308
+ }
309
+
310
+ /**
311
+ * What a statement of a `remoteBatch` throws instead of running, because an
312
+ * earlier statement in the same request failed.
313
+ */
314
+ export const BATCH_SKIPPED = 'sp00ky: not run, an earlier statement in this batch failed';
315
+
316
+ /** A CRDT edit-log row (`<table>__crdt_log`): written once under a fresh id, never changed. */
317
+ const isLogTable = (table: string): boolean => table.endsWith('__crdt_log');
318
+
319
+ /**
320
+ * Which fields of the row already on the server must equal the payload of the
321
+ * CREATE at `i` for that row to count as this CREATE's own earlier commit, or
322
+ * `null` when the CREATE must not tolerate an existing row at all.
323
+ *
324
+ * Only a CREATE whose outcome is in doubt tolerates one: elsewhere "already
325
+ * exists" means someone else holds the id, a rejection as before. The check
326
+ * is every payload field except those a later in-doubt row of the same
327
+ * record, which may have committed in the same lost round, could have changed
328
+ * since; those rows run again after it and set them anyway. A later in-doubt
329
+ * DELETE or CREATE of the record may have replaced the row, so then existence
330
+ * is all that is checked. Rows that are not in doubt never ran.
331
+ *
332
+ * A CRDT log row's id is fresh and the row immutable, so its existence is
333
+ * proof enough, in doubt or not.
334
+ */
335
+ function matchFields(rows: ReadonlyArray<PendingMutationRow>, i: number, inDoubt: ReadonlySet<string>): string[] | null {
336
+ const row = rows[i];
337
+ if (isLogTable(row.tableName)) return [];
338
+ if (!inDoubt.has(row.id)) return null;
339
+ const changed = new Set<string>();
340
+ for (const later of rows.slice(i + 1)) {
341
+ if (later.recordId !== row.recordId || !inDoubt.has(later.id)) continue;
342
+ if (later.mutationType !== 'update') return [];
343
+ for (const key of Object.keys(later.data ?? {})) changed.add(key);
344
+ }
345
+ return Object.keys(row.data ?? {}).filter((key) => !changed.has(key));
346
+ }
347
+
348
+ function remoteStatement(rows: ReadonlyArray<PendingMutationRow>, i: number, vars: Vars, inDoubt: ReadonlySet<string>): string {
349
+ const row = rows[i];
350
+ vars[`id${i}`] = parseRecordIdString(row.recordId);
351
+ switch (row.mutationType) {
352
+ case 'create': {
353
+ const data = row.data ?? {};
354
+ const sets = Object.keys(data).map((key) => {
355
+ vars[`d${i}_${key}`] = data[key];
356
+ return `${key} = $d${i}_${key}`;
357
+ });
358
+ const create = `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
359
+ const match = matchFields(rows, i, inDoubt);
360
+ if (!match) return create;
361
+ // The CREATE runs unless the row is there and matches; on a mismatch it
362
+ // fails with SurrealDB's own "already exists", a rejection as before.
363
+ const differs = [`($was${i} = NONE)`, ...match.map((key) => `($was${i}.${key} != $d${i}_${key})`)];
364
+ return `LET $was${i} = (SELECT * FROM ONLY $id${i}); IF ${differs.join(' OR ')} { ${create} }`;
365
+ }
366
+ case 'update':
367
+ vars[`data${i}`] = row.data ?? {};
368
+ return `UPDATE $id${i} MERGE $data${i}`;
369
+ case 'delete':
370
+ return `DELETE $id${i}`;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Many outbox rows as ONE multi-statement request. Statement i answers row i
376
+ * and is its own transaction on the server, as before, but the statements are
377
+ * chained: statement i binds `$ok<i>` only when it commits, and statement i+1
378
+ * throws `BATCH_SKIPPED` without touching anything unless `$ok<i>` is bound
379
+ * (a LET lives for one request only). So the server stops at the first
380
+ * failure: everything before it is committed, nothing after it ran.
381
+ *
382
+ * Without the chain, statements after a failed one ran anyway. A retryable
383
+ * failure at i left the rows after i queued although they had committed; the
384
+ * re-sent CREATE then read "already exists", was taken for a rejection and
385
+ * rolled back locally while the server kept the row. Acking them instead
386
+ * would have let a later write land before an earlier one it may depend on.
387
+ *
388
+ * The chain cannot help when the answer itself is lost: the request may have
389
+ * committed any prefix of the batch. Rows named in `inDoubt` (mutation ids)
390
+ * are such rows. An UPDATE MERGE or a DELETE is safe to run twice, a CREATE
391
+ * is not, so an in-doubt CREATE checks the row already there first (see
392
+ * `matchFields`) and is a no-op when it is its own: the batch goes on in
393
+ * order, with no "already exists" to cut it.
394
+ */
395
+ export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>, inDoubt: ReadonlySet<string> = new Set()): RemoteBatch {
396
+ const vars: Vars = {};
397
+ const stmts = rows.map((_, i) => {
398
+ const guard = i === 0 ? '' : `IF !$ok${i - 1} { THROW "${BATCH_SKIPPED}" }; `;
399
+ return `LET $ok${i} = { ${guard}${remoteStatement(rows, i, vars, inDoubt)}; true }`;
400
+ });
401
+ return { sql: stmts.join(';\n'), vars };
402
+ }
403
+
404
+ // ---- rollback ----------------------------------------------------------------
405
+
406
+ export interface RevertPlan {
407
+ tx: LocalTx | null;
408
+ circuit: IngestRecord;
409
+ revert: FailedMutationRow['revert'];
410
+ }
411
+
412
+ /**
413
+ * How to undo a rejected mutation locally. A create is deleted; an update or
414
+ * delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
415
+ * comes back). Without a `beforeRecord` nothing can be restored.
416
+ */
417
+ export function planRevert(row: PendingMutationRow, before: Record<string, unknown> | null): RevertPlan {
418
+ const rid = parseRecordIdString(row.recordId);
419
+ if (row.mutationType === 'create') {
420
+ return {
421
+ tx: { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: rid } },
422
+ circuit: { table: row.tableName, op: 'DELETE', id: row.recordId, record: row.data ?? {} },
423
+ revert: 'full',
424
+ };
425
+ }
426
+ if (!before) {
427
+ return {
428
+ tx: null,
429
+ circuit: { table: row.tableName, op: 'UPDATE', id: row.recordId, record: {} },
430
+ revert: 'partial',
431
+ };
432
+ }
433
+ const { id: _id, ...content } = before;
434
+ return {
435
+ tx: { query: surql.seal<unknown>(surql.tx([surql.upsert('id', 'content')])), vars: { id: rid, content } },
436
+ circuit: {
437
+ table: row.tableName,
438
+ op: row.mutationType === 'delete' ? 'CREATE' : 'UPDATE',
439
+ id: row.recordId,
440
+ record: { ...before, id: rid },
441
+ },
442
+ revert: 'full',
443
+ };
444
+ }
445
+
446
+ export const failedRecordId = (mutationId: string): RecordId<string> =>
447
+ new RecordId(FAILED_TABLE, mutationId.slice(mutationId.indexOf(':') + 1));
448
+
449
+ export function buildFailedRow(
450
+ row: PendingMutationRow,
451
+ error: { message: string; kind: 'application' | 'unreplayable' },
452
+ before: Record<string, unknown> | null,
453
+ attempts: number,
454
+ now: number,
455
+ revert: FailedMutationRow['revert']
456
+ ): FailedMutationRow {
457
+ return {
458
+ id: row.id,
459
+ mutationType: row.mutationType,
460
+ recordId: row.recordId,
461
+ tableName: row.tableName,
462
+ data: row.data,
463
+ beforeRecord: before,
464
+ error,
465
+ attempts,
466
+ createdAt: row.createdAt,
467
+ failedAt: now,
468
+ revert,
469
+ };
470
+ }
471
+
472
+ /** ONE transaction: write the tray row, delete the pending row. */
473
+ export function moveToFailedTx(failed: FailedMutationRow): LocalTx {
474
+ const { id: _id, ...content } = failed;
475
+ return {
476
+ query: surql.seal<unknown>(surql.tx([surql.create('fid', 'failed'), surql.delete('mid')])),
477
+ vars: { fid: failedRecordId(failed.id), failed: content, mid: parseStoredRecordId(failed.id) },
478
+ };
479
+ }
480
+
481
+ export const deletePendingRow = (mutationId: string): { sql: string; vars: Vars } => ({
482
+ sql: 'DELETE $mid',
483
+ vars: { mid: parseStoredRecordId(mutationId) },
484
+ });
485
+
486
+ export const deleteFailedRow = (mutationId: string): { sql: string; vars: Vars } => ({
487
+ sql: 'DELETE $fid',
488
+ vars: { fid: failedRecordId(mutationId) },
489
+ });
490
+
491
+ export function parseFailedRow(row: unknown): FailedMutationRow | null {
492
+ if (!row || typeof row !== 'object') return null;
493
+ const r = row as Record<string, unknown>;
494
+ const type = r.mutationType;
495
+ if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
496
+ if (typeof r.recordId !== 'string') return null;
497
+ const err = (r.error ?? {}) as Record<string, unknown>;
498
+ return {
499
+ id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
500
+ mutationType: type,
501
+ recordId: r.recordId,
502
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(r.recordId),
503
+ data: r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined,
504
+ beforeRecord:
505
+ r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
506
+ error: {
507
+ message: typeof err.message === 'string' ? err.message : 'unknown',
508
+ kind: err.kind === 'unreplayable' ? 'unreplayable' : 'application',
509
+ },
510
+ attempts: typeof r.attempts === 'number' ? r.attempts : 0,
511
+ createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
512
+ failedAt: typeof r.failedAt === 'number' ? r.failedAt : 0,
513
+ revert: r.revert === 'partial' || r.revert === 'none' ? r.revert : 'full',
514
+ };
515
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { buildState } from '../testing/build';
5
+ import * as R from '../state/reducers';
6
+ import { defaultEnv } from '../query/env';
7
+ import { discardFailed, listFailed, retryFailed } from './tray.saga';
8
+
9
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {} } }] } as any);
10
+ const failedRow = {
11
+ id: '_00_failed_mutations:m1',
12
+ mutationType: 'create',
13
+ recordId: 'thing:1',
14
+ tableName: 'thing',
15
+ data: { a: 1 },
16
+ error: { message: 'denied', kind: 'application' },
17
+ attempts: 1,
18
+ createdAt: 1,
19
+ failedAt: 2,
20
+ revert: 'full',
21
+ };
22
+
23
+ describe('tray', () => {
24
+ it('lists parsed rows, skipping junk', async () => {
25
+ const out = await runPure(listFailed(), { handlers: { 'local.query': () => [[failedRow, { nope: true }]] } });
26
+ expect(out.result).toEqual([expect.objectContaining({ id: '_00_pending_mutations:m1', mutationType: 'create' })]);
27
+ expect((await runPure(listFailed(), { handlers: { 'local.query': () => [] } })).result).toEqual([]);
28
+ });
29
+ it('retry re-applies the write with a new id, drops the tray row, updates the count; missing rows return false', async () => {
30
+ const out = await runPure(retryFailed(env, '_00_pending_mutations:m1'), {
31
+ state: R.setFailedCount(2)(buildState()),
32
+ handlers: {
33
+ 'local.query': (e: any) => {
34
+ if (e.sql === 'SELECT * FROM $ids') {
35
+ expect(e.vars.ids).toEqual([new RecordId('_00_failed_mutations', 'm1')]);
36
+ return [[failedRow]];
37
+ }
38
+ if (e.sql === 'DELETE $fid') return [];
39
+ return [];
40
+ },
41
+ 'local.execute': () => ({ id: new RecordId('thing', '1'), a: 1 }),
42
+ 'ssp.ingest': () => undefined,
43
+ },
44
+ });
45
+ expect(out.result).toBe(true);
46
+ expect(out.state.outbox).toEqual([expect.objectContaining({ id: 'mutation-1', type: 'create', recordId: 'thing:1' })]);
47
+ expect(out.state.failedCount).toBe(1);
48
+ expect(out.emitted).toContainEqual({ type: 'tray:changed', count: 1 });
49
+ expect(out.dispatched).toContainEqual({ type: 'Drain' });
50
+ const missing = await runPure(retryFailed(env, '_00_pending_mutations:zz'), { handlers: { 'local.query': () => [[]] } });
51
+ expect(missing.result).toBe(false);
52
+ });
53
+ it('discard drops the row and never goes below zero', async () => {
54
+ const out = await runPure(discardFailed('_00_pending_mutations:m1'), {
55
+ state: buildState(),
56
+ handlers: { 'local.query': (e: any) => (e.sql === 'SELECT * FROM $ids' ? [[failedRow]] : []) },
57
+ });
58
+ expect(out.result).toBe(true);
59
+ expect(out.state.failedCount).toBe(0);
60
+ expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'failed-mutations-changed', count: 0 } });
61
+ expect((await runPure(discardFailed('nope'), { handlers: { 'local.query': () => [] } })).result).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,48 @@
1
+ import type { Saga } from '../kernel/saga';
2
+ import { fx } from '../kernel/effects';
3
+ import * as R from '../state/reducers';
4
+ import type { SagaEnv } from '../query/env';
5
+ import { deleteFailedRow, failedRecordId, loadFailedRows, parseFailedRow, type FailedMutationRow } from './rows';
6
+ import { write } from './write.saga';
7
+
8
+ /** Every rejected mutation still in the tray, oldest first. */
9
+ export function* listFailed(): Saga<FailedMutationRow[]> {
10
+ const res = (yield fx.local.query(loadFailedRows())) as unknown[];
11
+ const raw = Array.isArray(res?.[0]) ? (res[0] as unknown[]) : [];
12
+ return raw.map(parseFailedRow).filter((r): r is FailedMutationRow => r !== null);
13
+ }
14
+
15
+ function* loadFailed(mutationId: string): Saga<FailedMutationRow | null> {
16
+ const res = (yield fx.local.query('SELECT * FROM $ids', { ids: [failedRecordId(mutationId)] })) as unknown[];
17
+ const raw = Array.isArray(res?.[0]) ? (res[0] as unknown[])[0] : undefined;
18
+ return raw ? parseFailedRow(raw) : null;
19
+ }
20
+
21
+ function* dropFailed(mutationId: string): Saga<void> {
22
+ const del = deleteFailedRow(mutationId);
23
+ yield fx.local.query(del.sql, del.vars);
24
+ const count = Math.max(0, ((yield fx.state.read((s) => s.failedCount)) as number) - 1);
25
+ yield fx.state.update(R.setFailedCount(count));
26
+ yield fx.emit({ type: 'tray:changed', count });
27
+ yield fx.emit({ type: 'tabs:broadcast', message: { type: 'failed-mutations-changed', count } });
28
+ }
29
+
30
+ /**
31
+ * Re-apply a rejected mutation as a NEW optimistic write (fresh mutation id,
32
+ * the optimistic local change comes back, the outbox drains), then drop the
33
+ * tray row.
34
+ */
35
+ export function* retryFailed(env: SagaEnv, mutationId: string): Saga<boolean> {
36
+ const failed = yield* loadFailed(mutationId);
37
+ if (!failed) return false;
38
+ yield* write(env, { kind: failed.mutationType, recordId: failed.recordId, data: failed.data });
39
+ yield* dropFailed(mutationId);
40
+ return true;
41
+ }
42
+
43
+ export function* discardFailed(mutationId: string): Saga<boolean> {
44
+ const failed = yield* loadFailed(mutationId);
45
+ if (!failed) return false;
46
+ yield* dropFailed(mutationId);
47
+ return true;
48
+ }