@functional-systems/srcx 0.1.0-dev.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.
- package/CHANGELOG.md +20 -0
- package/CONTRIBUTING.md +54 -0
- package/DESIGN.md +1057 -0
- package/LICENSE +202 -0
- package/README.md +209 -0
- package/RELEASING.md +137 -0
- package/VALIDATION.md +181 -0
- package/dist/build.d.ts +94 -0
- package/dist/build.js +433 -0
- package/dist/build.js.map +1 -0
- package/dist/chunk.d.ts +30 -0
- package/dist/chunk.js +296 -0
- package/dist/chunk.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +276 -0
- package/dist/cli.js.map +1 -0
- package/dist/common.d.ts +17 -0
- package/dist/common.js +74 -0
- package/dist/common.js.map +1 -0
- package/dist/git.d.ts +38 -0
- package/dist/git.js +224 -0
- package/dist/git.js.map +1 -0
- package/dist/publish.d.ts +60 -0
- package/dist/publish.js +341 -0
- package/dist/publish.js.map +1 -0
- package/dist/releases.d.ts +8 -0
- package/dist/releases.js +79 -0
- package/dist/releases.js.map +1 -0
- package/dist/remote.d.ts +53 -0
- package/dist/remote.js +136 -0
- package/dist/remote.js.map +1 -0
- package/dist/repository.d.ts +29 -0
- package/dist/repository.js +180 -0
- package/dist/repository.js.map +1 -0
- package/dist/search.d.ts +46 -0
- package/dist/search.js +181 -0
- package/dist/search.js.map +1 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +35 -0
- package/dist/settings.js.map +1 -0
- package/package.json +58 -0
- package/src/build.ts +587 -0
- package/src/chunk.ts +328 -0
- package/src/cli.ts +366 -0
- package/src/common.ts +71 -0
- package/src/git.ts +286 -0
- package/src/publish.ts +487 -0
- package/src/releases.ts +108 -0
- package/src/remote.ts +230 -0
- package/src/repository.ts +306 -0
- package/src/search.ts +282 -0
- package/src/settings.ts +59 -0
package/src/publish.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { mkdir, rm, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
atomic,
|
|
6
|
+
canonical,
|
|
7
|
+
hash,
|
|
8
|
+
invariant,
|
|
9
|
+
optionalJson,
|
|
10
|
+
sleep,
|
|
11
|
+
type Doc,
|
|
12
|
+
} from "./common.js";
|
|
13
|
+
import { corpusMetadata, records, validateBuild, type Build } from "./build.js";
|
|
14
|
+
import { branch, tag, one, type CollectionStore } from "./remote.js";
|
|
15
|
+
export type Binding = {
|
|
16
|
+
repoId: string;
|
|
17
|
+
indexId: string;
|
|
18
|
+
repoKey: string;
|
|
19
|
+
configHash: string;
|
|
20
|
+
};
|
|
21
|
+
export type Published = Doc & {
|
|
22
|
+
commitOid: string;
|
|
23
|
+
tagName: string;
|
|
24
|
+
snapshotId: string;
|
|
25
|
+
writer: string;
|
|
26
|
+
buildId: string;
|
|
27
|
+
recordsHash: string;
|
|
28
|
+
inventoryHash: string;
|
|
29
|
+
attemptId: string;
|
|
30
|
+
};
|
|
31
|
+
export type Journal = {
|
|
32
|
+
buildId: string;
|
|
33
|
+
recordsHash: string;
|
|
34
|
+
inventoryHash: string;
|
|
35
|
+
artifact: string;
|
|
36
|
+
attemptId: string;
|
|
37
|
+
writer: string;
|
|
38
|
+
source: string;
|
|
39
|
+
baseline?: Published;
|
|
40
|
+
phase: "branch" | "writing" | "waiting" | "publishing";
|
|
41
|
+
batches: {
|
|
42
|
+
operation: string;
|
|
43
|
+
hash: string;
|
|
44
|
+
outcome: "unknown" | "accepted" | "superseded";
|
|
45
|
+
}[];
|
|
46
|
+
candidate?: { name: string; snapshotId: string };
|
|
47
|
+
};
|
|
48
|
+
export function versionTag(oid: string): string {
|
|
49
|
+
return `ver-${hash(oid).slice(0, 40)}`;
|
|
50
|
+
}
|
|
51
|
+
export function versionId(oid: string): string {
|
|
52
|
+
return `version-${oid}`;
|
|
53
|
+
}
|
|
54
|
+
export async function exclusive<T>(
|
|
55
|
+
directory: string,
|
|
56
|
+
work: () => Promise<T>,
|
|
57
|
+
): Promise<T> {
|
|
58
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
59
|
+
const lock = join(directory, "writer.lock");
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(lock, { mode: 0o700 });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if ((e as NodeJS.ErrnoException).code === "EEXIST") {
|
|
64
|
+
let owner = "unknown";
|
|
65
|
+
try {
|
|
66
|
+
owner = await readFile(join(lock, "owner.json"), "utf8");
|
|
67
|
+
} catch {}
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Collection is locked: ${lock}. Owner: ${owner.trim()}. After a crash, verify no importing process is running before removing this lock directory.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await atomic(join(lock, "owner.json"), {
|
|
76
|
+
pid: process.pid,
|
|
77
|
+
startedAt: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
return await work();
|
|
80
|
+
} finally {
|
|
81
|
+
await rm(lock, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function matchesManifest(
|
|
85
|
+
doc: Doc | undefined,
|
|
86
|
+
b: Build,
|
|
87
|
+
binding: Binding,
|
|
88
|
+
attemptId?: string,
|
|
89
|
+
): boolean {
|
|
90
|
+
return (
|
|
91
|
+
!!doc &&
|
|
92
|
+
doc.kind === "manifest" &&
|
|
93
|
+
doc.role === "corpus" &&
|
|
94
|
+
doc.repoId === binding.repoId &&
|
|
95
|
+
doc.indexId === binding.indexId &&
|
|
96
|
+
doc.configHash === b.configHash &&
|
|
97
|
+
doc.commitOid === b.commitOid &&
|
|
98
|
+
doc.buildId === b.buildId &&
|
|
99
|
+
doc.recordsHash === b.recordsHash &&
|
|
100
|
+
doc.inventoryHash === b.inventoryHash &&
|
|
101
|
+
(!attemptId || doc.attemptId === attemptId)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
/** Exact immutable inventory/content comparison, not merely a marker/count check. */
|
|
105
|
+
export async function validateCandidate(
|
|
106
|
+
store: CollectionStore,
|
|
107
|
+
name: string,
|
|
108
|
+
b: Build,
|
|
109
|
+
binding: Binding,
|
|
110
|
+
attemptId: string,
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const root = await one(store, tag(name), "__manifest__");
|
|
113
|
+
if (!matchesManifest(root, b, binding, attemptId)) return false;
|
|
114
|
+
const expected = new Map(Object.entries(b.recordHashes));
|
|
115
|
+
for (const d of corpusMetadata(b, binding.repoId, binding.indexId, attemptId))
|
|
116
|
+
expected.set(d.id, hash(d));
|
|
117
|
+
for await (const d of store.list(tag(name))) {
|
|
118
|
+
if (expected.get(d.id) !== hash(d)) return false;
|
|
119
|
+
expected.delete(d.id);
|
|
120
|
+
}
|
|
121
|
+
if (expected.size) return false;
|
|
122
|
+
if (b.counts.chunks) {
|
|
123
|
+
let query: Record<string, unknown> = {
|
|
124
|
+
queryString: { query: "kind:chunk" },
|
|
125
|
+
};
|
|
126
|
+
for await (const d of records(b.directory)) {
|
|
127
|
+
if (d.kind !== "chunk") continue;
|
|
128
|
+
// Keep a complete surface token: the standard analyzer may retain dots
|
|
129
|
+
// (e.g. code.ts), so extracting an alphanumeric substring is not a valid probe.
|
|
130
|
+
const term = String(d.symbol ?? d.searchText).match(/\S+/u)?.[0];
|
|
131
|
+
if (!term) continue;
|
|
132
|
+
query = {
|
|
133
|
+
bool: [
|
|
134
|
+
{
|
|
135
|
+
queryString: {
|
|
136
|
+
query: "chunk",
|
|
137
|
+
defaultField: "kind",
|
|
138
|
+
skipSyntax: true,
|
|
139
|
+
},
|
|
140
|
+
occur: "filter",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
queryString: {
|
|
144
|
+
query: d.fileId,
|
|
145
|
+
defaultField: "fileId",
|
|
146
|
+
skipSyntax: true,
|
|
147
|
+
},
|
|
148
|
+
occur: "filter",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
queryString: {
|
|
152
|
+
query: term,
|
|
153
|
+
defaultField: "searchText",
|
|
154
|
+
skipSyntax: true,
|
|
155
|
+
},
|
|
156
|
+
occur: "must",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
const found = await store.query(name, query, 1);
|
|
163
|
+
if (
|
|
164
|
+
!found.length ||
|
|
165
|
+
found.some(
|
|
166
|
+
(h) =>
|
|
167
|
+
h.doc.kind !== "chunk" || b.recordHashes[h.doc.id] !== hash(h.doc),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
async function* batches(
|
|
175
|
+
input: AsyncIterable<Doc> | Iterable<Doc>,
|
|
176
|
+
): AsyncGenerator<Doc[]> {
|
|
177
|
+
let batch: Doc[] = [];
|
|
178
|
+
let bytes = 32;
|
|
179
|
+
for await (const doc of input) {
|
|
180
|
+
const size = Buffer.byteLength(canonical(doc)) + 1;
|
|
181
|
+
invariant(size < 4_900_000, "Document exceeds safe request limit.");
|
|
182
|
+
if (batch.length >= 500 || bytes + size > 4_900_000) {
|
|
183
|
+
yield batch;
|
|
184
|
+
batch = [];
|
|
185
|
+
bytes = 32;
|
|
186
|
+
}
|
|
187
|
+
batch.push(doc);
|
|
188
|
+
bytes += size;
|
|
189
|
+
}
|
|
190
|
+
if (batch.length) yield batch;
|
|
191
|
+
}
|
|
192
|
+
export async function published(
|
|
193
|
+
store: CollectionStore,
|
|
194
|
+
binding: Binding,
|
|
195
|
+
): Promise<Published[]> {
|
|
196
|
+
const tags = await store.tags();
|
|
197
|
+
const result: Published[] = [];
|
|
198
|
+
// Control reads include accepted writes, while code reads always pin an immutable Tag.
|
|
199
|
+
for (const t of tags.filter((t) => t.name.startsWith("ver-"))) {
|
|
200
|
+
const root = await one(store, tag(t.name), "__manifest__");
|
|
201
|
+
if (
|
|
202
|
+
!root ||
|
|
203
|
+
root.repoId !== binding.repoId ||
|
|
204
|
+
root.indexId !== binding.indexId ||
|
|
205
|
+
root.configHash !== binding.configHash
|
|
206
|
+
)
|
|
207
|
+
continue;
|
|
208
|
+
const v = await one(
|
|
209
|
+
store,
|
|
210
|
+
branch("main"),
|
|
211
|
+
versionId(String(root.commitOid)),
|
|
212
|
+
true,
|
|
213
|
+
);
|
|
214
|
+
if (
|
|
215
|
+
v?.role === "published" &&
|
|
216
|
+
v.tagName === t.name &&
|
|
217
|
+
v.snapshotId === t.snapshotId &&
|
|
218
|
+
v.buildId === root.buildId &&
|
|
219
|
+
v.attemptId === root.attemptId &&
|
|
220
|
+
v.recordsHash === root.recordsHash &&
|
|
221
|
+
v.inventoryHash === root.inventoryHash
|
|
222
|
+
)
|
|
223
|
+
result.push(v as Published);
|
|
224
|
+
}
|
|
225
|
+
return result.sort(
|
|
226
|
+
(a, b) =>
|
|
227
|
+
String(a.commitTime).localeCompare(String(b.commitTime)) ||
|
|
228
|
+
a.commitOid.localeCompare(b.commitOid),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
/** Caller holds the per-collection lock for the complete import. */
|
|
232
|
+
export async function publish(args: {
|
|
233
|
+
store: CollectionStore;
|
|
234
|
+
binding: Binding;
|
|
235
|
+
build: Build;
|
|
236
|
+
state: string;
|
|
237
|
+
baseline?: { version: Published; build: Build };
|
|
238
|
+
resume?: boolean;
|
|
239
|
+
timeoutMs?: number;
|
|
240
|
+
pollMs?: number;
|
|
241
|
+
}): Promise<Published> {
|
|
242
|
+
const { store, binding, build: b, state } = args;
|
|
243
|
+
invariant(
|
|
244
|
+
b.repoKey === binding.repoKey && b.configHash === binding.configHash,
|
|
245
|
+
"Build does not belong to this index.",
|
|
246
|
+
);
|
|
247
|
+
invariant(
|
|
248
|
+
!b.preset.embedding,
|
|
249
|
+
"CLI publication currently supports only the embedding=none preset.",
|
|
250
|
+
);
|
|
251
|
+
await validateBuild(b);
|
|
252
|
+
const journalPath = join(state, "pending.json");
|
|
253
|
+
let j = await optionalJson<Journal>(journalPath);
|
|
254
|
+
if (j) {
|
|
255
|
+
invariant(
|
|
256
|
+
j.buildId === b.buildId &&
|
|
257
|
+
j.recordsHash === b.recordsHash &&
|
|
258
|
+
j.inventoryHash === b.inventoryHash,
|
|
259
|
+
"Another build is pending. Resume the artifact recorded in pending.json before importing a new commit.",
|
|
260
|
+
);
|
|
261
|
+
invariant(
|
|
262
|
+
args.resume,
|
|
263
|
+
"Pending import found; use --resume with the same artifact.",
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const existing = (await published(store, binding)).find(
|
|
267
|
+
(v) => v.commitOid === b.commitOid,
|
|
268
|
+
);
|
|
269
|
+
if (existing) {
|
|
270
|
+
invariant(
|
|
271
|
+
await validateCandidate(
|
|
272
|
+
store,
|
|
273
|
+
existing.tagName,
|
|
274
|
+
b,
|
|
275
|
+
binding,
|
|
276
|
+
existing.attemptId,
|
|
277
|
+
),
|
|
278
|
+
"Existing published version differs from this build.",
|
|
279
|
+
);
|
|
280
|
+
await atomic(join(state, "last-build.json"), {
|
|
281
|
+
artifact: b.directory,
|
|
282
|
+
version: existing,
|
|
283
|
+
});
|
|
284
|
+
if (j) await rm(journalPath);
|
|
285
|
+
return existing;
|
|
286
|
+
}
|
|
287
|
+
if (!j) {
|
|
288
|
+
let source = "checkpoint-empty";
|
|
289
|
+
let baseline: Published | undefined;
|
|
290
|
+
if (args.baseline) {
|
|
291
|
+
const prev = args.baseline;
|
|
292
|
+
await validateBuild(prev.build);
|
|
293
|
+
invariant(
|
|
294
|
+
prev.build.repoKey === b.repoKey &&
|
|
295
|
+
prev.build.configHash === b.configHash,
|
|
296
|
+
"Baseline configuration mismatch.",
|
|
297
|
+
);
|
|
298
|
+
const current = (await store.branches()).find(
|
|
299
|
+
(x) => x.name === prev.version.writer,
|
|
300
|
+
);
|
|
301
|
+
invariant(
|
|
302
|
+
current?.snapshotId === prev.version.snapshotId,
|
|
303
|
+
"Writer baseline has changed; refusing to fork it.",
|
|
304
|
+
);
|
|
305
|
+
invariant(
|
|
306
|
+
await validateCandidate(
|
|
307
|
+
store,
|
|
308
|
+
prev.version.tagName,
|
|
309
|
+
prev.build,
|
|
310
|
+
binding,
|
|
311
|
+
prev.version.attemptId,
|
|
312
|
+
),
|
|
313
|
+
"Baseline Tag validation failed.",
|
|
314
|
+
);
|
|
315
|
+
source = prev.version.writer;
|
|
316
|
+
baseline = prev.version;
|
|
317
|
+
}
|
|
318
|
+
const attemptId = randomUUID();
|
|
319
|
+
j = {
|
|
320
|
+
buildId: b.buildId,
|
|
321
|
+
recordsHash: b.recordsHash,
|
|
322
|
+
inventoryHash: b.inventoryHash,
|
|
323
|
+
artifact: b.directory,
|
|
324
|
+
attemptId,
|
|
325
|
+
writer: `work-${attemptId}`,
|
|
326
|
+
source,
|
|
327
|
+
baseline,
|
|
328
|
+
phase: "branch",
|
|
329
|
+
batches: [],
|
|
330
|
+
};
|
|
331
|
+
await atomic(journalPath, j);
|
|
332
|
+
}
|
|
333
|
+
const save = () => atomic(journalPath, j);
|
|
334
|
+
if (j.phase === "branch") {
|
|
335
|
+
const branches = await store.branches();
|
|
336
|
+
const found = branches.find((x) => x.name === j!.writer);
|
|
337
|
+
if (found)
|
|
338
|
+
invariant(
|
|
339
|
+
found.parent === j.source &&
|
|
340
|
+
found.snapshotId === (j.baseline?.snapshotId ?? null),
|
|
341
|
+
"Partially created writer has an unexpected baseline.",
|
|
342
|
+
);
|
|
343
|
+
else {
|
|
344
|
+
const source = branches.find((x) => x.name === j!.source);
|
|
345
|
+
invariant(
|
|
346
|
+
source &&
|
|
347
|
+
(j.baseline
|
|
348
|
+
? source.snapshotId === j.baseline.snapshotId
|
|
349
|
+
: source.snapshotId === null),
|
|
350
|
+
"Source Branch is no longer the expected baseline.",
|
|
351
|
+
);
|
|
352
|
+
await store.branch(j.writer, j.source);
|
|
353
|
+
}
|
|
354
|
+
j.phase = "writing";
|
|
355
|
+
await save();
|
|
356
|
+
}
|
|
357
|
+
const metadata = corpusMetadata(
|
|
358
|
+
b,
|
|
359
|
+
binding.repoId,
|
|
360
|
+
binding.indexId,
|
|
361
|
+
j.attemptId,
|
|
362
|
+
);
|
|
363
|
+
const marker = metadata.at(-1)!;
|
|
364
|
+
if (j.phase === "writing") {
|
|
365
|
+
// Reconcile from the immutable old Tag, never from a half-written writer.
|
|
366
|
+
const oldIds = new Set<string>();
|
|
367
|
+
const oldHashes = new Map<string, string>();
|
|
368
|
+
if (j.baseline)
|
|
369
|
+
for await (const d of store.list(tag(j.baseline.tagName))) {
|
|
370
|
+
oldIds.add(d.id);
|
|
371
|
+
oldHashes.set(d.id, hash(d));
|
|
372
|
+
}
|
|
373
|
+
const newIds = new Set([
|
|
374
|
+
...Object.keys(b.recordHashes),
|
|
375
|
+
...metadata.map((d) => d.id),
|
|
376
|
+
]);
|
|
377
|
+
const obsolete = [...oldIds].filter((id) => !newIds.has(id));
|
|
378
|
+
const mutate = async (
|
|
379
|
+
operation: string,
|
|
380
|
+
payload: unknown,
|
|
381
|
+
send: () => Promise<void>,
|
|
382
|
+
) => {
|
|
383
|
+
const receipt = {
|
|
384
|
+
operation,
|
|
385
|
+
hash: hash(payload),
|
|
386
|
+
outcome: "unknown" as "unknown" | "accepted",
|
|
387
|
+
};
|
|
388
|
+
j!.batches.push(receipt);
|
|
389
|
+
await save();
|
|
390
|
+
await send();
|
|
391
|
+
receipt.outcome = "accepted";
|
|
392
|
+
await save();
|
|
393
|
+
};
|
|
394
|
+
for (let i = 0; i < obsolete.length; i += 500) {
|
|
395
|
+
const ids = obsolete.slice(i, i + 500);
|
|
396
|
+
await mutate("delete", ids, () => store.delete(j!.writer, ids));
|
|
397
|
+
}
|
|
398
|
+
async function* changed() {
|
|
399
|
+
for await (const d of records(b.directory))
|
|
400
|
+
if (oldHashes.get(d.id) !== hash(d)) yield d;
|
|
401
|
+
for (const d of metadata.slice(0, -1)) yield d;
|
|
402
|
+
}
|
|
403
|
+
for await (const docs of batches(changed()))
|
|
404
|
+
await mutate("upsert", docs, () => store.upsert(j!.writer, docs));
|
|
405
|
+
// An explicit same-build replay supersedes unknown earlier receipts only after every write ACKs.
|
|
406
|
+
await mutate("final-marker", [marker], () =>
|
|
407
|
+
store.upsert(j!.writer, [marker]),
|
|
408
|
+
);
|
|
409
|
+
for (const receipt of j.batches)
|
|
410
|
+
if (receipt.outcome === "unknown") receipt.outcome = "superseded";
|
|
411
|
+
j.phase = "waiting";
|
|
412
|
+
await save();
|
|
413
|
+
}
|
|
414
|
+
const deadline = Date.now() + (args.timeoutMs ?? 120_000);
|
|
415
|
+
const pause = args.pollMs ?? 1000;
|
|
416
|
+
let rejectedSnapshot: string | undefined;
|
|
417
|
+
while (j.phase === "waiting") {
|
|
418
|
+
invariant(
|
|
419
|
+
Date.now() < deadline,
|
|
420
|
+
"Index validation timed out; build remains unpublished. Resume this artifact with --resume.",
|
|
421
|
+
);
|
|
422
|
+
const visible = await one(store, branch(j.writer), "__manifest__", false);
|
|
423
|
+
if (!matchesManifest(visible, b, binding, j.attemptId)) {
|
|
424
|
+
await sleep(pause);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (rejectedSnapshot) {
|
|
428
|
+
const head = (await store.branches()).find(
|
|
429
|
+
(b) => b.name === j!.writer,
|
|
430
|
+
)?.snapshotId;
|
|
431
|
+
if (!head || head === rejectedSnapshot) {
|
|
432
|
+
await sleep(pause);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const candidate = await store.tag(`try-${randomUUID()}`, branch(j.writer));
|
|
437
|
+
if (
|
|
438
|
+
!(await validateCandidate(store, candidate.name, b, binding, j.attemptId))
|
|
439
|
+
) {
|
|
440
|
+
rejectedSnapshot = candidate.snapshotId;
|
|
441
|
+
await sleep(pause);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
j.candidate = candidate;
|
|
445
|
+
j.phase = "publishing";
|
|
446
|
+
await save();
|
|
447
|
+
}
|
|
448
|
+
invariant(j.candidate, "Missing validated candidate.");
|
|
449
|
+
invariant(
|
|
450
|
+
await validateCandidate(store, j.candidate.name, b, binding, j.attemptId),
|
|
451
|
+
"Validated candidate is missing or changed.",
|
|
452
|
+
);
|
|
453
|
+
const name = versionTag(b.commitOid);
|
|
454
|
+
const found = (await store.tags()).find((t) => t.name === name);
|
|
455
|
+
const final = found ?? (await store.tag(name, tag(j.candidate.name)));
|
|
456
|
+
invariant(
|
|
457
|
+
final.snapshotId === j.candidate.snapshotId,
|
|
458
|
+
"Published Tag name already points to a different snapshot.",
|
|
459
|
+
);
|
|
460
|
+
const result: Published = {
|
|
461
|
+
id: versionId(b.commitOid),
|
|
462
|
+
kind: "manifest",
|
|
463
|
+
role: "published",
|
|
464
|
+
schemaVersion: 1,
|
|
465
|
+
configHash: b.configHash,
|
|
466
|
+
repoId: binding.repoId,
|
|
467
|
+
indexId: binding.indexId,
|
|
468
|
+
commitOid: b.commitOid,
|
|
469
|
+
commitTime: b.commitTime,
|
|
470
|
+
tagName: name,
|
|
471
|
+
snapshotId: final.snapshotId,
|
|
472
|
+
writer: j.writer,
|
|
473
|
+
buildId: b.buildId,
|
|
474
|
+
recordsHash: b.recordsHash,
|
|
475
|
+
inventoryHash: b.inventoryHash,
|
|
476
|
+
attemptId: j.attemptId,
|
|
477
|
+
counts: b.counts,
|
|
478
|
+
validation: { inventory: true, content: true, query: true },
|
|
479
|
+
};
|
|
480
|
+
await store.upsert("main", [result]);
|
|
481
|
+
await atomic(join(state, "last-build.json"), {
|
|
482
|
+
artifact: b.directory,
|
|
483
|
+
version: result,
|
|
484
|
+
});
|
|
485
|
+
await rm(journalPath);
|
|
486
|
+
return result;
|
|
487
|
+
}
|
package/src/releases.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hash, invariant, type Doc } from "./common.js";
|
|
2
|
+
import { gitTags } from "./git.js";
|
|
3
|
+
import { one, branch, type CollectionStore } from "./remote.js";
|
|
4
|
+
import { published, type Binding, type Published } from "./publish.js";
|
|
5
|
+
export const releaseAlias = (ref: string) => `rel-${hash(ref).slice(0, 40)}`;
|
|
6
|
+
export const releaseId = (ref: string) => `ref-${hash(ref)}`;
|
|
7
|
+
/** Only observed local tags. Absence is not deletion authority; this command never prunes. */
|
|
8
|
+
export async function syncTags(
|
|
9
|
+
store: CollectionStore,
|
|
10
|
+
binding: Binding,
|
|
11
|
+
path: string,
|
|
12
|
+
): Promise<Doc[]> {
|
|
13
|
+
const releases = await gitTags(path),
|
|
14
|
+
versions = await published(store, binding),
|
|
15
|
+
aliases = await store.aliases(),
|
|
16
|
+
results: Doc[] = [];
|
|
17
|
+
for (const release of releases) {
|
|
18
|
+
const version = versions.find((v) => v.commitOid === release.oid);
|
|
19
|
+
const name = releaseAlias(release.ref);
|
|
20
|
+
let control: Doc = {
|
|
21
|
+
id: releaseId(release.ref),
|
|
22
|
+
kind: "manifest",
|
|
23
|
+
role: "git-ref",
|
|
24
|
+
schemaVersion: 1,
|
|
25
|
+
configHash: binding.configHash,
|
|
26
|
+
repoId: binding.repoId,
|
|
27
|
+
indexId: binding.indexId,
|
|
28
|
+
gitRef: release.ref,
|
|
29
|
+
desiredOid: release.oid,
|
|
30
|
+
aliasName: name,
|
|
31
|
+
status: "pending",
|
|
32
|
+
};
|
|
33
|
+
// Save moved-ref intent first so interrupted sync cannot expose the stale target as current.
|
|
34
|
+
await store.upsert("main", [control]);
|
|
35
|
+
if (version) {
|
|
36
|
+
const alias = aliases.find((a) => a.name === name);
|
|
37
|
+
if (
|
|
38
|
+
!alias ||
|
|
39
|
+
alias.target !== version.tagName ||
|
|
40
|
+
alias.kind !== "TAG" ||
|
|
41
|
+
alias.dangling
|
|
42
|
+
)
|
|
43
|
+
await store.alias(name, version.tagName, !!alias);
|
|
44
|
+
control = {
|
|
45
|
+
...control,
|
|
46
|
+
status: "available",
|
|
47
|
+
tagName: version.tagName,
|
|
48
|
+
snapshotId: version.snapshotId,
|
|
49
|
+
};
|
|
50
|
+
await store.upsert("main", [control]);
|
|
51
|
+
}
|
|
52
|
+
results.push(control);
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
export async function resolveVersion(
|
|
57
|
+
store: CollectionStore,
|
|
58
|
+
binding: Binding,
|
|
59
|
+
selector: string,
|
|
60
|
+
): Promise<Published> {
|
|
61
|
+
const versions = await published(store, binding);
|
|
62
|
+
const ref = selector.startsWith("refs/tags/")
|
|
63
|
+
? selector
|
|
64
|
+
: `refs/tags/${selector}`;
|
|
65
|
+
const control = await one(store, branch("main"), releaseId(ref), true);
|
|
66
|
+
// Like Git import, a known tag name takes precedence over an OID spelling.
|
|
67
|
+
// Its pending or invalid state must not fall back to an unrelated commit.
|
|
68
|
+
if (!control && !selector.startsWith("refs/")) {
|
|
69
|
+
const direct = versions.filter(
|
|
70
|
+
(v) =>
|
|
71
|
+
v.commitOid === selector ||
|
|
72
|
+
v.tagName === selector ||
|
|
73
|
+
(/^[a-f0-9]{7,64}$/.test(selector) && v.commitOid.startsWith(selector)),
|
|
74
|
+
);
|
|
75
|
+
if (direct.length) {
|
|
76
|
+
invariant(direct.length === 1, "Ambiguous commit prefix.");
|
|
77
|
+
return direct[0]!;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
invariant(
|
|
81
|
+
control?.gitRef === ref &&
|
|
82
|
+
control.repoId === binding.repoId &&
|
|
83
|
+
control.indexId === binding.indexId &&
|
|
84
|
+
control.configHash === binding.configHash,
|
|
85
|
+
"Version is not indexed or Git tag has not been synchronized.",
|
|
86
|
+
);
|
|
87
|
+
invariant(
|
|
88
|
+
control.status === "available",
|
|
89
|
+
"Git tag target is pending; import its desired commit and run git sync-tags.",
|
|
90
|
+
);
|
|
91
|
+
const alias = (await store.aliases()).find(
|
|
92
|
+
(a) => a.name === control.aliasName,
|
|
93
|
+
);
|
|
94
|
+
invariant(
|
|
95
|
+
alias?.kind === "TAG" &&
|
|
96
|
+
!alias.dangling &&
|
|
97
|
+
alias.target === control.tagName,
|
|
98
|
+
"Git tag Alias is out of sync; run git sync-tags.",
|
|
99
|
+
);
|
|
100
|
+
const result = versions.find(
|
|
101
|
+
(v) =>
|
|
102
|
+
v.commitOid === control.desiredOid &&
|
|
103
|
+
v.tagName === alias.target &&
|
|
104
|
+
v.snapshotId === control.snapshotId,
|
|
105
|
+
);
|
|
106
|
+
invariant(result, "Release target is not a published version.");
|
|
107
|
+
return result;
|
|
108
|
+
}
|