@doubling/types 0.3.0 → 0.4.0

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.
package/README.md CHANGED
@@ -18,6 +18,13 @@ The duplication was load-bearing for shipping (web and sync run in different env
18
18
 
19
19
  ## Layout
20
20
 
21
+ - `src/crdt-store.ts`: `UpdateRecord`, `Snapshot`, `UpdateStore`, and
22
+ `updateStoreConformance`. The CRDT update-store contract plus the behavioural
23
+ cases both `web/` and `sync/` run against their own backends. It lives here
24
+ because it is the only dependency-free part of the CRDT stack — types over
25
+ `Uint8Array` and `string`, since a store treats updates as opaque bytes — and
26
+ because a package with shared runtime deps cannot be shared at all under this
27
+ repo's nested install strategy (DOU-379).
21
28
  - `src/firestore.ts`: `FileEntity`, `FolderEntity`, `Entity`, `OrgMembership`, `OrgMember`, `Team`, `TeamMember`, `UserProfile`, `Scope`.
22
29
  - `src/mime.ts`: `EXT_TO_MIME`, `mimeTypeFromExt`, `isTextMimeType`.
23
30
  - `src/paths.ts`: `extFromPath`, `storagePathFor`.
@@ -0,0 +1,86 @@
1
+ /** One stored update: the opaque CRDT bytes plus its ordering id. */
2
+ export interface UpdateRecord {
3
+ id: string;
4
+ update: Uint8Array;
5
+ }
6
+ /** A folded snapshot plus the highest update id it covers. */
7
+ export interface Snapshot {
8
+ state: Uint8Array;
9
+ /** null means "no known baseline" — replay the whole log. */
10
+ baselineId: string | null;
11
+ }
12
+ /**
13
+ * The storage seam between a CRDT provider and any concrete backend.
14
+ *
15
+ * Identity contract:
16
+ *
17
+ * - Every `UpdateRecord` has a unique, monotonically-ordered `id`.
18
+ * Backends choose the encoding (server timestamp + doc id, a counter,
19
+ * a ULID) but iterating in id order must replay in append order.
20
+ * - `baselineId` is the highest id covered by the current snapshot.
21
+ * `loadUpdatesAfter(baselineId)` returns strictly newer records.
22
+ * - Ids exist only on records **read back** from the store.
23
+ * `appendUpdate` returns void: the backend assigns the id (a server
24
+ * timestamp, in Firestore's case), so resolving it at append time
25
+ * costs a round trip for a value no caller uses (DOU-377).
26
+ *
27
+ * Deliberately absent: any method that folds or deletes. Compaction is
28
+ * destructive and only safe when the reader, the snapshot writer and the
29
+ * deleter are one actor in one consistent view, which a client cannot be
30
+ * while others append. It is the `compactYjsDoc` callable, and
31
+ * `firestore.rules` denies clients `delete` on `yupdates` and writes on
32
+ * `yjs/state` so this is enforced rather than conventional (DOU-409).
33
+ *
34
+ * This is a deliberate narrowing of what the two local interfaces
35
+ * declared before DOU-379. Both carried an optional
36
+ * `saveSnapshotAndCompact?(...)`, present so the in-memory stores could
37
+ * exercise fold semantics while the Firestore stores omitted it. Nothing
38
+ * ever called it *through the interface* — the tests that seed snapshots
39
+ * hold a concrete `MemoryUpdateStore` — so it was optional surface on a
40
+ * contract that no longer describes a store's job. The in-memory stores
41
+ * keep the method for those tests; it is simply not part of the contract.
42
+ */
43
+ export interface UpdateStore {
44
+ loadSnapshot(): Promise<Snapshot | null>;
45
+ loadUpdatesAfter(baselineId: string | null): Promise<UpdateRecord[]>;
46
+ appendUpdate(update: Uint8Array): Promise<void>;
47
+ /** Stream records as they are appended.
48
+ *
49
+ * `baselineId` filters the stream exactly as `loadUpdatesAfter` does,
50
+ * and the provider passes what it already loaded. Without it every
51
+ * client re-received the document's whole history on attach --
52
+ * history it had just loaded itself -- and again on every reconnect.
53
+ * DOU-388 measured the shape: one append reaches every subscribed
54
+ * client, so at 200 collaborators a reconnect storm meant 200 clients
55
+ * each re-reading everything (DOU-395).
56
+ *
57
+ * Implementations must honour it, including in-memory ones, so tests
58
+ * exercise the same contract production does. */
59
+ subscribeUpdates(onUpdate: (record: UpdateRecord) => void, baselineId?: string | null): () => void;
60
+ }
61
+ /** Minimal assertion surface, so this module stays dependency-free and
62
+ * can be driven by `node:assert`, vitest, or anything else. */
63
+ export interface ConformanceAsserts {
64
+ equal(actual: unknown, expected: unknown, message?: string): void;
65
+ deepEqual(actual: unknown, expected: unknown, message?: string): void;
66
+ ok(value: unknown, message?: string): void;
67
+ }
68
+ export interface ConformanceCase {
69
+ name: string;
70
+ run(store: UpdateStore, a: ConformanceAsserts): Promise<void>;
71
+ }
72
+ /**
73
+ * Behavioural cases every `UpdateStore` must satisfy, expressed against
74
+ * the interface alone so one list can drive every backend.
75
+ *
76
+ * Each case gets a *fresh* store from the caller's factory — several
77
+ * assert on the empty state, and a shared store would make them
78
+ * order-dependent.
79
+ *
80
+ * Scope note: these cover the store contract, not Yjs semantics. Updates
81
+ * are arbitrary bytes here on purpose — a store that only works for
82
+ * well-formed Yjs payloads is a store that inspects payloads it should be
83
+ * treating as opaque. Convergence is covered separately.
84
+ */
85
+ export declare const updateStoreConformance: ConformanceCase[];
86
+ //# sourceMappingURL=crdt-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt-store.d.ts","sourceRoot":"","sources":["../src/crdt-store.ts"],"names":[],"mappings":"AA6BA,qEAAqE;AACrE,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,8DAA8D;AAC9D,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,UAAU,CAAC;IAClB,6DAA6D;IAC7D,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,WAAW,WAAW;IAC1B,YAAY,IAAI,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAC;IACzC,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IACrE,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD;;;;;;;;;;;qDAWiD;IACjD,gBAAgB,CACd,QAAQ,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,IAAI,EACxC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,GACzB,MAAM,IAAI,CAAC;CACf;AAID;+DAC+D;AAC/D,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClE,SAAS,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtE,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,KAAK,EAAE,WAAW,EAAE,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAaD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB,EAAE,eAAe,EAgGnD,CAAC"}
@@ -0,0 +1,140 @@
1
+ // The CRDT update-store contract, shared by web/ and sync/.
2
+ //
3
+ // WHY THIS LIVES IN @doubling/types
4
+ //
5
+ // The Yjs provider and its store backends exist twice — once in
6
+ // `web/src/yjs/`, once in `sync/`. That duplication is not carelessness:
7
+ // the root `.npmrc` sets `install-strategy=nested` on purpose, because
8
+ // `desktop/scripts/after-pack.cjs` needs `sync/node_modules/firebase` to
9
+ // be a real directory when it copies the daemon into the .app bundle,
10
+ // `functions/` deploys with its own `node_modules`, and
11
+ // `@doubling/compound-sync` publishes standalone. So a shared workspace
12
+ // package that imported `yjs` or `firebase` would resolve its *own*
13
+ // nested copies of them, and two instances of either is worse than the
14
+ // duplication: two `yjs` copies break Yjs' client-id identity, two
15
+ // `firebase` copies break the SDK's runtime type checks on
16
+ // `collection()` / `doc()` inputs (DOU-379).
17
+ //
18
+ // This file is the part that *can* be shared, because it has no runtime
19
+ // dependencies at all. It is types and plain functions over `Uint8Array`
20
+ // and `string` — the store treats updates as opaque bytes, so nothing
21
+ // here needs to know what Yjs is.
22
+ //
23
+ // The payoff is compile-time: both `FirestoreUpdateStore`s and both
24
+ // `MemoryUpdateStore`s declare `implements UpdateStore` against *this*
25
+ // interface, so a signature drifting on one side is a type error rather
26
+ // than something a reader has to notice. That is what actually failed
27
+ // before — the divergences found in this subsystem were all found by
28
+ // reading, never by a check.
29
+ const encoder = new TextEncoder();
30
+ const decoder = new TextDecoder();
31
+ function bytes(s) {
32
+ return encoder.encode(s);
33
+ }
34
+ function text(b) {
35
+ return decoder.decode(b);
36
+ }
37
+ /**
38
+ * Behavioural cases every `UpdateStore` must satisfy, expressed against
39
+ * the interface alone so one list can drive every backend.
40
+ *
41
+ * Each case gets a *fresh* store from the caller's factory — several
42
+ * assert on the empty state, and a shared store would make them
43
+ * order-dependent.
44
+ *
45
+ * Scope note: these cover the store contract, not Yjs semantics. Updates
46
+ * are arbitrary bytes here on purpose — a store that only works for
47
+ * well-formed Yjs payloads is a store that inspects payloads it should be
48
+ * treating as opaque. Convergence is covered separately.
49
+ */
50
+ export const updateStoreConformance = [
51
+ {
52
+ name: 'loadSnapshot returns null before anything is written',
53
+ async run(store, a) {
54
+ a.equal(await store.loadSnapshot(), null);
55
+ },
56
+ },
57
+ {
58
+ name: 'loadUpdatesAfter(null) is empty on a fresh store',
59
+ async run(store, a) {
60
+ a.deepEqual(await store.loadUpdatesAfter(null), []);
61
+ },
62
+ },
63
+ {
64
+ name: 'appended updates read back in append order',
65
+ async run(store, a) {
66
+ await store.appendUpdate(bytes('one'));
67
+ await store.appendUpdate(bytes('two'));
68
+ await store.appendUpdate(bytes('three'));
69
+ const all = await store.loadUpdatesAfter(null);
70
+ a.deepEqual(all.map((r) => text(r.update)), ['one', 'two', 'three']);
71
+ },
72
+ },
73
+ {
74
+ name: 'ids sort in the same order records were appended',
75
+ async run(store, a) {
76
+ await store.appendUpdate(bytes('a'));
77
+ await store.appendUpdate(bytes('b'));
78
+ await store.appendUpdate(bytes('c'));
79
+ const ids = (await store.loadUpdatesAfter(null)).map((r) => r.id);
80
+ const sorted = [...ids].sort();
81
+ a.deepEqual(ids, sorted, 'iterating in id order must replay in append order — the ordering contract');
82
+ },
83
+ },
84
+ {
85
+ name: 'loadUpdatesAfter(baselineId) excludes the baseline itself',
86
+ async run(store, a) {
87
+ await store.appendUpdate(bytes('first'));
88
+ await store.appendUpdate(bytes('second'));
89
+ const all = await store.loadUpdatesAfter(null);
90
+ a.equal(all.length, 2);
91
+ const after = await store.loadUpdatesAfter(all[0].id);
92
+ a.deepEqual(after.map((r) => text(r.update)), ['second']);
93
+ },
94
+ },
95
+ {
96
+ name: 'the newest id yields an empty tail',
97
+ async run(store, a) {
98
+ await store.appendUpdate(bytes('only'));
99
+ const all = await store.loadUpdatesAfter(null);
100
+ a.deepEqual(await store.loadUpdatesAfter(all[all.length - 1].id), []);
101
+ },
102
+ },
103
+ {
104
+ name: 'an empty update round-trips as empty, not as absent',
105
+ async run(store, a) {
106
+ // Conflating "zero bytes" with "no record" is the shape of bug this
107
+ // subsystem keeps producing: DOU-404 (blob read failure faked as
108
+ // empty content) and the DOU-378 drain() bug (wrote '' over real
109
+ // content) were both that mistake. A store must keep them distinct.
110
+ await store.appendUpdate(new Uint8Array(0));
111
+ const all = await store.loadUpdatesAfter(null);
112
+ a.equal(all.length, 1, 'an empty update is still a record');
113
+ a.equal(all[0].update.length, 0);
114
+ },
115
+ },
116
+ {
117
+ name: 'subscribeUpdates delivers records appended after subscribing',
118
+ async run(store, a) {
119
+ const seen = [];
120
+ const unsubscribe = store.subscribeUpdates((r) => seen.push(text(r.update)));
121
+ await store.appendUpdate(bytes('live'));
122
+ // Backends deliver asynchronously; give the listener a turn.
123
+ await new Promise((r) => setTimeout(r, 50));
124
+ a.ok(seen.includes('live'), `expected delivery, saw ${JSON.stringify(seen)}`);
125
+ unsubscribe();
126
+ },
127
+ },
128
+ {
129
+ name: 'unsubscribe stops delivery',
130
+ async run(store, a) {
131
+ const seen = [];
132
+ const unsubscribe = store.subscribeUpdates((r) => seen.push(text(r.update)));
133
+ unsubscribe();
134
+ await store.appendUpdate(bytes('after-unsubscribe'));
135
+ await new Promise((r) => setTimeout(r, 50));
136
+ a.ok(!seen.includes('after-unsubscribe'), `listener fired after unsubscribe: ${JSON.stringify(seen)}`);
137
+ },
138
+ },
139
+ ];
140
+ //# sourceMappingURL=crdt-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt-store.js","sourceRoot":"","sources":["../src/crdt-store.ts"],"names":[],"mappings":"AAAA,4DAA4D;AAC5D,EAAE;AACF,oCAAoC;AACpC,EAAE;AACF,gEAAgE;AAChE,yEAAyE;AACzE,uEAAuE;AACvE,yEAAyE;AACzE,sEAAsE;AACtE,wDAAwD;AACxD,wEAAwE;AACxE,oEAAoE;AACpE,uEAAuE;AACvE,mEAAmE;AACnE,2DAA2D;AAC3D,6CAA6C;AAC7C,EAAE;AACF,wEAAwE;AACxE,yEAAyE;AACzE,sEAAsE;AACtE,kCAAkC;AAClC,EAAE;AACF,oEAAoE;AACpE,uEAAuE;AACvE,wEAAwE;AACxE,sEAAsE;AACtE,qEAAqE;AACrE,6BAA6B;AAmF7B,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;AAClC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;AAElC,SAAS,KAAK,CAAC,CAAS;IACtB,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC;AAED,SAAS,IAAI,CAAC,CAAa;IACzB,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAsB;IACvD;QACE,IAAI,EAAE,sDAAsD;QAC5D,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,CAAC,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,YAAY,EAAE,EAAE,IAAI,CAAC,CAAC;QAC5C,CAAC;KACF;IACD;QACE,IAAI,EAAE,kDAAkD;QACxD,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;QACtD,CAAC;KACF;IACD;QACE,IAAI,EAAE,4CAA4C;QAClD,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YACvC,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;YACvC,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;YACzC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;YAC/C,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC;QACvE,CAAC;KACF;IACD;QACE,IAAI,EAAE,kDAAkD;QACxD,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACrC,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACrC,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC;YACrC,MAAM,GAAG,GAAG,CAAC,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAClE,MAAM,MAAM,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC;YAC/B,CAAC,CAAC,SAAS,CACT,GAAG,EACH,MAAM,EACN,2EAA2E,CAC5E,CAAC;QACJ,CAAC;KACF;IACD;QACE,IAAI,EAAE,2DAA2D;QACjE,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;YACzC,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAC1C,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;YAC/C,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;YACvB,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC,EAAE,CAAC,CAAC;YACvD,CAAC,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC5D,CAAC;KACF;IACD;QACE,IAAI,EAAE,oCAAoC;QAC1C,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;YAC/C,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QACzE,CAAC;KACF;IACD;QACE,IAAI,EAAE,qDAAqD;QAC3D,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,oEAAoE;YACpE,iEAAiE;YACjE,iEAAiE;YACjE,oEAAoE;YACpE,MAAM,KAAK,CAAC,YAAY,CAAC,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC;YAC5C,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC;YAC/C,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE,mCAAmC,CAAC,CAAC;YAC5D,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACpC,CAAC;KACF;IACD;QACE,IAAI,EAAE,8DAA8D;QACpE,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,GAAa,EAAE,CAAC;YAC1B,MAAM,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC7E,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;YACxC,6DAA6D;YAC7D,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;YAC5C,CAAC,CAAC,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,0BAA0B,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YAC9E,WAAW,EAAE,CAAC;QAChB,CAAC;KACF;IACD;QACE,IAAI,EAAE,4BAA4B;QAClC,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC;YAChB,MAAM,IAAI,GAAa,EAAE,CAAC;YAC1B,MAAM,WAAW,GAAG,KAAK,CAAC,gBAAgB,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC7E,WAAW,EAAE,CAAC;YACd,MAAM,KAAK,CAAC,YAAY,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC,CAAC;YACrD,MAAM,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;YAC5C,CAAC,CAAC,EAAE,CACF,CAAC,IAAI,CAAC,QAAQ,CAAC,mBAAmB,CAAC,EACnC,qCAAqC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAC5D,CAAC;QACJ,CAAC;KACF;CACF,CAAC"}
@@ -0,0 +1,34 @@
1
+ /** The slice of `Y.Text` the generators need. `Y.Text` satisfies this
2
+ * structurally, which is what keeps this module dependency-free. */
3
+ export interface TextLike {
4
+ readonly length: number;
5
+ insert(index: number, content: string): void;
6
+ delete(index: number, length: number): void;
7
+ }
8
+ /** mulberry32. Small, fast, adequate for shuffling and picking offsets;
9
+ * not cryptographic and does not need to be. */
10
+ export declare function makeRng(seed: number): () => number;
11
+ export declare function pick<T>(rng: () => number, xs: readonly T[]): T;
12
+ export declare function shuffled<T>(rng: () => number, xs: readonly T[]): T[];
13
+ /** Fixed seeds, named rather than a range so a failing one can be pinned
14
+ * in isolation while debugging. */
15
+ export declare const SEEDS: readonly [1, 7, 42, 1337, 90210, 555555];
16
+ /** Deliberately includes a newline, a non-ASCII letter and an astral-plane
17
+ * emoji: CRDT text indices are UTF-16 code units, so a surrogate pair is
18
+ * where off-by-one position handling shows up. */
19
+ export declare const WORDS: readonly ["alpha", "beta ", "gamma", " delta", "eps", "\n", "ζ", "🙂"];
20
+ export type Edit = {
21
+ kind: 'insert';
22
+ index: number;
23
+ text: string;
24
+ } | {
25
+ kind: 'delete';
26
+ index: number;
27
+ length: number;
28
+ };
29
+ /** Generate an edit valid against a text of the given length. Positions and
30
+ * lengths are clamped so generated edits are always applicable — the
31
+ * property under test is convergence, not the CRDT's bounds checking. */
32
+ export declare function generateEdit(rng: () => number, currentLength: number): Edit;
33
+ export declare function applyEdit(text: TextLike, edit: Edit): void;
34
+ //# sourceMappingURL=crdt-testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt-testing.d.ts","sourceRoot":"","sources":["../src/crdt-testing.ts"],"names":[],"mappings":"AAwBA;oEACoE;AACpE,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7C;AAED;gDACgD;AAChD,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,MAAM,CASlD;AAED,wBAAgB,IAAI,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,MAAM,EAAE,EAAE,EAAE,SAAS,CAAC,EAAE,GAAG,CAAC,CAE9D;AAED,wBAAgB,QAAQ,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,MAAM,EAAE,EAAE,EAAE,SAAS,CAAC,EAAE,GAAG,CAAC,EAAE,CAOpE;AAED;mCACmC;AACnC,eAAO,MAAM,KAAK,0CAA4C,CAAC;AAE/D;;kDAEkD;AAClD,eAAO,MAAM,KAAK,wEAAyE,CAAC;AAE5F,MAAM,MAAM,IAAI,GACZ;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD;;yEAEyE;AACzE,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,IAAI,CAW3E;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,GAAG,IAAI,CAQ1D"}
@@ -0,0 +1,79 @@
1
+ // Deterministic edit generators for CRDT convergence testing.
2
+ //
3
+ // WHY THESE ARE SHARED
4
+ //
5
+ // The provider and its stores exist twice (web/src/yjs/ and sync/) and
6
+ // cannot be collapsed into one package — see the header of ./crdt-store.ts
7
+ // for why. That makes it easy for the two sides' *tests* to drift as well
8
+ // as their implementations, and a shared implementation with two different
9
+ // generators would still let them disagree about what "an edit" is.
10
+ //
11
+ // These live here for the same reason the contract does: they have no
12
+ // runtime dependencies. `applyEdit` takes a structural `TextLike` rather
13
+ // than a `Y.Text`, which `Y.Text` satisfies without this module importing
14
+ // yjs — so it stays shareable under the repo's nested install strategy,
15
+ // where a shared package with runtime deps would resolve its own copies.
16
+ //
17
+ // DETERMINISM
18
+ //
19
+ // Seeded, never Math.random. A property test whose failures cannot be
20
+ // replayed is a flake generator: the seed appears in every assertion
21
+ // message so a failure reproduces exactly. The seed list is fixed rather
22
+ // than time-derived so CI runs the same cases every time — widening
23
+ // coverage is a deliberate edit to SEEDS, not a side effect of the clock.
24
+ /** mulberry32. Small, fast, adequate for shuffling and picking offsets;
25
+ * not cryptographic and does not need to be. */
26
+ export function makeRng(seed) {
27
+ let a = seed >>> 0;
28
+ return () => {
29
+ a = (a + 0x6d2b79f5) >>> 0;
30
+ let t = a;
31
+ t = Math.imul(t ^ (t >>> 15), t | 1);
32
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
33
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
34
+ };
35
+ }
36
+ export function pick(rng, xs) {
37
+ return xs[Math.floor(rng() * xs.length)];
38
+ }
39
+ export function shuffled(rng, xs) {
40
+ const out = xs.slice();
41
+ for (let i = out.length - 1; i > 0; i -= 1) {
42
+ const j = Math.floor(rng() * (i + 1));
43
+ [out[i], out[j]] = [out[j], out[i]];
44
+ }
45
+ return out;
46
+ }
47
+ /** Fixed seeds, named rather than a range so a failing one can be pinned
48
+ * in isolation while debugging. */
49
+ export const SEEDS = [1, 7, 42, 1337, 90210, 555_555];
50
+ /** Deliberately includes a newline, a non-ASCII letter and an astral-plane
51
+ * emoji: CRDT text indices are UTF-16 code units, so a surrogate pair is
52
+ * where off-by-one position handling shows up. */
53
+ export const WORDS = ['alpha', 'beta ', 'gamma', ' delta', 'eps', '\n', 'ζ', '🙂'];
54
+ /** Generate an edit valid against a text of the given length. Positions and
55
+ * lengths are clamped so generated edits are always applicable — the
56
+ * property under test is convergence, not the CRDT's bounds checking. */
57
+ export function generateEdit(rng, currentLength) {
58
+ if (currentLength > 0 && rng() < 0.3) {
59
+ const index = Math.floor(rng() * currentLength);
60
+ const length = Math.max(1, Math.floor(rng() * Math.min(5, currentLength - index)));
61
+ return { kind: 'delete', index, length };
62
+ }
63
+ return {
64
+ kind: 'insert',
65
+ index: Math.floor(rng() * (currentLength + 1)),
66
+ text: pick(rng, WORDS),
67
+ };
68
+ }
69
+ export function applyEdit(text, edit) {
70
+ if (edit.kind === 'insert') {
71
+ text.insert(Math.min(edit.index, text.length), edit.text);
72
+ return;
73
+ }
74
+ const index = Math.min(edit.index, text.length);
75
+ const length = Math.min(edit.length, text.length - index);
76
+ if (length > 0)
77
+ text.delete(index, length);
78
+ }
79
+ //# sourceMappingURL=crdt-testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crdt-testing.js","sourceRoot":"","sources":["../src/crdt-testing.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './crdt-store.js';
2
+ export * from './crdt-testing.js';
1
3
  export * from './firestore.js';
2
4
  export * from './mime.js';
3
5
  export * from './paths.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // relative imports in TypeScript source, even though the actual file
5
5
  // on disk is `.ts`. tsc and Node's ESM loader rewrite to the
6
6
  // compiled `.js` at build / runtime.
7
+ export * from './crdt-store.js';
8
+ export * from './crdt-testing.js';
7
9
  export * from './firestore.js';
8
10
  export * from './mime.js';
9
11
  export * from './paths.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,EAAE;AACF,6DAA6D;AAC7D,qEAAqE;AACrE,6DAA6D;AAC7D,qCAAqC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,EAAE;AACF,6DAA6D;AAC7D,qEAAqE;AACrE,6DAA6D;AAC7D,qCAAqC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,WAAW,CAAC;AAC1B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/types",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared Firestore document interfaces and MIME / path helpers consumed by web/ and sync/. Single source of truth so the two consumers can never drift on a document shape (DOU-182, TS Phase 2 of the monorepo TypeScript migration).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",