@spooky-sync/core 0.0.1-canary.26 → 0.0.1-canary.260

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 (228) hide show
  1. package/AGENTS.md +78 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2197 -80
  4. package/dist/index.js +12745 -2588
  5. package/dist/otel/index.d.ts +2 -2
  6. package/dist/otel/index.js +6 -6
  7. package/dist/sqlite-open.js +331 -0
  8. package/dist/sqlite-worker.d.ts +1 -0
  9. package/dist/sqlite-worker.js +439 -0
  10. package/dist/tabs-broker-worker.d.ts +8 -0
  11. package/dist/tabs-broker-worker.js +472 -0
  12. package/dist/types.d.ts +787 -46
  13. package/package.json +16 -8
  14. package/scripts/check-broker-bundle.mjs +33 -0
  15. package/skills/{spooky-core → sp00ky-core}/SKILL.md +30 -12
  16. package/skills/{spooky-core → sp00ky-core}/references/auth.md +1 -1
  17. package/skills/{spooky-core → sp00ky-core}/references/config.md +2 -2
  18. package/src/boot/auth-flip.saga.test.ts +82 -0
  19. package/src/boot/auth-flip.saga.ts +62 -0
  20. package/src/boot/boot.saga.test.ts +151 -0
  21. package/src/boot/boot.saga.ts +128 -0
  22. package/src/boot/bucket-switch.saga.test.ts +158 -0
  23. package/src/boot/bucket-switch.saga.ts +115 -0
  24. package/src/boot/preload.saga.test.ts +51 -0
  25. package/src/boot/preload.saga.ts +31 -0
  26. package/src/bucket-blurhash.test.ts +148 -0
  27. package/src/build-globals.d.ts +12 -0
  28. package/src/client/bucket-handle.test.ts +117 -0
  29. package/src/client/bucket-handle.ts +227 -0
  30. package/src/client/router.test.ts +56 -0
  31. package/src/client/router.ts +96 -0
  32. package/src/client/runtime.test.ts +192 -0
  33. package/src/client/runtime.ts +296 -0
  34. package/src/client/services.ts +382 -0
  35. package/src/client/sp00ky-client.test.ts +264 -0
  36. package/src/client/sp00ky-client.ts +499 -0
  37. package/src/events/events.test.ts +2 -1
  38. package/src/events/index.ts +3 -0
  39. package/src/index.ts +37 -2
  40. package/src/kernel/constants.test.ts +19 -0
  41. package/src/kernel/constants.ts +61 -0
  42. package/src/kernel/effects.test.ts +40 -0
  43. package/src/kernel/effects.ts +159 -0
  44. package/src/kernel/events.ts +56 -0
  45. package/src/kernel/interpreter.test.ts +116 -0
  46. package/src/kernel/interpreter.ts +148 -0
  47. package/src/kernel/saga.test.ts +89 -0
  48. package/src/kernel/saga.ts +73 -0
  49. package/src/modules/app-release/index.test.ts +125 -0
  50. package/src/modules/app-release/index.ts +209 -0
  51. package/src/modules/auth/auth.local-first.test.ts +141 -0
  52. package/src/modules/auth/events/index.ts +2 -1
  53. package/src/modules/auth/index.ts +138 -25
  54. package/src/modules/crdt/crdt-field.ts +294 -0
  55. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  56. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  57. package/src/modules/crdt/index.ts +463 -0
  58. package/src/modules/crdt/loro-loader.ts +25 -0
  59. package/src/modules/devtools/flags.ts +349 -0
  60. package/src/modules/devtools/index.ts +459 -47
  61. package/src/modules/devtools/notify-throttle.test.ts +154 -0
  62. package/src/modules/devtools/state-shape.test.ts +149 -0
  63. package/src/modules/devtools/storage-info.test.ts +79 -0
  64. package/src/modules/devtools/storage-info.ts +168 -0
  65. package/src/modules/devtools/versions.test.ts +74 -0
  66. package/src/modules/devtools/versions.ts +110 -0
  67. package/src/modules/feature-flag/index.test.ts +251 -0
  68. package/src/modules/feature-flag/index.ts +316 -0
  69. package/src/modules/ref-tables.test.ts +91 -0
  70. package/src/modules/ref-tables.ts +88 -0
  71. package/src/mutation/jobs.test.ts +25 -0
  72. package/src/mutation/jobs.ts +41 -0
  73. package/src/mutation/mutation-id.test.ts +37 -0
  74. package/src/mutation/mutation-id.ts +35 -0
  75. package/src/mutation/push.saga.test.ts +285 -0
  76. package/src/mutation/push.saga.ts +215 -0
  77. package/src/mutation/rows.test.ts +201 -0
  78. package/src/mutation/rows.ts +377 -0
  79. package/src/mutation/tray.saga.test.ts +63 -0
  80. package/src/mutation/tray.saga.ts +48 -0
  81. package/src/mutation/write.saga.test.ts +163 -0
  82. package/src/mutation/write.saga.ts +166 -0
  83. package/src/otel/index.ts +13 -10
  84. package/src/query/env.test.ts +23 -0
  85. package/src/query/env.ts +56 -0
  86. package/src/query/fetch.saga.test.ts +156 -0
  87. package/src/query/fetch.saga.ts +115 -0
  88. package/src/query/hash.test.ts +14 -0
  89. package/src/query/hash.ts +18 -0
  90. package/src/query/id-set-plan.test.ts +132 -0
  91. package/src/query/lifecycle.saga.test.ts +122 -0
  92. package/src/query/lifecycle.saga.ts +101 -0
  93. package/src/query/materialize.saga.test.ts +82 -0
  94. package/src/query/materialize.saga.ts +75 -0
  95. package/src/query/materialize.test.ts +50 -0
  96. package/src/query/materialize.ts +44 -0
  97. package/src/query/membership.saga.test.ts +317 -0
  98. package/src/query/membership.saga.ts +262 -0
  99. package/src/query/membership.test.ts +118 -0
  100. package/src/query/membership.ts +171 -0
  101. package/src/query/register.saga.test.ts +293 -0
  102. package/src/query/register.saga.ts +226 -0
  103. package/src/query/render-set.test.ts +36 -0
  104. package/src/query/render-set.ts +60 -0
  105. package/src/query/sql.test.ts +62 -0
  106. package/src/query/sql.ts +79 -0
  107. package/src/query/window-query.test.ts +70 -0
  108. package/src/query/window-query.ts +195 -0
  109. package/src/services/blobs/blob-cache.test.ts +359 -0
  110. package/src/services/blobs/blob-cache.ts +603 -0
  111. package/src/services/blobs/blob-manifest.ts +227 -0
  112. package/src/services/blobs/blob-store.test.ts +77 -0
  113. package/src/services/blobs/blob-store.ts +359 -0
  114. package/src/services/blobs/blob.fixture.ts +90 -0
  115. package/src/services/blobs/index.ts +70 -0
  116. package/src/services/database/cache-engine.ts +193 -0
  117. package/src/services/database/connection-supervisor.test.ts +318 -0
  118. package/src/services/database/connection-supervisor.ts +422 -0
  119. package/src/services/database/database.query-timeout.test.ts +140 -0
  120. package/src/services/database/database.responses.test.ts +43 -0
  121. package/src/services/database/database.ts +145 -62
  122. package/src/services/database/engine-factory.ts +33 -0
  123. package/src/services/database/errors.ts +34 -0
  124. package/src/services/database/events/index.ts +2 -1
  125. package/src/services/database/index.ts +7 -0
  126. package/src/services/database/local-migrator.ts +30 -27
  127. package/src/services/database/local.test.ts +64 -0
  128. package/src/services/database/local.ts +484 -67
  129. package/src/services/database/plan-render.test.ts +159 -0
  130. package/src/services/database/plan-render.ts +108 -0
  131. package/src/services/database/relation-resolver.test.ts +467 -0
  132. package/src/services/database/relation-resolver.ts +0 -0
  133. package/src/services/database/remote.ts +222 -15
  134. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  135. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  136. package/src/services/database/sqlite-cache-engine.ts +1349 -0
  137. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  138. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  139. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  140. package/src/services/database/sqlite-lock-verify.ts +45 -0
  141. package/src/services/database/sqlite-open.test.ts +150 -0
  142. package/src/services/database/sqlite-open.ts +164 -0
  143. package/src/services/database/sqlite-plan-sql.test.ts +104 -0
  144. package/src/services/database/sqlite-plan-sql.ts +153 -0
  145. package/src/services/database/sqlite-projection.test.ts +121 -0
  146. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  147. package/src/services/database/sqlite-select.test.ts +246 -0
  148. package/src/services/database/sqlite-select.ts +131 -0
  149. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  150. package/src/services/database/sqlite-transport.ts +224 -0
  151. package/src/services/database/sqlite-worker.ts +437 -0
  152. package/src/services/database/surql-translate.ts +416 -0
  153. package/src/services/database/surreal-cache-engine.ts +161 -0
  154. package/src/services/logger/index.ts +3 -2
  155. package/src/services/persistence/localstorage.ts +2 -2
  156. package/src/services/persistence/resilient.ts +11 -4
  157. package/src/services/persistence/surrealdb.ts +10 -10
  158. package/src/services/stream-processor/index.ts +796 -84
  159. package/src/services/stream-processor/permissions.test.ts +47 -0
  160. package/src/services/stream-processor/permissions.ts +53 -0
  161. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  162. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  163. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  164. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  165. package/src/services/stream-processor/wasm-types.ts +59 -3
  166. package/src/services/tabs/broker-client.ts +283 -0
  167. package/src/services/tabs/broker.test.ts +327 -0
  168. package/src/services/tabs/coordinator.test.ts +365 -0
  169. package/src/services/tabs/coordinator.ts +633 -0
  170. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  171. package/src/services/tabs/leader-locks.ts +75 -0
  172. package/src/services/tabs/protocol.ts +262 -0
  173. package/src/services/tabs/support.ts +36 -0
  174. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  175. package/src/sp00ky.ts +5 -0
  176. package/src/state/client-state.ts +191 -0
  177. package/src/state/lifecycle.test.ts +118 -0
  178. package/src/state/lifecycle.ts +86 -0
  179. package/src/state/reducers.test.ts +280 -0
  180. package/src/state/reducers.ts +488 -0
  181. package/src/state/selectors.test.ts +149 -0
  182. package/src/state/selectors.ts +184 -0
  183. package/src/sync/connection.saga.test.ts +86 -0
  184. package/src/sync/connection.saga.ts +85 -0
  185. package/src/sync/live.saga.test.ts +178 -0
  186. package/src/sync/live.saga.ts +107 -0
  187. package/src/sync/policy.health.test.ts +56 -0
  188. package/src/sync/policy.test.ts +659 -0
  189. package/src/sync/policy.ts +502 -0
  190. package/src/sync/poll.saga.test.ts +51 -0
  191. package/src/sync/poll.saga.ts +36 -0
  192. package/src/sync/tabs.saga.test.ts +74 -0
  193. package/src/sync/tabs.saga.ts +93 -0
  194. package/src/testing/adapters.ts +133 -0
  195. package/src/testing/build.ts +63 -0
  196. package/src/testing/fake-services.ts +34 -0
  197. package/src/testing/run-pure.test.ts +92 -0
  198. package/src/testing/run-pure.ts +128 -0
  199. package/src/testing/services.ts +17 -0
  200. package/src/types.ts +591 -14
  201. package/src/utils/blurhash.ts +90 -0
  202. package/src/utils/error-classification.test.ts +54 -0
  203. package/src/utils/error-classification.ts +25 -0
  204. package/src/utils/index.ts +79 -13
  205. package/src/utils/parser.test.ts +49 -120
  206. package/src/utils/parser.ts +32 -2
  207. package/src/utils/semver.test.ts +32 -0
  208. package/src/utils/semver.ts +30 -0
  209. package/src/utils/surql.ts +30 -18
  210. package/src/utils/utils.test.ts +2 -1
  211. package/src/utils/withRetry.test.ts +1 -1
  212. package/tsdown.config.ts +86 -1
  213. package/vitest.config.ts +48 -0
  214. package/src/modules/cache/index.ts +0 -241
  215. package/src/modules/cache/types.ts +0 -19
  216. package/src/modules/data/data.test.ts +0 -58
  217. package/src/modules/data/index.ts +0 -879
  218. package/src/modules/sync/engine.ts +0 -173
  219. package/src/modules/sync/events/index.ts +0 -77
  220. package/src/modules/sync/index.ts +0 -3
  221. package/src/modules/sync/queue/index.ts +0 -2
  222. package/src/modules/sync/queue/queue-down.ts +0 -89
  223. package/src/modules/sync/queue/queue-up.ts +0 -223
  224. package/src/modules/sync/scheduler.ts +0 -84
  225. package/src/modules/sync/sync.ts +0 -421
  226. package/src/modules/sync/utils.test.ts +0 -311
  227. package/src/modules/sync/utils.ts +0 -167
  228. package/src/spooky.ts +0 -395
