@spooky-sync/core 0.0.1-canary.32 → 0.0.1-canary.320

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 (259) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2034 -137
  4. package/dist/index.js +15137 -2434
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +337 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +379 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1811 -38
  19. package/dist/worker-edges.js +439 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +180 -0
  32. package/src/boot/boot.saga.ts +142 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +51 -0
  46. package/src/client/services.ts +404 -0
  47. package/src/client/sp00ky-client.test.ts +303 -0
  48. package/src/client/sp00ky-client.ts +586 -0
  49. package/src/events/events.test.ts +2 -1
  50. package/src/events/index.ts +3 -0
  51. package/src/index.ts +69 -1
  52. package/src/kernel/constants.test.ts +19 -0
  53. package/src/kernel/constants.ts +80 -0
  54. package/src/kernel/effects.test.ts +40 -0
  55. package/src/kernel/effects.ts +163 -0
  56. package/src/kernel/events.ts +57 -0
  57. package/src/kernel/interpreter.test.ts +116 -0
  58. package/src/kernel/interpreter.ts +148 -0
  59. package/src/kernel/saga.test.ts +89 -0
  60. package/src/kernel/saga.ts +73 -0
  61. package/src/live/index.test.ts +478 -0
  62. package/src/live/index.ts +1013 -0
  63. package/src/modules/app-release/index.test.ts +125 -0
  64. package/src/modules/app-release/index.ts +209 -0
  65. package/src/modules/auth/auth.local-first.test.ts +169 -0
  66. package/src/modules/auth/events/index.ts +9 -2
  67. package/src/modules/auth/impersonation-banner.ts +443 -0
  68. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  69. package/src/modules/auth/impersonation.test.ts +377 -0
  70. package/src/modules/auth/impersonation.ts +179 -0
  71. package/src/modules/auth/index.ts +466 -19
  72. package/src/modules/crdt/crdt-field.ts +294 -0
  73. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  74. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  75. package/src/modules/crdt/index.ts +463 -0
  76. package/src/modules/crdt/loro-loader.ts +25 -0
  77. package/src/modules/devtools/flags.ts +349 -0
  78. package/src/modules/devtools/index.ts +746 -95
  79. package/src/modules/devtools/mutations.test.ts +92 -0
  80. package/src/modules/devtools/mutations.ts +160 -0
  81. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  82. package/src/modules/devtools/state-shape.test.ts +263 -0
  83. package/src/modules/devtools/storage-info.test.ts +79 -0
  84. package/src/modules/devtools/storage-info.ts +168 -0
  85. package/src/modules/devtools/versions.test.ts +74 -0
  86. package/src/modules/devtools/versions.ts +110 -0
  87. package/src/modules/feature-flag/index.test.ts +298 -0
  88. package/src/modules/feature-flag/index.ts +316 -0
  89. package/src/modules/ref-tables.test.ts +91 -0
  90. package/src/modules/ref-tables.ts +88 -0
  91. package/src/modules/web-push/index.test.ts +824 -0
  92. package/src/modules/web-push/index.ts +1027 -0
  93. package/src/mutation/jobs.test.ts +25 -0
  94. package/src/mutation/jobs.ts +41 -0
  95. package/src/mutation/mutation-id.test.ts +37 -0
  96. package/src/mutation/mutation-id.ts +35 -0
  97. package/src/mutation/pending-writes.integration.test.ts +175 -0
  98. package/src/mutation/push.saga.test.ts +285 -0
  99. package/src/mutation/push.saga.ts +217 -0
  100. package/src/mutation/rows.test.ts +231 -0
  101. package/src/mutation/rows.ts +427 -0
  102. package/src/mutation/tray.saga.test.ts +63 -0
  103. package/src/mutation/tray.saga.ts +48 -0
  104. package/src/mutation/write.saga.test.ts +316 -0
  105. package/src/mutation/write.saga.ts +268 -0
  106. package/src/otel/index.ts +10 -7
  107. package/src/pure.ts +65 -0
  108. package/src/push/types.ts +174 -0
  109. package/src/query/env.test.ts +23 -0
  110. package/src/query/env.ts +56 -0
  111. package/src/query/fetch.saga.test.ts +199 -0
  112. package/src/query/fetch.saga.ts +120 -0
  113. package/src/query/hash.test.ts +14 -0
  114. package/src/query/hash.ts +18 -0
  115. package/src/query/id-set-plan.test.ts +132 -0
  116. package/src/query/lifecycle.saga.test.ts +274 -0
  117. package/src/query/lifecycle.saga.ts +159 -0
  118. package/src/query/materialize.saga.test.ts +115 -0
  119. package/src/query/materialize.saga.ts +84 -0
  120. package/src/query/materialize.test.ts +58 -0
  121. package/src/query/materialize.ts +57 -0
  122. package/src/query/membership.saga.test.ts +350 -0
  123. package/src/query/membership.saga.ts +278 -0
  124. package/src/query/membership.test.ts +118 -0
  125. package/src/query/membership.ts +201 -0
  126. package/src/query/register.saga.test.ts +311 -0
  127. package/src/query/register.saga.ts +229 -0
  128. package/src/query/render-set.test.ts +47 -0
  129. package/src/query/render-set.ts +71 -0
  130. package/src/query/sql.test.ts +81 -0
  131. package/src/query/sql.ts +112 -0
  132. package/src/query/window-query.test.ts +70 -0
  133. package/src/query/window-query.ts +195 -0
  134. package/src/services/blobs/blob-cache.test.ts +359 -0
  135. package/src/services/blobs/blob-cache.ts +603 -0
  136. package/src/services/blobs/blob-manifest.ts +227 -0
  137. package/src/services/blobs/blob-store.test.ts +77 -0
  138. package/src/services/blobs/blob-store.ts +359 -0
  139. package/src/services/blobs/blob.fixture.ts +90 -0
  140. package/src/services/blobs/index.ts +70 -0
  141. package/src/services/database/cache-engine.ts +199 -0
  142. package/src/services/database/connection-supervisor.test.ts +318 -0
  143. package/src/services/database/connection-supervisor.ts +422 -0
  144. package/src/services/database/database.query-timeout.test.ts +140 -0
  145. package/src/services/database/database.responses.test.ts +43 -0
  146. package/src/services/database/database.ts +146 -61
  147. package/src/services/database/engine-factory.ts +33 -0
  148. package/src/services/database/errors.ts +34 -0
  149. package/src/services/database/events/index.ts +2 -1
  150. package/src/services/database/index.ts +7 -0
  151. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  152. package/src/services/database/local-migrator.integration.test.ts +110 -0
  153. package/src/services/database/local-migrator.ts +59 -16
  154. package/src/services/database/local.test.ts +64 -0
  155. package/src/services/database/local.ts +487 -63
  156. package/src/services/database/plan-render.test.ts +159 -0
  157. package/src/services/database/plan-render.ts +108 -0
  158. package/src/services/database/relation-resolver.test.ts +484 -0
  159. package/src/services/database/relation-resolver.ts +0 -0
  160. package/src/services/database/remote.ts +255 -6
  161. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  162. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  163. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  164. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  165. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  166. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  167. package/src/services/database/sqlite-lock-verify.ts +45 -0
  168. package/src/services/database/sqlite-open.test.ts +150 -0
  169. package/src/services/database/sqlite-open.ts +164 -0
  170. package/src/services/database/sqlite-plan-sql.test.ts +145 -0
  171. package/src/services/database/sqlite-plan-sql.ts +182 -0
  172. package/src/services/database/sqlite-projection.test.ts +121 -0
  173. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  174. package/src/services/database/sqlite-select.test.ts +261 -0
  175. package/src/services/database/sqlite-select.ts +131 -0
  176. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  177. package/src/services/database/sqlite-transport.ts +224 -0
  178. package/src/services/database/sqlite-worker.ts +437 -0
  179. package/src/services/database/surql-translate.ts +416 -0
  180. package/src/services/database/surreal-cache-engine.ts +161 -0
  181. package/src/services/logger/index.test.ts +132 -0
  182. package/src/services/logger/index.ts +189 -7
  183. package/src/services/persistence/localstorage.ts +2 -2
  184. package/src/services/persistence/resilient.ts +10 -3
  185. package/src/services/persistence/surrealdb.ts +4 -4
  186. package/src/services/stream-processor/index.ts +783 -68
  187. package/src/services/stream-processor/permissions.test.ts +47 -0
  188. package/src/services/stream-processor/permissions.ts +53 -0
  189. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  190. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  191. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  192. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  193. package/src/services/stream-processor/wasm-types.ts +58 -2
  194. package/src/services/tabs/broker-client.ts +283 -0
  195. package/src/services/tabs/broker.test.ts +327 -0
  196. package/src/services/tabs/coordinator.test.ts +386 -0
  197. package/src/services/tabs/coordinator.ts +638 -0
  198. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  199. package/src/services/tabs/leader-locks.ts +75 -0
  200. package/src/services/tabs/protocol.ts +262 -0
  201. package/src/services/tabs/support.ts +36 -0
  202. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  203. package/src/sp00ky.ts +5 -395
  204. package/src/state/client-state.ts +200 -0
  205. package/src/state/lifecycle.test.ts +141 -0
  206. package/src/state/lifecycle.ts +99 -0
  207. package/src/state/reducers.test.ts +304 -0
  208. package/src/state/reducers.ts +526 -0
  209. package/src/state/selectors.test.ts +175 -0
  210. package/src/state/selectors.ts +222 -0
  211. package/src/sw/index.test.ts +582 -0
  212. package/src/sw/index.ts +607 -0
  213. package/src/sw/store.ts +96 -0
  214. package/src/sync/connection.saga.test.ts +86 -0
  215. package/src/sync/connection.saga.ts +85 -0
  216. package/src/sync/live.saga.test.ts +178 -0
  217. package/src/sync/live.saga.ts +107 -0
  218. package/src/sync/policy.health.test.ts +56 -0
  219. package/src/sync/policy.test.ts +659 -0
  220. package/src/sync/policy.ts +502 -0
  221. package/src/sync/poll.saga.test.ts +51 -0
  222. package/src/sync/poll.saga.ts +36 -0
  223. package/src/sync/tabs.saga.test.ts +78 -0
  224. package/src/sync/tabs.saga.ts +96 -0
  225. package/src/testing/adapters.ts +133 -0
  226. package/src/testing/build.ts +63 -0
  227. package/src/testing/fake-services.ts +48 -0
  228. package/src/testing/run-pure.test.ts +92 -0
  229. package/src/testing/run-pure.ts +128 -0
  230. package/src/testing/services.ts +17 -0
  231. package/src/types.ts +624 -10
  232. package/src/utils/blurhash.ts +90 -0
  233. package/src/utils/edges.ts +39 -0
  234. package/src/utils/error-classification.test.ts +54 -0
  235. package/src/utils/error-classification.ts +25 -0
  236. package/src/utils/index.ts +78 -12
  237. package/src/utils/parser.test.ts +67 -102
  238. package/src/utils/parser.ts +64 -2
  239. package/src/utils/semver.test.ts +32 -0
  240. package/src/utils/semver.ts +30 -0
  241. package/src/utils/sha256.ts +15 -0
  242. package/src/utils/surql.ts +30 -18
  243. package/src/utils/utils.test.ts +3 -2
  244. package/tsdown.config.ts +117 -3
  245. package/vitest.config.ts +48 -0
  246. package/src/modules/cache/index.ts +0 -241
  247. package/src/modules/cache/types.ts +0 -19
  248. package/src/modules/data/data.test.ts +0 -58
  249. package/src/modules/data/index.ts +0 -879
  250. package/src/modules/sync/engine.ts +0 -173
  251. package/src/modules/sync/events/index.ts +0 -77
  252. package/src/modules/sync/index.ts +0 -3
  253. package/src/modules/sync/queue/index.ts +0 -2
  254. package/src/modules/sync/queue/queue-down.ts +0 -89
  255. package/src/modules/sync/queue/queue-up.ts +0 -223
  256. package/src/modules/sync/scheduler.ts +0 -84
  257. package/src/modules/sync/sync.ts +0 -421
  258. package/src/modules/sync/utils.test.ts +0 -311
  259. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,90 @@
