@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,50 @@
1
+ /**
2
+ * Byte columns reach the client in several shapes depending on the engine and
3
+ * the path: `Uint8Array` (SQLite, a fresh local write), `ArrayBuffer` (the
4
+ * surrealdb SDK's CBOR decode), a typed-array view, or a `number[]` (bytes
5
+ * nested in a FLEXIBLE object on the SurrealDB-WASM engine). Normalize all of
6
+ * them; anything else is not bytes.
7
+ */
8
+ export function asBytes(value: unknown): Uint8Array | undefined {
9
+ if (value instanceof Uint8Array) return value;
10
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
11
+ if (ArrayBuffer.isView(value)) {
12
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
13
+ }
14
+ if (Array.isArray(value) && value.length > 0 && value.every((n) => typeof n === 'number')) {
15
+ return Uint8Array.from(value as number[]);
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ export function decodeBase64(b64: string): Uint8Array {
21
+ if (typeof atob === 'function') {
22
+ const binary = atob(b64);
23
+ const bytes = new Uint8Array(binary.length);
24
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
25
+ return bytes;
26
+ }
27
+ return new Uint8Array(Buffer.from(b64, 'base64'));
28
+ }
29
+
30
+ export function encodeBase64(bytes: Uint8Array): string {
31
+ if (typeof btoa === 'function') {
32
+ let binary = '';
33
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
34
+ return btoa(binary);
35
+ }
36
+ return Buffer.from(bytes).toString('base64');
37
+ }
38
+
39
+ /**
40
+ * A random Loro peer id below 2^53 (so it is exact as a JS number), as the
41
+ * decimal string Loro uses for `PeerID`. Each tab picks a new one per session
42
+ * and claims it in `_00_crdt_peer` before any of its edits are sent.
43
+ */
44
+ export function randomPeer(): `${number}` {
45
+ const words = new Uint32Array(2);
46
+ crypto.getRandomValues(words);
47
+ const high = words[0] & 0x1fffff; // 21 bits
48
+ const id = high * 0x100000000 + words[1];
49
+ return String(id === 0 ? 1 : id) as `${number}`;
50
+ }
@@ -0,0 +1,125 @@
1
+ import type { LoroDoc } from 'loro-crdt';
2
+ import type { CrdtKind } from './mirror';
3
+
4
+ // ==================== CURSOR UTILITIES ====================
5
+
6
+ export const CURSOR_COLORS = [
7
+ '#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
8
+ '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
9
+ ];
10
+
11
+ export function cursorColorFromName(name: string): string {
12
+ let hash = 0;
13
+ for (let i = 0; i < name.length; i++) {
14
+ hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
15
+ }
16
+ return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
17
+ }
18
+
19
+ export { decodeBase64, encodeBase64 } from './bytes';
20
+
21
+ // ==================== CRDT FIELD ====================
22
+
23
+ /** What a field handle needs from the manager that owns its session. */
24
+ export interface CrdtFieldHost {
25
+ pushPresence(state: Uint8Array): void;
26
+ }
27
+
28
+ /**
29
+ * An open collaborative field: the Loro document an editor binds to. Edits
30
+ * made on it are sent as the client's own edit-log rows; other clients' rows
31
+ * are imported into it as they arrive. Every open of the same field in one tab
32
+ * shares this document.
33
+ */
34
+ export class CrdtField {
35
+ private _onCursorUpdate: ((data: Uint8Array) => void) | null = null;
36
+ private pendingCursorUpdate: Uint8Array | null = null;
37
+ private loaded = false;
38
+ private loadedListeners = new Set<() => void>();
39
+
40
+ constructor(
41
+ readonly table: string,
42
+ readonly recordId: string,
43
+ readonly fieldName: string,
44
+ readonly kind: CrdtKind,
45
+ readonly cursorsEnabled: boolean,
46
+ private readonly doc: LoroDoc,
47
+ private readonly host: CrdtFieldHost
48
+ ) {}
49
+
50
+ getDoc(): LoroDoc {
51
+ return this.doc;
52
+ }
53
+
54
+ /** Whether the document holds anything yet (local state or edits). */
55
+ hasContent(): boolean {
56
+ return this.doc.oplogVersion().length() > 0;
57
+ }
58
+
59
+ /**
60
+ * Whether the document is known: this device had it, it was handed a draft,
61
+ * or the server answered (with content, or that it has none yet). Until then
62
+ * an empty document means "not loaded", not "empty": show the column's text
63
+ * instead, and do not bind an editor (an editor binding an empty document
64
+ * creates structure that competes with the real one).
65
+ */
66
+ isLoaded(): boolean {
67
+ return this.loaded;
68
+ }
69
+
70
+ /** Runs `cb` once the field is loaded (at once if it already is). Returns
71
+ * an unsubscribe. */
72
+ onLoaded(cb: () => void): () => void {
73
+ if (this.loaded) {
74
+ cb();
75
+ return () => {};
76
+ }
77
+ this.loadedListeners.add(cb);
78
+ return () => this.loadedListeners.delete(cb);
79
+ }
80
+
81
+ /** @internal The manager calls this when the document became known. */
82
+ markLoaded(): void {
83
+ if (this.loaded) return;
84
+ this.loaded = true;
85
+ for (const cb of [...this.loadedListeners]) {
86
+ try {
87
+ cb();
88
+ } catch {
89
+ /* a listener's failure is its own */
90
+ }
91
+ }
92
+ this.loadedListeners.clear();
93
+ }
94
+
95
+ /** Publish this session's presence (an `EphemeralStore` encoding, e.g. the
96
+ * editor's cursor). No-op unless the field is `@cursor`. */
97
+ pushCursorState(encoded: Uint8Array): void {
98
+ if (this.cursorsEnabled) this.host.pushPresence(encoded);
99
+ }
100
+
101
+ /** Called with every other session's presence blob. A blob that arrives
102
+ * before the editor sets this is replayed when it does. */
103
+ set onCursorUpdate(cb: ((data: Uint8Array) => void) | null) {
104
+ this._onCursorUpdate = cb;
105
+ if (cb && this.pendingCursorUpdate) {
106
+ const data = this.pendingCursorUpdate;
107
+ this.pendingCursorUpdate = null;
108
+ try {
109
+ cb(data);
110
+ } catch {
111
+ /* the editor logs its own apply failures */
112
+ }
113
+ }
114
+ }
115
+
116
+ get onCursorUpdate() {
117
+ return this._onCursorUpdate;
118
+ }
119
+
120
+ /** @internal Deliver one remote presence blob. */
121
+ deliverPresence(data: Uint8Array): void {
122
+ if (this._onCursorUpdate) this._onCursorUpdate(data);
123
+ else this.pendingCursorUpdate = data;
124
+ }
125
+ }
@@ -0,0 +1,469 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import * as loro from 'loro-crdt';
3
+ import { LoroDoc } from 'loro-crdt';
4
+ import { check } from './acceptance';
5
+ import { asBytes } from './bytes';
6
+ import { canonicalState, stateHash } from './hash';
7
+ import { CrdtManager, type CrdtPorts } from './index';
8
+ import { renderText } from './mirror';
9
+
10
+ const silent = { child: () => silent, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } as any;
11
+
12
+ const schema = {
13
+ tables: [
14
+ { name: 'doc', columns: { body: { type: 'string', crdt: 'text' }, title: { type: 'string' } } },
15
+ { name: 'doc__crdt_log', columns: { doc: {}, field: {}, peer: {}, data: {} } },
16
+ ],
17
+ } as any;
18
+
19
+ type Row = { id: string; doc: string; field: string; peer: string; data: Uint8Array };
20
+
21
+ /** One fake backend shared by every client: the edit log, the peer claims
22
+ * and the published documents, plus what each client's local store holds. */
23
+ class World {
24
+ log: Row[] = [];
25
+ peers = new Map<string, string>();
26
+ published = new Map<string, { rev: number; snapshot: Uint8Array; upto?: unknown; state_hash?: string }>();
27
+ online = true;
28
+ /** Offline remote calls never answer (queued behind a reconnect) instead of failing. */
29
+ hang = false;
30
+ /** A slow device store: each local write takes this long (real timers). */
31
+ storeDelayMs = 0;
32
+ /** A connected but slow server: reads and the log's first answer wait for `release()`. */
33
+ slow = false;
34
+ private held: Array<() => void> = [];
35
+ release() {
36
+ for (const go of this.held.splice(0)) go();
37
+ }
38
+ private watchers = new Set<{ table: string; where: Record<string, unknown>; cb: (rows: any[]) => void }>();
39
+
40
+ client(user: string, store = new Map<string, any>(), logger: any = silent) {
41
+ const local = {
42
+ getById: async (_t: string, id: any) => store.get(String(id)) ?? null,
43
+ upsert: async (_t: string, id: any, data: any) => {
44
+ const copy = structuredClone(data);
45
+ if (this.storeDelayMs) await new Promise((resolve) => setTimeout(resolve, this.storeDelayMs));
46
+ store.set(String(id), copy);
47
+ },
48
+ } as any;
49
+ const remote = {
50
+ subscribeConnection: () => () => {},
51
+ getStatus: () => (this.online ? 'connected' : 'disconnected'),
52
+ query: async (sql: string, vars: any) => {
53
+ if (this.slow) await new Promise<void>((go) => this.held.push(go));
54
+ if (!this.online && this.hang) return new Promise(() => {});
55
+ if (!this.online) throw new Error('not connected');
56
+ if (sql.startsWith('CREATE')) {
57
+ const owner = this.peers.get(vars.peer);
58
+ if (owner) throw new Error(`Database record \`_00_crdt_peer:${vars.peer}\` already exists`);
59
+ this.peers.set(vars.peer, user);
60
+ return [[{ id: `_00_crdt_peer:${vars.peer}` }]];
61
+ }
62
+ if (sql.startsWith('SELECT VALUE author')) return [this.peers.get(vars.peer) === user ? user : null];
63
+ if (sql.includes('_00_crdt_doc')) return [this.published.get(`${vars.doc}|${vars.field}`) ?? null];
64
+ return [[]];
65
+ },
66
+ } as any;
67
+ const manager = new CrdtManager(schema, local, remote, logger, 300);
68
+ const ports: CrdtPorts = {
69
+ watch: async (table, where, cb) => {
70
+ const w = { table, where, cb };
71
+ this.watchers.add(w);
72
+ if (this.slow) {
73
+ const settled = new Promise<void>((go) =>
74
+ this.held.push(() => {
75
+ cb(this.rowsFor(table, where));
76
+ go();
77
+ })
78
+ );
79
+ return { stop: () => void this.watchers.delete(w), settled };
80
+ }
81
+ cb(this.rowsFor(table, where));
82
+ return { stop: () => void this.watchers.delete(w), settled: Promise.resolve() };
83
+ },
84
+ create: async (id, data: any) => {
85
+ const owner = this.peers.get(data.peer);
86
+ if (owner !== user) throw new Error('peer not owned');
87
+ this.log.push({ id, doc: data.doc, field: data.field, peer: data.peer, data: data.data });
88
+ queueMicrotask(() => this.notify());
89
+ return data;
90
+ },
91
+ };
92
+ manager.attach(ports);
93
+ return { manager, store };
94
+ }
95
+
96
+ rowsFor(table: string, where: Record<string, unknown>) {
97
+ if (!table.endsWith('__crdt_log')) return [];
98
+ return this.log.filter((r) => r.doc === where.doc && r.field === where.field);
99
+ }
100
+
101
+ notify() {
102
+ for (const w of this.watchers) w.cb(this.rowsFor(w.table, w.where));
103
+ }
104
+
105
+ /** Fold like the SSP: publish `server` as the document and drop the log. */
106
+ async fold(doc: string, field: string, server: LoroDoc, opts: { rev?: number; hash?: string } = {}) {
107
+ this.published.set(`${doc}|${field}`, {
108
+ rev: opts.rev ?? 1,
109
+ snapshot: server.export({ mode: 'snapshot' }),
110
+ upto: { vv: Object.fromEntries(server.oplogVersion().toJSON()), frontiers: server.oplogFrontiers() },
111
+ state_hash: opts.hash ?? (await stateHash(loro, server)),
112
+ });
113
+ this.log = this.log.filter((r) => !(r.doc === doc && r.field === field));
114
+ this.notify();
115
+ }
116
+ }
117
+
118
+ const text = (field: { getDoc(): LoroDoc }) => field.getDoc().getText('body').toString();
119
+
120
+ /** Let real async work (the WebCrypto digest) finish while timers are fake. */
121
+ async function realWait(ms = 50) {
122
+ vi.useRealTimers();
123
+ await new Promise((resolve) => setTimeout(resolve, ms));
124
+ vi.useFakeTimers();
125
+ }
126
+
127
+ describe('CRDT fields', () => {
128
+ beforeEach(() => vi.useFakeTimers());
129
+ afterEach(() => vi.useRealTimers());
130
+
131
+ it('send only their own ops as log rows, and concurrent edits converge', async () => {
132
+ const world = new World();
133
+ const a = world.client('user:a');
134
+ const b = world.client('user:b');
135
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
136
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
137
+
138
+ fa.getDoc().getText('body').insert(0, 'Hello');
139
+ fa.getDoc().commit();
140
+ fb.getDoc().getText('body').insert(0, 'World');
141
+ fb.getDoc().commit();
142
+ await vi.advanceTimersByTimeAsync(400);
143
+
144
+ expect(world.log).toHaveLength(2);
145
+ for (const row of world.log) {
146
+ expect(check(loro, row.data, row.peer).ok).toBe(true);
147
+ }
148
+ expect(text(fa)).toBe(text(fb));
149
+ expect(text(fa)).toHaveLength(10);
150
+
151
+ // A's next edit carries only A's new op, not B's imported ones.
152
+ fa.getDoc().getText('body').insert(0, '>');
153
+ fa.getDoc().commit();
154
+ await vi.advanceTimersByTimeAsync(400);
155
+ const last = world.log.at(-1)!;
156
+ const verdict = check(loro, last.data, last.peer);
157
+ expect(verdict.ok && verdict.accepted.peer).toBe(fa.getDoc().peerIdStr);
158
+ expect(text(fb)).toBe(text(fa));
159
+ });
160
+
161
+ it('a burst of typing goes out after the pause, or after the max wait at the latest', async () => {
162
+ const world = new World();
163
+ const a = world.client('user:a');
164
+ const f = await a.manager.open('doc', 'doc:1', 'body');
165
+ for (let i = 0; i < 10; i++) {
166
+ f.getDoc().getText('body').insert(i, 'x');
167
+ f.getDoc().commit();
168
+ await vi.advanceTimersByTimeAsync(250);
169
+ }
170
+ expect(world.log.length).toBeGreaterThanOrEqual(1);
171
+ expect(world.log.length).toBeLessThan(10);
172
+ });
173
+
174
+ it('send nothing until the peer is claimed, then everything at once', async () => {
175
+ const world = new World();
176
+ world.online = false;
177
+ const a = world.client('user:a');
178
+ const b = world.client('user:b');
179
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
180
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
181
+ fa.getDoc().getText('body').insert(0, 'offline edit');
182
+ fa.getDoc().commit();
183
+ await vi.advanceTimersByTimeAsync(1000);
184
+ expect(world.log).toHaveLength(0);
185
+
186
+ world.online = true;
187
+ await vi.advanceTimersByTimeAsync(6000);
188
+ expect(world.log).toHaveLength(1);
189
+ expect(text(fb)).toBe('offline edit');
190
+ });
191
+
192
+ it("send a closed session's unsent edits under its own peer on the next open", async () => {
193
+ const world = new World();
194
+ world.online = false;
195
+ const store = new Map<string, any>();
196
+ const first = world.client('user:a', store);
197
+ const f1 = await first.manager.open('doc', 'doc:1', 'body');
198
+ const oldPeer = f1.getDoc().peerIdStr;
199
+ f1.getDoc().getText('body').insert(0, 'kept');
200
+ f1.getDoc().commit();
201
+ first.manager.closeAll({ flush: true });
202
+ await vi.advanceTimersByTimeAsync(1000);
203
+ expect(world.log).toHaveLength(0);
204
+
205
+ world.online = true;
206
+ const second = world.client('user:a', store); // a reload: same device store, new peer
207
+ const f2 = await second.manager.open('doc', 'doc:1', 'body');
208
+ expect(text(f2)).toBe('kept');
209
+ expect(f2.getDoc().peerIdStr).not.toBe(oldPeer);
210
+ await vi.advanceTimersByTimeAsync(400);
211
+ expect(world.log.map((r) => r.peer)).toEqual([oldPeer]);
212
+ expect(world.peers.get(oldPeer)).toBe('user:a');
213
+ });
214
+
215
+ it('open, adopt a draft and close offline without waiting on the server', async () => {
216
+ const world = new World();
217
+ world.online = false;
218
+ world.hang = true;
219
+ const store = new Map<string, any>();
220
+ const a = world.client('user:a', store);
221
+ const draft = await a.manager.createDraft('doc', 'body');
222
+ draft.getDoc().getText('body').insert(0, 'written offline');
223
+ draft.getDoc().commit();
224
+
225
+ let done = false;
226
+ void (async () => {
227
+ const f = await a.manager.open('doc', 'doc:1', 'body', draft);
228
+ expect(text(f)).toBe('written offline');
229
+ await a.manager.close('doc', 'doc:1', 'body');
230
+ done = true;
231
+ })();
232
+ await realWait();
233
+ expect(done).toBe(true);
234
+ expect(world.log).toHaveLength(0);
235
+ // Kept on the device, to go out from the next open.
236
+ expect([...store.values()].some((row) => row.snapshot)).toBe(true);
237
+ });
238
+
239
+ it('a field is loaded once the server answers, and at once with a local copy or a draft', async () => {
240
+ const world = new World();
241
+ const server = new LoroDoc();
242
+ server.getText('body').insert(0, 'from the server');
243
+ server.commit();
244
+ await world.fold('doc:1', 'body', server);
245
+ world.slow = true;
246
+ const store = new Map<string, any>();
247
+ const a = world.client('user:a', store);
248
+ const opening = a.manager.open('doc', 'doc:1', 'body');
249
+ await vi.advanceTimersByTimeAsync(9000);
250
+ const f = await opening;
251
+ // Handed out after the bounded wait, but empty only because nothing came.
252
+ expect(text(f)).toBe('');
253
+ expect(f.isLoaded()).toBe(false);
254
+ let loaded = 0;
255
+ f.onLoaded(() => loaded++);
256
+ world.release();
257
+ await realWait();
258
+ expect(text(f)).toBe('from the server');
259
+ expect(f.isLoaded()).toBe(true);
260
+ expect(loaded).toBe(1);
261
+ await a.manager.close('doc', 'doc:1', 'body');
262
+
263
+ // This device has it now: loaded before the server says anything.
264
+ const again = await (async () => {
265
+ const opening2 = a.manager.open('doc', 'doc:1', 'body');
266
+ await vi.advanceTimersByTimeAsync(9000);
267
+ return opening2;
268
+ })();
269
+ expect(again.isLoaded()).toBe(true);
270
+ world.release();
271
+
272
+ const draft = await a.manager.createDraft('doc', 'body');
273
+ draft.getDoc().getText('body').insert(0, 'new');
274
+ draft.getDoc().commit();
275
+ const fresh = await a.manager.open('doc', 'doc:2', 'body', draft);
276
+ expect(fresh.isLoaded()).toBe(true);
277
+ });
278
+
279
+ it('a reopen right after a close starts from what the close saved', async () => {
280
+ vi.useRealTimers();
281
+ const world = new World();
282
+ world.storeDelayMs = 30;
283
+ const a = world.client('user:a');
284
+ const draft = await a.manager.createDraft('doc', 'body');
285
+ draft.getDoc().getText('body').insert(0, 'from the form');
286
+ draft.getDoc().commit();
287
+ await a.manager.open('doc', 'doc:1', 'body', draft);
288
+ void a.manager.close('doc', 'doc:1', 'body');
289
+ const reopened = await a.manager.open('doc', 'doc:1', 'body');
290
+ expect(text(reopened)).toBe('from the form');
291
+ });
292
+
293
+ it('never import a row that fails the acceptance rule', async () => {
294
+ const world = new World();
295
+ const b = world.client('user:b');
296
+ const forged = new LoroDoc();
297
+ forged.setPeerId(42);
298
+ forged.getText('body').insert(0, 'forged');
299
+ forged.commit();
300
+ world.log.push({ id: 'doc__crdt_log:x', doc: 'doc:1', field: 'body', peer: '7', data: forged.export({ mode: 'update' }) });
301
+ world.log.push({ id: 'doc__crdt_log:y', doc: 'doc:1', field: 'body', peer: '42', data: forged.export({ mode: 'snapshot' }) });
302
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
303
+ expect(text(fb)).toBe('');
304
+ });
305
+
306
+ it('a first open on a device starts from the published document', async () => {
307
+ const world = new World();
308
+ const server = new LoroDoc();
309
+ server.getText('body').insert(0, 'published');
310
+ server.commit();
311
+ world.published.set('doc:1|body', { rev: 3, snapshot: server.export({ mode: 'snapshot' }) });
312
+ const a = world.client('user:a');
313
+ const f = await a.manager.open('doc', 'doc:1', 'body');
314
+ expect(f.hasContent()).toBe(true);
315
+ expect(text(f)).toBe('published');
316
+ });
317
+
318
+ it('share one document between opens and stop with the last close', async () => {
319
+ const world = new World();
320
+ const a = world.client('user:a');
321
+ const [x, y] = await Promise.all([a.manager.open('doc', 'doc:1', 'body'), a.manager.open('doc', 'doc:1', 'body')]);
322
+ expect(x).toBe(y);
323
+ a.manager.close('doc', 'doc:1', 'body');
324
+ x.getDoc().getText('body').insert(0, 'still open');
325
+ x.getDoc().commit();
326
+ await vi.advanceTimersByTimeAsync(400);
327
+ expect(world.log).toHaveLength(1);
328
+ a.manager.close('doc', 'doc:1', 'body');
329
+ await vi.advanceTimersByTimeAsync(10);
330
+ const reopened = await a.manager.open('doc', 'doc:1', 'body');
331
+ expect(reopened).not.toBe(x);
332
+ });
333
+
334
+ it('adopt a draft made before the record existed and send it under the draft peer', async () => {
335
+ const world = new World();
336
+ const a = world.client('user:a');
337
+ const b = world.client('user:b');
338
+ const draft = await a.manager.createDraft('doc', 'body');
339
+ draft.getDoc().getText('body').insert(0, 'first words');
340
+ draft.getDoc().commit();
341
+ await vi.advanceTimersByTimeAsync(1000);
342
+ expect(world.log).toHaveLength(0);
343
+
344
+ const f = await a.manager.open('doc', 'doc:1', 'body', draft);
345
+ expect(text(f)).toBe('first words');
346
+ await vi.advanceTimersByTimeAsync(10);
347
+ expect(world.log.map((r) => r.peer)).toEqual([draft.getDoc().peerIdStr]);
348
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
349
+ expect(text(fb)).toBe('first words');
350
+ });
351
+
352
+ it('a device that missed folded rows catches up from the published document', async () => {
353
+ const world = new World();
354
+ const a = world.client('user:a');
355
+ const b = world.client('user:b');
356
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
357
+ fa.getDoc().getText('body').insert(0, 'Hello');
358
+ fa.getDoc().commit();
359
+ await vi.advanceTimersByTimeAsync(400);
360
+ const fb = await b.manager.open('doc', 'doc:1', 'body');
361
+ expect(text(fb)).toBe('Hello');
362
+
363
+ // The server folded in an edit whose row B never received.
364
+ const server = LoroDoc.fromSnapshot(fa.getDoc().export({ mode: 'snapshot' }));
365
+ const other = new LoroDoc();
366
+ other.setPeerId(99);
367
+ other.import(server.export({ mode: 'update' }));
368
+ other.getText('body').insert(5, ' world');
369
+ other.commit();
370
+ server.import(other.export({ mode: 'update' }));
371
+ await world.fold('doc:1', 'body', server);
372
+ await vi.advanceTimersByTimeAsync(1100);
373
+ expect(text(fb)).toBe('Hello world');
374
+ });
375
+
376
+ it('report a document that differs from the published one at the same version', async () => {
377
+ const world = new World();
378
+ const warn = vi.fn();
379
+ const logger = { ...silent, warn, child: () => logger };
380
+ const a = world.client('user:a', new Map(), logger);
381
+ const fa = await a.manager.open('doc', 'doc:1', 'body');
382
+ fa.getDoc().getText('body').insert(0, 'same ops');
383
+ fa.getDoc().commit();
384
+ await vi.advanceTimersByTimeAsync(400);
385
+ const server = LoroDoc.fromSnapshot(fa.getDoc().export({ mode: 'snapshot' }));
386
+
387
+ await world.fold('doc:1', 'body', server, { hash: await stateHash(loro, server) });
388
+ await vi.advanceTimersByTimeAsync(1100);
389
+ await realWait();
390
+ expect(warn).not.toHaveBeenCalled();
391
+
392
+ fa.getDoc().getText('body').insert(0, '>');
393
+ fa.getDoc().commit();
394
+ await vi.advanceTimersByTimeAsync(400);
395
+ server.import(fa.getDoc().export({ mode: 'update' }));
396
+ await world.fold('doc:1', 'body', server, { rev: 2, hash: 'f'.repeat(64) });
397
+ await vi.advanceTimersByTimeAsync(1100);
398
+ await realWait();
399
+ expect(warn).toHaveBeenCalledTimes(1);
400
+ });
401
+
402
+ it('refuse to open a field that is not @crdt', async () => {
403
+ const world = new World();
404
+ const a = world.client('user:a');
405
+ await expect(a.manager.open('doc', 'doc:1', 'title')).rejects.toThrow(/not a CRDT field/);
406
+ });
407
+ });
408
+
409
+ describe('acceptance rule', () => {
410
+ it('refuses blobs that make the decoder trap, without throwing', async () => {
411
+ const fixtures = await import('../../../../ssp-node/tests/fixtures/crdt_hostile_blobs.json');
412
+ const hostile = (fixtures.default ?? fixtures) as Array<{ peer: string; blob: string }>;
413
+ for (const h of hostile) {
414
+ const bytes = Uint8Array.from(Buffer.from(h.blob, 'base64'));
415
+ expect(check(loro, bytes, h.peer).ok).toBe(false);
416
+ }
417
+ });
418
+
419
+ it('normalizes every byte shape a store can hand back', () => {
420
+ const b = new Uint8Array([1, 2, 3]);
421
+ expect(asBytes(b)).toBe(b);
422
+ expect(Array.from(asBytes(b.buffer)!)).toEqual([1, 2, 3]);
423
+ expect(Array.from(asBytes([1, 2, 3])!)).toEqual([1, 2, 3]);
424
+ expect(asBytes('nope')).toBeUndefined();
425
+ });
426
+ });
427
+
428
+ describe('state hash', () => {
429
+ it('matches the server fold on the shared vectors', async () => {
430
+ const fixtures = await import('../../../../ssp-node/tests/fixtures/crdt_state_hash.json');
431
+ const vectors = (fixtures.default ?? fixtures) as Array<{ name: string; snapshot: string; canonical: string; hash: string }>;
432
+ expect(vectors.length).toBeGreaterThan(0);
433
+ for (const v of vectors) {
434
+ const doc = LoroDoc.fromSnapshot(Uint8Array.from(Buffer.from(v.snapshot, 'base64')));
435
+ expect(canonicalState(loro, doc), v.name).toBe(v.canonical);
436
+ expect(await stateHash(loro, doc), v.name).toBe(v.hash);
437
+ }
438
+ });
439
+ });
440
+
441
+ describe('text mirror', () => {
442
+ it('renders text and prosemirror documents like the server fold', () => {
443
+ const doc = new LoroDoc();
444
+ doc.getText('summary').insert(0, 'héllo 🌍');
445
+ expect(renderText(doc, 'summary', 'text')).toBe('héllo 🌍');
446
+
447
+ const pm = new LoroDoc();
448
+ const root = pm.getMap('doc');
449
+ root.set('nodeName', 'doc');
450
+ const blocks = root.setContainer('children', new loro.LoroList());
451
+ const para = (parent: InstanceType<typeof loro.LoroList>, name: string) => {
452
+ const m = parent.insertContainer(parent.length, new loro.LoroMap());
453
+ m.set('nodeName', name);
454
+ return m.setContainer('children', new loro.LoroList());
455
+ };
456
+ const run = (parent: InstanceType<typeof loro.LoroList>, s: string) =>
457
+ parent.insertContainer(parent.length, new loro.LoroText()).insert(0, s);
458
+ run(para(blocks, 'heading'), 'Title');
459
+ const p = para(blocks, 'paragraph');
460
+ run(p, 'one ');
461
+ run(p, 'bold');
462
+ p.insertContainer(p.length, new loro.LoroMap()).set('nodeName', 'hard_break');
463
+ run(p, 'two');
464
+ para(blocks, 'paragraph');
465
+ pm.commit();
466
+ // Same fixture and expectation as `mirror.rs`.
467
+ expect(renderText(pm, 'body', 'prosemirror')).toBe('Title\none bold\ntwo\n');
468
+ });
469
+ });