@@ -0,0 +1,377 @@
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
+ export const PENDING_ROW_VERSION = 2;
14
+
15
+ /** One `_00_pending_mutations` row as this client writes it (v2). */
16
+ export interface PendingMutationRow {
17
+ id: string;
18
+ mutationType: MutationEventType;
19
+ recordId: string;
20
+ tableName: string;
21
+ data?: Record<string, unknown>;
22
+ beforeRecord?: Record<string, unknown> | null;
23
+ createdAt: number;
24
+ v: number;
25
+ }
26
+
27
+ export interface FailedMutationRow {
28
+ id: string;
29
+ mutationType: MutationEventType;
30
+ recordId: string;
31
+ tableName: string;
32
+ data?: Record<string, unknown>;
33
+ beforeRecord?: Record<string, unknown> | null;
34
+ error: { message: string; kind: 'application' | 'unreplayable' };
35
+ attempts: number;
36
+ createdAt: number;
37
+ failedAt: number;
38
+ revert: 'full' | 'partial' | 'none';
39
+ }
40
+
41
+ /**
42
+ * Parse a record id READ BACK FROM THE STORE, stripping SurrealDB's `⟨⟩`
43
+ * escaping (outbox ids contain `_`, so they come back escaped).
44
+ */
45
+ export function parseStoredRecordId(id: string): RecordId<string> {
46
+ const [table, ...rest] = id.split(':');
47
+ let raw = rest.join(':');
48
+ if (raw.startsWith('⟨') && raw.endsWith('⟩')) raw = raw.slice(1, -1);
49
+ return new RecordId(table, raw);
50
+ }
51
+
52
+ /** Stable `table:id` string for an id read from the store, escaping removed. */
53
+ export const storedIdString = (id: unknown): string => {
54
+ if (id instanceof RecordId) return `${id.table}:${String(id.id)}`;
55
+ const rid = parseStoredRecordId(String(id));
56
+ return `${rid.table}:${rid.id}`;
57
+ };
58
+
59
+ /** Timestamp prefix of a v1/v2 mutation id, or `null` for a legacy numeric id. */
60
+ export function createdAtFromId(id: string): number | null {
61
+ const raw = id.includes(':') ? id.slice(id.indexOf(':') + 1) : id;
62
+ const m = /^⟨?(\d{13})/.exec(raw);
63
+ return m ? Number(m[1]) : null;
64
+ }
65
+
66
+ /**
67
+ * Read any row shape this table ever had. Legacy rows (`v` missing) lack
68
+ * `tableName`, `createdAt` and `beforeRecord`. A create without `data` is not
69
+ * replayable and returns `null`.
70
+ */
71
+ export function parsePendingRow(row: unknown): PendingMutationRow | null {
72
+ if (!row || typeof row !== 'object') return null;
73
+ const r = row as Record<string, unknown>;
74
+ const type = r.mutationType;
75
+ if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
76
+ if (typeof r.recordId !== 'string' && !(r.recordId instanceof RecordId)) return null;
77
+ const id = storedIdString(r.id);
78
+ const recordId = typeof r.recordId === 'string' ? r.recordId : storedIdString(r.recordId);
79
+ const data = r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined;
80
+ if (type === 'create' && !data) return null;
81
+ return {
82
+ id,
83
+ mutationType: type,
84
+ recordId,
85
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(recordId),
86
+ data,
87
+ beforeRecord:
88
+ r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : undefined,
89
+ createdAt: typeof r.createdAt === 'number' ? r.createdAt : (createdAtFromId(id) ?? 0),
90
+ v: typeof r.v === 'number' ? r.v : 1,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Re-type a stored row's payload from the schema before it is pushed. The
96
+ * store keeps JSON, so a `record<user>` field comes back as `'user:x'` and a
97
+ * datetime as a string; the server coerces neither. Unknown keys stay as they
98
+ * are, and a payload the schema rejects is sent raw so the server's answer,
99
+ * not a local throw, decides its fate.
100
+ */
101
+ export function hydrateRowData(row: PendingMutationRow, columns: Record<string, unknown> | null): PendingMutationRow {
102
+ if (!row.data || !columns) return row;
103
+ try {
104
+ return { ...row, data: parseQueryParams(columns as never, row.data) };
105
+ } catch {
106
+ return row;
107
+ }
108
+ }
109
+
110
+ export const toOutboxItem = (row: PendingMutationRow): OutboxItem => ({
111
+ id: row.id,
112
+ type: row.mutationType,
113
+ recordId: row.recordId,
114
+ table: row.tableName,
115
+ status: 'pending',
116
+ ackedAt: null,
117
+ attempts: 0,
118
+ });
119
+
120
+ export const loadPendingRows = (): string => `SELECT * FROM ${PENDING_TABLE} ORDER BY id ASC`;
121
+ export const loadFailedRows = (): string => `SELECT * FROM ${FAILED_TABLE} ORDER BY failedAt ASC`;
122
+
123
+ // ---- local write transactions ------------------------------------------------
124
+
125
+ export interface LocalTx {
126
+ query: SealedQuery<unknown>;
127
+ vars: Vars;
128
+ }
129
+
130
+ const pendingInsert = (type: MutationEventType, withData: boolean, withBefore: boolean): string => {
131
+ const fields = [
132
+ `mutationType = '${type}'`,
133
+ 'recordId = $id',
134
+ 'tableName = $table',
135
+ 'createdAt = $createdAt',
136
+ `v = ${PENDING_ROW_VERSION}`,
137
+ ];
138
+ if (withData) fields.push('data = $data');
139
+ if (withBefore) fields.push('beforeRecord = $before');
140
+ return `CREATE ONLY $mid SET ${fields.join(', ')}`;
141
+ };
142
+
143
+ export interface WritePlanInput {
144
+ recordId: RecordId<string>;
145
+ mutationId: RecordId<string>;
146
+ table: string;
147
+ data?: Record<string, unknown>;
148
+ before?: Record<string, unknown> | null;
149
+ now: number;
150
+ }
151
+
152
+ /** `CREATE` the row and its outbox entry in one transaction; result index 0 is the row. */
153
+ export function planCreateTx(input: WritePlanInput): LocalTx {
154
+ const data = input.data ?? {};
155
+ const keys = Object.keys(data).map((key) => ({ key, variable: `data_${key}` }));
156
+ const vars: Vars = { id: input.recordId, mid: input.mutationId, table: input.table, createdAt: input.now, data };
157
+ for (const { key, variable } of keys) vars[variable] = data[key];
158
+ return {
159
+ query: surql.seal<unknown>(surql.tx([surql.createSet('id', keys), pendingInsert('create', true, false)]), {
160
+ resultIndex: 0,
161
+ }),
162
+ vars,
163
+ };
164
+ }
165
+
166
+ /** Bump `_00_rv`, MERGE the patch, write the outbox row (with `beforeRecord`); returns `{ target }`. */
167
+ export function planUpdateTx(input: WritePlanInput): LocalTx {
168
+ return {
169
+ query: surql.seal<unknown>(
170
+ surql.tx([
171
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
172
+ surql.let('updated', surql.updateMerge('id', 'data')),
173
+ pendingInsert('update', true, true),
174
+ surql.returnObject([{ key: 'target', variable: 'updated' }]),
175
+ ])
176
+ ),
177
+ vars: {
178
+ id: input.recordId,
179
+ mid: input.mutationId,
180
+ table: input.table,
181
+ createdAt: input.now,
182
+ data: input.data ?? {},
183
+ before: input.before ?? null,
184
+ },
185
+ };
186
+ }
187
+
188
+ /** DELETE the row and write the outbox entry (with `beforeRecord`). */
189
+ export function planDeleteTx(input: WritePlanInput): LocalTx {
190
+ return {
191
+ query: surql.seal<unknown>(surql.tx([surql.delete('id'), pendingInsert('delete', false, true)])),
192
+ vars: {
193
+ id: input.recordId,
194
+ mid: input.mutationId,
195
+ table: input.table,
196
+ createdAt: input.now,
197
+ before: input.before ?? null,
198
+ },
199
+ };
200
+ }
201
+
202
+ /** Debounced update: bump `_00_rv` and MERGE now; the outbox row comes later. */
203
+ export function planLocalOnlyUpdateTx(input: { recordId: RecordId<string>; data: Record<string, unknown> }): LocalTx {
204
+ return {
205
+ query: surql.seal<unknown>(
206
+ surql.tx([
207
+ surql.updateSet('id', [{ statement: '_00_rv += 1' }]),
208
+ surql.let('updated', surql.updateMerge('id', 'data')),
209
+ surql.returnObject([{ key: 'target', variable: 'updated' }]),
210
+ ])
211
+ ),
212
+ vars: { id: input.recordId, data: input.data },
213
+ };
214
+ }
215
+
216
+ /** The outbox row of a flushed debounced update, on its own. */
217
+ export function planDeferredOutboxRowTx(input: WritePlanInput): LocalTx {
218
+ return {
219
+ query: surql.seal<unknown>(surql.tx([pendingInsert('update', true, true)])),
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
+ // ---- remote push -------------------------------------------------------------
232
+
233
+ export interface RemoteBatch {
234
+ sql: string;
235
+ vars: Vars;
236
+ }
237
+
238
+ /**
239
+ * Many outbox rows as ONE multi-statement request. Statement i answers row i.
240
+ * Each statement is its own transaction on the server, as before.
241
+ */
242
+ export function remoteBatch(rows: ReadonlyArray<PendingMutationRow>): RemoteBatch {
243
+ const vars: Vars = {};
244
+ const stmts = rows.map((row, i) => {
245
+ const rid = parseRecordIdString(row.recordId);
246
+ vars[`id${i}`] = rid;
247
+ switch (row.mutationType) {
248
+ case 'create': {
249
+ const data = row.data ?? {};
250
+ const sets = Object.keys(data).map((key) => {
251
+ vars[`d${i}_${key}`] = data[key];
252
+ return `${key} = $d${i}_${key}`;
253
+ });
254
+ return `CREATE ONLY $id${i} SET ${sets.join(', ')}`;
255
+ }
256
+ case 'update':
257
+ vars[`data${i}`] = row.data ?? {};
258
+ return `UPDATE $id${i} MERGE $data${i}`;
259
+ case 'delete':
260
+ return `DELETE $id${i}`;
261
+ }
262
+ });
263
+ return { sql: stmts.join(';\n'), vars };
264
+ }
265
+
266
+ // ---- rollback ----------------------------------------------------------------
267
+
268
+ export interface RevertPlan {
269
+ tx: LocalTx | null;
270
+ circuit: IngestRecord;
271
+ revert: FailedMutationRow['revert'];
272
+ }
273
+
274
+ /**
275
+ * How to undo a rejected mutation locally. A create is deleted; an update or
276
+ * delete is restored from `beforeRecord` (REPLACE, so the pre-bump `_00_rv`
277
+ * comes back). Without a `beforeRecord` nothing can be restored.
278
+ */
279
+ export function planRevert(row: PendingMutationRow, before: Record<string, unknown> | null): RevertPlan {
280
+ const rid = parseRecordIdString(row.recordId);
281
+ if (row.mutationType === 'create') {
282
+ return {
283
+ tx: { query: surql.seal<unknown>(surql.tx([surql.delete('id')])), vars: { id: rid } },
284
+ circuit: { table: row.tableName, op: 'DELETE', id: row.recordId, record: row.data ?? {} },
285
+ revert: 'full',
286
+ };
287
+ }
288
+ if (!before) {
289
+ return {
290
+ tx: null,
291
+ circuit: { table: row.tableName, op: 'UPDATE', id: row.recordId, record: {} },
292
+ revert: 'partial',
293
+ };
294
+ }
295
+ const { id: _id, ...content } = before;
296
+ return {
297
+ tx: { query: surql.seal<unknown>(surql.tx([surql.upsert('id', 'content')])), vars: { id: rid, content } },
298
+ circuit: {
299
+ table: row.tableName,
300
+ op: row.mutationType === 'delete' ? 'CREATE' : 'UPDATE',
301
+ id: row.recordId,
302
+ record: { ...before, id: rid },
303
+ },
304
+ revert: 'full',
305
+ };
306
+ }
307
+
308
+ export const failedRecordId = (mutationId: string): RecordId<string> =>
309
+ new RecordId(FAILED_TABLE, mutationId.slice(mutationId.indexOf(':') + 1));
310
+
311
+ export function buildFailedRow(
312
+ row: PendingMutationRow,
313
+ error: { message: string; kind: 'application' | 'unreplayable' },
314
+ before: Record<string, unknown> | null,
315
+ attempts: number,
316
+ now: number,
317
+ revert: FailedMutationRow['revert']
318
+ ): FailedMutationRow {
319
+ return {
320
+ id: row.id,
321
+ mutationType: row.mutationType,
322
+ recordId: row.recordId,
323
+ tableName: row.tableName,
324
+ data: row.data,
325
+ beforeRecord: before,
326
+ error,
327
+ attempts,
328
+ createdAt: row.createdAt,
329
+ failedAt: now,
330
+ revert,
331
+ };
332
+ }
333
+
334
+ /** ONE transaction: write the tray row, delete the pending row. */
335
+ export function moveToFailedTx(failed: FailedMutationRow): LocalTx {
336
+ const { id: _id, ...content } = failed;
337
+ return {
338
+ query: surql.seal<unknown>(surql.tx([surql.create('fid', 'failed'), surql.delete('mid')])),
339
+ vars: { fid: failedRecordId(failed.id), failed: content, mid: parseStoredRecordId(failed.id) },
340
+ };
341
+ }
342
+
343
+ export const deletePendingRow = (mutationId: string): { sql: string; vars: Vars } => ({
344
+ sql: 'DELETE $mid',
345
+ vars: { mid: parseStoredRecordId(mutationId) },
346
+ });
347
+
348
+ export const deleteFailedRow = (mutationId: string): { sql: string; vars: Vars } => ({
349
+ sql: 'DELETE $fid',
350
+ vars: { fid: failedRecordId(mutationId) },
351
+ });
352
+
353
+ export function parseFailedRow(row: unknown): FailedMutationRow | null {
354
+ if (!row || typeof row !== 'object') return null;
355
+ const r = row as Record<string, unknown>;
356
+ const type = r.mutationType;
357
+ if (type !== 'create' && type !== 'update' && type !== 'delete') return null;
358
+ if (typeof r.recordId !== 'string') return null;
359
+ const err = (r.error ?? {}) as Record<string, unknown>;
360
+ return {
361
+ id: storedIdString(r.id).replace(`${FAILED_TABLE}:`, `${PENDING_TABLE}:`),
362
+ mutationType: type,
363
+ recordId: r.recordId,
364
+ tableName: typeof r.tableName === 'string' ? r.tableName : extractTablePart(r.recordId),
365
+ data: r.data && typeof r.data === 'object' ? (r.data as Record<string, unknown>) : undefined,
366
+ beforeRecord:
367
+ r.beforeRecord && typeof r.beforeRecord === 'object' ? (r.beforeRecord as Record<string, unknown>) : null,
368
+ error: {
369
+ message: typeof err.message === 'string' ? err.message : 'unknown',
370
+ kind: err.kind === 'unreplayable' ? 'unreplayable' : 'application',
371
+ },
372
+ attempts: typeof r.attempts === 'number' ? r.attempts : 0,
373
+ createdAt: typeof r.createdAt === 'number' ? r.createdAt : 0,
374
+ failedAt: typeof r.failedAt === 'number' ? r.failedAt : 0,
375
+ revert: r.revert === 'partial' || r.revert === 'none' ? r.revert : 'full',
376
+ };
377
+ }
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from 'surrealdb';
3
+ import { runPure } from '../testing/run-pure';
4
+ import { buildEntry, buildState } from '../testing/build';
5
+ import { defaultEnv } from '../query/env';
6
+ import { flushWrite, write } from './write.saga';
7
+
8
+ const env = defaultEnv({ tables: [{ name: 'thing', columns: { a: {}, b: {} } }] } as any);
9
+ const rid = new RecordId('thing', '1');
10
+ const withQuery = () => buildState([buildEntry({ def: { hash: 'q', tableName: 'thing' } })]);
11
+
12
+ describe('write', () => {
13
+ it('rejects unknown tables', async () => {
14
+ await expect(runPure(write(env, { kind: 'create', recordId: 'nope:1', data: {} }))).rejects.toThrow('Table nope not found');
15
+ });
16
+
17
+ it('create: local tx, outbox item, circuit ingest, version 1, event, drain; dirties the table', async () => {
18
+ const out = await runPure(write(env, { kind: 'create', recordId: 'thing:1', data: { a: 1, junk: 2 } }), {
19
+ state: withQuery(),
20
+ handlers: {
21
+ 'local.execute': (e: any) => {
22
+ expect(e.query.sql).toContain("mutationType = 'create'");
23
+ expect(e.vars.data).toEqual({ a: 1 });
24
+ return { id: rid, a: 1 };
25
+ },
26
+ 'ssp.ingest': (e: any) => {
27
+ expect(e.records).toEqual([{ table: 'thing', op: 'CREATE', id: 'thing:1', record: { id: rid, a: 1, _00_rv: 1 } }]);
28
+ },
29
+ },
30
+ });
31
+ expect(out.result).toEqual({ mutationId: 'mutation-1', record: { id: rid, a: 1 } });
32
+ expect(out.state.outbox).toEqual([{ id: 'mutation-1', type: 'create', recordId: 'thing:1', table: 'thing', status: 'pending', ackedAt: null, attempts: 0 }]);
33
+ expect(out.state.versions.get('thing:1')).toBe(1);
34
+ expect(out.state.dirty.has('q')).toBe(true);
35
+ expect(out.emitted[0]).toMatchObject({ type: 'mutation:event', event: { type: 'create', tableName: 'thing' } });
36
+ expect(out.dispatched).toEqual([{ type: 'Drain' }]);
37
+ expect(out.log.filter((e) => e.kind === 'local.query')).toHaveLength(0);
38
+ });
39
+
40
+ it('update: reads before, tx with beforeRecord, version from the returned row; delete: before feeds the circuit', async () => {
41
+ const upd = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 2 } }), {
42
+ state: withQuery(),
43
+ handlers: {
44
+ 'local.query': () => [{ id: rid, a: 1, _00_rv: 4 }],
45
+ 'local.execute': (e: any) => {
46
+ expect(e.vars.before).toEqual({ id: rid, a: 1, _00_rv: 4 });
47
+ return { target: { id: rid, a: 2, _00_rv: 5 } };
48
+ },
49
+ 'ssp.ingest': (e: any) => expect(e.records[0]).toMatchObject({ op: 'UPDATE', record: { a: 2, _00_rv: 5 } }),
50
+ },
51
+ });
52
+ expect(upd.result.record).toEqual({ id: rid, a: 2, _00_rv: 5 });
53
+ expect(upd.state.versions.get('thing:1')).toBe(5);
54
+ expect(upd.emitted[0]).toMatchObject({ event: { beforeRecord: { a: 1 } } });
55
+ const noRow = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 2 } }), {
56
+ state: withQuery(),
57
+ handlers: { 'local.query': () => [null], 'local.execute': () => ({ target: null }), 'ssp.ingest': () => undefined },
58
+ });
59
+ expect(noRow.result.record).toBeNull();
60
+ expect(noRow.state.versions.get('thing:1')).toBe(1);
61
+ const del = await runPure(write(env, { kind: 'delete', recordId: 'thing:1' }), {
62
+ state: buildState([], (s) => ({ ...s, versions: new Map([['thing:1', 3]]) })),
63
+ handlers: {
64
+ 'local.query': () => [{ id: rid, a: 1, _00_rv: 3 }],
65
+ 'local.execute': () => undefined,
66
+ 'ssp.ingest': (e: any) => expect(e.records[0]).toEqual({ table: 'thing', op: 'DELETE', id: 'thing:1', record: { id: rid, a: 1, _00_rv: 3 } }),
67
+ },
68
+ });
69
+ expect(del.result.record).toBeNull();
70
+ expect(del.state.versions.has('thing:1')).toBe(false);
71
+ expect(del.state.outbox[0].type).toBe('delete');
72
+ const delNoRow = await runPure(write(env, { kind: 'delete', recordId: 'thing:1' }), {
73
+ state: buildState(),
74
+ handlers: {
75
+ 'local.query': () => [[]],
76
+ 'local.execute': () => undefined,
77
+ 'ssp.ingest': (e: any) => expect(e.records[0].record).toEqual({}),
78
+ },
79
+ });
80
+ expect(delNoRow.emitted[0]).toMatchObject({ event: { beforeRecord: undefined } });
81
+ });
82
+
83
+ it('a failing circuit ingest is logged, the write still stands; a follower broadcasts instead of draining', async () => {
84
+ const out = await runPure(write(env, { kind: 'create', recordId: 'thing:1', data: {} }), {
85
+ state: { ...buildState(), tabRole: 'follower' },
86
+ handlers: {
87
+ 'local.execute': () => ({ id: rid }),
88
+ 'ssp.ingest': () => {
89
+ throw new Error('wasm');
90
+ },
91
+ },
92
+ });
93
+ expect(out.emitted.some((e) => e.type === 'log' && e.level === 'error')).toBe(true);
94
+ expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'outbox-changed', mutationId: 'mutation-1' } });
95
+ expect(out.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ id: 'thing:1', op: 'CREATE' })] } });
96
+ expect(out.dispatched).toEqual([]);
97
+ expect(out.state.outbox).toHaveLength(1);
98
+ });
99
+
100
+ it('debounced update: applies locally, merges the patch per key, arms the flush timer; flush writes one outbox row', async () => {
101
+ const first = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: true } }), {
102
+ state: withQuery(),
103
+ handlers: {
104
+ 'local.query': () => [{ id: rid, a: 0, _00_rv: 1 }],
105
+ 'local.execute': () => ({ target: { id: rid, a: 1, _00_rv: 2 } }),
106
+ 'ssp.ingest': () => undefined,
107
+ },
108
+ });
109
+ expect(first.result).toEqual({ mutationId: '', record: { id: rid, a: 1, _00_rv: 2 } });
110
+ expect(first.state.outbox).toEqual([]);
111
+ const key = 'thing:1::a';
112
+ expect(first.state.pendingWrites.get(key)).toMatchObject({ data: { a: 1 }, before: { a: 0 } });
113
+ expect(first.timers.get(`debounce:${key}`)).toEqual({ ms: 300, event: { type: 'FlushWrite', key } });
114
+ expect(first.state.dirty.has('q')).toBe(true);
115
+ const second = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 5 }, options: { debounced: { delay: 50, key: 'recordId_x_fields' } } }), {
116
+ state: first.state,
117
+ handlers: {
118
+ 'local.execute': () => ({ target: { id: rid, a: 5, _00_rv: 3 } }),
119
+ 'ssp.ingest': () => {
120
+ throw new Error('wasm');
121
+ },
122
+ },
123
+ });
124
+ expect(second.log.filter((e) => e.kind === 'local.query')).toHaveLength(0);
125
+ expect(second.state.pendingWrites.get(key)).toMatchObject({ data: { a: 5 }, before: { a: 0 } });
126
+ expect(second.timers.get(`debounce:${key}`)!.ms).toBe(50);
127
+ const byRecord = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { b: 1 }, options: { debounced: { key: 'recordId' } } }), {
128
+ state: withQuery(),
129
+ handlers: { 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
130
+ });
131
+ expect(byRecord.state.pendingWrites.has('thing:1')).toBe(true);
132
+ expect(byRecord.result.record).toBeNull();
133
+ const noData = await runPure(write(env, { kind: 'update', recordId: 'thing:1', options: { debounced: true } }), {
134
+ state: withQuery(),
135
+ handlers: { 'local.query': () => undefined, 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
136
+ });
137
+ expect(noData.state.pendingWrites.has('thing:1::')).toBe(true);
138
+ const flushedNoBefore = await runPure(flushWrite(env, 'thing:1'), { state: byRecord.state, handlers: { 'local.execute': () => undefined } });
139
+ expect(flushedNoBefore.emitted[0]).toMatchObject({ event: { beforeRecord: undefined } });
140
+ const flushed = await runPure(flushWrite(env, key), {
141
+ state: second.state,
142
+ handlers: {
143
+ 'local.execute': (e: any) => {
144
+ expect(e.query.sql).toContain("mutationType = 'update'");
145
+ expect(e.vars).toMatchObject({ data: { a: 5 }, before: { id: rid, a: 0, _00_rv: 1 }, table: 'thing' });
146
+ },
147
+ },
148
+ });
149
+ expect(flushed.state.pendingWrites.size).toBe(0);
150
+ expect(flushed.state.outbox).toEqual([expect.objectContaining({ id: 'mutation-1', type: 'update', recordId: 'thing:1' })]);
151
+ expect(flushed.emitted[0]).toMatchObject({ type: 'mutation:event', event: { type: 'update', data: { a: 5 } } });
152
+ expect(flushed.dispatched).toEqual([{ type: 'Drain' }]);
153
+ const nothing = await runPure(flushWrite(env, 'missing'), { state: buildState() });
154
+ expect(nothing.dispatched).toEqual([]);
155
+ const follower = await runPure(flushWrite(env, key), { state: { ...second.state, tabRole: 'follower' }, handlers: { 'local.execute': () => undefined } });
156
+ expect(follower.emitted).toContainEqual(expect.objectContaining({ type: 'tabs:broadcast' }));
157
+ const leaderDebounced = await runPure(write(env, { kind: 'update', recordId: 'thing:1', data: { a: 1 }, options: { debounced: true } }), {
158
+ state: { ...withQuery(), tabRole: 'leader' },
159
+ handlers: { 'local.query': () => [], 'local.execute': () => undefined, 'ssp.ingest': () => undefined },
160
+ });
161
+ expect(leaderDebounced.emitted).toContainEqual({ type: 'tabs:broadcast', message: { type: 'ingest', records: [expect.objectContaining({ op: 'UPDATE' })] } });
162
+ });
163
+ });