@doubling/compound-sync 1.12.4 → 1.12.5

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.
@@ -0,0 +1,41 @@
1
+ export interface TeamData {
2
+ name?: string;
3
+ [key: string]: unknown;
4
+ }
5
+ export type TeamCallback = (teamId: string, teamData: TeamData) => void;
6
+ export interface TeamRegistryOptions {
7
+ onSetup: TeamCallback;
8
+ onTeardown: TeamCallback;
9
+ }
10
+ export type TeamDocChange = {
11
+ type: 'added';
12
+ id: string;
13
+ data: TeamData;
14
+ } | {
15
+ type: 'modified';
16
+ id: string;
17
+ data: TeamData;
18
+ } | {
19
+ type: 'removed';
20
+ id: string;
21
+ };
22
+ export declare class TeamRegistry {
23
+ private readonly teams;
24
+ private readonly teamIdsByName;
25
+ private readonly onSetup;
26
+ private readonly onTeardown;
27
+ constructor({ onSetup, onTeardown }: TeamRegistryOptions);
28
+ bootstrap(teamId: string, teamData: TeamData): boolean;
29
+ add(teamId: string, teamData: TeamData): boolean;
30
+ modify(teamId: string, teamData: TeamData): boolean;
31
+ remove(teamId: string): boolean;
32
+ applyChange(change: TeamDocChange): boolean;
33
+ has(teamId: string): boolean;
34
+ get(teamId: string): TeamData | undefined;
35
+ getIdByName(teamName: string): string | undefined;
36
+ size(): number;
37
+ entries(): IterableIterator<[string, TeamData]>;
38
+ names(): string[];
39
+ teamIdsByNameMap(): Map<string, string>;
40
+ }
41
+ //# sourceMappingURL=team-registry.d.ts.map
package/team-registry.js CHANGED
@@ -17,90 +17,99 @@
17
17
  // });
18
18
  // // For each docChange from onSnapshot on the teams collection:
19
19
  // registry.applyChange({ type: 'added', id, data });
