@spooky-sync/core 0.0.1-canary.35 → 0.0.1-canary.351

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 (269) hide show
  1. package/AGENTS.md +90 -0
  2. package/README.md +59 -16
  3. package/dist/index.d.ts +2135 -142
  4. package/dist/index.js +16570 -2575
  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/otel/index.js +3 -3
  9. package/dist/pure.d.ts +401 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +392 -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 +477 -0
  18. package/dist/types.d.ts +1917 -38
  19. package/dist/worker-edges.js +498 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +48 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +28 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +173 -0
  27. package/skills/sp00ky-core/SKILL.md +37 -7
  28. package/skills/sp00ky-core/references/config.md +7 -1
  29. package/src/boot/auth-flip.saga.test.ts +174 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +203 -0
  32. package/src/boot/boot.saga.ts +146 -0
  33. package/src/boot/bucket-switch.saga.test.ts +191 -0
  34. package/src/boot/bucket-switch.saga.ts +151 -0
  35. package/src/boot/preload.saga.test.ts +96 -0
  36. package/src/boot/preload.saga.ts +45 -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 +195 -0
  40. package/src/client/bucket-handle.ts +352 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +102 -0
  43. package/src/client/runtime.test.ts +299 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.test.ts +74 -0
  46. package/src/client/services.ts +437 -0
  47. package/src/client/sp00ky-client.test.ts +341 -0
  48. package/src/client/sp00ky-client.ts +625 -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 +171 -0
  56. package/src/kernel/events.ts +59 -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 +512 -0
  62. package/src/live/index.ts +1059 -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 +228 -0
  66. package/src/modules/auth/auth.session-recheck.test.ts +140 -0
  67. package/src/modules/auth/events/index.ts +9 -2
  68. package/src/modules/auth/impersonation-banner.ts +443 -0
  69. package/src/modules/auth/impersonation.integration.test.ts +205 -0
  70. package/src/modules/auth/impersonation.test.ts +377 -0
  71. package/src/modules/auth/impersonation.ts +179 -0
  72. package/src/modules/auth/index.ts +543 -22
  73. package/src/modules/crdt/acceptance.ts +75 -0
  74. package/src/modules/crdt/bytes.ts +50 -0
  75. package/src/modules/crdt/crdt-field.ts +125 -0
  76. package/src/modules/crdt/crdt.test.ts +546 -0
  77. package/src/modules/crdt/hash.ts +103 -0
  78. package/src/modules/crdt/index.ts +821 -0
  79. package/src/modules/crdt/loro-loader.ts +25 -0
  80. package/src/modules/crdt/mirror.ts +37 -0
  81. package/src/modules/devtools/flags.ts +349 -0
  82. package/src/modules/devtools/index.ts +748 -95
  83. package/src/modules/devtools/mutations.test.ts +92 -0
  84. package/src/modules/devtools/mutations.ts +160 -0
  85. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  86. package/src/modules/devtools/state-shape.test.ts +263 -0
  87. package/src/modules/devtools/storage-info.test.ts +79 -0
  88. package/src/modules/devtools/storage-info.ts +168 -0
  89. package/src/modules/devtools/versions.test.ts +74 -0
  90. package/src/modules/devtools/versions.ts +110 -0
  91. package/src/modules/feature-flag/index.test.ts +298 -0
  92. package/src/modules/feature-flag/index.ts +316 -0
  93. package/src/modules/ref-tables.test.ts +93 -0
  94. package/src/modules/ref-tables.ts +91 -0
  95. package/src/modules/web-push/index.test.ts +824 -0
  96. package/src/modules/web-push/index.ts +1027 -0
  97. package/src/mutation/ingest-outage.integration.test.ts +400 -0
  98. package/src/mutation/jobs.test.ts +25 -0
  99. package/src/mutation/jobs.ts +41 -0
  100. package/src/mutation/mutation-id.test.ts +37 -0
  101. package/src/mutation/mutation-id.ts +35 -0
  102. package/src/mutation/pending-writes.integration.test.ts +175 -0
  103. package/src/mutation/push.saga.test.ts +361 -0
  104. package/src/mutation/push.saga.ts +245 -0
  105. package/src/mutation/rows.test.ts +278 -0
  106. package/src/mutation/rows.ts +499 -0
  107. package/src/mutation/tray.saga.test.ts +63 -0
  108. package/src/mutation/tray.saga.ts +48 -0
  109. package/src/mutation/write.saga.test.ts +316 -0
  110. package/src/mutation/write.saga.ts +268 -0
  111. package/src/otel/index.ts +10 -7
  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 +188 -0
  126. package/src/query/materialize.saga.ts +96 -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 +482 -0
  130. package/src/query/membership.saga.ts +315 -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 +156 -61
  155. package/src/services/database/engine-factory.ts +33 -0
  156. package/src/services/database/errors.ts +34 -0
  157. package/src/services/database/events/index.ts +2 -1
  158. package/src/services/database/index.ts +7 -0
  159. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  160. package/src/services/database/local-migrator.integration.test.ts +110 -0
  161. package/src/services/database/local-migrator.ts +61 -16
  162. package/src/services/database/local.test.ts +150 -0
  163. package/src/services/database/local.ts +508 -63
  164. package/src/services/database/plan-render.test.ts +159 -0
  165. package/src/services/database/plan-render.ts +108 -0
  166. package/src/services/database/relation-resolver.test.ts +484 -0
  167. package/src/services/database/relation-resolver.ts +0 -0
  168. package/src/services/database/remote.ts +285 -6
  169. package/src/services/database/sqlite-cache-engine.test.ts +628 -0
  170. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  171. package/src/services/database/sqlite-cache-engine.ts +1401 -0
  172. package/src/services/database/sqlite-column-reset.integration.test.ts +100 -0
  173. package/src/services/database/sqlite-column-reset.ts +91 -0
  174. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  175. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  176. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  177. package/src/services/database/sqlite-lock-verify.ts +45 -0
  178. package/src/services/database/sqlite-open.test.ts +150 -0
  179. package/src/services/database/sqlite-open.ts +164 -0
  180. package/src/services/database/sqlite-plan-sql.test.ts +164 -0
  181. package/src/services/database/sqlite-plan-sql.ts +196 -0
  182. package/src/services/database/sqlite-projection.test.ts +121 -0
  183. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  184. package/src/services/database/sqlite-select.test.ts +261 -0
  185. package/src/services/database/sqlite-select.ts +131 -0
  186. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  187. package/src/services/database/sqlite-transport.ts +224 -0
  188. package/src/services/database/sqlite-worker.ts +437 -0
  189. package/src/services/database/surql-translate.ts +417 -0
  190. package/src/services/database/surreal-cache-engine.ts +163 -0
  191. package/src/services/logger/index.test.ts +132 -0
  192. package/src/services/logger/index.ts +189 -7
  193. package/src/services/persistence/localstorage.ts +2 -2
  194. package/src/services/persistence/resilient.ts +10 -3
  195. package/src/services/persistence/surrealdb.ts +4 -4
  196. package/src/services/stream-processor/index.ts +806 -67
  197. package/src/services/stream-processor/permissions.test.ts +47 -0
  198. package/src/services/stream-processor/permissions.ts +53 -0
  199. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  200. package/src/services/stream-processor/stream-processor.prime.test.ts +234 -0
  201. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  202. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  203. package/src/services/stream-processor/wasm-types.ts +58 -2
  204. package/src/services/tabs/broker-client.ts +284 -0
  205. package/src/services/tabs/broker.test.ts +381 -0
  206. package/src/services/tabs/coordinator.test.ts +386 -0
  207. package/src/services/tabs/coordinator.ts +646 -0
  208. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  209. package/src/services/tabs/leader-locks.ts +75 -0
  210. package/src/services/tabs/protocol.ts +262 -0
  211. package/src/services/tabs/support.ts +36 -0
  212. package/src/services/tabs/tabs-broker-worker.ts +652 -0
  213. package/src/sp00ky.ts +5 -395
  214. package/src/state/client-state.ts +216 -0
  215. package/src/state/lifecycle.test.ts +184 -0
  216. package/src/state/lifecycle.ts +127 -0
  217. package/src/state/reducers.test.ts +319 -0
  218. package/src/state/reducers.ts +550 -0
  219. package/src/state/selectors.test.ts +188 -0
  220. package/src/state/selectors.ts +259 -0
  221. package/src/sw/index.test.ts +582 -0
  222. package/src/sw/index.ts +607 -0
  223. package/src/sw/store.ts +96 -0
  224. package/src/sync/connection.saga.test.ts +97 -0
  225. package/src/sync/connection.saga.ts +89 -0
  226. package/src/sync/live.saga.test.ts +229 -0
  227. package/src/sync/live.saga.ts +116 -0
  228. package/src/sync/policy.health.test.ts +56 -0
  229. package/src/sync/policy.test.ts +663 -0
  230. package/src/sync/policy.ts +515 -0
  231. package/src/sync/poll.saga.test.ts +51 -0
  232. package/src/sync/poll.saga.ts +36 -0
  233. package/src/sync/tabs.saga.test.ts +78 -0
  234. package/src/sync/tabs.saga.ts +96 -0
  235. package/src/testing/adapters.ts +137 -0
  236. package/src/testing/build.ts +63 -0
  237. package/src/testing/fake-services.ts +63 -0
  238. package/src/testing/run-pure.test.ts +92 -0
  239. package/src/testing/run-pure.ts +128 -0
  240. package/src/testing/services.ts +17 -0
  241. package/src/types.ts +661 -10
  242. package/src/utils/blurhash.ts +90 -0
  243. package/src/utils/edges.ts +57 -0
  244. package/src/utils/error-classification.test.ts +105 -0
  245. package/src/utils/error-classification.ts +64 -0
  246. package/src/utils/index.ts +89 -12
  247. package/src/utils/parser.test.ts +67 -102
  248. package/src/utils/parser.ts +64 -2
  249. package/src/utils/semver.test.ts +32 -0
  250. package/src/utils/semver.ts +30 -0
  251. package/src/utils/sha256.ts +15 -0
  252. package/src/utils/surql.ts +29 -18
  253. package/src/utils/utils.test.ts +3 -2
  254. package/tsdown.config.ts +117 -3
  255. package/vitest.config.ts +48 -0
  256. package/src/modules/cache/index.ts +0 -241
  257. package/src/modules/cache/types.ts +0 -19
  258. package/src/modules/data/data.test.ts +0 -58
  259. package/src/modules/data/index.ts +0 -879
  260. package/src/modules/sync/engine.ts +0 -173
  261. package/src/modules/sync/events/index.ts +0 -77
  262. package/src/modules/sync/index.ts +0 -3
  263. package/src/modules/sync/queue/index.ts +0 -2
  264. package/src/modules/sync/queue/queue-down.ts +0 -89
  265. package/src/modules/sync/queue/queue-up.ts +0 -223
  266. package/src/modules/sync/scheduler.ts +0 -84
  267. package/src/modules/sync/sync.ts +0 -421
  268. package/src/modules/sync/utils.test.ts +0 -311
  269. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,821 @@
