@spooky-sync/core 0.0.1-canary.33 → 0.0.1-canary.331

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