design-share 0.1.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/src/state.js ADDED
@@ -0,0 +1,204 @@
1
+ import crypto from 'node:crypto';
2
+ import { git, gitInput, tryGit } from './git.js';
3
+
4
+ const REF = 'refs/design-share/state';
5
+ const REMOTE_REF = 'refs/design-share/remote';
6
+
7
+ export function emptyState() {
8
+ return { version: 1, shares: {}, comments: {} };
9
+ }
10
+
11
+ export function newId() {
12
+ return crypto.randomBytes(6).toString('hex');
13
+ }
14
+
15
+ function newer(a, b) {
16
+ return (a.updatedAt || 0) >= (b.updatedAt || 0) ? a : b;
17
+ }
18
+
19
+ function mergeReplies(a = [], b = []) {
20
+ const byId = new Map();
21
+ for (const r of [...a, ...b]) {
22
+ if (!byId.has(r.id)) byId.set(r.id, r);
23
+ }
24
+ return [...byId.values()].sort((x, y) => (x.createdAt || 0) - (y.createdAt || 0));
25
+ }
26
+
27
+ export function mergeStates(a, b) {
28
+ if (!a) return b || emptyState();
29
+ if (!b) return a;
30
+ const out = emptyState();
31
+ const shareKeys = new Set([...Object.keys(a.shares || {}), ...Object.keys(b.shares || {})]);
32
+ for (const k of shareKeys) {
33
+ const sa = (a.shares || {})[k];
34
+ const sb = (b.shares || {})[k];
35
+ out.shares[k] = sa && sb ? newer(sa, sb) : (sa || sb);
36
+ }
37
+ const commentKeys = new Set([...Object.keys(a.comments || {}), ...Object.keys(b.comments || {})]);
38
+ for (const k of commentKeys) {
39
+ const ca = (a.comments || {})[k];
40
+ const cb = (b.comments || {})[k];
41
+ if (ca && cb) {
42
+ const win = newer(ca, cb);
43
+ out.comments[k] = { ...win, replies: mergeReplies(ca.replies, cb.replies) };
44
+ } else {
45
+ out.comments[k] = ca || cb;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ async function readRef(repoRoot, ref) {
52
+ const raw = await tryGit(repoRoot, ['show', `${ref}:state.json`]);
53
+ if (!raw) return null;
54
+ try { return JSON.parse(raw); } catch { return null; }
55
+ }
56
+
57
+ export class StateStore {
58
+ constructor(repoRoot, identity) {
59
+ this.repoRoot = repoRoot;
60
+ this.identity = identity;
61
+ this.state = emptyState();
62
+ this.hasRemote = false;
63
+ this.lastSync = null;
64
+ this.lastSyncError = null;
65
+ this.dirty = false;
66
+ }
67
+
68
+ async init() {
69
+ this.hasRemote = !!(await tryGit(this.repoRoot, ['remote', 'get-url', 'origin']));
70
+ const local = await readRef(this.repoRoot, REF);
71
+ if (local) this.state = mergeStates(this.state, local);
72
+ }
73
+
74
+ mutate(fn) {
75
+ fn(this.state);
76
+ this.dirty = true;
77
+ }
78
+
79
+ async writeRef() {
80
+ const json = JSON.stringify(this.state, null, 2) + '\n';
81
+ const blob = await gitInput(this.repoRoot, ['hash-object', '-w', '--stdin'], json);
82
+ const tree = await gitInput(this.repoRoot, ['mktree'], `100644 blob ${blob}\tstate.json\n`);
83
+ const parent = await tryGit(this.repoRoot, ['rev-parse', '--verify', '--quiet', REF]);
84
+ const args = ['commit-tree', tree, '-m', 'design-share state'];
85
+ if (parent) args.push('-p', parent);
86
+ const env = {
87
+ GIT_AUTHOR_NAME: this.identity.name || 'design-share',
88
+ GIT_AUTHOR_EMAIL: this.identity.email || 'design-share@local',
89
+ GIT_COMMITTER_NAME: this.identity.name || 'design-share',
90
+ GIT_COMMITTER_EMAIL: this.identity.email || 'design-share@local',
91
+ };
92
+ const commit = await git(this.repoRoot, args, { env });
93
+ await git(this.repoRoot, ['update-ref', REF, commit]);
94
+ }
95
+
96
+ // Fetch remote state, merge, persist locally, push if anything moved.
97
+ async sync() {
98
+ try {
99
+ let remoteState = null;
100
+ if (this.hasRemote) {
101
+ await tryGit(this.repoRoot, ['fetch', 'origin', `+${REF}:${REMOTE_REF}`]);
102
+ remoteState = await readRef(this.repoRoot, REMOTE_REF);
103
+ }
104
+ const localRefState = await readRef(this.repoRoot, REF);
105
+ const before = JSON.stringify(this.state);
106
+ this.state = mergeStates(mergeStates(this.state, localRefState), remoteState);
107
+ const changed = JSON.stringify(this.state) !== before;
108
+
109
+ const refOutdated = JSON.stringify(localRefState) !== JSON.stringify(this.state);
110
+ if (this.dirty || refOutdated) {
111
+ await this.writeRef();
112
+ this.dirty = false;
113
+ }
114
+
115
+ if (this.hasRemote) {
116
+ const localSha = await tryGit(this.repoRoot, ['rev-parse', '--verify', '--quiet', REF]);
117
+ const remoteSha = await tryGit(this.repoRoot, ['rev-parse', '--verify', '--quiet', REMOTE_REF]);
118
+ if (localSha && localSha !== remoteSha) {
119
+ // Histories on each machine advance independently, so a plain push
120
+ // gets rejected as non fast forward. We merged content above, which
121
+ // makes force safe apart from a small write race between teammates.
122
+ await git(this.repoRoot, ['push', '--quiet', '--force', 'origin', `${REF}:${REF}`]);
123
+ }
124
+ }
125
+ this.lastSync = Date.now();
126
+ this.lastSyncError = null;
127
+ return changed;
128
+ } catch (err) {
129
+ this.lastSyncError = err.message;
130
+ return false;
131
+ }
132
+ }
133
+
134
+ share({ branch, title }) {
135
+ const key = `${this.identity.slug}/${branch}`;
136
+ const now = Date.now();
137
+ this.mutate((s) => {
138
+ const existing = s.shares[key];
139
+ s.shares[key] = {
140
+ user: this.identity.slug,
141
+ name: this.identity.name,
142
+ email: this.identity.email,
143
+ branch,
144
+ title: title || (existing && existing.title) || branch,
145
+ sharedAt: (existing && existing.sharedAt) || now,
146
+ updatedAt: now,
147
+ active: true,
148
+ };
149
+ });
150
+ }
151
+
152
+ unshare(branch) {
153
+ const key = `${this.identity.slug}/${branch}`;
154
+ this.mutate((s) => {
155
+ if (s.shares[key]) {
156
+ s.shares[key] = { ...s.shares[key], active: false, updatedAt: Date.now() };
157
+ }
158
+ });
159
+ }
160
+
161
+ addComment(data) {
162
+ const id = newId();
163
+ const now = Date.now();
164
+ this.mutate((s) => {
165
+ s.comments[id] = {
166
+ id,
167
+ user: this.identity.slug,
168
+ name: this.identity.name,
169
+ createdAt: now,
170
+ updatedAt: now,
171
+ replies: [],
172
+ ...data,
173
+ };
174
+ });
175
+ return id;
176
+ }
177
+
178
+ reply(commentId, text) {
179
+ const now = Date.now();
180
+ this.mutate((s) => {
181
+ const c = s.comments[commentId];
182
+ if (!c) return;
183
+ c.replies = [...(c.replies || []), {
184
+ id: newId(),
185
+ user: this.identity.slug,
186
+ name: this.identity.name,
187
+ text,
188
+ createdAt: now,
189
+ }];
190
+ c.updatedAt = now;
191
+ });
192
+ }
193
+
194
+ setResolved(commentId, resolved) {
195
+ const now = Date.now();
196
+ this.mutate((s) => {
197
+ const c = s.comments[commentId];
198
+ if (!c) return;
199
+ c.resolvedAt = resolved ? now : null;
200
+ c.resolvedBy = resolved ? this.identity.slug : null;
201
+ c.updatedAt = now;
202
+ });
203
+ }
204
+ }