1
+ import type { SchemaStructure } from '@spooky-sync/query-builder';
2
+ import type { LoroDoc } from 'loro-crdt';
3
+ import { RecordId } from 'surrealdb';
4
+ import type { LocalStore, RemoteDatabaseService } from '../../services/database/index';
5
+ import type { Logger } from '../../services/logger/index';
6
+ import { parseRecordIdString } from '../../utils/index';
7
+ import { check } from './acceptance';
8
+ import { asBytes, randomPeer } from './bytes';
9
+ import { CrdtField } from './crdt-field';
10
+ import { stateHash } from './hash';
11
+ import { loadLoro } from './loro-loader';
12
+ import type { CrdtKind } from './mirror';
13
+
14
+ export { CrdtField, cursorColorFromName, CURSOR_COLORS } from './crdt-field';
15
+
16
+ /**
17
+ * Collaborative (`@crdt`) fields.
18
+ *
19
+ * Each open field is a Loro document. The client never sends the document,
20
+ * only its own edits: every batch of local edits becomes one row of the
21
+ * generated `<table>__crdt_log` table, written through the ordinary outbox
22
+ * (so it survives offline, reloads and retries), and every client receives
23
+ * the other rows through an ordinary query and merges them. Nobody writes the
24
+ * canonical state but the SSP, which folds the log into `_00_crdt_doc` and the
25
+ * parent field's plain text (see `packages/ssp-node/src/crdt`).
26
+ *
27
+ * A row is imported only if it passes the acceptance rule the fold applies
28
+ * (`./acceptance`), so this tab and the server always hold the same ops.
29
+ *
30
+ * Edits carry this tab's Loro peer id, claimed for the signed-in user in
31
+ * `_00_crdt_peer` before the first row is sent; the database refuses a row
32
+ * whose peer its author does not own. A tab picks a fresh peer per session.
33
+ * Offline, edits stay local (and in the local snapshot) until the claim and
34
+ * the outbox can go through.
35
+ */
36
+
37
+ /** A followed query: stop following it, and know when the server answered. */
38
+ export interface Watch {
39
+ stop: () => void;
40
+ /** Resolves once the server's membership for the query is known and its
41
+ * rows have landed locally (never rejects; may never resolve offline). */
42
+ settled: Promise<void>;
43
+ }
44
+
45
+ /** The runtime pieces the manager needs from the client facade. */
46
+ export interface CrdtPorts {
47
+ /** Register a query on `table` filtered by `where` and follow its rows. */
48
+ watch(table: string, where: Record<string, unknown>, onRows: (rows: Record<string, unknown>[]) => void): Promise<Watch>;
49
+ /** Optimistic create through the outbox. */
50
+ create(id: string, data: Record<string, unknown>): Promise<unknown>;
51
+ }
52
+
53
+ /** Local-only state of one field: the merged document and own unsent edits. */
54
+ interface LocalState {
55
+ snapshot?: Uint8Array;
56
+ /** Server fold this snapshot includes (for the canonical refetch). */
57
+ rev: number;
58
+ /** Every peer this device edited the field under, and how far its ops
59
+ * are already in the outbox. */
60
+ peers: Record<string, { enqueued: number }>;
61
+ }
62
+
63
+ interface Session {
64
+ key: string;
65
+ table: string;
66
+ recordId: string;
67
+ field: string;
68
+ kind: CrdtKind;
69
+ cursor: boolean;
70
+ doc: LoroDoc;
71
+ handle: CrdtField;
72
+ refs: number;
73
+ local: LocalState;
74
+ /** Log rows already looked at (imported, refused or own). */
75
+ seen: Set<string>;
76
+ /** Log rows in the latest delivery: when some vanish, the server folded. */
77
+ present: Set<string>;
78
+ /** Highest published rev this device's document was checked against. */
79
+ verifiedRev: number;
80
+ verifyTimer: ReturnType<typeof setTimeout> | null;
81
+ disposers: Array<() => void>;
82
+ flushTimer: ReturnType<typeof setTimeout> | null;
83
+ /** When the armed flush timer fires. */
84
+ flushDueAt: number;
85
+ /** When this session last sent edits (0: not yet). */
86
+ lastSentAt: number;
87
+ persistTimer: ReturnType<typeof setTimeout> | null;
88
+ flushing: Promise<void> | null;
89
+ /** A flush asked for while one was running: it runs once that one is done. */
90
+ flushQueued: Promise<void> | null;
91
+ refetching: boolean;
92
+ presenceTimer: ReturnType<typeof setTimeout> | null;
93
+ presencePending: Uint8Array | null;
94
+ /** This session wrote a presence row (removed again on close). */
95
+ presenceSent: boolean;
96
+ /** The latest import of delivered rows (chained, so awaiting it awaits all). */
97
+ ingesting: Promise<void>;
98
+ /** The server answered the published-document read (with a row or none). */
99
+ canonicalAnswered: boolean;
100
+ /** The log watch delivered the server's first answer. */
101
+ logSettled: boolean;
102
+ /** The second published-document read of a first open (see `recheckCanonical`). */
103
+ recheck: Promise<void> | null;
104
+ }
105
+
106
+ const LOCAL_TABLE = '_00_crdt_local';
107
+ const LOG_SUFFIX = '__crdt_log';
108
+ const PRESENCE_SUFFIX = '__crdt_presence';
109
+ const PERSIST_DEBOUNCE_MS = 400;
110
+ const PRESENCE_THROTTLE_MS = 250;
111
+ const CLAIM_RETRY_MS = 5000;
112
+ /** Longest an open or close waits on the server (the first answer for a
113
+ * field with nothing local, the last edits going out) before going on
114
+ * without it. Offline it does not wait at all. */
115
+ const SERVER_WAIT_MS = 4000;
116
+ /** Pause after a fold is noticed before checking the published document. */
117
+ const VERIFY_DELAY_MS = 1000;
118
+
119
+ export class CrdtManager {
120
+ private sessions = new Map<string, Session>();
121
+ private opening = new Map<string, Promise<Session>>();
122
+ /** Closes still saving their field's local state, by session key. */
123
+ private saving = new Map<string, Promise<void>>();
124
+ private ports: CrdtPorts | null = null;
125
+ private logger: Logger;
126
+ private peer: `${number}` = randomPeer();
127
+ /** The current peer's claim: settled once the database holds it for us. */
128
+ private claim: Promise<boolean> | null = null;
129
+ private claimRetry: ReturnType<typeof setTimeout> | null = null;
130
+ private connectionUnsubscribes: Array<() => void> = [];
131
+
132
+ constructor(
133
+ private schema: SchemaStructure,
134
+ private local: LocalStore,
135
+ private remote: RemoteDatabaseService,
136
+ logger: Logger,
137
+ /** First edit after a quiet spell: wait this long to batch the burst. */
138
+ private debounceMs: number = 50,
139
+ /** Shortest gap between two sends of one field, while edits keep coming. */
140
+ private sendIntervalMs: number = 200
141
+ ) {
142
+ this.logger = logger.child({ service: 'CrdtManager' });
143
+ this.connectionUnsubscribes.push(
144
+ this.remote.subscribeConnection('connected', () => {
145
+ for (const s of this.sessions.values()) {
146
+ this.scheduleFlush(s, 0);
147
+ // Opened while the server could not answer: read it now.
148
+ if (!s.handle.isLoaded() && !s.canonicalAnswered) void this.refetchCanonical(s);
149
+ }
150
+ })
151
+ );
152
+ }
153
+
154
+ /** Hand over the runtime ports. Called once by the client facade. */
155
+ attach(ports: CrdtPorts): void {
156
+ this.ports = ports;
157
+ }
158
+
159
+ /**
160
+ * A new signed-in identity (boot, sign-in, sign-out): edits from here on
161
+ * belong to it, under a peer it claims. Edits already made stay with the
162
+ * peer they were made under.
163
+ */
164
+ setSessionId(_salt: string): void {
165
+ this.peer = randomPeer();
166
+ this.claim = null;
167
+ for (const s of this.sessions.values()) s.doc.setPeerId(this.peer);
168
+ }
169
+
170
+ dispose(): void {
171
+ for (const off of this.connectionUnsubscribes.splice(0)) {
172
+ try {
173
+ off();
174
+ } catch {
175
+ /* ignore */
176
+ }
177
+ }
178
+ if (this.claimRetry) clearTimeout(this.claimRetry);
179
+ }
180
+
181
+ /**
182
+ * Open a collaborative field. Every open of the same field shares one
183
+ * document; close each open with {@link close}.
184
+ *
185
+ * `draft` (from {@link createDraft}) hands over edits made before the record
186
+ * existed: they are merged in and sent like this tab's own.
187
+ */
188
+ async open(table: string, recordId: string, field: string, draft?: CrdtField): Promise<CrdtField> {
189
+ const { kind, cursor } = this.fieldSpec(table, field);
190
+ const key = `${table}|${recordId}|${field}`;
191
+ let session = this.sessions.get(key);
192
+ if (session) {
193
+ session.refs++;
194
+ } else {
195
+ const inFlight = this.opening.get(key);
196
+ if (inFlight) {
197
+ session = await inFlight;
198
+ session.refs++;
199
+ } else {
200
+ const pending = this.start(key, table, recordId, field, kind, cursor, !!draft).finally(() => this.opening.delete(key));
201
+ this.opening.set(key, pending);
202
+ session = await pending;
203
+ }
204
+ }
205
+ if (draft) this.adopt(session, draft.getDoc());
206
+ return session.handle;
207
+ }
208
+
209
+ /**
210
+ * A detached document for a field of a record that does not exist yet (a
211
+ * "new thread" form). Nothing is sent; pass it to {@link open} once the
212
+ * record is created and its edits become the record's first ones.
213
+ */
214
+ async createDraft(table: string, field: string): Promise<CrdtField> {
215
+ const { kind, cursor } = this.fieldSpec(table, field);
216
+ const loro = await loadLoro();
217
+ return new CrdtField(table, '', field, kind, cursor, new loro.LoroDoc(), { pushPresence: () => {} });
218
+ }
219
+
220
+ private adopt(s: Session, draft: LoroDoc): void {
221
+ const peers = [...draft.oplogVersion().toJSON().keys()];
222
+ if (peers.length === 0) return;
223
+ this.tryImport(s.doc, draft.export({ mode: 'update' }), s.key);
224
+ // The draft's ops carry its own peer: claim and send them under it.
225
+ for (const peer of peers) s.local.peers[String(peer)] ??= { enqueued: 0 };
226
+ this.schedulePersist(s);
227
+ this.scheduleFlush(s, 0);
228
+ }
229
+
230
+ /** Close one open. The last close of a field resolves once its unsent
231
+ * edits are in the outbox, or once they are saved on the device when the
232
+ * server does not answer in time (offline: at once). */
233
+ close(table: string, recordId: string, field: string): Promise<void> {
234
+ const s = this.sessions.get(`${table}|${recordId}|${field}`);
235
+ if (!s || --s.refs > 0) return Promise.resolve();
236
+ return this.stop(s, true);
237
+ }
238
+
239
+ /** Close every field. `flush: false` (a bucket switch) drops unsent edits
240
+ * from this session's memory; they stay in the local snapshot of the
241
+ * bucket they belong to. */
242
+ closeAll(options: { flush?: boolean } = {}): void {
243
+ for (const s of [...this.sessions.values()]) void this.stop(s, options.flush ?? true);
244
+ }
245
+
246
+ // ------------------------------------------------------------------
247
+
248
+ private fieldSpec(table: string, field: string): { kind: CrdtKind; cursor: boolean } {
249
+ const t = this.schema.tables.find((x) => x.name === table);
250
+ if (!t) throw new Error(`CrdtManager: unknown table '${table}'`);
251
+ const col = (t.columns as Record<string, any>)[field];
252
+ if (!col) throw new Error(`CrdtManager: unknown field '${table}.${field}'`);
253
+ if (!col.crdt) {
254
+ throw new Error(
255
+ `CrdtManager: '${table}.${field}' is not a CRDT field. Add \`-- @crdt text\` or \`-- @crdt prosemirror\` above its DEFINE FIELD and re-run \`spky generate\`.`
256
+ );
257
+ }
258
+ const kind: CrdtKind = col.crdt === 'prosemirror' ? 'prosemirror' : 'text';
259
+ return { kind, cursor: !!col.cursor };
260
+ }
261
+
262
+ private async start(
263
+ key: string,
264
+ table: string,
265
+ recordId: string,
266
+ field: string,
267
+ kind: CrdtKind,
268
+ cursor: boolean,
269
+ /** Opened with a draft: the record was just created here, so the server
270
+ * has nothing for it yet and the open does not wait on it. */
271
+ fresh = false
272
+ ): Promise<Session> {
273
+ if (!this.ports) throw new Error('CrdtManager: not attached to a client');
274
+ const loro = await loadLoro();
275
+ const doc = new loro.LoroDoc();
276
+ doc.setPeerId(this.peer);
277
+
278
+ // A close of this field may still be saving: read what it saves.
279
+ await this.saving.get(key);
280
+ const local = await this.readLocal(recordId, field);
281
+ if (local.snapshot) this.tryImport(doc, local.snapshot, key);
282
+
283
+ const session: Session = {
284
+ key,
285
+ table,
286
+ recordId,
287
+ field,
288
+ kind,
289
+ cursor,
290
+ doc,
291
+ handle: undefined as unknown as CrdtField,
292
+ refs: 1,
293
+ local,
294
+ seen: new Set(),
295
+ present: new Set(),
296
+ verifiedRev: 0,
297
+ verifyTimer: null,
298
+ disposers: [],
299
+ flushTimer: null,
300
+ flushDueAt: 0,
301
+ lastSentAt: 0,
302
+ persistTimer: null,
303
+ flushing: null,
304
+ flushQueued: null,
305
+ refetching: false,
306
+ presenceTimer: null,
307
+ presencePending: null,
308
+ presenceSent: false,
309
+ ingesting: Promise.resolve(),
310
+ canonicalAnswered: false,
311
+ logSettled: false,
312
+ recheck: null,
313
+ };
314
+ session.handle = new CrdtField(table, recordId, field, kind, cursor, doc, {
315
+ pushPresence: (state) => this.pushPresence(session, state),
316
+ });
317
+ // Known from the start: this device's copy, or a record just created here.
318
+ if (local.snapshot || fresh) session.handle.markLoaded();
319
+ this.sessions.set(key, session);
320
+
321
+ // Claimed now, so the first edits do not wait on the round trip.
322
+ void this.ensureClaimed();
323
+ // First open on this device: start from the published document. Read at
324
+ // the same time as the log query below registers, not before it: each is
325
+ // a round trip, and the import order does not matter (ops whose
326
+ // predecessors are still missing wait inside Loro until they arrive).
327
+ const canonical = !local.snapshot && !fresh ? this.refetchCanonical(session) : null;
328
+
329
+ session.disposers.push(
330
+ doc.subscribeLocalUpdates(() => {
331
+ // Remember every peer that made edits here: a later identity change
332
+ // swaps the peer, and these ops must still go out under this one.
333
+ session.local.peers[this.peer] ??= { enqueued: 0 };
334
+ this.scheduleFlush(session);
335
+ this.schedulePersist(session);
336
+ })
337
+ );
338
+ const ports = this.ports;
339
+ const logWatch = ports.watch(`${table}${LOG_SUFFIX}`, { doc: recordId, field }, (rows) => {
340
+ session.ingesting = session.ingesting.then(() => this.ingest(session, rows));
341
+ });
342
+ const presenceWatch = cursor
343
+ ? ports.watch(`${table}${PRESENCE_SUFFIX}`, { doc: recordId, field }, (rows) => this.ingestPresence(session, rows))
344
+ : null;
345
+ const [log, presence] = await Promise.all([logWatch, presenceWatch]);
346
+ session.disposers.push(log.stop);
347
+ if (presence) session.disposers.push(presence.stop);
348
+ void log.settled.then(async () => {
349
+ session.logSettled = true;
350
+ await session.ingesting;
351
+ await this.recheckCanonical(session, canonical);
352
+ this.checkLoaded(session);
353
+ });
354
+ // Offline the published document is read once the connection is back
355
+ // instead of holding the open.
356
+ if (canonical) await this.serverWait(canonical);
357
+ // An editor binding an empty document creates its root structure, and
358
+ // two clients doing that at once keep only one of them (with whatever was
359
+ // typed into the other). With nothing local, hand the document out only
360
+ // once the server's rows for it are in.
361
+ if (!session.handle.hasContent() && !fresh) await this.serverWait(log.settled);
362
+ await session.ingesting;
363
+ if (session.logSettled) await this.serverWait(this.recheckCanonical(session, canonical));
364
+ // Edits a previous session made but never sent (closed tab, offline).
365
+ this.scheduleFlush(session, 0);
366
+ this.logger.debug({ key, peer: this.peer, Category: 'sp00ky-client::CrdtManager::open' }, 'CRDT field opened');
367
+ return session;
368
+ }
369
+
370
+ private async stop(s: Session, flush: boolean): Promise<void> {
371
+ this.sessions.delete(s.key);
372
+ for (const off of s.disposers.splice(0)) {
373
+ try {
374
+ off();
375
+ } catch {
376
+ /* ignore */
377
+ }
378
+ }
379
+ if (s.flushTimer) clearTimeout(s.flushTimer);
380
+ if (s.persistTimer) clearTimeout(s.persistTimer);
381
+ if (s.presenceTimer) clearTimeout(s.presenceTimer);
382
+ if (s.verifyTimer) clearTimeout(s.verifyTimer);
383
+ if (s.presenceSent) void this.dropPresence(s);
384
+ if (flush) {
385
+ const saved = this.persist(s);
386
+ this.saving.set(s.key, saved);
387
+ await saved;
388
+ if (this.saving.get(s.key) === saved) this.saving.delete(s.key);
389
+ await this.serverWait(this.flush(s));
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Wait for server-bound `work`, at most {@link SERVER_WAIT_MS}, and not at
395
+ * all while disconnected: a remote call made then queues behind the
396
+ * reconnect for up to the query timeout, and an offline "new thread" form
397
+ * must not hang on it. The work itself goes on either way.
398
+ */
399
+ private serverWait(work: Promise<unknown>): Promise<unknown> {
400
+ if (this.remote.getStatus() !== 'connected') return Promise.resolve();
401
+ return Promise.race([work, delay(SERVER_WAIT_MS)]);
402
+ }
403
+
404
+ // --- receiving ---------------------------------------------------
405
+
406
+ private async ingest(s: Session, rows: Record<string, unknown>[]): Promise<void> {
407
+ const loro = await loadLoro();
408
+ const ids = new Set(rows.map((row) => String(row.id ?? '')));
409
+ for (const id of s.present) {
410
+ if (!ids.has(id)) {
411
+ this.scheduleVerify(s);
412
+ break;
413
+ }
414
+ }
415
+ s.present = ids;
416
+ const blobs: Uint8Array[] = [];
417
+ for (const row of rows) {
418
+ const id = String(row.id ?? '');
419
+ if (!id || s.seen.has(id)) continue;
420
+ s.seen.add(id);
421
+ const blob = asBytes(row.data);
422
+ const verdict = check(loro, blob, row.peer == null ? undefined : String(row.peer));
423
+ if (!verdict.ok) {
424
+ this.logger.debug({ row: id, reason: verdict.reason, Category: 'sp00ky-client::CrdtManager::ingest' }, 'CRDT log row refused');
425
+ continue;
426
+ }
427
+ blobs.push(blob!);
428
+ }
429
+ if (blobs.length === 0) return;
430
+ let pending = false;
431
+ for (const blob of blobs) {
432
+ const status = this.tryImport(s.doc, blob, s.key);
433
+ if (status?.pending) pending = true;
434
+ }
435
+ this.checkLoaded(s);
436
+ this.schedulePersist(s);
437
+ // Ops depending on rows that were folded away before this device saw
438
+ // them: the published snapshot holds those predecessors.
439
+ if (pending) void this.refetchCanonical(s);
440
+ }
441
+
442
+ /** Mark the field loaded once its content arrived, or the server answered
443
+ * both reads (the published document and the log) with nothing. */
444
+ private checkLoaded(s: Session): void {
445
+ if (s.handle.hasContent() || (s.canonicalAnswered && s.logSettled)) s.handle.markLoaded();
446
+ }
447
+
448
+ private tryImport(doc: LoroDoc, blob: Uint8Array, key: string): { pending: boolean } | null {
449
+ try {
450
+ const status = doc.import(blob);
451
+ return { pending: !!status.pending && status.pending.size > 0 };
452
+ } catch (e) {
453
+ this.logger.warn({ key, error: String(e), Category: 'sp00ky-client::CrdtManager::import' }, 'CRDT import failed');
454
+ return null;
455
+ }
456
+ }
457
+
458
+ private async refetchCanonical(s: Session): Promise<void> {
459
+ if (s.refetching) return;
460
+ s.refetching = true;
461
+ try {
462
+ const [row] = await this.remote.query<[{ rev?: number; snapshot?: unknown } | null]>(
463
+ "SELECT rev, snapshot FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
464
+ { doc: s.recordId, field: s.field }
465
+ );
466
+ const snapshot = asBytes(row?.snapshot);
467
+ if (snapshot) {
468
+ this.tryImport(s.doc, snapshot, s.key);
469
+ s.local.rev = Math.max(s.local.rev, row?.rev ?? 0);
470
+ this.schedulePersist(s);
471
+ }
472
+ s.canonicalAnswered = true;
473
+ this.checkLoaded(s);
474
+ } catch (e) {
475
+ // Offline or no published document yet: the log rows still arrive.
476
+ this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::refetch' }, 'No canonical CRDT document fetched');
477
+ } finally {
478
+ s.refetching = false;
479
+ }
480
+ }
481
+
482
+ /**
483
+ * A first open reads the published document and the log at the same time,
484
+ * and a fold landing between the two hides the field from both: it
485
+ * publishes its snapshot, then deletes the rows the log query was about to
486
+ * return, after the published-document read came back empty. Nothing seen
487
+ * means nothing to verify, so the editor stayed empty until the next edit
488
+ * (a second device opening a just-created thread, under e2e load). Once the
489
+ * log has answered, a field that is still empty reads the published
490
+ * document again: the fold that took its rows committed before deleting
491
+ * them. Once per open; `first` is the first read (null with a local copy or
492
+ * a draft, which need none).
493
+ */
494
+ private recheckCanonical(s: Session, first: Promise<void> | null): Promise<void> {
495
+ if (!first) return Promise.resolve();
496
+ s.recheck ??= (async () => {
497
+ await first;
498
+ await s.ingesting;
499
+ if (!s.handle.hasContent()) await this.refetchCanonical(s);
500
+ })();
501
+ return s.recheck;
502
+ }
503
+
504
+ // --- checking against the published document ---------------------
505
+
506
+ private scheduleVerify(s: Session): void {
507
+ if (s.verifyTimer) clearTimeout(s.verifyTimer);
508
+ s.verifyTimer = setTimeout(() => {
509
+ s.verifyTimer = null;
510
+ void this.verify(s);
511
+ }, VERIFY_DELAY_MS);
512
+ }
513
+
514
+ /**
515
+ * After a fold: compare this device's document with the one the server
516
+ * published, at the version the server published. Missing ops (rows this
517
+ * device never got before they were folded away) are repaired by merging
518
+ * the published snapshot in. The same ops giving a different state cannot
519
+ * be merged away; that is logged.
520
+ */
521
+ private async verify(s: Session): Promise<void> {
522
+ type Published = {
523
+ rev?: number;
524
+ state_hash?: string;
525
+ upto?: { vv?: Record<string, number>; frontiers?: { peer: `${number}`; counter: number }[] };
526
+ };
527
+ let row: Published | null;
528
+ try {
529
+ [row] = await this.remote.query<[Published | null]>(
530
+ "SELECT rev, upto, state_hash FROM ONLY type::record('_00_crdt_doc', [type::record($doc), $field])",
531
+ { doc: s.recordId, field: s.field }
532
+ );
533
+ } catch {
534
+ return; // offline: the next fold checks again
535
+ }
536
+ const rev = row?.rev ?? 0;
537
+ const frontiers = row?.upto?.frontiers;
538
+ if (!row?.state_hash || !frontiers || !row.upto?.vv || rev <= s.verifiedRev || !this.sessions.has(s.key)) return;
539
+
540
+ const have = s.doc.oplogVersion().toJSON();
541
+ const behind = Object.entries(row.upto.vv).some(([peer, end]) => (have.get(peer as `${number}`) ?? 0) < end);
542
+ if (behind) {
543
+ this.logger.info({ key: s.key, rev, Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document behind the published one; merging it in');
544
+ await this.refetchCanonical(s);
545
+ return;
546
+ }
547
+ s.verifiedRev = rev;
548
+ // WebCrypto is missing on insecure origins: nothing to check with there.
549
+ if (!globalThis.crypto?.subtle) return;
550
+ const loro = await loadLoro();
551
+ let fork: LoroDoc | undefined;
552
+ try {
553
+ fork = s.doc.forkAt(frontiers);
554
+ const hash = await stateHash(loro, fork);
555
+ if (hash !== row.state_hash) {
556
+ this.logger.warn(
557
+ { key: s.key, rev, hash, published: row.state_hash, Category: 'sp00ky-client::CrdtManager::verify' },
558
+ 'CRDT document differs from the published one at the same version'
559
+ );
560
+ }
561
+ } catch (e) {
562
+ this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::verify' }, 'CRDT document check failed');
563
+ } finally {
564
+ fork?.free();
565
+ }
566
+ }
567
+
568
+ // --- sending -----------------------------------------------------
569
+
570
+ /**
571
+ * Arm the send. Edits go out on a steady cadence while they keep coming:
572
+ * the first one after a quiet spell waits `debounceMs` (one row for the
573
+ * burst), later ones at least `sendIntervalMs` after the previous send. An
574
+ * armed timer is never pushed back, so continuous typing cannot hold the
575
+ * edits; it is only brought forward (`delay`, e.g. 0 on reconnect).
576
+ */
577
+ private scheduleFlush(s: Session, delay?: number): void {
578
+ const now = Date.now();
579
+ const due = delay !== undefined ? now + delay : Math.max(now + this.debounceMs, s.lastSentAt + this.sendIntervalMs);
580
+ if (s.flushTimer) {
581
+ if (s.flushDueAt <= due) return;
582
+ clearTimeout(s.flushTimer);
583
+ }
584
+ s.flushDueAt = due;
585
+ s.flushTimer = setTimeout(() => {
586
+ s.flushTimer = null;
587
+ void this.flush(s);
588
+ }, due - now);
589
+ }
590
+
591
+ /** Send what is unsent. A call while a send runs queues one more after it,
592
+ * so edits made during that send are not left waiting for the next edit. */
593
+ private flush(s: Session): Promise<void> {
594
+ if (s.flushing) {
595
+ s.flushQueued ??= s.flushing.then(() => {
596
+ s.flushQueued = null;
597
+ return this.flush(s);
598
+ });
599
+ return s.flushQueued;
600
+ }
601
+ s.flushing = this.sendOwnEdits(s).finally(() => {
602
+ s.flushing = null;
603
+ });
604
+ return s.flushing;
605
+ }
606
+
607
+ /** Send every own peer's ops that are not in the outbox yet, one row per
608
+ * peer. Only the peer's own ops travel (`updates-in-range`): a plain
609
+ * `from: version` export would carry ops imported from others too, and
610
+ * the acceptance rule refuses such a row. */
611
+ private async sendOwnEdits(s: Session): Promise<void> {
612
+ const vv = s.doc.oplogVersion();
613
+ const own = new Set([...Object.keys(s.local.peers), this.peer]);
614
+ const due = [...own]
615
+ .map((peer) => ({ peer, end: vv.get(peer as `${number}`) ?? 0, from: s.local.peers[peer]?.enqueued ?? 0 }))
616
+ .filter((p) => p.end > p.from);
617
+ if (due.length === 0 || !this.ports) return;
618
+ s.lastSentAt = Date.now();
619
+ if (!(await this.ensureClaimed())) {
620
+ this.retryLater(s);
621
+ return;
622
+ }
623
+ for (const { peer, end, from } of due) {
624
+ if (peer !== this.peer && !(await this.claimPeer(peer as `${number}`))) continue;
625
+ const data = s.doc.export({
626
+ mode: 'updates-in-range',
627
+ spans: [{ id: { peer: peer as `${number}`, counter: from }, len: end - from }],
628
+ });
629
+ try {
630
+ await this.ports.create(`${s.table}${LOG_SUFFIX}:${rowKey()}`, {
631
+ doc: s.recordId,
632
+ field: s.field,
633
+ peer,
634
+ data,
635
+ });
636
+ } catch (e) {
637
+ this.logger.warn({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::flush' }, 'Queueing CRDT edits failed');
638
+ this.retryLater(s);
639
+ return;
640
+ }
641
+ s.local.peers[peer] = { enqueued: end };
642
+ }
643
+ // The outbox marks reach the device with the next persist. An open field
644
+ // sends every few hundred ms while typing, and a persist exports the whole
645
+ // document, so it rides the persist debounce; a crash in between only
646
+ // re-sends rows, which import as no-ops. A closing field saves now.
647
+ if (this.sessions.get(s.key) === s) this.schedulePersist(s);
648
+ else await this.persist(s);
649
+ }
650
+
651
+ private retryLater(s: Session): void {
652
+ if (this.sessions.get(s.key) === s) this.scheduleFlush(s, CLAIM_RETRY_MS);
653
+ }
654
+
655
+ /** Claim this session's peer once; a failure (offline, signed out) is retried. */
656
+ private ensureClaimed(): Promise<boolean> {
657
+ if (!this.claim) {
658
+ const peer = this.peer;
659
+ this.claim = this.claimPeer(peer).then((ok) => {
660
+ if (!ok && this.peer === peer) this.claim = null;
661
+ return ok;
662
+ });
663
+ }
664
+ return this.claim;
665
+ }
666
+
667
+ private async claimPeer(peer: `${number}`): Promise<boolean> {
668
+ try {
669
+ // A refused CREATE (signed out, impersonating) answers with no row.
670
+ const [created] = await this.remote.query<[unknown[]]>("CREATE type::record('_00_crdt_peer', $peer) RETURN id", { peer });
671
+ return Array.isArray(created) && created.length > 0;
672
+ } catch (e) {
673
+ if (!/already exists/i.test(String(e))) return false;
674
+ // Ours from an earlier claim (only its owner can read it), or taken.
675
+ try {
676
+ const [owner] = await this.remote.query<[unknown]>(
677
+ "SELECT VALUE author FROM ONLY type::record('_00_crdt_peer', $peer)",
678
+ { peer }
679
+ );
680
+ if (owner != null) return true;
681
+ } catch {
682
+ return false;
683
+ }
684
+ this.logger.error({ peer, Category: 'sp00ky-client::CrdtManager::claim' }, 'CRDT peer id taken by another user');
685
+ if (peer === this.peer) this.setSessionId('');
686
+ return false;
687
+ }
688
+ }
689
+
690
+ // --- local state -------------------------------------------------
691
+
692
+ private localId(recordId: string, field: string): RecordId {
693
+ return new RecordId(LOCAL_TABLE, `${recordId}|${field}`);
694
+ }
695
+
696
+ private async readLocal(recordId: string, field: string): Promise<LocalState> {
697
+ try {
698
+ const row = await this.local.getById(LOCAL_TABLE, this.localId(recordId, field));
699
+ if (row) {
700
+ return {
701
+ snapshot: asBytes(row.snapshot),
702
+ rev: Number(row.rev ?? 0),
703
+ peers: (row.peers as LocalState['peers']) ?? {},
704
+ };
705
+ }
706
+ } catch (e) {
707
+ this.logger.debug({ error: String(e), Category: 'sp00ky-client::CrdtManager::readLocal' }, 'No local CRDT state');
708
+ }
709
+ return { rev: 0, peers: {} };
710
+ }
711
+
712
+ private schedulePersist(s: Session): void {
713
+ if (s.persistTimer) return;
714
+ s.persistTimer = setTimeout(() => {
715
+ s.persistTimer = null;
716
+ void this.persist(s);
717
+ }, PERSIST_DEBOUNCE_MS);
718
+ }
719
+
720
+ /** Save the merged document. Another tab may have saved too: merge with
721
+ * its snapshot rather than overwrite it, and keep the further of the two
722
+ * outbox marks per peer. */
723
+ private async persist(s: Session): Promise<void> {
724
+ try {
725
+ const stored = await this.readLocal(s.recordId, s.field);
726
+ if (stored.snapshot) this.tryImport(s.doc, stored.snapshot, s.key);
727
+ for (const [peer, mark] of Object.entries(stored.peers)) {
728
+ const mine = s.local.peers[peer]?.enqueued ?? 0;
729
+ if (mark.enqueued > mine) s.local.peers[peer] = { enqueued: mark.enqueued };
730
+ }
731
+ s.local.rev = Math.max(s.local.rev, stored.rev);
732
+ s.local.snapshot = s.doc.export({ mode: 'snapshot' });
733
+ await this.local.upsert(
734
+ LOCAL_TABLE,
735
+ this.localId(s.recordId, s.field),
736
+ { doc: s.recordId, field: s.field, snapshot: s.local.snapshot, rev: s.local.rev, peers: s.local.peers },
737
+ 'replace'
738
+ );
739
+ } catch (e) {
740
+ this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::persist' }, 'Saving local CRDT state failed');
741
+ }
742
+ }
743
+
744
+ // --- presence ----------------------------------------------------
745
+
746
+ private pushPresence(s: Session, state: Uint8Array): void {
747
+ s.presencePending = state;
748
+ if (s.presenceTimer) return;
749
+ s.presenceTimer = setTimeout(() => {
750
+ s.presenceTimer = null;
751
+ const data = s.presencePending;
752
+ s.presencePending = null;
753
+ if (data) void this.sendPresence(s, data);
754
+ }, PRESENCE_THROTTLE_MS);
755
+ }
756
+
757
+ /** Presence is the latest state only: no outbox, no offline queue. */
758
+ private async sendPresence(s: Session, state: Uint8Array): Promise<void> {
759
+ if (!(await this.ensureClaimed())) return;
760
+ try {
761
+ await this.remote.query(
762
+ // One row per session and field, keyed by a plain identifier (an array
763
+ // key does not survive the client's record-id handling). `$session`
764
+ // is a protected SurrealDB variable: the session id binds as `$sid`.
765
+ `UPSERT type::record($table, $key) SET doc = type::record($doc), field = $field, session = $sid, state = $state RETURN NONE`,
766
+ {
767
+ table: `${s.table}${PRESENCE_SUFFIX}`,
768
+ key: presenceKey(this.peer, s.field, s.recordId),
769
+ doc: s.recordId,
770
+ field: s.field,
771
+ sid: this.peer,
772
+ state,
773
+ }
774
+ );
775
+ s.presenceSent = true;
776
+ } catch (e) {
777
+ this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence update failed');
778
+ }
779
+ }
780
+
781
+ /** Remove this session's cursor when the field closes; the server's TTL
782
+ * sweep covers a tab that never gets here. */
783
+ private async dropPresence(s: Session): Promise<void> {
784
+ try {
785
+ await this.remote.query(`DELETE type::record($table, $key) RETURN NONE`, {
786
+ table: `${s.table}${PRESENCE_SUFFIX}`,
787
+ key: presenceKey(this.peer, s.field, s.recordId),
788
+ });
789
+ } catch (e) {
790
+ this.logger.debug({ key: s.key, error: String(e), Category: 'sp00ky-client::CrdtManager::presence' }, 'Presence removal failed');
791
+ }
792
+ }
793
+
794
+
795
+ private ingestPresence(s: Session, rows: Record<string, unknown>[]): void {
796
+ for (const row of rows) {
797
+ if (String(row.session ?? '') === this.peer) continue;
798
+ const state = asBytes(row.state);
799
+ if (state) s.handle.deliverPresence(state);
800
+ }
801
+ }
802
+ }
803
+
804
+ /** `p<session>_<field>_<record key>`, letters, digits and `_` only. */
805
+ function presenceKey(session: string, field: string, recordId: string): string {
806
+ const key = recordId.slice(recordId.indexOf(':') + 1).replace(/[^A-Za-z0-9_]/g, '_');
807
+ return `p${session}_${field}_${key}`;
808
+ }
809
+
810
+ function delay(ms: number): Promise<void> {
811
+ return new Promise((resolve) => setTimeout(resolve, ms));
812
+ }
813
+
814
+ function rowKey(): string {
815
+ const bytes = new Uint8Array(12);
816
+ crypto.getRandomValues(bytes);
817
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
818
+ }
819
+
820
+ // Kept for callers that parse record ids next to CRDT fields.
821
+ export { parseRecordIdString };