@frehilm/ordna-core 0.1.4 → 0.2.1

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 (65) hide show
  1. package/README.md +34 -0
  2. package/dist/config.d.ts +26 -0
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +41 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/git.d.ts +10 -0
  7. package/dist/git.d.ts.map +1 -1
  8. package/dist/git.js +14 -27
  9. package/dist/git.js.map +1 -1
  10. package/dist/index.d.ts +3 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/schema.d.ts +18 -1
  15. package/dist/schema.d.ts.map +1 -1
  16. package/dist/schema.js.map +1 -1
  17. package/dist/storage/auto-detect.d.ts +82 -0
  18. package/dist/storage/auto-detect.d.ts.map +1 -0
  19. package/dist/storage/auto-detect.js +158 -0
  20. package/dist/storage/auto-detect.js.map +1 -0
  21. package/dist/storage/auto-push.d.ts +36 -0
  22. package/dist/storage/auto-push.d.ts.map +1 -0
  23. package/dist/storage/auto-push.js +100 -0
  24. package/dist/storage/auto-push.js.map +1 -0
  25. package/dist/storage/backend.d.ts +86 -0
  26. package/dist/storage/backend.d.ts.map +1 -0
  27. package/dist/storage/backend.js +14 -0
  28. package/dist/storage/backend.js.map +1 -0
  29. package/dist/storage/backends/file.d.ts +31 -0
  30. package/dist/storage/backends/file.d.ts.map +1 -0
  31. package/dist/storage/backends/file.js +213 -0
  32. package/dist/storage/backends/file.js.map +1 -0
  33. package/dist/storage/backends/hybrid.d.ts +39 -0
  34. package/dist/storage/backends/hybrid.d.ts.map +1 -0
  35. package/dist/storage/backends/hybrid.js +270 -0
  36. package/dist/storage/backends/hybrid.js.map +1 -0
  37. package/dist/storage/backends/namespace.d.ts +55 -0
  38. package/dist/storage/backends/namespace.d.ts.map +1 -0
  39. package/dist/storage/backends/namespace.js +907 -0
  40. package/dist/storage/backends/namespace.js.map +1 -0
  41. package/dist/storage/file-io.d.ts +38 -0
  42. package/dist/storage/file-io.d.ts.map +1 -0
  43. package/dist/storage/file-io.js +69 -0
  44. package/dist/storage/file-io.js.map +1 -0
  45. package/dist/storage/git-ref.d.ts +77 -0
  46. package/dist/storage/git-ref.d.ts.map +1 -0
  47. package/dist/storage/git-ref.js +184 -0
  48. package/dist/storage/git-ref.js.map +1 -0
  49. package/dist/storage/markdown.d.ts +17 -0
  50. package/dist/storage/markdown.d.ts.map +1 -0
  51. package/dist/storage/markdown.js +17 -0
  52. package/dist/storage/markdown.js.map +1 -0
  53. package/dist/storage/sync-ref.d.ts +82 -0
  54. package/dist/storage/sync-ref.d.ts.map +1 -0
  55. package/dist/storage/sync-ref.js +191 -0
  56. package/dist/storage/sync-ref.js.map +1 -0
  57. package/dist/store.d.ts +56 -8
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/store.js +93 -115
  60. package/dist/store.js.map +1 -1
  61. package/dist/watcher.d.ts +33 -1
  62. package/dist/watcher.d.ts.map +1 -1
  63. package/dist/watcher.js +12 -30
  64. package/dist/watcher.js.map +1 -1
  65. package/package.json +1 -1
