@functional-systems/srcx 0.1.0-dev.4 → 0.1.0-dev.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.
- package/CHANGELOG.md +27 -0
- package/DESIGN.md +43 -8
- package/README.md +61 -7
- package/VALIDATION.md +83 -7
- package/dist/build.d.ts +2 -1
- package/dist/build.js +1 -1
- package/dist/build.js.map +1 -1
- package/dist/cli.js +37 -13
- package/dist/cli.js.map +1 -1
- package/dist/publish.d.ts +18 -0
- package/dist/publish.js +143 -14
- package/dist/publish.js.map +1 -1
- package/dist/releases.js +21 -3
- package/dist/releases.js.map +1 -1
- package/package.json +4 -3
- package/src/build.ts +3 -1
- package/src/cli.ts +46 -12
- package/src/publish.ts +204 -14
- package/src/releases.ts +36 -3
package/src/publish.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type Journal = {
|
|
|
36
36
|
attemptId: string;
|
|
37
37
|
writer: string;
|
|
38
38
|
source: string;
|
|
39
|
+
gitRef?: string;
|
|
40
|
+
reuseWriter?: boolean;
|
|
39
41
|
baseline?: Published;
|
|
40
42
|
phase: "branch" | "writing" | "waiting" | "publishing";
|
|
41
43
|
batches: {
|
|
@@ -45,6 +47,43 @@ export type Journal = {
|
|
|
45
47
|
}[];
|
|
46
48
|
candidate?: { name: string; snapshotId: string };
|
|
47
49
|
};
|
|
50
|
+
/** The applied snapshot may differ from the shared canonical commit Tag. */
|
|
51
|
+
export type TrackedBranch = Doc & {
|
|
52
|
+
gitRef: string;
|
|
53
|
+
writer: string;
|
|
54
|
+
applied?: Published;
|
|
55
|
+
publishedTagName?: string;
|
|
56
|
+
pending?: { attemptId: string; buildId: string; commitOid: string };
|
|
57
|
+
};
|
|
58
|
+
export function gitBranchName(ref: string): string {
|
|
59
|
+
invariant(
|
|
60
|
+
ref.startsWith("refs/heads/") && ref.length > 11,
|
|
61
|
+
"Expected a full Git branch ref.",
|
|
62
|
+
);
|
|
63
|
+
return `git-${hash(ref).slice(0, 40)}`;
|
|
64
|
+
}
|
|
65
|
+
export const gitBranchId = (ref: string): string => `branch-${hash(ref)}`;
|
|
66
|
+
export const lastBuildPath = (state: string, ref?: string): string =>
|
|
67
|
+
join(state, ref ? `last-build-${hash(ref)}.json` : "last-build.json");
|
|
68
|
+
export async function trackedBranch(
|
|
69
|
+
store: CollectionStore,
|
|
70
|
+
binding: Binding,
|
|
71
|
+
ref: string,
|
|
72
|
+
): Promise<TrackedBranch | undefined> {
|
|
73
|
+
const doc = await one(store, branch("main"), gitBranchId(ref), true);
|
|
74
|
+
if (!doc) return undefined;
|
|
75
|
+
invariant(
|
|
76
|
+
doc.kind === "manifest" &&
|
|
77
|
+
doc.role === "git-branch" &&
|
|
78
|
+
doc.gitRef === ref &&
|
|
79
|
+
doc.writer === gitBranchName(ref) &&
|
|
80
|
+
doc.repoId === binding.repoId &&
|
|
81
|
+
doc.indexId === binding.indexId &&
|
|
82
|
+
doc.configHash === binding.configHash,
|
|
83
|
+
"Tracked branch identity mismatch.",
|
|
84
|
+
);
|
|
85
|
+
return doc as TrackedBranch;
|
|
86
|
+
}
|
|
48
87
|
export function versionTag(oid: string): string {
|
|
49
88
|
return `ver-${hash(oid).slice(0, 40)}`;
|
|
50
89
|
}
|
|
@@ -255,7 +294,9 @@ export async function publish(args: {
|
|
|
255
294
|
invariant(
|
|
256
295
|
j.buildId === b.buildId &&
|
|
257
296
|
j.recordsHash === b.recordsHash &&
|
|
258
|
-
j.inventoryHash === b.inventoryHash
|
|
297
|
+
j.inventoryHash === b.inventoryHash &&
|
|
298
|
+
(j.gitRef === b.branchRef ||
|
|
299
|
+
(j.reuseWriter === undefined && j.writer.startsWith("work-"))),
|
|
259
300
|
"Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
|
|
260
301
|
);
|
|
261
302
|
invariant(
|
|
@@ -266,6 +307,22 @@ export async function publish(args: {
|
|
|
266
307
|
const existing = (await published(store, binding)).find(
|
|
267
308
|
(v) => v.commitOid === b.commitOid,
|
|
268
309
|
);
|
|
310
|
+
const tracked = b.branchRef
|
|
311
|
+
? await trackedBranch(store, binding, b.branchRef)
|
|
312
|
+
: undefined;
|
|
313
|
+
if (tracked?.pending)
|
|
314
|
+
invariant(
|
|
315
|
+
j?.attemptId === tracked.pending.attemptId,
|
|
316
|
+
"Git branch has a pending import; resume with its original retry journal.",
|
|
317
|
+
);
|
|
318
|
+
if (j?.gitRef && j.phase !== "branch")
|
|
319
|
+
invariant(
|
|
320
|
+
tracked?.pending?.attemptId === j.attemptId ||
|
|
321
|
+
(j.phase === "publishing" &&
|
|
322
|
+
!tracked?.pending &&
|
|
323
|
+
tracked?.applied?.attemptId === j.attemptId),
|
|
324
|
+
"Tracked branch import ownership changed; refusing to replay this journal.",
|
|
325
|
+
);
|
|
269
326
|
if (existing) {
|
|
270
327
|
invariant(
|
|
271
328
|
await validateCandidate(
|
|
@@ -277,17 +334,79 @@ export async function publish(args: {
|
|
|
277
334
|
),
|
|
278
335
|
"Existing published version differs from this build.",
|
|
279
336
|
);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
337
|
+
// A shared commit Tag does not mean this Git branch has applied that commit.
|
|
338
|
+
if (!j && (!b.branchRef || tracked?.applied?.commitOid === b.commitOid)) {
|
|
339
|
+
if (tracked?.applied) {
|
|
340
|
+
const writer = (await store.branches()).find(
|
|
341
|
+
(x) => x.name === tracked.writer,
|
|
342
|
+
);
|
|
343
|
+
invariant(
|
|
344
|
+
writer?.snapshotId === tracked.applied.snapshotId,
|
|
345
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
346
|
+
);
|
|
347
|
+
invariant(
|
|
348
|
+
await validateCandidate(
|
|
349
|
+
store,
|
|
350
|
+
tracked.applied.tagName,
|
|
351
|
+
b,
|
|
352
|
+
binding,
|
|
353
|
+
tracked.applied.attemptId,
|
|
354
|
+
),
|
|
355
|
+
"Tracked branch baseline validation failed.",
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
await atomic(lastBuildPath(state, b.branchRef), {
|
|
359
|
+
artifact: b.directory,
|
|
360
|
+
version: existing,
|
|
361
|
+
});
|
|
362
|
+
return existing;
|
|
363
|
+
}
|
|
364
|
+
// Legacy manual imports can finish bookkeeping after publication on retry.
|
|
365
|
+
if (j && !j.gitRef) {
|
|
366
|
+
await atomic(lastBuildPath(state), {
|
|
367
|
+
artifact: b.directory,
|
|
368
|
+
version: existing,
|
|
369
|
+
});
|
|
370
|
+
await rm(journalPath);
|
|
371
|
+
return existing;
|
|
372
|
+
}
|
|
286
373
|
}
|
|
287
374
|
if (!j) {
|
|
288
375
|
let source = "checkpoint-empty";
|
|
289
376
|
let baseline: Published | undefined;
|
|
290
|
-
if (
|
|
377
|
+
if (b.branchRef) {
|
|
378
|
+
const writer = (await store.branches()).find(
|
|
379
|
+
(x) => x.name === gitBranchName(b.branchRef!),
|
|
380
|
+
);
|
|
381
|
+
if (tracked?.applied) {
|
|
382
|
+
baseline = tracked.applied;
|
|
383
|
+
invariant(
|
|
384
|
+
writer?.snapshotId === baseline.snapshotId,
|
|
385
|
+
"Writer baseline has changed; refusing to update tracked branch.",
|
|
386
|
+
);
|
|
387
|
+
const pin = (await store.tags()).find(
|
|
388
|
+
(t) => t.name === baseline!.tagName,
|
|
389
|
+
);
|
|
390
|
+
const root = await one(store, tag(baseline.tagName), "__manifest__");
|
|
391
|
+
invariant(
|
|
392
|
+
pin?.snapshotId === baseline.snapshotId &&
|
|
393
|
+
root?.repoId === binding.repoId &&
|
|
394
|
+
root.indexId === binding.indexId &&
|
|
395
|
+
root.configHash === binding.configHash &&
|
|
396
|
+
root.buildId === baseline.buildId &&
|
|
397
|
+
root.attemptId === baseline.attemptId &&
|
|
398
|
+
root.recordsHash === baseline.recordsHash &&
|
|
399
|
+
root.inventoryHash === baseline.inventoryHash &&
|
|
400
|
+
root.commitOid === baseline.commitOid,
|
|
401
|
+
"Tracked branch baseline Tag is missing or changed.",
|
|
402
|
+
);
|
|
403
|
+
} else {
|
|
404
|
+
invariant(
|
|
405
|
+
!writer && !tracked,
|
|
406
|
+
"Unowned or unfinished tracked branch; retain its retry journal.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
} else if (args.baseline?.version.writer.startsWith("work-")) {
|
|
291
410
|
const prev = args.baseline;
|
|
292
411
|
await validateBuild(prev.build);
|
|
293
412
|
invariant(
|
|
@@ -322,7 +441,9 @@ export async function publish(args: {
|
|
|
322
441
|
inventoryHash: b.inventoryHash,
|
|
323
442
|
artifact: b.directory,
|
|
324
443
|
attemptId,
|
|
325
|
-
writer: `work-${attemptId}`,
|
|
444
|
+
writer: b.branchRef ? gitBranchName(b.branchRef) : `work-${attemptId}`,
|
|
445
|
+
gitRef: b.branchRef,
|
|
446
|
+
reuseWriter: !!tracked?.applied,
|
|
326
447
|
source,
|
|
327
448
|
baseline,
|
|
328
449
|
phase: "branch",
|
|
@@ -332,15 +453,48 @@ export async function publish(args: {
|
|
|
332
453
|
}
|
|
333
454
|
const save = () => atomic(journalPath, j);
|
|
334
455
|
if (j.phase === "branch") {
|
|
456
|
+
if (j.gitRef) {
|
|
457
|
+
// Record intent before any corpus mutation, including before branch creation.
|
|
458
|
+
// A fresh local state must not treat a lagging, partially written head as clean.
|
|
459
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
460
|
+
invariant(
|
|
461
|
+
(!current?.pending || current.pending.attemptId === j.attemptId) &&
|
|
462
|
+
current?.applied?.snapshotId === j.baseline?.snapshotId,
|
|
463
|
+
"Git branch ownership or baseline changed before import.",
|
|
464
|
+
);
|
|
465
|
+
await store.upsert("main", [
|
|
466
|
+
{
|
|
467
|
+
...current,
|
|
468
|
+
id: gitBranchId(j.gitRef),
|
|
469
|
+
kind: "manifest",
|
|
470
|
+
role: "git-branch",
|
|
471
|
+
schemaVersion: 1,
|
|
472
|
+
repoId: binding.repoId,
|
|
473
|
+
indexId: binding.indexId,
|
|
474
|
+
configHash: binding.configHash,
|
|
475
|
+
gitRef: j.gitRef,
|
|
476
|
+
writer: j.writer,
|
|
477
|
+
pending: {
|
|
478
|
+
attemptId: j.attemptId,
|
|
479
|
+
buildId: j.buildId,
|
|
480
|
+
commitOid: b.commitOid,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
]);
|
|
484
|
+
}
|
|
335
485
|
const branches = await store.branches();
|
|
336
486
|
const found = branches.find((x) => x.name === j!.writer);
|
|
337
487
|
if (found)
|
|
338
488
|
invariant(
|
|
339
|
-
found.parent === j.source &&
|
|
489
|
+
(j.reuseWriter || found.parent === j.source) &&
|
|
340
490
|
found.snapshotId === (j.baseline?.snapshotId ?? null),
|
|
341
491
|
"Partially created writer has an unexpected baseline.",
|
|
342
492
|
);
|
|
343
493
|
else {
|
|
494
|
+
invariant(
|
|
495
|
+
!j.reuseWriter,
|
|
496
|
+
"Tracked writer is missing; refusing to recreate a nonempty baseline.",
|
|
497
|
+
);
|
|
344
498
|
const source = branches.find((x) => x.name === j!.source);
|
|
345
499
|
invariant(
|
|
346
500
|
source &&
|
|
@@ -454,10 +608,12 @@ export async function publish(args: {
|
|
|
454
608
|
const found = (await store.tags()).find((t) => t.name === name);
|
|
455
609
|
const final = found ?? (await store.tag(name, tag(j.candidate.name)));
|
|
456
610
|
invariant(
|
|
457
|
-
|
|
611
|
+
existing
|
|
612
|
+
? final.snapshotId === existing.snapshotId
|
|
613
|
+
: final.snapshotId === j.candidate.snapshotId,
|
|
458
614
|
"Published Tag name already points to a different snapshot.",
|
|
459
615
|
);
|
|
460
|
-
const result: Published = {
|
|
616
|
+
const result: Published = existing ?? {
|
|
461
617
|
id: versionId(b.commitOid),
|
|
462
618
|
kind: "manifest",
|
|
463
619
|
role: "published",
|
|
@@ -477,8 +633,42 @@ export async function publish(args: {
|
|
|
477
633
|
counts: b.counts,
|
|
478
634
|
validation: { inventory: true, content: true, query: true },
|
|
479
635
|
};
|
|
480
|
-
await store.upsert("main", [result]);
|
|
481
|
-
|
|
636
|
+
if (!existing) await store.upsert("main", [result]);
|
|
637
|
+
if (j.gitRef) {
|
|
638
|
+
const current = await trackedBranch(store, binding, j.gitRef);
|
|
639
|
+
invariant(
|
|
640
|
+
current?.pending?.attemptId === j.attemptId ||
|
|
641
|
+
(!current?.pending && current?.applied?.attemptId === j.attemptId),
|
|
642
|
+
"Tracked branch publication ownership changed.",
|
|
643
|
+
);
|
|
644
|
+
// Pin the exact writer baseline even when another branch published this commit first.
|
|
645
|
+
const applied: Published = {
|
|
646
|
+
...result,
|
|
647
|
+
writer: j.writer,
|
|
648
|
+
snapshotId: j.candidate.snapshotId,
|
|
649
|
+
tagName:
|
|
650
|
+
final.snapshotId === j.candidate.snapshotId
|
|
651
|
+
? final.name
|
|
652
|
+
: j.candidate.name,
|
|
653
|
+
attemptId: j.attemptId,
|
|
654
|
+
};
|
|
655
|
+
await store.upsert("main", [
|
|
656
|
+
{
|
|
657
|
+
id: gitBranchId(j.gitRef),
|
|
658
|
+
kind: "manifest",
|
|
659
|
+
role: "git-branch",
|
|
660
|
+
schemaVersion: 1,
|
|
661
|
+
repoId: binding.repoId,
|
|
662
|
+
indexId: binding.indexId,
|
|
663
|
+
configHash: binding.configHash,
|
|
664
|
+
gitRef: j.gitRef,
|
|
665
|
+
writer: j.writer,
|
|
666
|
+
applied,
|
|
667
|
+
publishedTagName: result.tagName,
|
|
668
|
+
},
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
await atomic(lastBuildPath(state, j.gitRef), {
|
|
482
672
|
artifact: b.directory,
|
|
483
673
|
version: result,
|
|
484
674
|
});
|
package/src/releases.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { hash, invariant, type Doc } from "./common.js";
|
|
2
2
|
import { gitTags } from "./git.js";
|
|
3
3
|
import { one, branch, type CollectionStore } from "./remote.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
published,
|
|
6
|
+
trackedBranch,
|
|
7
|
+
type Binding,
|
|
8
|
+
type Published,
|
|
9
|
+
} from "./publish.js";
|
|
5
10
|
export const releaseAlias = (ref: string) => `rel-${hash(ref).slice(0, 40)}`;
|
|
6
11
|
export const releaseId = (ref: string) => `ref-${hash(ref)}`;
|
|
7
12
|
/** Only observed local tags. Absence is not deletion authority; this command never prunes. */
|
|
@@ -58,11 +63,39 @@ export async function resolveVersion(
|
|
|
58
63
|
binding: Binding,
|
|
59
64
|
selector: string,
|
|
60
65
|
): Promise<Published> {
|
|
61
|
-
const versions = await published(store, binding);
|
|
62
66
|
const ref = selector.startsWith("refs/tags/")
|
|
63
67
|
? selector
|
|
64
68
|
: `refs/tags/${selector}`;
|
|
65
|
-
const control =
|
|
69
|
+
const control = selector.startsWith("refs/heads/")
|
|
70
|
+
? undefined
|
|
71
|
+
: await one(store, branch("main"), releaseId(ref), true);
|
|
72
|
+
const headRef = selector.startsWith("refs/heads/")
|
|
73
|
+
? selector
|
|
74
|
+
: `refs/heads/${selector}`;
|
|
75
|
+
const head =
|
|
76
|
+
!selector.startsWith("refs/") || selector.startsWith("refs/heads/")
|
|
77
|
+
? await trackedBranch(store, binding, headRef)
|
|
78
|
+
: undefined;
|
|
79
|
+
invariant(
|
|
80
|
+
!(head && control),
|
|
81
|
+
"Ambiguous branch/tag name: use refs/heads/... or refs/tags/...",
|
|
82
|
+
);
|
|
83
|
+
// Pin moving ref control before listing versions. Otherwise publication can
|
|
84
|
+
// advance the ref to a version absent from the earlier list.
|
|
85
|
+
const versions = await published(store, binding);
|
|
86
|
+
if (head) {
|
|
87
|
+
invariant(
|
|
88
|
+
head.applied,
|
|
89
|
+
"Git branch has no published version yet; finish its import.",
|
|
90
|
+
);
|
|
91
|
+
const result = versions.find(
|
|
92
|
+
(v) =>
|
|
93
|
+
v.commitOid === head.applied!.commitOid &&
|
|
94
|
+
v.tagName === head.publishedTagName,
|
|
95
|
+
);
|
|
96
|
+
invariant(result, "Git branch target is not a published version.");
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
66
99
|
// Like Git import, a known tag name takes precedence over an OID spelling.
|
|
67
100
|
// Its pending or invalid state must not fall back to an unrelated commit.
|
|
68
101
|
if (!control && !selector.startsWith("refs/")) {
|