1
+ import { encode, decode, isBlurhashValid } from 'blurhash';
2
+
3
+ export { encode as encodeBlurhash, decode as decodeBlurhash, isBlurhashValid };
4
+
5
+ /**
6
+ * Blurhash generation settings. `true` enables with the defaults below, `false`
7
+ * disables. Resolution order for a put: per-call option > client config >
8
+ * default ON. See {@link Sp00kyConfig.blurhash}.
9
+ */
10
+ export type BlurhashSetting = boolean | BlurhashEncodeOptions;
11
+
12
+ export interface BlurhashEncodeOptions {
13
+ /** Horizontal detail components, 1-9. Defaults to 4. */
14
+ componentX?: number;
15
+ /** Vertical detail components, 1-9. Defaults to 3. */
16
+ componentY?: number;
17
+ }
18
+
19
+ /**
20
+ * Where an image's blurhash lives: a tiny sidecar object in the same bucket.
21
+ * Buckets have no per-object metadata channel (`put` is just `.put($content)`),
22
+ * so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
23
+ */
24
+ export function blurhashSidecarPath(path: string): string {
25
+ return `${path}.bh`;
26
+ }
27
+
28
+ /** Extensions `bucket.put` treats as images worth hashing. */
29
+ export const BLURHASH_IMAGE_EXTENSIONS = [
30
+ 'webp',
31
+ 'png',
32
+ 'jpg',
33
+ 'jpeg',
34
+ 'gif',
35
+ 'avif',
36
+ 'bmp',
37
+ ] as const;
38
+
39
+ const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join('|')})$`, 'i');
40
+
41
+ export function isImagePath(path: string): boolean {
42
+ return IMAGE_EXT_RE.test(path);
43
+ }
44
+
45
+ /** Longest edge of the downscale the hash is computed from. Blurhash carries at
46
+ * most 9x9 DCT components, so anything past ~32px is wasted decode work. */
47
+ const ENCODE_MAX_EDGE = 32;
48
+
49
+ /**
50
+ * Decode `content` as an image and compute its blurhash. Browser-only: returns
51
+ * null (never throws) when image decoding is unavailable (node, workers without
52
+ * canvas), when the bytes are not a decodable image, or on any other failure —
53
+ * a missing hash must never break the upload that triggered it.
54
+ */
55
+ export async function encodeImageToBlurhash(
56
+ content: string | Uint8Array | Blob,
57
+ options?: BlurhashEncodeOptions
58
+ ): Promise<string | null> {
59
+ if (typeof createImageBitmap !== 'function') return null;
60
+ let bitmap: ImageBitmap | null = null;
61
+ try {
62
+ const blob =
63
+ content instanceof Blob
64
+ ? content
65
+ : new Blob([content as unknown as BlobPart]);
66
+ bitmap = await createImageBitmap(blob);
67
+ const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
68
+ const width = Math.max(1, Math.round(bitmap.width * scale));
69
+ const height = Math.max(1, Math.round(bitmap.height * scale));
70
+ const canvas =
71
+ typeof OffscreenCanvas !== 'undefined'
72
+ ? new OffscreenCanvas(width, height)
73
+ : typeof document !== 'undefined'
74
+ ? Object.assign(document.createElement('canvas'), { width, height })
75
+ : null;
76
+ if (!canvas) return null;
77
+ const ctx = canvas.getContext('2d') as
78
+ | OffscreenCanvasRenderingContext2D
79
+ | CanvasRenderingContext2D
80
+ | null;
81
+ if (!ctx) return null;
82
+ ctx.drawImage(bitmap, 0, 0, width, height);
83
+ const { data } = ctx.getImageData(0, 0, width, height);
84
+ return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
85
+ } catch {
86
+ return null;
87
+ } finally {
88
+ bitmap?.close();
89
+ }
90
+ }
@@ -0,0 +1,39 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { InlineRow } from '../types';
3
+ import { encodeRecordId } from './index';
4
+
5
+ /**
6
+ * Reading `_00_list_ref` edges as a LIVE notification delivers them. Pure, so
7
+ * both the full client and the wasm-free live feed (`@spooky-sync/core/live`)
8
+ * decode the same wire shape the same way.
9
+ */
10
+
11
+ /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
12
+ export function hashOfEdge(value: unknown): string | null {
13
+ const inId = (value as { in?: unknown } | null)?.in;
14
+ if (!inId) return null;
15
+ const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, '');
16
+ return str.length > 0 ? str : null;
17
+ }
18
+
19
+ /**
20
+ * The row an edge notification carries, when the subscription joined it on.
21
+ *
22
+ * Without `FETCH out` the edge's `out` is a record id and there is nothing to
23
+ * land, so this returns null and the caller falls back to fetching the body.
24
+ * It also returns null for a row the session may not read (the join yields
25
+ * `out: null`) and for an edge whose target has been deleted.
26
+ */
27
+ export function rowOfEdge(value: unknown): InlineRow | null {
28
+ const edge = value as { out?: unknown; version?: unknown } | null;
29
+ const out = edge?.out;
30
+ if (!out || typeof out !== 'object' || Array.isArray(out) || out instanceof RecordId) return null;
31
+ // Must be a real RecordId: the landing path keys off `row.id` being one, and
32
+ // would otherwise record the version for a body it never wrote.
33
+ const rid = (out as { id?: unknown }).id;
34
+ if (!(rid instanceof RecordId)) return null;
35
+ const id = encodeRecordId(rid);
36
+ const version = typeof edge?.version === 'number' ? edge.version : null;
37
+ if (version === null) return null;
38
+ return { id, version, record: out as Record<string, unknown> };
39
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { classifySyncError } from './error-classification';
3
+
4
+ describe('classifySyncError', () => {
5
+ it('classifies surreal ConnectionUnavailableError as network', () => {
6
+ // Thrown synchronously by the WS engine's send() while the socket is down
7
+ // but reconnect hasn't fired. Message contains "connected", not "connection".
8
+ const err = new Error(
9
+ 'You must be connected to a SurrealDB instance before performing this operation'
10
+ );
11
+ expect(classifySyncError(err)).toBe('network');
12
+ });
13
+
14
+ it('classifies surreal CallTerminatedError as network', () => {
15
+ const err = new Error(
16
+ 'The call has been terminated because the connection was closed'
17
+ );
18
+ expect(classifySyncError(err)).toBe('network');
19
+ });
20
+
21
+ it.each([
22
+ 'WebSocket connection failed',
23
+ 'fetch failed',
24
+ 'connect ECONNREFUSED 127.0.0.1:8666',
25
+ 'request timed out',
26
+ 'The operation was aborted',
27
+ 'socket hang up',
28
+ ])('classifies %j as network', (message) => {
29
+ expect(classifySyncError(new Error(message))).toBe('network');
30
+ });
31
+
32
+ it.each([
33
+ // SurrealDB optimistic-concurrency conflict: retryable by definition.
34
+ 'Transaction conflict: Resource busy: . This transaction can be retried',
35
+ // Session state not re-applied yet after the SDK's reconnect handshake.
36
+ 'Specify a namespace to use',
37
+ 'Specify a database to use',
38
+ ])('classifies transient server state %j as network (retry, never rollback)', (message) => {
39
+ expect(classifySyncError(new Error(message))).toBe('network');
40
+ });
41
+
42
+ it.each([
43
+ 'Permission denied',
44
+ 'There was a problem with the database: record already exists',
45
+ 'Parse error: unexpected token',
46
+ ])('classifies %j as application', (message) => {
47
+ expect(classifySyncError(new Error(message))).toBe('application');
48
+ });
49
+
50
+ it('handles non-Error values', () => {
51
+ expect(classifySyncError('connection reset')).toBe('network');
52
+ expect(classifySyncError('boom')).toBe('application');
53
+ });
54
+ });
@@ -1,5 +1,12 @@
1
1
  const NETWORK_ERROR_PATTERNS = [
2
2
  'connection',
3
+ // surreal's ConnectionUnavailableError reads "You must be connected to a
4
+ // SurrealDB instance..." — it contains "connected", not "connection", so it
5
+ // slips past the pattern above. The WS engine throws it synchronously from
6
+ // `send()` while the socket is down but reconnect hasn't fired yet, so it's the
7
+ // canonical error on an idle-dropped socket; classify it as network.
8
+ 'must be connected',
9
+ 'connectionunavailable',
3
10
  'timeout',
4
11
  'timed out',
5
12
  'websocket',
@@ -12,6 +19,24 @@ const NETWORK_ERROR_PATTERNS = [
12
19
  'enotfound',
13
20
  'epipe',
14
21
  'abort',
22
+ // Transient server-side states that are NOT the mutation's fault and clear
23
+ // on their own, so the outbox must retry them rather than roll the write
24
+ // back. Seen 2026-09-08 on whitepawn: a 1200-game import lost 752 games and
25
+ // ~1500 player_name rows because every queued CREATE that hit one of these
26
+ // during a DB stall + WebSocket reconnect was classified `application`,
27
+ // rolled back locally and discarded - silent data loss.
28
+ //
29
+ // - SurrealDB optimistic-concurrency conflicts: "Transaction conflict:
30
+ // Resource busy: . This transaction can be retried".
31
+ // - A socket whose namespace/identity has not been re-applied yet after the
32
+ // SDK's own reconnect handshake: "Specify a namespace to use" /
33
+ // "Specify a database to use". The statement is fine; the session is not
34
+ // ready.
35
+ 'transaction conflict',
36
+ 'resource busy',
37
+ 'can be retried',
38
+ 'specify a namespace',
39
+ 'specify a database',
15
40
  ];
16
41
 
17
42
  export function classifySyncError(error: unknown): 'network' | 'application' {
@@ -1,7 +1,7 @@
1
- import { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
1
+ import type { GetTable, SchemaStructure, TableModel, TableNames } from '@spooky-sync/query-builder';
2
2
  import { Uuid, RecordId, Duration } from 'surrealdb';
3
- import { Logger } from '../services/logger/index';
4
- import { QueryTimeToLive } from '../types';
3
+ import type { Logger } from '../services/logger/index';
4
+ import type { QueryTimeToLive } from '../types';
5
5
 
6
6
  export * from './surql';
7
7
  export * from './parser';
@@ -19,7 +19,7 @@ export const compareRecordIds = (
19
19
  };
20
20
 
21
21
  export const encodeRecordId = (recordId: RecordId<string>): string => {
22
- return `${recordId.table.toString()}:${recordId.id}`;
22
+ return `${recordId.table.name}:${recordId.id}`;
23
23
  };
24
24
 
25
25
  export const extractIdPart = (id: string | RecordId<string>): string => {
@@ -39,7 +39,7 @@ export const extractTablePart = (id: string | RecordId<string>): string => {
39
39
  if (typeof id === 'string') {
40
40
  return id.split(':')[0];
41
41
  }
42
- return id.table.toString();
42
+ return id.table.name;
43
43
  };
44
44
 
45
45
  export const parseRecordIdString = (id: string): RecordId<string> => {
@@ -74,12 +74,12 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
74
74
  for (const field of Object.keys(table.columns)) {
75
75
  const column = table.columns[field] as any;
76
76
  const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
77
- if ((column.recordId || relation) && encoded[field] != null) {
77
+ if ((column.recordId || relation) && encoded[field] !== null && encoded[field] !== undefined) {
78
78
  if (encoded[field] instanceof RecordId) {
79
- encoded[field] = `${encoded[field].table.toString()}:${encoded[field].id}`;
79
+ encoded[field] = `${encoded[field].table.name}:${encoded[field].id}`;
80
80
  } else if (
81
81
  relation &&
82
- (encoded[field] instanceof Object || encoded[field] instanceof Array)
82
+ (encoded[field] instanceof Object || Array.isArray(encoded[field]))
83
83
  ) {
84
84
  if (Array.isArray(encoded[field])) {
85
85
  encoded[field] = encoded[field].map((item) =>
@@ -97,15 +97,25 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
97
97
 
98
98
  // ==================== TIME/DURATION UTILITIES ====================
99
99
 
100
+ /**
101
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
102
+ * releases expose it under `milliseconds` (current) or the older private
103
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
104
+ */
105
+ function durationMillis(duration: Duration): number {
106
+ const d = duration as { milliseconds?: number | bigint; _milliseconds?: number | bigint };
107
+ return Number(d.milliseconds || d._milliseconds || 0);
108
+ }
109
+
100
110
  /**
101
111
  * Parse duration string or Duration object to milliseconds
102
112
  */
103
113
  export function parseDuration(duration: QueryTimeToLive | Duration): number {
104
114
  if (duration instanceof Duration) {
105
- const ms = (duration as any).milliseconds || (duration as any)._milliseconds;
106
- if (ms) return Number(ms);
115
+ const ms = durationMillis(duration);
116
+ if (ms) return ms;
107
117
  const str = duration.toString();
108
- if (str !== '[object Object]') return parseDuration(str as any);
118
+ if (str !== '[object Object]') return parseDuration(str as QueryTimeToLive);
109
119
  return 600000;
110
120
  }
111
121
 
@@ -117,7 +127,7 @@ export function parseDuration(duration: QueryTimeToLive | Duration): number {
117
127
 
118
128
  const match = duration.match(/^(\d+)([smh])$/);
119
129
  if (!match) return 600000;
120
- const val = parseInt(match[1], 10);
130
+ const val = Number.parseInt(match[1], 10);
121
131
  const unit = match[2];
122
132
  switch (unit) {
123
133
  case 's':
@@ -137,6 +147,18 @@ export async function fileToUint8Array(file: File | Blob): Promise<Uint8Array> {
137
147
  return new Uint8Array(buffer);
138
148
  }
139
149
 
150
+ // ==================== TEXT UTILITIES ====================
151
+
152
+ /**
153
+ * Convert plain text to simple HTML paragraphs.
154
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
155
+ */
156
+ export function textToHtml(text: string): string {
157
+ return text
158
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
159
+ .split('\n').map((l) => `<p>${l || '<br>'}</p>`).join('');
160
+ }
161
+
140
162
  // ==================== DATABASE UTILITIES ====================
141
163
 
142
164
  /**
@@ -154,6 +176,9 @@ export async function withRetry<T>(
154
176
  return await operation();
155
177
  } catch (err: any) {
156
178
  lastError = err;
179
+ // A deadline expiry is explicitly not retryable: the op is still running
180
+ // in the engine, and a retry only queues a second copy behind it.
181
+ if (err?.retryable === false) throw err;
157
182
  if (
158
183
  err?.message?.includes('Can not open transaction') ||
159
184
  err?.message?.includes('transaction') ||
@@ -177,3 +202,44 @@ export async function withRetry<T>(
177
202
  }
178
203
  throw lastError;
179
204
  }
205
+
206
+ /**
207
+ * Reject after `timeoutMs` if `promise` hasn't settled.
208
+ *
209
+ * Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
210
+ * parks each call in a pending map and only rejects it when the socket reports
211
+ * a close. On a half-open socket (peer gone, no `close` event, `readyState`
212
+ * still OPEN) the call never settles at all, which wedges anything serialized
213
+ * behind it.
214
+ *
215
+ * `message` MUST contain "timed out" so `classifySyncError` treats the
216
+ * rejection as `network` — that's what makes the sync queues retry the
217
+ * operation instead of rolling the mutation back as an application error.
218
+ *
219
+ * Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
220
+ * is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
221
+ * settlement is absorbed, so attach no expectations to it after a timeout.
222
+ */
223
+ export function withTimeout<T>(
224
+ promise: Promise<T>,
225
+ timeoutMs: number,
226
+ message: string | (() => Error)
227
+ ): Promise<T> {
228
+ if (!(timeoutMs > 0)) return promise;
229
+ return new Promise<T>((resolve, reject) => {
230
+ const timer = setTimeout(
231
+ () => reject(typeof message === 'function' ? message() : new Error(message)),
232
+ timeoutMs
233
+ );
234
+ promise.then(
235
+ (value) => {
236
+ clearTimeout(timer);
237
+ resolve(value);
238
+ },
239
+ (err) => {
240
+ clearTimeout(timer);
241
+ reject(err);
242
+ }
243
+ );
244
+ });
245
+ }
@@ -1,125 +1,90 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { RecordId } from 'surrealdb';
3
- import { parseParams, cleanRecord } from './parser';
1
+ import { describe, expect, it } from 'vitest';
2
+ import { RecordId } from '@spooky-sync/query-builder';
3
+ import { absentOptionalColumns, parseParams, parseQueryParams } from './parser';
4
4
 
5
- describe('parseParams', () => {
6
- it('passes through plain values unchanged', () => {
7
- const schema = {
8
- name: { type: 'string' as const, optional: false },
9
- age: { type: 'number' as const, optional: false },
10
- };
11
- const result = parseParams(schema, { name: 'Alice', age: 30 });
12
- expect(result).toEqual({ name: 'Alice', age: 30 });
13
- });
14
-
15
- it('converts string to RecordId for recordId columns', () => {
16
- const schema = {
17
- owner: { type: 'string' as const, optional: false, recordId: true },
18
- };
19
- const result = parseParams(schema, { owner: 'user:123' });
20
- expect(result.owner).toBeInstanceOf(RecordId);
21
- expect(result.owner.table.toString()).toBe('user');
22
- expect(result.owner.id).toBe('123');
23
- });
5
+ const columns = {
6
+ white: { recordId: true },
7
+ black: { recordId: true },
8
+ owner: { recordId: true },
9
+ created_at: { dateTime: true },
10
+ result: {},
11
+ } as any;
24
12
 
25
- it('passes through existing RecordId for recordId columns', () => {
26
- const schema = {
27
- owner: { type: 'string' as const, optional: false, recordId: true },
28
- };
29
- const rid = new RecordId('user', '456');
30
- const result = parseParams(schema, { owner: rid });
31
- expect(result.owner).toBe(rid);
32
- });
33
-
34
- it('converts string to Date for dateTime columns', () => {
35
- const schema = {
36
- createdAt: { type: 'string' as const, optional: false, dateTime: true },
37
- };
38
- const result = parseParams(schema, { createdAt: '2024-01-01T00:00:00Z' });
39
- expect(result.createdAt).toBeInstanceOf(Date);
40
- expect(result.createdAt.toISOString()).toBe('2024-01-01T00:00:00.000Z');
41
- });
13
+ describe('parseQueryParams', () => {
14
+ // The regression this exists for: an `_or` group binds under synthetic names,
15
+ // and dropping them left `(white = $white__or0 OR black = $black__or1)` with
16
+ // nothing bound, so every OR query registered and matched no rows at all.
17
+ it('keeps an _or branch param and types it through its field', () => {
18
+ const parsed = parseQueryParams(columns, {
19
+ owner: 'user:abc',
20
+ white__or0: 'player_name:PN_1',
21
+ black__or1: 'player_name:PN_1',
22
+ });
42
23
 
43
- it('converts number (timestamp) to Date for dateTime columns', () => {
44
- const schema = {
45
- createdAt: { type: 'string' as const, optional: false, dateTime: true },
46
- };
47
- const ts = 1704067200000; // 2024-01-01T00:00:00.000Z
48
- const result = parseParams(schema, { createdAt: ts });
49
- expect(result.createdAt).toBeInstanceOf(Date);
50
- expect(result.createdAt.getTime()).toBe(ts);
24
+ expect(Object.keys(parsed).sort()).toEqual(['black__or1', 'owner', 'white__or0']);
25
+ expect(parsed.white__or0).toBeInstanceOf(RecordId);
26
+ expect(String(parsed.white__or0)).toBe('player_name:PN_1');
27
+ expect(parsed.black__or1).toBeInstanceOf(RecordId);
51
28
  });
52
29
 
53
- it('passes through existing Date for dateTime columns', () => {
54
- const schema = {
55
- createdAt: { type: 'string' as const, optional: false, dateTime: true },
56
- };
57
- const date = new Date('2024-01-01');
58
- const result = parseParams(schema, { createdAt: date });
59
- expect(result.createdAt).toBe(date);
30
+ it('passes a param it cannot resolve through untouched instead of dropping it', () => {
31
+ const parsed = parseQueryParams(columns, { in: 'anything', limit: 30 });
32
+ expect(parsed).toEqual({ in: 'anything', limit: 30 });
60
33
  });
61
34
 
62
- it('skips undefined values', () => {
63
- const schema = {
64
- name: { type: 'string' as const, optional: false },
65
- age: { type: 'number' as const, optional: true },
66
- };
67
- const result = parseParams(schema, { name: 'Alice', age: undefined });
68
- expect(result).toEqual({ name: 'Alice' });
69
- expect('age' in result).toBe(false);
70
- });
71
-
72
- it('throws on invalid recordId value', () => {
73
- const schema = {
74
- owner: { type: 'string' as const, optional: false, recordId: true },
75
- };
76
- expect(() => parseParams(schema, { owner: 12345 })).toThrow('Invalid value for owner');
35
+ it('still types and keeps plain column params, and skips undefined', () => {
36
+ const parsed = parseQueryParams(columns, {
37
+ owner: 'user:abc',
38
+ created_at: '2026-01-01T00:00:00.000Z',
39
+ result: undefined,
40
+ });
41
+ expect(parsed.owner).toBeInstanceOf(RecordId);
42
+ expect(parsed.created_at).toBeInstanceOf(Date);
43
+ expect('result' in parsed).toBe(false);
77
44
  });
45
+ });
78
46
 
79
- it('throws on invalid dateTime value', () => {
80
- const schema = {
81
- createdAt: { type: 'string' as const, optional: false, dateTime: true },
82
- };
83
- expect(() => parseParams(schema, { createdAt: true })).toThrow(
84
- 'Invalid value for createdAt'
85
- );
47
+ describe('parseParams', () => {
48
+ // Records keep the old behaviour: a field the schema does not know is dropped
49
+ // rather than written.
50
+ it('drops a field that is not a column', () => {
51
+ const parsed = parseParams(columns, { result: '1-0', nonsense: 'x', white__or0: 'y' });
52
+ expect(parsed).toEqual({ result: '1-0' });
86
53
  });
87
54
  });
88
55
 
89
- describe('cleanRecord', () => {
90
- const tableSchema = {
91
- name: { type: 'string' as const, optional: false },
92
- age: { type: 'number' as const, optional: false },
93
- };
56
+ describe('absentOptionalColumns', () => {
57
+ const schema = {
58
+ title: { type: 'string', optional: false },
59
+ club: { type: 'string', recordId: true, optional: true },
60
+ note: { type: 'string', optional: true },
61
+ _00_local: { type: 'string', optional: true },
62
+ id: { type: 'string', optional: true },
63
+ } as any;
94
64
 
95
- it('keeps schema fields and strips non-schema fields', () => {
96
- const record = { id: 'user:1', name: 'Alice', age: 30, _internal: true, computed_score: 99 };
97
- const result = cleanRecord(tableSchema, record);
98
- expect(result).toEqual({ id: 'user:1', name: 'Alice', age: 30 });
65
+ // The bug: `UPDATE broadcast:x UNSET club` leaves no `club` key in the body,
66
+ // and a MERGE that does not name it keeps the stale local value.
67
+ it('names every optional column the row lacks, as undefined', () => {
68
+ const cleared = absentOptionalColumns(schema, { id: 'broadcast:x', title: 't', note: 'n' });
69
+ expect(cleared).toStrictEqual({ club: undefined });
70
+ expect(Object.prototype.hasOwnProperty.call(cleared, 'club')).toBe(true);
99
71
  });
100
72
 
101
- it('always preserves id even though it is not in schema', () => {
102
- const record = { id: 'user:2', extra: 'gone' };
103
- const result = cleanRecord(tableSchema, record);
104
- expect(result).toEqual({ id: 'user:2' });
73
+ it('treats an explicit undefined (a decoded NONE) as absent', () => {
74
+ expect(absentOptionalColumns(schema, { title: 't', club: undefined, note: 'n' })).toStrictEqual(
75
+ { club: undefined }
76
+ );
105
77
  });
106
78
 
107
- it('does not coerce or transform values', () => {
108
- const date = new Date('2024-01-01');
109
- const schema = { created: { type: 'string' as const, optional: false, dateTime: true } };
110
- const record = { id: 'x:1', created: date };
111
- const result = cleanRecord(schema, record);
112
- expect(result.created).toBe(date);
79
+ it('never names a required column, id, or _00_* bookkeeping', () => {
80
+ expect(absentOptionalColumns(schema, {})).toStrictEqual({ club: undefined, note: undefined });
113
81
  });
114
82
 
115
- it('returns only id when record has no schema fields', () => {
116
- const record = { id: 'user:3', unknown1: 'a', unknown2: 'b' };
117
- const result = cleanRecord(tableSchema, record);
118
- expect(result).toEqual({ id: 'user:3' });
83
+ it('keeps a present value, even a falsy one', () => {
84
+ expect(absentOptionalColumns(schema, { club: null, note: '' })).toStrictEqual({});
119
85
  });
120
86
 
121
- it('handles empty record', () => {
122
- const result = cleanRecord(tableSchema, {});
123
- expect(result).toEqual({});
87
+ it('is empty without a schema', () => {
88
+ expect(absentOptionalColumns(undefined, { a: 1 })).toStrictEqual({});
124
89
  });
125
90
  });
@@ -1,4 +1,5 @@
1
- import { ColumnSchema, RecordId } from '@spooky-sync/query-builder';
1
+ import type { ColumnSchema} from '@spooky-sync/query-builder';
2
+ import { RecordId, baseFieldOfParam } from '@spooky-sync/query-builder';
2
3
  import { parseRecordIdString } from './index';
3
4
  import { DateTime } from 'surrealdb';
4
5
 
@@ -8,13 +9,49 @@ export function cleanRecord(
8
9
  ): Record<string, any> {
9
10
  const cleaned: Record<string, any> = {};
10
11
  for (const [key, value] of Object.entries(record)) {
11
- if (key === 'id' || key in tableSchema) {
12
+ if (key === 'id' || key.startsWith('_00_') || key in tableSchema) {
12
13
  cleaned[key] = value;
13
14
  }
14
15
  }
15
16
  return cleaned;
16
17
  }
17
18
 
19
+ /**
20
+ * The OPTIONAL schema columns a full server row does not carry, each mapped to
21
+ * `undefined`, for spreading into a body that is landed with MERGE.
22
+ *
23
+ * The server omits an optional field that is unset (`UPDATE t UNSET club`
24
+ * leaves no `club` key in `SELECT *` or the SSP body), and MERGE keeps every
25
+ * key the patch does not mention, so without this the local store held on to
26
+ * a cleared value forever. `undefined` is the one representation both engines
27
+ * read as "remove": the SurrealDB SDK encodes it as NONE, which MERGE removes
28
+ * (NULL would be a type error on an `option<record<...>>` field), and the
29
+ * SQLite engine turns it into the `null` member that `json_patch` removes (see
30
+ * `serializeMergeRow`).
31
+ *
32
+ * Required columns are left alone. A SCHEMAFULL server cannot hold NONE in
33
+ * one, so its absence means the row was not readable in full (a field
34
+ * permission), not that it was cleared, and NONE in a typed local store is a
35
+ * coercion error that would fail the whole chunk's transaction. `id` and the
36
+ * local-only `_00_*` bookkeeping are never touched.
37
+ */
38
+ export function absentOptionalColumns(
39
+ tableSchema: Record<string, ColumnSchema> | undefined,
40
+ record: Record<string, unknown>
41
+ ): Record<string, undefined> {
42
+ const cleared: Record<string, undefined> = {};
43
+ if (!tableSchema) return cleared;
44
+ for (const [key, column] of Object.entries(tableSchema)) {
45
+ if (key === 'id' || key.startsWith('_00_') || column?.optional !== true) continue;
46
+ if (record[key] === undefined) cleared[key] = undefined;
47
+ }
48
+ return cleared;
49
+ }
50
+
51
+ /**
52
+ * Parse a RECORD's fields against the table schema. Anything the schema does not
53
+ * know is dropped, which is what keeps a stray field out of a write.
54
+ */
18
55
  export function parseParams(
19
56
  tableSchema: Record<string, ColumnSchema>,
20
57
  params: Record<string, any>
@@ -30,6 +67,31 @@ export function parseParams(
30
67
  return parsedParams;
31
68
  }
32
69
 
70
+ /**
71
+ * Parse a QUERY's params. Unlike a record's fields, a param name is not always a
72
+ * column: an `_or` branch binds under a synthetic `white__or0` (see
73
+ * `orParamName` in @spooky-sync/query-builder), and the surql that references it
74
+ * is already written. So resolve the column through the synthetic name, and keep
75
+ * a param we cannot type rather than dropping it.
76
+ *
77
+ * Dropping was the bug: `parseParams` kept only column-named params, so every
78
+ * `_or` query registered with `$or0`/`$or1` unbound and matched no rows at all,
79
+ * silently (rowCount 0, errorCount 0).
80
+ */
81
+ export function parseQueryParams(
82
+ tableSchema: Record<string, ColumnSchema>,
83
+ params: Record<string, any>
84
+ ) {
85
+ const parsedParams: Record<string, any> = {};
86
+ for (const [key, value] of Object.entries(params)) {
87
+ if (value === undefined) continue;
88
+ const column = tableSchema[key] ?? tableSchema[baseFieldOfParam(key)];
89
+ parsedParams[key] = column ? parseValue(key, column, value) : value;
90
+ }
91
+
92
+ return parsedParams;
93
+ }
94
+
33
95
  function parseValue(name: string, column: ColumnSchema, value: any) {
34
96
  if (column.recordId) {
35
97
  if (value instanceof RecordId) return value;