@@ -0,0 +1,907 @@
1
+ import { parseId } from "../../ids.js";
2
+ import { ARCHIVED_STATUS, isKnownStatus, } from "../backend.js";
3
+ import { GitRunner } from "../git-ref.js";
4
+ import { defaultSectionsFor, parseTaskBytes, serializeTask, } from "../markdown.js";
5
+ import { SyncRef } from "../sync-ref.js";
6
+ const REF_PREFIX = "refs/ordna/tasks/";
7
+ const TASK_REF_PATTERN = `${REF_PREFIX}*`;
8
+ const NAMESPACE_FETCH_REFSPEC = `+${REF_PREFIX}*:${REF_PREFIX}*`;
9
+ const STATE_REF_NAME = "refs/ordna/state";
10
+ const STATE_PUSH_REFSPEC = `+${STATE_REF_NAME}:${STATE_REF_NAME}`;
11
+ const STATE_FETCH_REFSPEC = `+${STATE_REF_NAME}:${STATE_REF_NAME}`;
12
+ const PUSH_DEBOUNCE_MS = 50;
13
+ // Sentinel refname used internally to schedule the state ref push.
14
+ const STATE_PUSH_SENTINEL = STATE_REF_NAME;
15
+ function today() {
16
+ return new Date().toISOString().slice(0, 10);
17
+ }
18
+ function nowIso() {
19
+ return new Date().toISOString();
20
+ }
21
+ function refnameFor(id) {
22
+ return `${REF_PREFIX}${id}`;
23
+ }
24
+ function idFromRefname(refname) {
25
+ if (!refname.startsWith(REF_PREFIX))
26
+ return null;
27
+ const id = refname.slice(REF_PREFIX.length);
28
+ return id.length > 0 ? id : null;
29
+ }
30
+ function isCASConflict(err) {
31
+ if (!(err instanceof Error))
32
+ return false;
33
+ const msg = err.message.toLowerCase();
34
+ return (msg.includes("update-ref") &&
35
+ (msg.includes("cannot lock ref") ||
36
+ msg.includes("is at") ||
37
+ msg.includes("expected") ||
38
+ msg.includes("missing")));
39
+ }
40
+ /**
41
+ * Heuristic: detect a push rejection from the thrown git error. Covers
42
+ * the three flavours we care about:
43
+ * - `[rejected] ... (stale info)` — `--force-with-lease` denied
44
+ * - `[rejected] ... (non-fast-forward)` — plain refused update
45
+ * - `[rejected] ... (fetch first)` — same family
46
+ *
47
+ * Network failures and auth errors fall through to the generic logger.
48
+ */
49
+ function isPushRejection(err) {
50
+ if (!(err instanceof Error))
51
+ return false;
52
+ const msg = err.message.toLowerCase();
53
+ return (msg.includes("rejected") ||
54
+ msg.includes("stale info") ||
55
+ msg.includes("non-fast-forward") ||
56
+ msg.includes("fetch first"));
57
+ }
58
+ /**
59
+ * Namespace storage backend.
60
+ *
61
+ * Tasks live as git **blobs** under `refs/ordna/tasks/<id>` — one ref
62
+ * per task, no working-tree files. `git status` stays clean. `git log`
63
+ * on branches doesn't see task mutations.
64
+ *
65
+ * **ID allocation.** A shared `refs/ordna/state` ref carries a
66
+ * `SyncRef`-managed JSON blob `{next_id, ops}`. Same primitive as
67
+ * hybrid: CAS in-process, auto-fetch-and-retry on conflict.
68
+ *
69
+ * **Bootstrap.** On `init()` if the state ref is missing, we scan
70
+ * existing `refs/ordna/tasks/*` and seed `next_id` from the max
71
+ * numeric id. Safe across concurrent processes (CAS).
72
+ *
73
+ * **Sync.** Every mutation schedules a per-ref push with
74
+ * `--force-with-lease` (per-ref CAS at the protocol level). On a
75
+ * rejected `create` (offline collision), the backend fetches,
76
+ * reallocates a fresh id via `SyncRef`, rewrites the local blob's
77
+ * `id:` field, cascades the rewrite through any local `depends_on`
78
+ * references to the old id, and emits a `renamed` event. Update
79
+ * collisions are deliberately loud — silently picking a winner would
80
+ * lose user edits.
81
+ *
82
+ * **Auto-fetch.** A configurable timer (default 60s) keeps the local
83
+ * snapshot fresh without manual pulls. A `fetch()` method exposes it
84
+ * manually for the TUI key / web button.
85
+ *
86
+ * **Audit log.** The `ops` array in the state blob records every
87
+ * `create`/`update`/`archive`/`delete`/`rename`. `rename` entries
88
+ * carry `renamedFrom` so the UIs can show a "previously known as X"
89
+ * banner on the affected task.
90
+ */
91
+ export class NamespaceBackend {
92
+ cwd;
93
+ config;
94
+ kind = "namespace";
95
+ #initPromise = null;
96
+ #git;
97
+ #sync = null;
98
+ #cachedActor = null;
99
+ // Push pipeline (replaces the simple PushQueue used in earlier T-032).
100
+ #pendingPushes = new Map();
101
+ #pushTimer = null;
102
+ #pushInFlight = null;
103
+ #pushRetryPending = false;
104
+ // Watcher (poll-based, refs have no kernel-level change notification).
105
+ #pollTimer = null;
106
+ #listeners = new Set();
107
+ #lastSnapshot = new Map();
108
+ #pollIntervalMs;
109
+ // Auto-fetch (60s by default; 0 disables).
110
+ #autoFetchIntervalMs;
111
+ #autoFetchTimer = null;
112
+ #autoFetchInFlight = null;
113
+ #remoteChecked = false;
114
+ #remoteExists = false;
115
+ #disposed = false;
116
+ #autoRenumberOnConflict;
117
+ constructor(cwd, config) {
118
+ this.cwd = cwd;
119
+ this.config = config;
120
+ this.#git = new GitRunner(cwd);
121
+ this.#pollIntervalMs = config.namespace?.pollIntervalMs ?? 1000;
122
+ this.#autoFetchIntervalMs = config.namespace?.autoFetchIntervalMs ?? 60000;
123
+ this.#autoRenumberOnConflict =
124
+ config.namespace?.autoRenumberOnConflict ?? true;
125
+ }
126
+ async init() {
127
+ await this.#git.ensureRepository();
128
+ this.#sync = new SyncRef(this.#git, STATE_REF_NAME);
129
+ await this.#bootstrapStateIfMissing();
130
+ if (this.#autoFetchIntervalMs > 0) {
131
+ this.#scheduleAutoFetch();
132
+ }
133
+ }
134
+ async #bootstrapStateIfMissing() {
135
+ const sync = this.#sync;
136
+ // Compute the high-water mark from existing task refs so an upgrade
137
+ // from a pre-state-ref namespace install (or a fresh clone before
138
+ // the state ref was pushed) gets the correct next_id.
139
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
140
+ let maxNumeric = 0;
141
+ for (const { refname } of refs) {
142
+ const id = idFromRefname(refname);
143
+ if (!id)
144
+ continue;
145
+ const n = parseId(this.config, id);
146
+ if (n !== null && n > maxNumeric)
147
+ maxNumeric = n;
148
+ }
149
+ // ensureInitialized only writes if the state ref doesn't yet exist
150
+ // (CAS with empty expected). If two processes race, one wins and
151
+ // the other adopts.
152
+ await sync.ensureInitialized({
153
+ next_id: maxNumeric + 1,
154
+ ops: [],
155
+ });
156
+ }
157
+ async #ensureInit() {
158
+ if (!this.#initPromise)
159
+ this.#initPromise = this.init();
160
+ return this.#initPromise;
161
+ }
162
+ async dispose() {
163
+ this.#disposed = true;
164
+ if (this.#pollTimer) {
165
+ clearTimeout(this.#pollTimer);
166
+ this.#pollTimer = null;
167
+ }
168
+ if (this.#autoFetchTimer) {
169
+ clearTimeout(this.#autoFetchTimer);
170
+ this.#autoFetchTimer = null;
171
+ }
172
+ if (this.#autoFetchInFlight) {
173
+ try {
174
+ await this.#autoFetchInFlight;
175
+ }
176
+ catch {
177
+ // already logged in #runAutoFetch
178
+ }
179
+ }
180
+ // Flush any pending pushes so the last mutation lands on origin
181
+ // before the process exits. flushPushes cancels the debounce
182
+ // timer itself; we leave #pushTimer alone here so it can hand
183
+ // the pending batch to the flusher.
184
+ await this.#flushPushes();
185
+ this.#listeners.clear();
186
+ this.#lastSnapshot.clear();
187
+ }
188
+ // ---------------- reads ----------------
189
+ async list(options = {}) {
190
+ await this.#ensureInit();
191
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
192
+ const renamedMap = await this.#buildRenamedFromMap();
193
+ const tasks = [];
194
+ for (const { refname, oid } of refs) {
195
+ const id = idFromRefname(refname);
196
+ if (!id)
197
+ continue;
198
+ try {
199
+ const raw = await this.#git.catBlob(oid);
200
+ const task = parseTaskBytes(raw, `ref:${refname}`);
201
+ // filePath is set by parseTaskBytes to the synthetic
202
+ // `ref:` value — strip so consumers see undefined and
203
+ // take the "no on-disk file" branch.
204
+ delete task.filePath;
205
+ const renamedFrom = renamedMap.get(id);
206
+ if (renamedFrom)
207
+ task.renamed_from = renamedFrom;
208
+ tasks.push(task);
209
+ }
210
+ catch {
211
+ // Skip unreadable / malformed blobs silently.
212
+ }
213
+ }
214
+ let filtered = tasks;
215
+ if (options.status)
216
+ filtered = filtered.filter((t) => t.status === options.status);
217
+ if (options.assignee)
218
+ filtered = filtered.filter((t) => t.assignee === options.assignee);
219
+ if (options.tag) {
220
+ const tag = options.tag;
221
+ filtered = filtered.filter((t) => t.tags.includes(tag));
222
+ }
223
+ filtered.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
224
+ return filtered;
225
+ }
226
+ async get(id) {
227
+ await this.#ensureInit();
228
+ const refname = refnameFor(id);
229
+ const refs = await this.#git.forEachRef(refname);
230
+ const entry = refs.find((r) => r.refname === refname);
231
+ if (!entry)
232
+ return null;
233
+ try {
234
+ const raw = await this.#git.catBlob(entry.oid);
235
+ const task = parseTaskBytes(raw, `ref:${refname}`);
236
+ delete task.filePath;
237
+ const renamedFrom = await this.#lookupRenamedFrom(id);
238
+ if (renamedFrom)
239
+ task.renamed_from = renamedFrom;
240
+ return task;
241
+ }
242
+ catch {
243
+ return null;
244
+ }
245
+ }
246
+ // ---------------- writes ----------------
247
+ async create(input) {
248
+ await this.#ensureInit();
249
+ const sync = this.#sync;
250
+ const status = input.status ?? this.config.statuses[0];
251
+ if (!status)
252
+ throw new Error("Config has no statuses defined.");
253
+ if (!isKnownStatus(this.config, status)) {
254
+ throw new Error(`Status "${status}" is not in configured statuses.`);
255
+ }
256
+ // Allocate via SyncRef — CAS-retries on conflict, auto-fetches the
257
+ // state ref from origin before retry.
258
+ const id = await sync.allocateNextId(this.config);
259
+ const now = today();
260
+ const task = {
261
+ id,
262
+ title: input.title,
263
+ status,
264
+ assignee: input.assignee ?? null,
265
+ priority: input.priority ?? null,
266
+ tags: input.tags ?? [],
267
+ depends_on: input.depends_on ?? [],
268
+ created_at: now,
269
+ updated_at: now,
270
+ sections: defaultSectionsFor(this.config.schema),
271
+ extra_frontmatter: {},
272
+ rawContent: "",
273
+ };
274
+ const serialized = serializeTask(task, this.config.schema);
275
+ task.rawContent = serialized;
276
+ // hash-object first — blob is durable in .git/objects/ from here;
277
+ // any subsequent failure leaves an orphan, recoverable until the
278
+ // next `git gc --prune`.
279
+ const newOid = await this.#git.hashObject(serialized);
280
+ // CAS update-ref with empty expected-old. Belt-and-braces: SyncRef
281
+ // just handed us a fresh id, so a local collision means the state
282
+ // ref is out of sync with reality. We surface that loudly rather
283
+ // than silently mask it.
284
+ try {
285
+ await this.#git.updateRef(refnameFor(id), newOid, "");
286
+ }
287
+ catch (err) {
288
+ if (isCASConflict(err)) {
289
+ throw new Error(`ordna: ${id} already exists locally despite a fresh allocation. State ref may be out of sync — try \`git update-ref -d ${STATE_REF_NAME}\` and retry; the next init() will reseed from existing task refs.`);
290
+ }
291
+ throw err;
292
+ }
293
+ await sync.appendOp(await this.#buildOp("create", id));
294
+ this.#schedulePush({
295
+ refname: refnameFor(id),
296
+ newOid,
297
+ expectedOld: "",
298
+ isCreate: true,
299
+ });
300
+ this.#schedulePushState();
301
+ return task;
302
+ }
303
+ async update(id, patch) {
304
+ await this.#ensureInit();
305
+ const sync = this.#sync;
306
+ const refname = refnameFor(id);
307
+ const refs = await this.#git.forEachRef(refname);
308
+ const entry = refs.find((r) => r.refname === refname);
309
+ if (!entry)
310
+ throw new Error(`Task ${id} not found.`);
311
+ const currentOid = entry.oid;
312
+ const raw = await this.#git.catBlob(currentOid);
313
+ const existing = parseTaskBytes(raw, `ref:${refname}`);
314
+ delete existing.filePath;
315
+ const next = {
316
+ ...existing,
317
+ title: patch.title ?? existing.title,
318
+ status: patch.status ?? existing.status,
319
+ assignee: patch.assignee !== undefined ? patch.assignee : existing.assignee,
320
+ priority: patch.priority !== undefined ? patch.priority : existing.priority,
321
+ tags: patch.tags ?? existing.tags,
322
+ depends_on: patch.depends_on ?? existing.depends_on,
323
+ sections: patch.sections ?? existing.sections,
324
+ updated_at: today(),
325
+ };
326
+ if (next.status !== existing.status &&
327
+ !isKnownStatus(this.config, next.status)) {
328
+ throw new Error(`Status "${next.status}" is not in configured statuses.`);
329
+ }
330
+ const serialized = serializeTask(next, this.config.schema);
331
+ next.rawContent = serialized;
332
+ const newOid = await this.#git.hashObject(serialized);
333
+ try {
334
+ await this.#git.updateRef(refname, newOid, currentOid);
335
+ }
336
+ catch (err) {
337
+ if (isCASConflict(err)) {
338
+ throw new Error(`ordna: ${id} moved underneath us. Another writer updated this task between our read and write; pull (\`git fetch origin '+${refname}:${refname}'\`) and retry.`);
339
+ }
340
+ throw err;
341
+ }
342
+ // Light op classification: archive transitions get their own kind;
343
+ // everything else is a generic update. Mirrors hybrid.
344
+ const opKind = patch.status === ARCHIVED_STATUS ? "archive" : "update";
345
+ await sync.appendOp(await this.#buildOp(opKind, id));
346
+ this.#schedulePush({
347
+ refname,
348
+ newOid,
349
+ expectedOld: currentOid,
350
+ isCreate: false,
351
+ });
352
+ this.#schedulePushState();
353
+ return next;
354
+ }
355
+ async delete(id) {
356
+ await this.#ensureInit();
357
+ const sync = this.#sync;
358
+ const refname = refnameFor(id);
359
+ const refs = await this.#git.forEachRef(refname);
360
+ const entry = refs.find((r) => r.refname === refname);
361
+ if (!entry)
362
+ throw new Error(`Task ${id} not found.`);
363
+ const oldOid = entry.oid;
364
+ try {
365
+ await this.#git.deleteRef(refname, oldOid);
366
+ }
367
+ catch (err) {
368
+ if (isCASConflict(err)) {
369
+ throw new Error(`ordna: ${id} moved underneath us before delete. Another writer changed this task; pull and retry.`);
370
+ }
371
+ throw err;
372
+ }
373
+ await sync.appendOp(await this.#buildOp("delete", id));
374
+ // Delete-push with lease so we don't clobber an in-flight remote
375
+ // update. Soft-fail like the other push paths — the local delete
376
+ // has already happened; a rejection just means the remote has
377
+ // diverged and the user needs to reconcile.
378
+ await this.#checkRemote();
379
+ if (this.#remoteExists) {
380
+ try {
381
+ await this.#git.run([
382
+ "push",
383
+ `--force-with-lease=${refname}:${oldOid}`,
384
+ "origin",
385
+ `:${refname}`,
386
+ ]);
387
+ }
388
+ catch (err) {
389
+ const msg = err instanceof Error ? err.message : String(err);
390
+ console.error(`[ordna-namespace] delete-push for ${refname} failed: ${msg}`);
391
+ }
392
+ }
393
+ this.#schedulePushState();
394
+ }
395
+ // ---------------- watch ----------------
396
+ watch(listener) {
397
+ this.#listeners.add(listener);
398
+ // Seed the snapshot once and start polling on the first
399
+ // subscription so the initial poll doesn't classify existing
400
+ // refs as `added`.
401
+ if (this.#listeners.size === 1 && this.#pollTimer === null) {
402
+ void this.#seedSnapshot().then(() => {
403
+ if (!this.#disposed && this.#listeners.size > 0) {
404
+ this.#schedulePoll();
405
+ }
406
+ });
407
+ }
408
+ return async () => {
409
+ this.#listeners.delete(listener);
410
+ if (this.#listeners.size === 0 && this.#pollTimer !== null) {
411
+ clearTimeout(this.#pollTimer);
412
+ this.#pollTimer = null;
413
+ }
414
+ };
415
+ }
416
+ // ---------------- commit (deliberate no-op) ----------------
417
+ async commit(_message) {
418
+ // Tasks live outside the working tree; there's nothing to stage
419
+ // or commit. Auto-push handles sync silently. We could throw
420
+ // "namespace doesn't support commit," but `ordna commit` is
421
+ // muscle memory — making it a silent success matches the
422
+ // "working tree stays clean" model better.
423
+ }
424
+ // ---------------- fetch ----------------
425
+ async fetch() {
426
+ await this.#ensureInit();
427
+ const start = Date.now();
428
+ await this.#checkRemote();
429
+ if (!this.#remoteExists)
430
+ return { refsUpdated: 0, durationMs: 0 };
431
+ const before = await this.#snapshotRefs();
432
+ // Fetch task refs and the state ref. State ref may not yet exist
433
+ // on origin (mixed-version teams); ignore that failure.
434
+ await this.#git.fetchRefspec(NAMESPACE_FETCH_REFSPEC);
435
+ try {
436
+ await this.#git.fetchRefspec(STATE_FETCH_REFSPEC);
437
+ }
438
+ catch {
439
+ // remote doesn't have the state ref yet — fine
440
+ }
441
+ if (this.#sync)
442
+ this.#sync.invalidate();
443
+ const after = await this.#snapshotRefs();
444
+ const changed = this.#countRefDiff(before, after);
445
+ return { refsUpdated: changed, durationMs: Date.now() - start };
446
+ }
447
+ // ---------------- internals: push pipeline ----------------
448
+ #schedulePush(push) {
449
+ // Coalesce per refname: keep the original expectedOld (the value
450
+ // the remote has when we first scheduled), update newOid to the
451
+ // latest. isCreate stays as captured on first schedule — if a
452
+ // create+update happen back-to-back without a flush, the remote
453
+ // still sees "this ref didn't exist; now it does."
454
+ const existing = this.#pendingPushes.get(push.refname);
455
+ if (existing && existing.refname !== STATE_PUSH_SENTINEL) {
456
+ existing.newOid = push.newOid;
457
+ }
458
+ else {
459
+ this.#pendingPushes.set(push.refname, { ...push });
460
+ }
461
+ this.#armPushTimer();
462
+ }
463
+ #schedulePushState() {
464
+ this.#pendingPushes.set(STATE_PUSH_SENTINEL, {
465
+ refname: STATE_PUSH_SENTINEL,
466
+ newOid: "",
467
+ expectedOld: "",
468
+ isCreate: false,
469
+ });
470
+ this.#armPushTimer();
471
+ }
472
+ #armPushTimer() {
473
+ if (this.#pushTimer)
474
+ clearTimeout(this.#pushTimer);
475
+ const t = setTimeout(() => {
476
+ this.#pushTimer = null;
477
+ void this.#drainPushes();
478
+ }, PUSH_DEBOUNCE_MS);
479
+ // Don't keep the Node process alive on the push timer alone —
480
+ // host owns lifetime, dispose() flushes anything pending.
481
+ t.unref?.();
482
+ this.#pushTimer = t;
483
+ }
484
+ async #drainPushes() {
485
+ if (this.#pushInFlight) {
486
+ this.#pushRetryPending = true;
487
+ return;
488
+ }
489
+ this.#pushInFlight = this.#runPushBatch().finally(() => {
490
+ this.#pushInFlight = null;
491
+ if (this.#pushRetryPending) {
492
+ this.#pushRetryPending = false;
493
+ void this.#drainPushes();
494
+ }
495
+ });
496
+ }
497
+ async #runPushBatch() {
498
+ await this.#checkRemote();
499
+ if (!this.#remoteExists) {
500
+ this.#pendingPushes.clear();
501
+ return;
502
+ }
503
+ // Take the current batch; new schedules accumulate into the next.
504
+ const batch = Array.from(this.#pendingPushes.values());
505
+ this.#pendingPushes.clear();
506
+ // State ref push first — best-effort, force is fine since SyncRef
507
+ // has CAS-managed the ref in-process already.
508
+ const stateInBatch = batch.find((p) => p.refname === STATE_PUSH_SENTINEL);
509
+ if (stateInBatch) {
510
+ try {
511
+ await this.#git.pushRef(STATE_PUSH_REFSPEC);
512
+ }
513
+ catch (err) {
514
+ const msg = err instanceof Error ? err.message : String(err);
515
+ console.error(`[ordna-namespace] state push failed: ${msg}`);
516
+ }
517
+ }
518
+ // Task ref pushes with per-ref leases. We don't bail on first
519
+ // rejection — each ref is reconciled independently so a single
520
+ // collision doesn't block the rest.
521
+ for (const push of batch) {
522
+ if (push.refname === STATE_PUSH_SENTINEL)
523
+ continue;
524
+ await this.#pushTaskRef(push);
525
+ }
526
+ }
527
+ async #pushTaskRef(push) {
528
+ try {
529
+ await this.#git.pushRefWithLease(push.refname, push.newOid, push.expectedOld);
530
+ }
531
+ catch (err) {
532
+ const msg = err instanceof Error ? err.message : String(err);
533
+ if (!isPushRejection(err)) {
534
+ console.error(`[ordna-namespace] push failed for ${push.refname}: ${msg}`);
535
+ return;
536
+ }
537
+ if (push.isCreate && this.#autoRenumberOnConflict) {
538
+ await this.#reconcileCreateCollision(push);
539
+ }
540
+ else {
541
+ // Update collision (or auto-renumber disabled). Loud.
542
+ console.error(`[ordna-namespace] push rejected for ${push.refname}; remote has diverged. Run \`git fetch origin '+${push.refname}:${push.refname}'\` and reconcile manually.`);
543
+ }
544
+ }
545
+ }
546
+ /**
547
+ * Push of a `create` was rejected because the remote already has
548
+ * this id (another offline writer landed it first). Recover by:
549
+ *
550
+ * 1. Fetching origin (so our local refs reflect the remote winner).
551
+ * 2. Reading the blob we tried to push (we still have the OID).
552
+ * 3. Allocating a fresh id via SyncRef.
553
+ * 4. Re-serialising with the new id, writing a new ref.
554
+ * 5. Cascading the rewrite through any local `depends_on`
555
+ * references to the old id.
556
+ * 6. Logging a `rename` op (with `renamedFrom`) in the audit log.
557
+ * 7. Scheduling the new refs for push.
558
+ * 8. Emitting a `renamed` event so the UI can toast + show the
559
+ * "previously known as X" banner.
560
+ */
561
+ async #reconcileCreateCollision(push) {
562
+ const sync = this.#sync;
563
+ const oldId = idFromRefname(push.refname);
564
+ if (!oldId)
565
+ return;
566
+ try {
567
+ // 1. Fetch — our local copy of `push.refname` will be clobbered
568
+ // by the remote's value (that's fine; we abandon the local
569
+ // write of that ref and recreate under a new id).
570
+ await this.#git.fetchRefspec(NAMESPACE_FETCH_REFSPEC);
571
+ try {
572
+ await this.#git.fetchRefspec(STATE_FETCH_REFSPEC);
573
+ }
574
+ catch {
575
+ // state ref may not exist on origin yet
576
+ }
577
+ sync.invalidate();
578
+ // 2. Read the blob we wanted to push. push.newOid is the OID
579
+ // we hashed locally; the blob still exists in .git/objects/
580
+ // even though the ref no longer points at it.
581
+ const ourBlob = await this.#git.catBlob(push.newOid);
582
+ const ourTask = parseTaskBytes(ourBlob, `ref:${push.refname}`);
583
+ delete ourTask.filePath;
584
+ // 3. Allocate a fresh id. After the fetch, the SyncRef cache
585
+ // has been invalidated and a new read reflects the merged
586
+ // remote+local view of `next_id`. The allocator may still
587
+ // hand us an id that's taken by a *different* local task
588
+ // (e.g. A had T-001 + T-002 local, T-001 collided, state
589
+ // says next_id=2 but T-002 is locally occupied). Loop until
590
+ // we get a genuinely free slot — burning ids is fine, they
591
+ // are cheap.
592
+ let newId = await sync.allocateNextId(this.config);
593
+ for (let attempt = 0; attempt < 100; attempt++) {
594
+ const existing = await this.#git.forEachRef(refnameFor(newId));
595
+ if (existing.find((r) => r.refname === refnameFor(newId)) === undefined) {
596
+ break;
597
+ }
598
+ newId = await sync.allocateNextId(this.config);
599
+ }
600
+ // 4. Re-serialise with the new id.
601
+ const renamed = {
602
+ ...ourTask,
603
+ id: newId,
604
+ updated_at: today(),
605
+ };
606
+ const renamedSerialized = serializeTask(renamed, this.config.schema);
607
+ renamed.rawContent = renamedSerialized;
608
+ const renamedOid = await this.#git.hashObject(renamedSerialized);
609
+ await this.#git.updateRef(refnameFor(newId), renamedOid, "");
610
+ // 5. Cascade. Any local task whose depends_on referenced oldId
611
+ // gets rewritten to newId. Naive sweep — accepts rare false
612
+ // positives where a remote teammate's task coincidentally
613
+ // depended on the colliding id (their content is untouched
614
+ // locally, so the cascade only catches genuinely-local edits).
615
+ await this.#cascadeDependsOnRewrite(oldId, newId);
616
+ // 6. Audit log.
617
+ await sync.appendOp({
618
+ ts: nowIso(),
619
+ actor: await this.#resolveActor(),
620
+ op: "rename",
621
+ id: newId,
622
+ renamedFrom: oldId,
623
+ });
624
+ // 7. Schedule pushes.
625
+ this.#schedulePush({
626
+ refname: refnameFor(newId),
627
+ newOid: renamedOid,
628
+ expectedOld: "",
629
+ isCreate: true,
630
+ });
631
+ this.#schedulePushState();
632
+ // 8. Notify watchers.
633
+ this.#emit({ type: "renamed", oldId, newId, task: renamed });
634
+ }
635
+ catch (err) {
636
+ const msg = err instanceof Error ? err.message : String(err);
637
+ console.error(`[ordna-namespace] reconcile failed for ${push.refname}: ${msg}`);
638
+ }
639
+ }
640
+ async #cascadeDependsOnRewrite(oldId, newId) {
641
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
642
+ for (const { refname, oid } of refs) {
643
+ const id = idFromRefname(refname);
644
+ if (!id || id === newId)
645
+ continue;
646
+ try {
647
+ const raw = await this.#git.catBlob(oid);
648
+ const task = parseTaskBytes(raw, `ref:${refname}`);
649
+ delete task.filePath;
650
+ if (!task.depends_on.includes(oldId))
651
+ continue;
652
+ const next = {
653
+ ...task,
654
+ depends_on: task.depends_on.map((d) => d === oldId ? newId : d),
655
+ updated_at: today(),
656
+ };
657
+ const serialized = serializeTask(next, this.config.schema);
658
+ next.rawContent = serialized;
659
+ const newOid = await this.#git.hashObject(serialized);
660
+ try {
661
+ await this.#git.updateRef(refname, newOid, oid);
662
+ }
663
+ catch {
664
+ // Ref moved underneath the cascade. Skip — the next
665
+ // pass (e.g. on the next mutation) will pick it up
666
+ // if needed; we don't loop here to avoid contention.
667
+ continue;
668
+ }
669
+ this.#schedulePush({
670
+ refname,
671
+ newOid,
672
+ expectedOld: oid,
673
+ isCreate: false,
674
+ });
675
+ this.#emit({ type: "changed", task: next });
676
+ }
677
+ catch {
678
+ // skip unreadable tasks
679
+ }
680
+ }
681
+ }
682
+ async #flushPushes() {
683
+ // Cancel the debounce timer so we don't accidentally fire a
684
+ // duplicate drain alongside ours.
685
+ if (this.#pushTimer) {
686
+ clearTimeout(this.#pushTimer);
687
+ this.#pushTimer = null;
688
+ }
689
+ // Kick off a drain now if there's anything queued and nothing
690
+ // already running. Otherwise the in-flight drain (or the empty
691
+ // state) handles it.
692
+ if (this.#pendingPushes.size > 0 && !this.#pushInFlight) {
693
+ void this.#drainPushes();
694
+ }
695
+ // Wait until the pipeline is fully quiet — drains may chain via
696
+ // the retry-pending flag (e.g. a reconcile schedules a new push
697
+ // while the previous batch is running).
698
+ while (this.#pushInFlight) {
699
+ await this.#pushInFlight;
700
+ }
701
+ }
702
+ // ---------------- internals: rename history ----------------
703
+ /**
704
+ * Walk the audit log in reverse and return a map of `currentId →
705
+ * mostRecentPreviousId` for every renamed task. Cheap because the
706
+ * state blob is cached in SyncRef; we re-walk only when the cache
707
+ * has been invalidated (by a fetch or a CAS conflict).
708
+ */
709
+ async #buildRenamedFromMap() {
710
+ const map = new Map();
711
+ if (!this.#sync)
712
+ return map;
713
+ try {
714
+ const state = await this.#sync.read();
715
+ // Walk in reverse so the first hit per id is the most recent.
716
+ for (let i = state.ops.length - 1; i >= 0; i--) {
717
+ const op = state.ops[i];
718
+ if (!op || op.op !== "rename" || !op.renamedFrom)
719
+ continue;
720
+ if (map.has(op.id))
721
+ continue;
722
+ map.set(op.id, op.renamedFrom);
723
+ }
724
+ }
725
+ catch {
726
+ // State blob unreadable — return empty map; banner just doesn't show.
727
+ }
728
+ return map;
729
+ }
730
+ async #lookupRenamedFrom(id) {
731
+ if (!this.#sync)
732
+ return null;
733
+ try {
734
+ const state = await this.#sync.read();
735
+ for (let i = state.ops.length - 1; i >= 0; i--) {
736
+ const op = state.ops[i];
737
+ if (!op || op.op !== "rename" || op.id !== id)
738
+ continue;
739
+ return op.renamedFrom ?? null;
740
+ }
741
+ }
742
+ catch {
743
+ // fall through
744
+ }
745
+ return null;
746
+ }
747
+ // ---------------- internals: actor / audit op builder ----------------
748
+ async #buildOp(op, id) {
749
+ return {
750
+ ts: nowIso(),
751
+ actor: await this.#resolveActor(),
752
+ op,
753
+ id,
754
+ };
755
+ }
756
+ async #resolveActor() {
757
+ if (this.#cachedActor !== null)
758
+ return this.#cachedActor;
759
+ const fromGit = await this.#git.userEmail();
760
+ if (fromGit) {
761
+ this.#cachedActor = fromGit;
762
+ return fromGit;
763
+ }
764
+ const fromEnv = process.env.ORDNA_ACTOR;
765
+ if (fromEnv && fromEnv.trim().length > 0) {
766
+ this.#cachedActor = fromEnv.trim();
767
+ return this.#cachedActor;
768
+ }
769
+ this.#cachedActor = "unknown";
770
+ return this.#cachedActor;
771
+ }
772
+ // ---------------- internals: watcher poll ----------------
773
+ async #seedSnapshot() {
774
+ await this.#ensureInit();
775
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
776
+ this.#lastSnapshot.clear();
777
+ for (const { refname, oid } of refs) {
778
+ this.#lastSnapshot.set(refname, oid);
779
+ }
780
+ }
781
+ #schedulePoll() {
782
+ if (this.#disposed)
783
+ return;
784
+ const t = setTimeout(() => {
785
+ this.#pollTimer = null;
786
+ void this.#poll();
787
+ }, this.#pollIntervalMs);
788
+ t.unref?.();
789
+ this.#pollTimer = t;
790
+ }
791
+ async #poll() {
792
+ if (this.#disposed || this.#listeners.size === 0)
793
+ return;
794
+ try {
795
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
796
+ const next = new Map();
797
+ for (const { refname, oid } of refs)
798
+ next.set(refname, oid);
799
+ await this.#diffAndEmit(this.#lastSnapshot, next);
800
+ this.#lastSnapshot = next;
801
+ }
802
+ catch (err) {
803
+ const msg = err instanceof Error ? err.message : String(err);
804
+ console.error(`[ordna-namespace] poll failed: ${msg}`);
805
+ }
806
+ finally {
807
+ if (!this.#disposed && this.#listeners.size > 0) {
808
+ this.#schedulePoll();
809
+ }
810
+ }
811
+ }
812
+ async #diffAndEmit(prev, next) {
813
+ for (const [refname, oid] of next) {
814
+ if (!prev.has(refname)) {
815
+ const task = await this.#parseRef(refname, oid);
816
+ if (task)
817
+ this.#emit({ type: "added", task });
818
+ continue;
819
+ }
820
+ if (prev.get(refname) !== oid) {
821
+ const task = await this.#parseRef(refname, oid);
822
+ if (task)
823
+ this.#emit({ type: "changed", task });
824
+ }
825
+ }
826
+ for (const [refname] of prev) {
827
+ if (!next.has(refname)) {
828
+ this.#emit({ type: "removed", filePath: refname });
829
+ }
830
+ }
831
+ }
832
+ async #parseRef(refname, oid) {
833
+ try {
834
+ const raw = await this.#git.catBlob(oid);
835
+ const task = parseTaskBytes(raw, `ref:${refname}`);
836
+ delete task.filePath;
837
+ return task;
838
+ }
839
+ catch {
840
+ return null;
841
+ }
842
+ }
843
+ #emit(event) {
844
+ for (const listener of this.#listeners) {
845
+ try {
846
+ listener(event);
847
+ }
848
+ catch (err) {
849
+ const msg = err instanceof Error ? err.message : String(err);
850
+ console.error(`[ordna-namespace] listener threw: ${msg}`);
851
+ }
852
+ }
853
+ }
854
+ async #checkRemote() {
855
+ if (this.#remoteChecked)
856
+ return;
857
+ this.#remoteChecked = true;
858
+ this.#remoteExists = await this.#git.hasRemote();
859
+ }
860
+ async #snapshotRefs() {
861
+ const refs = await this.#git.forEachRef(TASK_REF_PATTERN);
862
+ const map = new Map();
863
+ for (const { refname, oid } of refs)
864
+ map.set(refname, oid);
865
+ return map;
866
+ }
867
+ #countRefDiff(prev, next) {
868
+ let changed = 0;
869
+ for (const [refname, oid] of next) {
870
+ if (prev.get(refname) !== oid)
871
+ changed++;
872
+ }
873
+ for (const refname of prev.keys()) {
874
+ if (!next.has(refname))
875
+ changed++;
876
+ }
877
+ return changed;
878
+ }
879
+ #scheduleAutoFetch() {
880
+ if (this.#disposed || this.#autoFetchIntervalMs <= 0)
881
+ return;
882
+ const t = setTimeout(() => {
883
+ this.#autoFetchTimer = null;
884
+ void this.#runAutoFetch();
885
+ }, this.#autoFetchIntervalMs);
886
+ t.unref?.();
887
+ this.#autoFetchTimer = t;
888
+ }
889
+ async #runAutoFetch() {
890
+ if (this.#disposed)
891
+ return;
892
+ try {
893
+ this.#autoFetchInFlight = this.fetch();
894
+ await this.#autoFetchInFlight;
895
+ }
896
+ catch (err) {
897
+ const msg = err instanceof Error ? err.message : String(err);
898
+ console.error(`[ordna-namespace] auto-fetch failed: ${msg}`);
899
+ }
900
+ finally {
901
+ this.#autoFetchInFlight = null;
902
+ if (!this.#disposed)
903
+ this.#scheduleAutoFetch();
904
+ }
905
+ }
906
+ }
907
+ //# sourceMappingURL=namespace.js.map