@idleware/ringi 0.1.1 → 0.2.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.
@@ -1,10 +1,13 @@
1
1
  // git-store: the Store port on git. The store directory holds one bare repository (`store/.git`):
2
2
  // refs/heads/main the shared contract, one commit per amendment
3
- // refs/heads/<room>/control the room's control record; its first commit is the room's genesis
4
- // refs/heads/<room>/log/<agent> one log per member; each message is a commit
5
- // refs/tags/ringi/<genesis>/<agent>/<message>.<signal> signals, annotated tags in their author's namespace
6
- // refs/archive/<room>@<genesis8>/… an archived room, read-only
7
- // refs/ringi/accepted/<genesis>/<reader>/<log> readers' pins of accepted heads
3
+ // refs/heads/<room> the room: one linear branch, like a chat. Its first commit is the room's genesis
4
+ // (the root's, on top of main); then every message is a commit by its author, and
5
+ // every control record (Kind: room) a commit by the root
6
+ // refs/tags/<agent>.<signal>.<message12> signals: annotated tags without text on the message's commit
7
+ // refs/archive/<room>@<genesis8> an archived room, read-only
8
+ // refs/ringi/accepted/<genesis>/<reader>/<author> readers' pins of each author's accepted last message
9
+ // A member's log is its own commits in the room's branch, in branch order. The control files at the root of a room's
10
+ // tree (members, profile, signers) change only in the root's control records.
8
11
  // A signed hub signs every commit and tag with its author's key and verifies what it fetches (remote.ts).
9
12
  import fs from 'node:fs';
10
13
  import path from 'node:path';
@@ -12,8 +15,8 @@ import crypto from 'node:crypto';
12
15
  import { spawnSync } from 'node:child_process';
13
16
  import { cannotWrite, mkdirIn, SANDBOX } from '../../lib/index.js';
14
17
  import * as Remote from './remote.js';
15
- import { fail, note } from '../../lib/index.js';
16
- import { TEXT_MAX, checkOutgoing, parse } from '../../protocol/index.js';
18
+ import { fail, locked, note, strictJson, writeJsonAtomic } from '../../lib/index.js';
19
+ import { AGENT_RE, ID_RE, TEXT_MAX, checkOutgoing, compose, parse } from '../../protocol/index.js';
17
20
  import { checkCollisions, checkPath } from '../../protocol/files.js';
18
21
  const ZERO = '0'.repeat(40);
19
22
  const ident = (id) => ({ GIT_AUTHOR_NAME: id, GIT_AUTHOR_EMAIL: `${id}@ringi`, GIT_COMMITTER_NAME: id, GIT_COMMITTER_EMAIL: `${id}@ringi` });
@@ -26,8 +29,8 @@ const runGitOk = (args, o = {}) => {
26
29
  Remote.checkGitTimeout(r.error);
27
30
  return r;
28
31
  };
