@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,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,57 @@
1
+ import { RecordId } from 'surrealdb';
2
+ import type { EdgeDelta, 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
+ }
40
+
41
+ /**
42
+ * The membership change a `CREATE` or `UPDATE` notification reports: the
43
+ * edge's target joined the view (or its version moved). Null for anything a
44
+ * re-read must settle instead: a `DELETE` (a row leaving, or every edge of a
45
+ * view being rewritten), a subquery child (`parent` set), or an edge missing
46
+ * its target or version. With `FETCH out` the target is the joined row, whose
47
+ * `id` is the record id.
48
+ */
49
+ export function deltaOfEdge(action: string, value: unknown): EdgeDelta | null {
50
+ if (action !== 'CREATE' && action !== 'UPDATE') return null;
51
+ const hash = hashOfEdge(value);
52
+ const edge = value as { out?: unknown; version?: unknown; parent?: unknown };
53
+ if (!hash || edge.parent != null || typeof edge.version !== 'number') return null;
54
+ const out = edge.out instanceof RecordId ? edge.out : (edge.out as { id?: unknown } | null | undefined)?.id;
55
+ if (!(out instanceof RecordId)) return null;
56
+ return { hash, id: encodeRecordId(out), version: edge.version };
57
+ }
@@ -0,0 +1,116 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { classifySyncError, isRefusedSessionError } from './error-classification';
3
+
4
+ describe('classifySyncError', () => {
5
+ it('classifies a proxy with no database behind it as network', () => {
6
+ // Over HTTP the SDK's message is the response body. These are Traefik's, while
7
+ // a database restarts or moves in a version upgrade; a rollback would lose the
8
+ // write.
9
+ for (const body of ['Bad Gateway', 'Service Unavailable', 'Gateway Timeout', '404 page not found\n']) {
10
+ expect(classifySyncError(new Error(body))).toBe('network');
11
+ }
12
+ // Only as the whole message: a statement's own error can use the words.
13
+ expect(classifySyncError(new Error('Thrown error: bad gateway to the partner API'))).toBe('application');
14
+ });
15
+
16
+ it('classifies surreal ConnectionUnavailableError as network', () => {
17
+ // Thrown synchronously by the WS engine's send() while the socket is down
18
+ // but reconnect hasn't fired. Message contains "connected", not "connection".
19
+ const err = new Error(
20
+ 'You must be connected to a SurrealDB instance before performing this operation'
21
+ );
22
+ expect(classifySyncError(err)).toBe('network');
23
+ });
24
+
25
+ it('classifies surreal CallTerminatedError as network', () => {
26
+ const err = new Error(
27
+ 'The call has been terminated because the connection was closed'
28
+ );
29
+ expect(classifySyncError(err)).toBe('network');
30
+ });
31
+
32
+ it.each([
33
+ 'WebSocket connection failed',
34
+ 'fetch failed',
35
+ 'connect ECONNREFUSED 127.0.0.1:8666',
36
+ 'request timed out',
37
+ 'The operation was aborted',
38
+ 'socket hang up',
39
+ ])('classifies %j as network', (message) => {
40
+ expect(classifySyncError(new Error(message))).toBe('network');
41
+ });
42
+
43
+ it.each([
44
+ // SurrealDB optimistic-concurrency conflict: retryable by definition.
45
+ 'Transaction conflict: Resource busy: . This transaction can be retried',
46
+ // Session state not re-applied yet after the SDK's reconnect handshake.
47
+ 'Specify a namespace to use',
48
+ 'Specify a database to use',
49
+ ])('classifies transient server state %j as network (retry, never rollback)', (message) => {
50
+ expect(classifySyncError(new Error(message))).toBe('network');
51
+ });
52
+
53
+ // Verbatim from SurrealDB 3.1.5 and 3.3.4: the write was refused because
54
+ // sp00ky's own ingest event could not reach the scheduler / SSP. The outbox
55
+ // must keep these queued, never roll them back (see ingest-outage.integration.test.ts).
56
+ it.each([
57
+ // Refused connection and a post past the event TIMEOUT read the same.
58
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: error sending request for url (http://scheduler:9667/ingest)',
59
+ 'Error while processing event _00_item_delete: There was an error processing a remote HTTP request: error sending request for url (http://host.docker.internal:50466/ingest)',
60
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 500 Internal Server Error',
61
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 502 Bad Gateway',
62
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
63
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 408 Request Timeout',
64
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 429 Too Many Requests',
65
+ 'Error while processing event _00_item_mutation: failed to lookup address information: Name or service not known',
66
+ // An app event that wrote to a synced table carries the inner event.
67
+ 'Error while processing event app_fanout: Error while processing event _00_b_mutation: There was an error processing a remote HTTP request: 503 Service Unavailable',
68
+ ])('classifies a failed sp00ky ingest post %j as network (retry, never rollback)', (message) => {
69
+ expect(classifySyncError(new Error(message))).toBe('network');
70
+ });
71
+
72
+ it.each([
73
+ 'Permission denied',
74
+ // Verbatim from SurrealDB 3.3.4: a record user running a statement it may not.
75
+ 'IAM error: Not enough permissions to perform this action',
76
+ 'There was a problem with the database: record already exists',
77
+ 'Parse error: unexpected token',
78
+ "Couldn't coerce value for field `title` of `item:1`: Expected `string` but found `1`",
79
+ // An app's own event: its endpoint, its THROW, its business.
80
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: 500 Internal Server Error',
81
+ 'Error while processing event notify_crm: There was an error processing a remote HTTP request: error sending request for url (http://crm.example/hook)',
82
+ 'Error while processing event cf_guard: An error occurred: conformance guard: write refused',
83
+ // A THROW inside a sp00ky event is not a failed post.
84
+ 'Error while processing event _00_item_mutation: An error occurred: inner',
85
+ // A 4xx from /ingest no retry fixes: payload rejected, too large, secret mismatch.
86
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 400 Bad Request',
87
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 401 Unauthorized',
88
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 413 Payload Too Large',
89
+ 'Error while processing event _00_item_mutation: There was an error processing a remote HTTP request: 422 Unprocessable Entity',
90
+ ])('classifies %j as application', (message) => {
91
+ expect(classifySyncError(new Error(message))).toBe('application');
92
+ });
93
+
94
+ it('handles non-Error values', () => {
95
+ expect(classifySyncError('connection reset')).toBe('network');
96
+ expect(classifySyncError('boom')).toBe('application');
97
+ });
98
+ });
99
+
100
+ describe('isRefusedSessionError', () => {
101
+ it.each([
102
+ 'There was a problem with authentication',
103
+ 'An error occurred: There was a problem with authentication: this session has ended, sign in again',
104
+ 'The session has expired',
105
+ ])('takes %j as a refused session', (message) => {
106
+ expect(isRefusedSessionError(new Error(message))).toBe(true);
107
+ });
108
+
109
+ it.each([
110
+ 'There was a problem with the database: Invalid token',
111
+ 'There was a problem with the underlying connection',
112
+ 'Permission denied',
113
+ ])('does not take %j as one', (message) => {
114
+ expect(isRefusedSessionError(new Error(message))).toBe(false);
115
+ });
116
+ });
@@ -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,12 +19,67 @@ 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
 
42
+ /**
43
+ * A write SurrealDB refused because sp00ky's OWN ingest post failed: every
44
+ * synced table's `_00_<table>_mutation` / `_00_<table>_delete` event posts to
45
+ * the scheduler or SSP inside the write's transaction (`sp00ky.rs::ingest_post`),
46
+ * so a scheduler that is down, slow past the event TIMEOUT, or answering 5xx
47
+ * aborts the write. That is an outage, not the mutation's fault, and it clears
48
+ * on its own; classified `application` it was rolled back and discarded, so
49
+ * every write queued during a scheduler restart or a cold SSP was lost.
50
+ *
51
+ * Wording on SurrealDB 3.1.5 and 3.3.4 (the event name may be nested inside an
52
+ * app event that wrote to a synced table):
53
+ * "Error while processing event _00_item_mutation: There was an error
54
+ * processing a remote HTTP request: error sending request for url (...)"
55
+ * (refused connection or TIMEOUT), "...: 503 Service Unavailable" (5xx),
56
+ * "Error while processing event _00_item_mutation: failed to lookup address
57
+ * information: ..." (DNS).
58
+ *
59
+ * Only `_00_` events count: an app's own event posting to its own endpoint
60
+ * stays `application`, as does a 4xx from `/ingest` other than 408/429 (a
61
+ * malformed or oversized payload, a secret mismatch), which no retry fixes and
62
+ * which would otherwise hold the outbox's serial lane forever.
63
+ */
64
+ const SP00KY_EVENT_POST_FAILED =
65
+ /error while processing event _00_[^:\s]*: (?:there was an error processing a remote http request: (?:error sending request|5\d\d\b|408\b|429\b)|failed to lookup address information)/;
66
+
67
+ /**
68
+ * What the proxy in front of a database answers while the database is away: a
69
+ * restart, or the seconds of a version upgrade. Over HTTP the SDK's error message
70
+ * IS the response body, so these arrive as bare words, and an `application`
71
+ * reading rolls the write back. Anchored: a statement can mention these words,
72
+ * but SurrealDB never answers with nothing else.
73
+ */
74
+ const PROXY_UNAVAILABLE = /^(?:bad gateway|service unavailable|gateway timeout|404 page not found)\s*$/;
75
+
17
76
  export function classifySyncError(error: unknown): 'network' | 'application' {
18
77
  const message =
19
78
  error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
20
79
 
80
+ if (SP00KY_EVENT_POST_FAILED.test(message)) return 'network';
81
+ if (PROXY_UNAVAILABLE.test(message)) return 'network';
82
+
21
83
  for (const pattern of NETWORK_ERROR_PATTERNS) {
22
84
  if (message.includes(pattern)) {
23
85
  return 'network';
@@ -26,3 +88,15 @@ export function classifySyncError(error: unknown): 'network' | 'application' {
26
88
 
27
89
  return 'application';
28
90
  }
91
+
92
+ /**
93
+ * Whether the server refused the SESSION rather than the statement: its own
94
+ * wording for a token it will not accept ("There was a problem with
95
+ * authentication"), which is also what an app's AUTHENTICATE clause and
96
+ * sp00ky's session guard throw, and a session past its `DURATION FOR SESSION`.
97
+ */
98
+ export function isRefusedSessionError(error: unknown): boolean {
99
+ const message =
100
+ error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
101
+ return message.includes('problem with authentication') || message.includes('session has expired');
102
+ }
@@ -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> => {
@@ -51,6 +51,17 @@ export function generateId(): string {
51
51
  return Uuid.v4().toString().replace(/-/g, '');
52
52
  }
53
53
 
54
+ /**
55
+ * 32 random bytes, hex: the secret a page proves it owns its anonymous views
56
+ * with (`$config.owner` on `fn::query::register`). `getRandomValues` rather
57
+ * than `randomUUID`, which a page served over plain http does not have.
58
+ */
59
+ export function mintOwnerSecret(): string {
60
+ const bytes = new Uint8Array(32);
61
+ globalThis.crypto.getRandomValues(bytes);
62
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
63
+ }
64
+
54
65
  export function generateNewTableId<S extends SchemaStructure, T extends TableNames<S>>(
55
66
  tableName: T
56
67
  ): RecordId {
@@ -76,7 +87,7 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
76
87
  const relation = schema.relationships.find((r) => r.from === tableName && r.field === field);
77
88
  if ((column.recordId || relation) && encoded[field] !== null && encoded[field] !== undefined) {
78
89
  if (encoded[field] instanceof RecordId) {
79
- encoded[field] = `${encoded[field].table.toString()}:${encoded[field].id}`;
90
+ encoded[field] = `${encoded[field].table.name}:${encoded[field].id}`;
80
91
  } else if (
81
92
  relation &&
82
93
  (encoded[field] instanceof Object || Array.isArray(encoded[field]))
@@ -97,15 +108,25 @@ export function decodeFromSp00ky<S extends SchemaStructure, T extends TableNames
97
108
 
98
109
  // ==================== TIME/DURATION UTILITIES ====================
99
110
 
111
+ /**
112
+ * Read the millisecond count off a surrealdb `Duration`. Different `surrealdb`
113
+ * releases expose it under `milliseconds` (current) or the older private
114
+ * `_milliseconds` field, so read whichever is set; returns 0 when neither is.
115
+ */
116
+ function durationMillis(duration: Duration): number {
117
+ const d = duration as { milliseconds?: number | bigint; _milliseconds?: number | bigint };
118
+ return Number(d.milliseconds || d._milliseconds || 0);
119
+ }
120
+
100
121
  /**
101
122
  * Parse duration string or Duration object to milliseconds
102
123
  */
103
124
  export function parseDuration(duration: QueryTimeToLive | Duration): number {
104
125
  if (duration instanceof Duration) {
105
- const ms = (duration as any).milliseconds || (duration as any)._milliseconds;
106
- if (ms) return Number(ms);
126
+ const ms = durationMillis(duration);
127
+ if (ms) return ms;
107
128
  const str = duration.toString();
108
- if (str !== '[object Object]') return parseDuration(str as any);
129
+ if (str !== '[object Object]') return parseDuration(str as QueryTimeToLive);
109
130
  return 600000;
110
131
  }
111
132
 
@@ -137,6 +158,18 @@ export async function fileToUint8Array(file: File | Blob): Promise<Uint8Array> {
137
158
  return new Uint8Array(buffer);
138
159
  }
139
160
 
161
+ // ==================== TEXT UTILITIES ====================
162
+
163
+ /**
164
+ * Convert plain text to simple HTML paragraphs.
165
+ * Useful for seeding a rich-text editor (e.g. TipTap/ProseMirror) with fallback content.
166
+ */
167
+ export function textToHtml(text: string): string {
168
+ return text
169
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
170
+ .split('\n').map((l) => `<p>${l || '<br>'}</p>`).join('');
171
+ }
172
+
140
173
  // ==================== DATABASE UTILITIES ====================
141
174
 
142
175
  /**
@@ -154,6 +187,9 @@ export async function withRetry<T>(
154
187
  return await operation();
155
188
  } catch (err: any) {
156
189
  lastError = err;
190
+ // A deadline expiry is explicitly not retryable: the op is still running
191
+ // in the engine, and a retry only queues a second copy behind it.
192
+ if (err?.retryable === false) throw err;
157
193
  if (
158
194
  err?.message?.includes('Can not open transaction') ||
159
195
  err?.message?.includes('transaction') ||
@@ -177,3 +213,44 @@ export async function withRetry<T>(
177
213
  }
178
214
  throw lastError;
179
215
  }
216
+
217
+ /**
218
+ * Reject after `timeoutMs` if `promise` hasn't settled.
219
+ *
220
+ * Exists because a WebSocket RPC has no deadline of its own: the SurrealDB SDK
221
+ * parks each call in a pending map and only rejects it when the socket reports
222
+ * a close. On a half-open socket (peer gone, no `close` event, `readyState`
223
+ * still OPEN) the call never settles at all, which wedges anything serialized
224
+ * behind it.
225
+ *
226
+ * `message` MUST contain "timed out" so `classifySyncError` treats the
227
+ * rejection as `network` — that's what makes the sync queues retry the
228
+ * operation instead of rolling the mutation back as an application error.
229
+ *
230
+ * Non-positive `timeoutMs` returns `promise` unchanged. The underlying promise
231
+ * is NOT cancelled (nothing can cancel an in-flight RPC); its eventual
232
+ * settlement is absorbed, so attach no expectations to it after a timeout.
233
+ */
234
+ export function withTimeout<T>(
235
+ promise: Promise<T>,
236
+ timeoutMs: number,
237
+ message: string | (() => Error)
238
+ ): Promise<T> {
239
+ if (!(timeoutMs > 0)) return promise;
240
+ return new Promise<T>((resolve, reject) => {
241
+ const timer = setTimeout(
242
+ () => reject(typeof message === 'function' ? message() : new Error(message)),
243
+ timeoutMs
244
+ );
245
+ promise.then(
246
+ (value) => {
247
+ clearTimeout(timer);
248
+ resolve(value);
249
+ },
250
+ (err) => {
251
+ clearTimeout(timer);
252
+ reject(err);
253
+ }
254
+ );
255
+ });
256
+ }