20
-
21
20
  export class TeamRegistry {
22
- constructor({ onSetup, onTeardown }) {
23
- this.teams = new Map(); // teamId -> teamData
24
- this.teamIdsByName = new Map(); // teamName -> teamId
25
- this.onSetup = onSetup;
26
- this.onTeardown = onTeardown;
27
- }
28
-
29
- // Register a team in-memory without wiring listeners. Used during
30
- // startup so manifest reconcile can resolve team-scoped paths before
31
- // any file onSnapshot handlers run (DOU-55 ordering).
32
- bootstrap(teamId, teamData) {
33
- if (this.teams.has(teamId)) return false;
34
- this.teams.set(teamId, teamData);
35
- if (teamData && teamData.name) {
36
- this.teamIdsByName.set(teamData.name, teamId);
21
+ teams = new Map();
22
+ teamIdsByName = new Map();
23
+ onSetup;
24
+ onTeardown;
25
+ constructor({ onSetup, onTeardown }) {
26
+ this.onSetup = onSetup;
27
+ this.onTeardown = onTeardown;
37
28
  }
38
- return true;
39
- }
40
-
41
- // Idempotently register a team. Safe to call with the same team
42
- // multiple times; only the first call triggers onSetup.
43
- add(teamId, teamData) {
44
- if (this.teams.has(teamId)) return false;
45
- this.teams.set(teamId, teamData);
46
- if (teamData && teamData.name) {
47
- this.teamIdsByName.set(teamData.name, teamId);
29
+ // Register a team in-memory without wiring listeners. Used during
30
+ // startup so manifest reconcile can resolve team-scoped paths before
31
+ // any file onSnapshot handlers run (DOU-55 ordering).
32
+ bootstrap(teamId, teamData) {
33
+ if (this.teams.has(teamId))
34
+ return false;
35
+ this.teams.set(teamId, teamData);
36
+ if (teamData && teamData.name) {
37
+ this.teamIdsByName.set(teamData.name, teamId);
38
+ }
39
+ return true;
48
40
  }
49
- this.onSetup(teamId, teamData);
50
- return true;
51
- }
52
-
53
- // Update a team's metadata. If the team's name changed, treat as a
54
- // rename: teardown the old listener, register a new one (which
55
- // creates a new local folder under the new name). The old folder
56
- // is intentionally NOT renamed or removed; the user can clean it
57
- // up manually. This is conservative; a rename-aware implementation
58
- // is a follow-up.
59
- modify(teamId, teamData) {
60
- const prev = this.teams.get(teamId);
61
- if (!prev) {
62
- // Treat modify-of-unknown as an add. Firestore emits both
63
- // 'added' and 'modified' for the same doc under certain
64
- // listener-restart scenarios, so we want this path resilient.
65
- return this.add(teamId, teamData);
41
+ // Idempotently register a team. Safe to call with the same team
42
+ // multiple times; only the first call triggers onSetup.
43
+ add(teamId, teamData) {
44
+ if (this.teams.has(teamId))
45
+ return false;
46
+ this.teams.set(teamId, teamData);
47
+ if (teamData && teamData.name) {
48
+ this.teamIdsByName.set(teamData.name, teamId);
49
+ }
50
+ this.onSetup(teamId, teamData);
51
+ return true;
66
52
  }
67
- this.teams.set(teamId, teamData);
68
- const renamed = prev.name !== (teamData && teamData.name);
69
- if (renamed) {
70
- if (prev.name) this.teamIdsByName.delete(prev.name);
71
- if (teamData && teamData.name) {
72
- this.teamIdsByName.set(teamData.name, teamId);
73
- }
74
- this.onTeardown(teamId, prev);
75
- this.onSetup(teamId, teamData);
53
+ // Update a team's metadata. If the team's name changed, treat as a
54
+ // rename: teardown the old listener, register a new one (which
55
+ // creates a new local folder under the new name). The old folder
56
+ // is intentionally NOT renamed or removed; the user can clean it
57
+ // up manually. This is conservative; a rename-aware implementation
58
+ // is a follow-up.
59
+ modify(teamId, teamData) {
60
+ const prev = this.teams.get(teamId);
61
+ if (!prev) {
62
+ // Treat modify-of-unknown as an add. Firestore emits both
63
+ // 'added' and 'modified' for the same doc under certain
64
+ // listener-restart scenarios, so we want this path resilient.
65
+ return this.add(teamId, teamData);
66
+ }
67
+ this.teams.set(teamId, teamData);
68
+ const renamed = prev.name !== (teamData && teamData.name);
69
+ if (renamed) {
70
+ if (prev.name)
71
+ this.teamIdsByName.delete(prev.name);
72
+ if (teamData && teamData.name) {
73
+ this.teamIdsByName.set(teamData.name, teamId);
74
+ }
75
+ this.onTeardown(teamId, prev);
76
+ this.onSetup(teamId, teamData);
77
+ }
78
+ return renamed;
76
79
  }
77
- return renamed;
78
- }
79
-
80
- // Remove a team (e.g., user lost membership). Tears down the
81
- // listener; leaves any local folder alone.
82
- remove(teamId) {
83
- const prev = this.teams.get(teamId);
84
- if (!prev) return false;
85
- this.teams.delete(teamId);
86
- if (prev.name) this.teamIdsByName.delete(prev.name);
87
- this.onTeardown(teamId, prev);
88
- return true;
89
- }
90
-
91
- // Convenience: dispatch a Firestore docChange into add/modify/remove.
92
- applyChange({ type, id, data }) {
93
- if (type === 'added') return this.add(id, data);
94
- if (type === 'modified') return this.modify(id, data);
95
- if (type === 'removed') return this.remove(id);
96
- return false;
97
- }
98
-
99
- // Read-only accessors for the daemon's existing call sites.
100
- has(teamId) { return this.teams.has(teamId); }
101
- get(teamId) { return this.teams.get(teamId); }
102
- getIdByName(teamName) { return this.teamIdsByName.get(teamName); }
103
- size() { return this.teams.size; }
104
- entries() { return this.teams.entries(); }
105
- names() { return [...this.teams.values()].map(t => t.name).filter(Boolean); }
80
+ // Remove a team (e.g., user lost membership). Tears down the
81
+ // listener; leaves any local folder alone.
82
+ remove(teamId) {
83
+ const prev = this.teams.get(teamId);
84
+ if (!prev)
85
+ return false;
86
+ this.teams.delete(teamId);
87
+ if (prev.name)
88
+ this.teamIdsByName.delete(prev.name);
89
+ this.onTeardown(teamId, prev);
90
+ return true;
91
+ }
92
+ // Convenience: dispatch a Firestore docChange into add/modify/remove.
93
+ applyChange(change) {
94
+ if (change.type === 'added')
95
+ return this.add(change.id, change.data);
96
+ if (change.type === 'modified')
97
+ return this.modify(change.id, change.data);
98
+ return this.remove(change.id);
99
+ }
100
+ // Read-only accessors for the daemon's existing call sites.
101
+ has(teamId) { return this.teams.has(teamId); }
102
+ get(teamId) { return this.teams.get(teamId); }
103
+ getIdByName(teamName) { return this.teamIdsByName.get(teamName); }
104
+ size() { return this.teams.size; }
105
+ entries() { return this.teams.entries(); }
106
+ names() {
107
+ return [...this.teams.values()].map((t) => t.name).filter((n) => Boolean(n));
108
+ }
109
+ // The team-name -> team-id map the daemon hands to scopeFromLocalPath
110
+ // (which takes a Map<teamName, teamId> for routing local paths back
111
+ // to their owning team). Exposed as the live map, not a copy, so
112
+ // updates propagate without a re-publish step.
113
+ teamIdsByNameMap() { return this.teamIdsByName; }
106
114
  }
115
+ //# sourceMappingURL=team-registry.js.map
@@ -0,0 +1,4 @@
1
+ export declare function loadBindingState(localPath: string, fileId: string): Uint8Array | null;
2
+ export declare function saveBindingState(localPath: string, fileId: string, encoded: Uint8Array): void;
3
+ export declare function deleteBindingState(localPath: string, fileId: string): void;
4
+ //# sourceMappingURL=yjs-binding-state.d.ts.map
@@ -15,47 +15,50 @@
15
15
  // `<localPath>/.compound-yjs-binding/<fileId>.yjs`. The binary blob is
16
16
  // the output of Y.encodeStateAsUpdate(doc); the daemon applies it via
17
17
  // Y.applyUpdate(doc, blob) on restore.
18
-
19
18
  import fs from 'node:fs';
20
19
  import path from 'node:path';
21
-
22
20
  const STATE_DIR = '.compound-yjs-binding';
23
-
24
21
  function statePathFor(localPath, fileId) {
25
- return path.join(localPath, STATE_DIR, `${fileId}.yjs`);
22
+ return path.join(localPath, STATE_DIR, `${fileId}.yjs`);
26
23
  }
27
-
28
24
  // Load the encoded Y.Doc state for a file. Returns null if no state
29
25
  // exists yet (first run, or this fileId hasn't been synced before) or
30
26
  // if the file is unreadable for any reason (corruption, permissions).
31
27
  // Treating unreadable as "no state" is safe: the binding falls back to
32
28
  // its first-run behavior, which writes ytext to disk on initial flush.
33
29
  export function loadBindingState(localPath, fileId) {
34
- try {
35
- return fs.readFileSync(statePathFor(localPath, fileId));
36
- } catch {
37
- return null;
38
- }
30
+ try {
31
+ return fs.readFileSync(statePathFor(localPath, fileId));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
39
36
  }
40
-
41
37
  // Persist the encoded Y.Doc state for a file. Atomic via write-to-tmp +
42
38
  // rename so a crash mid-write can't leave a half-written .yjs file that
43
39
  // would corrupt the next startup. Best-effort: a failed save costs us a
44
40
  // cold-start reconciliation next launch, never data.
45
41
  export function saveBindingState(localPath, fileId, encoded) {
46
- const finalPath = statePathFor(localPath, fileId);
47
- const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
48
- try {
49
- fs.mkdirSync(path.dirname(finalPath), { recursive: true });
50
- fs.writeFileSync(tmpPath, encoded);
51
- fs.renameSync(tmpPath, finalPath);
52
- } catch {
53
- try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
54
- }
42
+ const finalPath = statePathFor(localPath, fileId);
43
+ const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
44
+ try {
45
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
46
+ fs.writeFileSync(tmpPath, encoded);
47
+ fs.renameSync(tmpPath, finalPath);
48
+ }
49
+ catch {
50
+ try {
51
+ fs.unlinkSync(tmpPath);
52
+ }
53
+ catch { /* ignore */ }
54
+ }
55
55
  }
56
-
57
56
  // Best-effort cleanup when a file is deleted from the workspace, so
58
57
  // stale .yjs blobs don't accumulate. Failing to delete is not fatal.
59
58
  export function deleteBindingState(localPath, fileId) {
60
- try { fs.unlinkSync(statePathFor(localPath, fileId)); } catch { /* ignore */ }
59
+ try {
60
+ fs.unlinkSync(statePathFor(localPath, fileId));
61
+ }
62
+ catch { /* ignore */ }
61
63
  }
64
+ //# sourceMappingURL=yjs-binding-state.js.map
@@ -0,0 +1,32 @@
1
+ import * as Y from 'yjs';
2
+ import { type YjsUpdateStore } from './yjs-provider.js';
3
+ export interface YjsFileBindingOptions {
4
+ debounceMs?: number;
5
+ onDiskWrite: (text: string) => void;
6
+ onDocStateChange?: ((encoded: Uint8Array) => void) | null;
7
+ initialDocState?: Uint8Array | null;
8
+ currentDiskText?: string | null;
9
+ }
10
+ export declare class YjsFileBinding {
11
+ readonly doc: Y.Doc;
12
+ readonly ytext: Y.Text;
13
+ private readonly store;
14
+ private readonly provider;
15
+ private readonly debounceMs;
16
+ private readonly onDiskWrite;
17
+ private readonly onDocStateChange;
18
+ private readonly initialDocState;
19
+ private readonly currentDiskText;
20
+ private flushTimer;
21
+ private lastDiskText;
22
+ private observer;
23
+ private stopped;
24
+ constructor(store: YjsUpdateStore, { debounceMs, onDiskWrite, onDocStateChange, initialDocState, currentDiskText, }: YjsFileBindingOptions);
25
+ start(): Promise<void>;
26
+ private _applyTextDiff;
27
+ private _emitDocState;
28
+ private _flushNow;
29
+ applyDiskText(newText: string): void;
30
+ stop(): Promise<void>;
31
+ }
32
+ //# sourceMappingURL=yjs-file-binding.d.ts.map