29
- const runGitRaw = (args, where) => {
30
- const r = spawnSync('git', args, { encoding: 'buffer', env: Remote.gitEnv(), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
32
+ const runGitRaw = (args, where, input) => {
33
+ const r = spawnSync('git', args, { input, encoding: 'buffer', env: Remote.gitEnv(), maxBuffer: 1 << 30, timeout: Remote.gitTimeout() });
31
34
  return check(args, r, where).stdout;
32
35
  };
33
36
  function check(args, r, where = 'the hub store') {
@@ -53,6 +56,9 @@ const splitBuf = (b, sep) => {
53
56
  return parts;
54
57
  };
55
58
  const REGULAR = new Set(['100644', '100755']);
59
+ // The root's principal, and the files only its control records may change.
60
+ const ROOT = 'operator';
61
+ const CONTROL_FILES = new Set(['ROOM.md', 'members.json', 'profile.json', 'allowed_signers']);
56
62
  export class GitStore {
57
63
  type = 'git';
58
64
  trust;
@@ -81,14 +87,15 @@ export class GitStore {
81
87
  isRef(s) { return /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(s ?? ''); }
82
88
  extends(older, newer) { return older === newer || this.isAncestor(older, newer); }
83
89
  // --- writing ---
84
- emptyTree() { return this.git(['hash-object', '-w', '-t', 'tree', '--stdin'], { input: '' }).trim(); }
85
- // A tree from a base tree with entries put on top; nested paths are fine.
86
- tree(base, entries) {
90
+ // A tree from a base tree without the `drop` paths, with entries put on top; nested paths are fine.
91
+ tree(base, entries, drop = []) {
87
92
  const idx = path.join(this.home, 'tmp', `index.${process.pid}.${crypto.randomBytes(4).toString('hex')}`);
88
93
  mkdirIn(this.home, path.dirname(idx));
89
94
  const env = { GIT_INDEX_FILE: idx };
90
95
  try {
91
96
  this.git(['read-tree', ...(base ? [base] : ['--empty'])], { env });
97
+ if (drop.length)
98
+ this.git(['update-index', '--index-info'], { env, input: drop.map(p => `0 ${ZERO}\t${p}\n`).join('') });
92
99
  for (const e of entries) {
93
100
  const sha = this.git(['hash-object', '-w', ...(e.file ? ['--', e.file] : ['--stdin'])], { input: e.content }).trim();
94
101
  this.git(['update-index', '--add', '--cacheinfo', `${e.mode},${sha},${e.path}`], { env });
@@ -107,8 +114,7 @@ export class GitStore {
107
114
  if (this.gitOk(['update-ref', ref, sha, old ?? ZERO]).status !== 0)
108
115
  return false;
109
116
  // A signed hub publishes at once; a rejected push keeps the commit here and alarms (remote.ts).
110
- Remote.push(this.h, ref);
111
- return true;
117
+ return Remote.push(this.h, ref, { behind: !!old && ref !== 'refs/heads/main' });
112
118
  }
113
119
  static create(hubDir, d, home, contract, text) {
114
120
  const dir = path.join(hubDir, d.store.path, '.git');
@@ -150,31 +156,72 @@ export class GitStore {
150
156
  return sha;
151
157
  }
152
158
  // --- rooms ---
153
- base(name) { return name.includes('@') ? `refs/archive/${name}` : `refs/heads/${name}`; }
159
+ ref(name) { return name.includes('@') ? `refs/archive/${name}` : `refs/heads/${name}`; }
160
+ transition(name) { return `refs/ringi/migrating/${name}`; }
161
+ tip(name) { return this.rev(this.ref(name)) ?? this.rev(this.transition(name)); }
162
+ // Rooms in ringi 0.1's layout (`<room>/control` and a branch per agent) are listed, and refused when opened.
154
163
  roomNames(archived) {
155
- const re = archived ? /^refs\/archive\/([^/]+)\/control$/ : /^refs\/heads\/([^/]+)\/control$/;
156
- return this.git(['for-each-ref', '--format=%(refname)', archived ? 'refs/archive/' : 'refs/heads/']).split('\n').map(r => re.exec(r)?.[1]).filter((x) => !!x).sort();
164
+ const prefix = archived ? 'refs/archive/' : 'refs/heads/';
165
+ const names = this.git(['for-each-ref', '--format=%(refname)', prefix]).split('\n').filter(Boolean).map(r => r.slice(prefix.length))
166
+ .map(n => n.replace(/\/control$/, '')).filter(n => !n.includes('/') && n !== 'main' && n.includes('@') === archived);
167
+ return [...new Set(names)].sort();
168
+ }
169
+ // A control record: the room's genesis, or a root commit of Kind: room.
170
+ isControl(room, sha, author, text) {
171
+ return sha === room.genesis || (author === ROOT && parse(text, x => this.isRef(x)).kind === 'room');
172
+ }
173
+ // The room's branch, oldest first, from its genesis. The last scan of each room is kept and grown by what the tip
174
+ // added on top of it.
175
+ scanned = new Map();
176
+ entries(room, tip) {
177
+ const last = this.scanned.get(room.genesis);
178
+ if (last?.tip === tip)
179
+ return last.entries;
180
+ const added = last ? this.scan(room, tip, last.tip) : [];
181
+ const entries = last && added.length && added[0].parent === last.tip ? [...last.entries, ...added] : this.scan(room, tip);
182
+ const judged = this.judged(room, tip, entries);
183
+ this.scanned.set(room.genesis, { tip, entries: judged });
184
+ return judged;
185
+ }
186
+ // On a signed hub the branch's verification says which commits are control records, and why the others that are no
187
+ // valid message are skipped; a commit off the branch's first parents is never a message (remote.ts).
188
+ judged(room, tip, entries) {
189
+ if (this.trust !== 'signed' || room.archived)
190
+ return entries;
191
+ const b = Remote.branch(this.h, { name: room.name, ref: this.ref(room.name), genesis: room.genesis }, tip);
192
+ const control = new Set(b.control);
193
+ return entries.map(e => ({ ...e, control: control.has(e.sha),
194
+ skipped: control.has(e.sha) || b.authors.has(e.sha) ? undefined : b.skipped.get(e.sha) ?? 'not on the room\'s branch' }));
195
+ }
196
+ scan(room, tip, after) {
197
+ const shas = this.git(['rev-list', '--topo-order', '--reverse', tip, '--not', after ?? `${room.genesis}^@`]).trim().split('\n').filter(Boolean);
198
+ return Remote.readCommits(this.h, shas).map(c => {
199
+ const text = c.body.toString('utf8');
200
+ return { sha: c.sha, parent: c.parents[0], author: c.author, text, control: this.isControl(room, c.sha, c.author, text) };
201
+ });
157
202
  }
158
203
  room(name) {
159
- const control = `${this.base(name)}/control`;
160
- const head = this.rev(control);
161
- if (!head)
204
+ const ref = this.ref(name);
205
+ const tip = this.tip(name);
206
+ if (!tip && this.rev(`${ref}/control`))
207
+ fail(`room ${name} is in the layout of ringi 0.1; run ringi room migrate ${name} --hub ${this.d.name} to keep its history in one branch`);
208
+ if (!tip)
162
209
  return null;
163
- const genesis = this.git(['rev-list', '--reverse', control, '^refs/heads/main']).split('\n')[0]
164
- || this.git(['rev-list', '--reverse', control]).split('\n').filter(Boolean).at(-1);
165
- const recs = this.git(['log', '--reverse', '--format=%H%x1f%B%x1e', control, `^${genesis}^`]).split('\x1e').map(s => s.trim()).filter(Boolean);
166
- return { name, archived: name.includes('@'), head, genesis, base: this.git(['rev-parse', `${genesis}^`]).trim(),
167
- control: recs.map(r => {
168
- const [rev, text = ''] = r.split('\x1f');
169
- return { rev: rev.trim(), text };
170
- }) };
210
+ const genesis = this.git(['rev-list', '--first-parent', '--reverse', tip, '^refs/heads/main']).split('\n')[0]
211
+ || this.git(['rev-list', '--first-parent', '--reverse', tip]).split('\n').filter(Boolean).at(-1);
212
+ const archived = name.includes('@');
213
+ if (this.trust === 'signed' && !archived && this.h.remoteAlarms?.[ref])
214
+ fail(`ALARM ${this.h.remoteAlarms[ref]}; room ${name} is quarantined`);
215
+ const control = this.entries({ name, genesis, archived }, tip).filter(e => e.control).map(e => ({ rev: e.sha, text: e.text }));
216
+ return { name, archived, head: control.at(-1).rev, genesis, tip, base: this.rev(`${genesis}^`) ?? ZERO, control };
171
217
  }
218
+ // Control files are read at a control record, never at the branch's tip.
172
219
  controlFile(room, name, rev) {
173
220
  const r = this.gitOk(['show', `${rev ?? room.head}:${name}`]);
174
221
  return r.status === 0 ? r.stdout : null;
175
222
  }
176
223
  createRoom(name, who, text, files) {
177
- const ref = `refs/heads/${name}/control`;
224
+ const ref = `refs/heads/${name}`;
178
225
  if (this.rev(ref))
179
226
  fail(`room ${name} exists in hub ${this.d.name}`);
180
227
  const sha = this.commit(who, text, this.tree(null, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [this.contractHead()]);
@@ -182,54 +229,252 @@ export class GitStore {
182
229
  fail(`room ${name} was created meanwhile`);
183
230
  return sha;
184
231
  }
185
- // Every other file of the control tree (a signed room's allowed_signers) is kept.
232
+ // Old messages keep their ids and parents. One control record joins the old tips, then all new writes share the
233
+ // room's branch. The transition ref makes the room readable even if the process exits between the two ref
234
+ // transactions (Git cannot create <room> and delete <room>/control in the same transaction).
235
+ migrateRoom(name) {
236
+ if (this.trust !== 'asserted')
237
+ fail('legacy room migration is only supported for local asserted hubs');
238
+ if (!ID_RE.test(name) || name === 'main')
239
+ fail('expected a room id');
240
+ const ref = this.ref(name), pending = this.transition(name);
241
+ const source = this.rev(pending) ?? this.rev(ref) ?? this.rev(`${ref}/control`) ?? fail(`no legacy room ${name}`);
242
+ const genesis = this.git(['rev-list', '--first-parent', '--reverse', source, '^refs/heads/main']).trim().split('\n')[0];
243
+ const readers = path.join(this.home, 'readers', this.d.name, genesis);
244
+ const files = fs.existsSync(readers) ? fs.readdirSync(readers).filter(f => f.endsWith('.json')).sort() : [];
245
+ // Readers pin tag objects while holding these same locks. Keep their state stable across both ref transactions
246
+ // and the explicit conversion of their old accepted objects; no new baseline is supplied.
247
+ const protect = (at) => at === files.length ? migrate() : locked(path.join(this.home, 'locks', `reader-${this.d.name}-${genesis.slice(0, 12)}-${files[at].slice(0, -5)}.lock`), () => protect(at + 1));
248
+ const migrate = () => {
249
+ if (!genesis || this.git(['show', '-s', '--format=%s', genesis]).trim() !== `room ${name}`)
250
+ fail('legacy room genesis does not name this room');
251
+ const finish = (sha) => {
252
+ this.git(['update-ref', '--stdin'], { input: `start\ncreate ${ref} ${sha}\ndelete ${pending} ${sha}\nprepare\ncommit\n` });
253
+ return sha;
254
+ };
255
+ const staged = this.rev(pending);
256
+ const current = this.rev(ref);
257
+ if (staged || current) {
258
+ const tip = staged ?? current;
259
+ const tags = this.migrateSignals(name, genesis, [tip], files.map(f => path.join(readers, f)));
260
+ if (staged)
261
+ finish(staged);
262
+ tags();
263
+ return tip;
264
+ }
265
+ const ctl = `${ref}/control`, head = this.rev(ctl) ?? fail(`no legacy room ${name}`);
266
+ const logs = this.refsUnder(`${ref}/log/`);
267
+ for (const [r, sha] of [[ctl, head], ...logs]) {
268
+ const who = r === ctl ? ROOT : r.slice(`${ref}/log/`.length);
269
+ if (!ID_RE.test(who) || !this.extends(genesis, sha))
270
+ fail(`ALARM ${r} does not descend from its room genesis`);
271
+ const commits = this.git(['rev-list', '--parents', '--reverse', sha, `^${genesis}`]).trim().split('\n').filter(Boolean);
272
+ let parent = genesis;
273
+ for (const row of commits) {
274
+ const [id, ...parents] = row.split(' ');
275
+ if (parents.length !== 1 || parents[0] !== parent)
276
+ fail(`ALARM ${r} is not linear`);
277
+ if (this.git(['show', '-s', '--format=%an', id]).trim() !== who)
278
+ fail(`ALARM ${r} contains another author's message`);
279
+ parent = id;
280
+ }
281
+ }
282
+ // Accepted heads must still be present before any ref moves. Migration never supplies a fresh baseline.
283
+ const byAuthor = new Map(logs.map(([r, sha]) => [r.slice(`${ref}/log/`.length), sha]));
284
+ const checkAccepted = (who, sha) => {
285
+ const tip = byAuthor.get(who);
286
+ if (!tip || !this.extends(sha, tip))
287
+ fail(`ALARM log ${who} lost accepted history; migration stopped`);
288
+ };
289
+ for (const [r, sha] of this.refsUnder(`refs/ringi/accepted/${genesis}/`))
290
+ checkAccepted(r.split('/').at(-1), sha);
291
+ for (const f of files) {
292
+ const st = strictJson(fs.readFileSync(path.join(readers, f), 'utf8'), f);
293
+ if (st.control && !this.extends(st.control, head))
294
+ fail('ALARM room control lost accepted history; migration stopped');
295
+ for (const [who, sha] of Object.entries(st.heads ?? {}))
296
+ checkAccepted(who, sha);
297
+ }
298
+ const tags = this.migrateSignals(name, genesis, [head, ...logs.map(([, id]) => id)], files.map(f => path.join(readers, f)));
299
+ const sha = this.commit(ROOT, compose(`migrate room ${name}`, [['To', '*'], ['Kind', 'room'], ['Ringi-Layout', 'branch/1']]), this.git(['rev-parse', `${head}^{tree}`]).trim(), [...new Set([head, ...logs.map(([, id]) => id)])]);
300
+ const old = [[ctl, head], ...logs];
301
+ const backup = `refs/ringi/legacy/${genesis}/`;
302
+ this.git(['update-ref', '--stdin'], { input: ['start', `create ${pending} ${sha}`,
303
+ ...old.flatMap(([r, id]) => [`create ${backup}${r.slice(`${ref}/`.length)} ${id}`, `delete ${r} ${id}`]),
304
+ 'prepare', 'commit', ''].join('\n') });
305
+ finish(sha);
306
+ tags();
307
+ return sha;
308
+ };
309
+ mkdirIn(this.home, path.join(this.home, 'locks'));
310
+ return protect(0);
311
+ }
312
+ // An asserted hub can explicitly convert its old tag annotations, keeping their target, tagger and date. The
313
+ // original objects and reader snapshots remain evidence. Ref changes are atomic; a repeat resumes reader writes
314
+ // after a crash without replacing an accepted object that changed or disappeared in either format.
315
+ migrateSignals(room, genesis, tips, readers) {
316
+ const prefix = `refs/tags/ringi/${genesis}/`, backup = `refs/ringi/legacy/${genesis}/signals/`;
317
+ const old = new Map(this.refsUnder(prefix)), saved = new Map(this.refsUnder(backup));
318
+ const liveTags = new Map(this.refsUnder('refs/tags/'));
319
+ const refs = [];
320
+ const changes = new Map();
321
+ const identities = new Map();
322
+ const targets = new Set(this.git(['rev-list', ...tips, `^${genesis}`]).trim().split('\n').filter(Boolean));
323
+ const kept = (sha) => tips.some(tip => this.extends(sha, tip));
324
+ for (const [ref, sha] of this.refsUnder(`refs/ringi/accepted/${genesis}/`)) {
325
+ if (!kept(sha))
326
+ fail(`ALARM ${ref} lost accepted history; migration stopped`);
327
+ }
328
+ for (const suffix of new Set([...old.keys()].map(r => r.slice(prefix.length)).concat([...saved.keys()].map(r => r.slice(backup.length))))) {
329
+ const before = saved.get(backup + suffix) ?? old.get(prefix + suffix);
330
+ const liveOld = old.get(prefix + suffix), recorded = saved.get(backup + suffix);
331
+ if (recorded && liveOld && recorded !== liveOld)
332
+ fail(`ALARM signal ${prefix + suffix} changed after migration; stopped`);
333
+ const m = /^([^/]+)\/((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(suffix);
334
+ if (!m || !AGENT_RE.test(m[1]))
335
+ return fail(`ALARM signal ${prefix + suffix} cannot be migrated to an unambiguous agent id`);
336
+ const [, actor, target, signal] = m;
337
+ if (this.git(['cat-file', '-t', before]).trim() !== 'tag')
338
+ fail(`ALARM signal ${prefix + suffix} is not annotated`);
339
+ const text = this.git(['cat-file', 'tag', before]);
340
+ const split = text.indexOf('\n\n'), header = text.slice(0, split), body = text.slice(split + 2);
341
+ const h = /^object ([0-9a-f]+)\ntype commit\ntag ([^\n]+)\n(tagger ([^<\n]+) <[^\n]*> \d+ [+-]\d{4})$/.exec(header);
342
+ if (!h || h[1] !== target || h[2] !== (prefix + suffix).slice('refs/tags/'.length) || h[4].trim() !== actor || !targets.has(target))
343
+ return fail(`ALARM signal ${prefix + suffix} disagrees with its name, actor or room target`);
344
+ if (body.includes('-----BEGIN'))
345
+ fail('signed legacy signals need their authors to sign the new names; migration stopped');
346
+ const p = parse(body, s => this.isRef(s));
347
+ for (const [k, value] of [['room', room], ['genesis', genesis], ['actor', actor], ['message', target], ['signal', signal]]) {
348
+ if (p.trailers[k]?.length !== 1 || p.trailers[k][0] !== value)
349
+ fail(`ALARM signal ${prefix + suffix} has inconsistent legacy metadata`);
350
+ }
351
+ const key = `${actor}.${signal}.${target.slice(0, 12)}`, ref = `refs/tags/${key}`;
352
+ const after = this.git(['mktag'], { input: `object ${target}\ntype commit\ntag ${key}\n${h[3]}\n\n` }).trim();
353
+ const existing = changes.get(key), live = liveTags.get(ref);
354
+ if ((existing && existing.after !== after) || (live && live !== after))
355
+ fail(`ALARM signal ${key} collides with another tag; migration stopped`);
356
+ if (recorded && !live)
357
+ fail(`ALARM migrated signal ${key} was removed; migration stopped`);
358
+ changes.set(key, { before, after });
359
+ identities.set(before, { key, target, signal });
360
+ identities.set(after, { key, target, signal });
361
+ if (!recorded)
362
+ refs.push(`create ${backup + suffix} ${before}`);
363
+ if (!live)
364
+ refs.push(`create ${ref} ${after}`);
365
+ if (liveOld)
366
+ refs.push(`delete ${prefix + suffix} ${liveOld}`);
367
+ }
368
+ const planned = readers.map(file => {
369
+ const text = fs.readFileSync(file, 'utf8');
370
+ const st = strictJson(text, file);
371
+ if (typeof st.control === 'string' && !kept(st.control))
372
+ fail('ALARM room control lost accepted history; migration stopped');
373
+ for (const sha of Object.values(st.heads ?? {})) {
374
+ if (!kept(sha))
375
+ fail('ALARM room log lost accepted history; migration stopped');
376
+ }
377
+ const next = {};
378
+ for (const [original, object] of Object.entries(st.signals ?? {})) {
379
+ const m = /(?:^|\/)([^/]+)\/((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(original);
380
+ // An early reader shortened a full ref to its last component and lost the actor. Its pinned object still
381
+ // proves the identity: recover it only from an exact, verified old object retained by this migration.
382
+ const bare = /^((?:[0-9a-f]{40}|[0-9a-f]{64}))\.([a-z][a-z0-9.-]*)$/.exec(original);
383
+ const identity = bare ? identities.get(object) : undefined;
384
+ if (bare && (!identity || identity.target !== bare[1] || identity.signal !== bare[2]))
385
+ fail(`ALARM accepted signal ${original} lost its verified identity; migration stopped`);
386
+ const key = m ? `${m[1]}.${m[3]}.${m[2].slice(0, 12)}` : identity?.key ?? original;
387
+ const change = changes.get(key);
388
+ if (m && !change)
389
+ fail(`ALARM accepted signal ${original} is missing; migration stopped`);
390
+ if (!change && /^[a-z0-9]+(?:[_-][a-z0-9]+)*\.[a-z][a-z0-9.-]*\.[0-9a-f]{12}$/.test(key)
391
+ && liveTags.get(`refs/tags/${key}`) !== object)
392
+ fail(`ALARM accepted signal ${original} is missing or replaced; migration stopped`);
393
+ if (change && object !== change.before && object !== change.after)
394
+ fail(`ALARM accepted signal ${original} was replaced; migration stopped`);
395
+ const value = change?.after ?? object;
396
+ if (next[key] && next[key] !== value)
397
+ fail(`ALARM accepted signal ${key} has conflicting objects; migration stopped`);
398
+ next[key] = value;
399
+ }
400
+ return { file, text, st, next, changed: JSON.stringify(st.signals ?? {}) !== JSON.stringify(next) };
401
+ });
402
+ return () => {
403
+ if (refs.length)
404
+ this.git(['update-ref', '--stdin'], { input: ['start', ...refs, 'prepare', 'commit', ''].join('\n') });
405
+ for (const p of planned) {
406
+ if (!p.changed)
407
+ continue;
408
+ // Separate from the live readers directory so the snapshot is never mistaken for another reader.
409
+ const dir = path.join(this.home, 'migration', this.d.name, genesis, 'readers');
410
+ mkdirIn(this.home, dir);
411
+ const snapshot = path.join(dir, `${path.basename(p.file)}.${crypto.createHash('sha256').update(p.text).digest('hex')}`);
412
+ if (!fs.existsSync(snapshot))
413
+ fs.writeFileSync(snapshot, p.text, { flag: 'wx', mode: 0o600 });
414
+ else if (fs.readFileSync(snapshot, 'utf8') !== p.text)
415
+ fail('ALARM migration reader snapshot changed; migration stopped');
416
+ writeJsonAtomic(p.file, { ...p.st, signals: p.next });
417
+ }
418
+ };
419
+ }
420
+ // A control record on top of the branch: the control files come from the last control record, never from the tip,
421
+ // and the messages' files are kept.
186
422
  updateControl(room, who, text, files) {
187
- const ref = `${this.base(room.name)}/control`;
188
- const sha = this.commit(who, text, this.tree(`${room.head}^{tree}`, Object.entries(files).map(([p, c]) => ({ path: p, content: c, mode: '100644' }))), [room.head]);
189
- if (!this.advance(ref, sha, room.head))
190
- fail(`room ${room.name}: its control moved; retry`);
191
- return sha;
423
+ const kept = [...CONTROL_FILES].flatMap(n => { const c = this.controlFile(room, n); return c === null ? [] : [[n, c]]; });
424
+ const all = { ...Object.fromEntries(kept), ...files };
425
+ return this.onTop(room, who, text, Object.entries(all).map(([p, c]) => ({ path: p, content: c, mode: '100644' })), [...CONTROL_FILES]);
192
426
  }
193
427
  refsUnder(prefix) {
194
428
  return this.git(['for-each-ref', '--format=%(refname) %(objectname)', prefix]).split('\n').filter(Boolean).map(l => l.split(' '));
195
429
  }
430
+ // The branch moves under the archive; its signals stay where they are, on its commits.
196
431
  archiveRoom(room, as) {
197
- const moves = [[`${this.base(room.name)}/`, `refs/archive/${as}/`], [`refs/tags/ringi/${room.genesis}/`, `refs/archive/${as}/signals/`]];
198
- for (const [from, to] of moves) {
199
- for (const [ref, sha] of this.refsUnder(from)) {
200
- this.git(['update-ref', `${to}${ref.slice(from.length)}`, sha, ZERO]);
201
- this.git(['update-ref', '-d', ref, sha]);
202
- }
203
- }
432
+ const tip = this.rev(this.ref(room.name)) ?? fail(`room ${room.name} is gone`);
433
+ this.git(['update-ref', `refs/archive/${as}`, tip, ZERO]);
434
+ this.git(['update-ref', '-d', this.ref(room.name), tip]);
204
435
  }
205
436
  deleteRoom(room) {
206
- for (const prefix of [`${this.base(room.name)}/`, `refs/ringi/accepted/${room.genesis}/`, `refs/tags/ringi/${room.genesis}/`]) {
207
- for (const [ref] of this.refsUnder(prefix))
208
- this.git(['update-ref', '-d', ref]);
209
- }
437
+ const signals = this.signals(room);
438
+ this.gitOk(['update-ref', '-d', this.ref(room.name)]);
439
+ for (const [ref] of this.refsUnder(`refs/ringi/accepted/${room.genesis}/`))
440
+ this.git(['update-ref', '-d', ref]);
441
+ for (const s of signals)
442
+ this.gitOk(['update-ref', '-d', s.ref]);
210
443
  }
211
- legacy(room) {
212
- const base = this.base(room.name);
213
- return { name: room.name, head: room.head, genesis: room.genesis, archived: room.archived, control: `${base}/control`, log: (who) => `${base}/log/${who}` };
444
+ remote(room) {
445
+ return { name: room.name, ref: this.ref(room.name), head: room.head, genesis: room.genesis, archived: room.archived };
214
446
  }
215
447
  roster(room) {
216
448
  if (this.trust !== 'signed' || room.archived)
217
449
  return null;
218
- const v = Remote.verifyRoom(this.h, this.legacy(room));
450
+ const v = Remote.verifyRoom(this.h, this.remote(room));
219
451
  return { members: v.members, signers: v.entries };
220
452
  }
221
453
  // --- logs ---
222
- logIds(room) {
223
- const prefix = `${this.base(room.name)}/log/`;
224
- return this.git(['for-each-ref', '--format=%(refname)', prefix]).split('\n').filter(Boolean).map(r => r.slice(prefix.length));
454
+ // Logs are read at the branch's live tip, as a reader sees the room now.
455
+ live(room) {
456
+ const tip = this.tip(room.name);
457
+ return tip ? this.entries(room, tip).filter(e => !e.control && !e.skipped) : [];
458
+ }
459
+ // What a signed hub skips in the room's branch, as alarms; the room reads on without it.
460
+ alarms(room) {
461
+ const tip = this.tip(room.name);
462
+ return tip ? this.entries(room, tip).filter(e => e.skipped).map(e => `commit ${e.sha.slice(0, 12)} by ${e.author || '(none)'}: ${e.skipped}`) : [];
225
463
  }
226
- logHead(room, id) { return this.rev(`${this.base(room.name)}/log/${id}`); }
227
- // The log's commits after the genesis, oldest first, read as bytes: the text is the commit message, the files are
228
- // the explicit Ringi-Files list, or the changed paths for records without it.
464
+ logIds(room) { return [...new Set(this.live(room).map(e => e.author))]; }
465
+ logHead(room, id) { return this.live(room).filter(e => e.author === id).at(-1)?.sha ?? null; }
466
+ // One author's messages after the genesis up to `head`, oldest first, read as bytes: the text is the commit message,
467
+ // the files are the explicit Ringi-Files list, or the paths the commit adds or changes for records without it.
229
468
  messages(room, id, head) {
230
- const raw = runGitRaw(this.args(['log', '--topo-order', '--reverse', '--no-renames', '--raw', '--no-abbrev', '--format=%x1e%H%x1f%P%x1f%ct%x1f%B%x1f', head, `^${room.genesis}`]), this.home);
231
- return splitBuf(raw, 0x1e).filter(b => b.length).map(rec => {
232
- const [sha, parent, ct, body, rest = Buffer.alloc(0)] = splitBuf(rec, 0x1f);
469
+ const tip = this.trust === 'signed' ? this.tip(room.name) : null;
470
+ const skipped = new Set(tip ? this.entries(room, tip).filter(e => e.skipped).map(e => e.sha) : []);
471
+ const raw = runGitRaw(this.args(['log', '--topo-order', '--reverse', '--no-renames', '--raw', '--no-abbrev', '--format=%x1e%H%x1f%P%x1f%ct%x1f', head, `^${room.genesis}`]), this.home);
472
+ const records = splitBuf(raw, 0x1e).filter(b => b.length).map(rec => splitBuf(rec, 0x1f));
473
+ const commits = new Map(Remote.readCommits(this.h, records.map(([sha]) => sha.toString())).map(c => [c.sha, c]));
474
+ return records.flatMap(([sha, parent, ct, rest = Buffer.alloc(0)]) => {
475
+ const { author, body } = commits.get(sha.toString());
476
+ if (author !== id || this.isControl(room, sha.toString(), id, body.toString('utf8')) || skipped.has(sha.toString()))
477
+ return [];
233
478
  let files = rest.toString('utf8').split('\n').map(l => /^:\d{6} (\d{6}) \S+ \S+ ([A-Z])\d*\t(.*)$/.exec(l)).filter((x) => !!x)
234
479
  .filter(([, , status]) => status !== 'D').map(([, mode, , p]) => ({ path: p, mode }));
235
480
  const declared = parse(body.toString('utf8'), s => this.isRef(s)).trailers['ringi-files'];
@@ -258,34 +503,42 @@ export class GitStore {
258
503
  for (const p of wanted)
259
504
  files.push({ path: p, mode: 'missing' });
260
505
  }
261
- return { id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
262
- files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] };
506
+ return [{ id: sha.toString(), parent: parent.toString() || null, time: Number(ct.toString()), text: body,
507
+ files: files.filter(f => REGULAR.has(f.mode)), refused: [...refused, ...files.filter(f => !REGULAR.has(f.mode))] }];
263
508
  });
264
509
  }
265
- // One commit on the author's log, compare-and-swap, so two writers of one log retry instead of forking it.
510
+ // One message on top of the room's branch.
266
511
  append(room, who, text, entries) {
267
512
  if (room.archived)
268
513
  fail(`room ${room.name} is archived: read-only`);
269
514
  if (checkOutgoing(text, s => this.isRef(s)).trailers['ringi-files'])
270
515
  fail('Ringi-Files is store metadata; supply attachments instead');
516
+ const own = entries.find(e => CONTROL_FILES.has(e.path));
517
+ if (own)
518
+ fail(`${own.path} is the room's own file; attach it under another path`);
271
519
  if (entries.length)
272
520
  text = `${text.trimEnd()}\nRingi-Files: ${JSON.stringify([...new Set(entries.map(e => e.path))])}\n`;
273
521
  if (Buffer.byteLength(text) > TEXT_MAX)
274
522
  fail('attachment list makes the message exceed 64 KiB; use fewer or shorter paths');
275
523
  checkOutgoing(text, s => this.isRef(s));
276
- const ref = `${this.base(room.name)}/log/${who}`;
524
+ return this.onTop(room, who, text, entries);
525
+ }
526
+ // A commit on the branch's tip, compare-and-swap: when another writer got there first, it is made again on the new tip.
527
+ onTop(room, who, text, entries, drop = []) {
528
+ const ref = this.ref(room.name);
277
529
  for (let i = 0; i < 8; i++) {
278
- const head = this.rev(ref);
279
- const base = head ? `${head}^{tree}` : null;
280
- const tree = entries.length ? this.tree(base, entries) : base ? this.git(['rev-parse', base]).trim() : this.emptyTree();
281
- const sha = this.commit(who, text, tree, [head ?? room.genesis]);
282
- if (this.advance(ref, sha, head))
530
+ const tip = this.rev(ref) ?? fail(`room ${room.name} is gone`);
531
+ if (drop.length && this.room(room.name)?.head !== room.head)
532
+ fail('room control moved while writing; retry the operation');
533
+ const tree = entries.length || drop.length ? this.tree(`${tip}^{tree}`, entries, drop) : this.git(['rev-parse', `${tip}^{tree}`]).trim();
534
+ const sha = this.commit(who, text, tree, [tip]);
535
+ if (this.advance(ref, sha, tip))
283
536
  return sha;
284
537
  }
285
- return fail(`the log ${who} kept moving while writing; retry`);
538
+ return fail(`room ${room.name} kept moving while writing; retry`);
286
539
  }
287
540
  verifyLog(room, id, head) {
288
- return this.trust === 'signed' ? Remote.verify(this.h, this.legacy(room), id, head) : null;
541
+ return this.trust === 'signed' ? Remote.verify(this.h, this.remote(room), id, head) : null;
289
542
  }
290
543
  pin(room, reader, id, rev) {
291
544
  if (!room.archived)
@@ -304,10 +557,10 @@ export class GitStore {
304
557
  .filter(([, , p]) => wanted.has(p)).map(([, n, p]) => [p, Number(n)]));
305
558
  }
306
559
  // --- signals ---
307
- // An annotated tag in its author's namespace under the room's genesis (a room recreated under the same name is
308
- // another namespace), signed on a signed hub. Writing the same signal twice is a no-op.
309
- signal(room, who, target, name, text) {
310
- const tag = `ringi/${room.genesis}/${who}/${name}`;
560
+ // An annotated tag without text in its author's namespace, on the message's commit, signed on a signed hub. The
561
+ // message's id in the name keeps it unique, so writing the same signal twice is a no-op.
562
+ signal(room, who, target, name) {
563
+ const tag = `${who}.${name}`;
311
564
  const ref = `refs/tags/${tag}`;
312
565
  if (this.gitOk(['rev-parse', '-q', '--verify', ref]).status === 0) {
313
566
  Remote.push(this.h, ref);
@@ -315,31 +568,68 @@ export class GitStore {
315
568
  }
316
569
  const sign = Remote.sign(this.h, who, this.home);
317
570
  const key = sign[0]?.slice(2);
318
- this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input: text, env: ident(who) });
571
+ this.git([...(key ? ['-c', `user.signingkey=${key}`] : []), 'tag', key ? '-s' : '-a', '-F', '-', tag, target], { input: '', env: ident(who) });
319
572
  Remote.push(this.h, ref);
320
573
  }
574
+ // The tags named `<actor>.<signal>.<message12>`, with the objects they name.
575
+ tags() {
576
+ return this.git(['for-each-ref', '--format=%(refname) %(objectname) %(objecttype)', 'refs/tags/']).split('\n').flatMap(line => {
577
+ const [ref, object, type] = line.split(' ');
578
+ const m = /^refs\/tags\/([a-z0-9]+(?:[_-][a-z0-9]+)*)\.([a-z][a-z0-9.-]*\.[0-9a-f]{12})$/.exec(ref ?? '');
579
+ return m ? [{ ref, actor: m[1], name: m[2], object, type }] : [];
580
+ });
581
+ }
582
+ // Annotated tag objects read whole, size-framed: their target, tagger and text are never split on bytes they hold.
583
+ tagObjects(shas) {
584
+ const out = new Map();
585
+ if (!shas.length)
586
+ return out;
587
+ const raw = runGitRaw(this.args(['cat-file', '--batch']), this.home, Buffer.from(shas.join('\n') + '\n'));
588
+ let at = 0;
589
+ for (const sha of shas) {
590
+ const nl = raw.indexOf(0x0a, at);
591
+ const [got, type, size] = raw.subarray(at, nl).toString('utf8').split(' ');
592
+ const body = raw.subarray(nl + 1, nl + 1 + Number(size));
593
+ at = nl + 1 + Number(size) + 1;
594
+ if (got !== sha || type !== 'tag')
595
+ continue;
596
+ const split = body.indexOf('\n\n');
597
+ const hdr = body.subarray(0, split < 0 ? body.length : split).toString('utf8');
598
+ const text = split < 0 ? '' : body.subarray(split + 2).toString('utf8').split('-----BEGIN SSH SIGNATURE-----')[0];
599
+ const target = /^object ([0-9a-f]+)\ntype commit$/m.exec(hdr)?.[1] ?? '';
600
+ out.set(sha, { target, tagger: /^tagger (.*?) <[^>\n]*> \d+ [+-]\d{4}$/m.exec(hdr)?.[1] ?? '', text });
601
+ }
602
+ return out;
603
+ }
604
+ // Every commit of the room's branch: a signal belongs to the room whose branch holds its message.
605
+ commitsOf(room) {
606
+ const tip = this.tip(room.name);
607
+ return new Set(tip ? this.entries(room, tip).map(e => e.sha) : []);
608
+ }
321
609
  signals(room) {
322
- const prefix = room.archived ? `refs/archive/${room.name}/signals/` : `refs/tags/ringi/${room.genesis}/`;
323
- const raw = this.git(['for-each-ref', '--format=%(refname)%1f%(objectname)%1f%(objecttype)%1f%(*objectname)%1f%(taggername)%1f%(contents)%1e', prefix]);
324
- const records = raw.split('\x1e').map(s => s.replace(/^\n/, '')).filter(Boolean).map(rec => {
325
- const [ref, object, type, target, tagger, text = ''] = rec.split('\x1f');
326
- const [actor, ...rest] = ref.slice(prefix.length).split('/');
610
+ const tags = this.tags(), objects = this.tagObjects(tags.filter(t => t.type === 'tag').map(t => t.object));
611
+ const mine = this.commitsOf(room);
612
+ const records = tags.flatMap(t => {
613
+ const o = objects.get(t.object);
327
614
  // Only an annotated tag on a commit is a signal; anything else points nowhere.
328
- return { ref, object, name: rest.join('/'), actor, target: type === 'tag' ? target : '', tagger, text };
615
+ if (!mine.has(o?.target || t.object))
616
+ return [];
617
+ return [{ ref: t.ref, object: t.object, name: t.name, actor: t.actor, target: o?.target ?? '', tagger: o?.tagger ?? '', text: o?.text ?? '' }];
329
618
  });
330
619
  if (this.trust !== 'signed' || room.archived)
331
620
  return records;
332
621
  return Remote.withVerification(this.h, () => records.filter(s => {
333
- const why = Remote.verifySignal(this.h, this.legacy(room), s.ref, s.object);
622
+ const why = Remote.verifySignal(this.h, this.remote(room), s.ref, s.object);
334
623
  if (why)
335
624
  note(`ALARM signal ${s.ref}: ${why}; quarantined and withheld`);
336
625
  return !why;
337
626
  }));
338
627
  }
339
628
  // --- signed stores ---
340
- admit(room, who, key) { return Remote.roomAdmit(this.h, this.legacy(room), who, key, this.home); }
341
- joinKey(room, who) { return Remote.join(this.h, this.legacy(room), who, this.home); }
342
- forgetLog(room, id) { Remote.forget(this.h, this.legacy(room), id); }
629
+ admit(room, who, key) { return Remote.roomAdmit(this.h, this.remote(room), who, key, this.home); }
630
+ joinKey(room, who) { return Remote.join(this.h, this.remote(room), who, this.home); }
631
+ // A room's transport alarm is the room's, whichever author the operator forgets.
632
+ forgetLog(room, _id) { Remote.forget(this.h, this.remote(room)); }
343
633
  rootKey() { return this.d.root ?? fail(`hub ${this.d.name} has no pinned root`); }
344
634
  rootRoster() { return Remote.rootRoster(this.h); }
345
635
  }