@functional-systems/srcx 0.1.0-dev.38 → 0.1.0-dev.39
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 +8 -0
- package/DESIGN.md +143 -0
- package/README.md +33 -1
- package/VALIDATION.md +65 -0
- package/dist/artifact.d.ts +31 -0
- package/dist/artifact.js +237 -0
- package/dist/artifact.js.map +1 -0
- package/dist/build.d.ts +5 -2
- package/dist/build.js +48 -56
- package/dist/build.js.map +1 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/publish.js +2 -2
- package/dist/publish.js.map +1 -1
- package/package.json +2 -2
- package/src/artifact.ts +303 -0
- package/src/build.ts +55 -54
- package/src/cli.ts +15 -1
- package/src/publish.ts +2 -2
package/src/artifact.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { constants, createReadStream } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
copyFile,
|
|
4
|
+
link,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
type FileHandle,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { createHash, randomUUID, type Hash } from "node:crypto";
|
|
13
|
+
import { createInterface } from "node:readline";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { canonical, hash, invariant, type Doc } from "./common.js";
|
|
16
|
+
import type { Build } from "./build.js";
|
|
17
|
+
|
|
18
|
+
export type ArtifactStorage = "jsonl" | "segments";
|
|
19
|
+
export type RecordPart = {
|
|
20
|
+
fileId: string;
|
|
21
|
+
digest: string;
|
|
22
|
+
byteLength: number;
|
|
23
|
+
recordCount: number;
|
|
24
|
+
};
|
|
25
|
+
const bufferLimit = 64 * 1024;
|
|
26
|
+
const documentLimit = 4_800_000;
|
|
27
|
+
function requireSegmentSupport(): void {
|
|
28
|
+
invariant(
|
|
29
|
+
constants.O_NOFOLLOW && constants.O_NONBLOCK,
|
|
30
|
+
"Segmented artifacts require no-follow file-open support.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const objectPath = (directory: string, digest: string) => {
|
|
34
|
+
invariant(/^[a-f0-9]{64}$/.test(digest), "Invalid artifact object digest.");
|
|
35
|
+
return join(directory, "objects", `${digest}.jsonl`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function validateStorage(b: Build): void {
|
|
39
|
+
invariant(b.format === 1 || b.format === 2, "Unsupported artifact format.");
|
|
40
|
+
if (b.format === 1) return;
|
|
41
|
+
const included = b.inventory.filter((e) => e.status === "included");
|
|
42
|
+
const files = new Map(included.map((e) => [e.fileId, e]));
|
|
43
|
+
invariant(
|
|
44
|
+
files.size === included.length &&
|
|
45
|
+
Array.isArray(b.recordParts) &&
|
|
46
|
+
b.recordParts.length === included.length,
|
|
47
|
+
"Invalid segmented artifact inventory.",
|
|
48
|
+
);
|
|
49
|
+
const seen = new Set<string>();
|
|
50
|
+
for (const part of b.recordParts) {
|
|
51
|
+
const entry = files.get(part?.fileId);
|
|
52
|
+
invariant(
|
|
53
|
+
part &&
|
|
54
|
+
typeof part.fileId === "string" &&
|
|
55
|
+
/^f-[a-f0-9]{64}$/.test(part.fileId) &&
|
|
56
|
+
entry &&
|
|
57
|
+
Array.isArray(entry.chunkIds) &&
|
|
58
|
+
!seen.has(part.fileId) &&
|
|
59
|
+
typeof part.digest === "string" &&
|
|
60
|
+
/^[a-f0-9]{64}$/.test(part.digest) &&
|
|
61
|
+
Number.isSafeInteger(part.byteLength) &&
|
|
62
|
+
part.byteLength > 0 &&
|
|
63
|
+
Number.isSafeInteger(part.recordCount) &&
|
|
64
|
+
part.recordCount === entry.chunkIds.length + 1,
|
|
65
|
+
"Invalid segmented artifact part.",
|
|
66
|
+
);
|
|
67
|
+
seen.add(part.fileId);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function objectDirectory(directory: string): Promise<void> {
|
|
72
|
+
requireSegmentSupport();
|
|
73
|
+
const path = join(directory, "objects");
|
|
74
|
+
const info = await lstat(path);
|
|
75
|
+
invariant(
|
|
76
|
+
info.isDirectory() &&
|
|
77
|
+
!info.isSymbolicLink() &&
|
|
78
|
+
(await realpath(path)) === join(await realpath(directory), "objects"),
|
|
79
|
+
"Artifact objects must be a local regular directory.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
async function openObject(
|
|
83
|
+
directory: string,
|
|
84
|
+
part: RecordPart,
|
|
85
|
+
): Promise<FileHandle> {
|
|
86
|
+
requireSegmentSupport();
|
|
87
|
+
const file = await open(
|
|
88
|
+
objectPath(directory, part.digest),
|
|
89
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
90
|
+
);
|
|
91
|
+
try {
|
|
92
|
+
const info = await file.stat();
|
|
93
|
+
invariant(
|
|
94
|
+
info.isFile() && info.size === part.byteLength,
|
|
95
|
+
"Artifact object is not a regular file of the expected size.",
|
|
96
|
+
);
|
|
97
|
+
return file;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
await file.close();
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Digest verification completes when the iterator is exhausted; validateBuild always exhausts it. */
|
|
105
|
+
export async function* artifactRecords(b: Build): AsyncGenerator<Doc> {
|
|
106
|
+
if (b.format === 1) {
|
|
107
|
+
const input = createReadStream(join(b.directory, "records.jsonl"), {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
});
|
|
110
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
111
|
+
try {
|
|
112
|
+
for await (const line of lines) if (line) yield JSON.parse(line) as Doc;
|
|
113
|
+
} finally {
|
|
114
|
+
lines.close();
|
|
115
|
+
input.destroy();
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
validateStorage(b);
|
|
120
|
+
await objectDirectory(b.directory);
|
|
121
|
+
const files = new Map(b.inventory.map((e) => [e.fileId, e]));
|
|
122
|
+
for (const part of b.recordParts!) {
|
|
123
|
+
const file = await openObject(b.directory, part);
|
|
124
|
+
const input = file.createReadStream({
|
|
125
|
+
autoClose: false,
|
|
126
|
+
highWaterMark: bufferLimit,
|
|
127
|
+
});
|
|
128
|
+
const digest = createHash("sha256"),
|
|
129
|
+
decoder = new TextDecoder("utf-8", { fatal: true });
|
|
130
|
+
let pending = "",
|
|
131
|
+
bytes = 0,
|
|
132
|
+
count = 0;
|
|
133
|
+
try {
|
|
134
|
+
for await (const chunk of input) {
|
|
135
|
+
const data = chunk as Buffer;
|
|
136
|
+
digest.update(data);
|
|
137
|
+
bytes += data.length;
|
|
138
|
+
pending += decoder.decode(data, { stream: true });
|
|
139
|
+
let newline: number;
|
|
140
|
+
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
141
|
+
const line = pending.slice(0, newline);
|
|
142
|
+
pending = pending.slice(newline + 1);
|
|
143
|
+
invariant(
|
|
144
|
+
line.length && Buffer.byteLength(line) <= documentLimit,
|
|
145
|
+
"Invalid artifact record size.",
|
|
146
|
+
);
|
|
147
|
+
const doc = JSON.parse(line) as Doc;
|
|
148
|
+
const entry = files.get(part.fileId)!;
|
|
149
|
+
invariant(
|
|
150
|
+
count === 0
|
|
151
|
+
? doc.kind === "file" && doc.id === part.fileId
|
|
152
|
+
: doc.kind === "chunk" &&
|
|
153
|
+
doc.fileId === part.fileId &&
|
|
154
|
+
doc.id === entry.chunkIds![count - 1],
|
|
155
|
+
"Artifact part membership mismatch.",
|
|
156
|
+
);
|
|
157
|
+
count++;
|
|
158
|
+
yield doc;
|
|
159
|
+
}
|
|
160
|
+
invariant(
|
|
161
|
+
Buffer.byteLength(pending) <= documentLimit,
|
|
162
|
+
"Artifact record exceeds safe document limit.",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
pending += decoder.decode();
|
|
166
|
+
invariant(
|
|
167
|
+
!pending &&
|
|
168
|
+
bytes === part.byteLength &&
|
|
169
|
+
count === part.recordCount &&
|
|
170
|
+
digest.digest("hex") === part.digest,
|
|
171
|
+
"Artifact object digest/count mismatch.",
|
|
172
|
+
);
|
|
173
|
+
} finally {
|
|
174
|
+
input.destroy();
|
|
175
|
+
await file.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Owns only this build's output; retained objects are verified again by full build validation. */
|
|
181
|
+
export class ArtifactWriter {
|
|
182
|
+
readonly recordHashes: Record<string, string> = {};
|
|
183
|
+
readonly parts: RecordPart[] = [];
|
|
184
|
+
private output?: FileHandle;
|
|
185
|
+
private pending: string[] = [];
|
|
186
|
+
private pendingBytes = 0;
|
|
187
|
+
private part?: {
|
|
188
|
+
fileId: string;
|
|
189
|
+
temp: string;
|
|
190
|
+
digest: Hash;
|
|
191
|
+
bytes: number;
|
|
192
|
+
count: number;
|
|
193
|
+
};
|
|
194
|
+
private constructor(
|
|
195
|
+
readonly directory: string,
|
|
196
|
+
readonly storage: ArtifactStorage,
|
|
197
|
+
) {}
|
|
198
|
+
static async create(
|
|
199
|
+
directory: string,
|
|
200
|
+
storage: ArtifactStorage,
|
|
201
|
+
): Promise<ArtifactWriter> {
|
|
202
|
+
const writer = new ArtifactWriter(directory, storage);
|
|
203
|
+
if (storage === "jsonl")
|
|
204
|
+
writer.output = await open(join(directory, "records.jsonl"), "wx", 0o600);
|
|
205
|
+
else {
|
|
206
|
+
requireSegmentSupport();
|
|
207
|
+
await mkdir(join(directory, "objects"), { mode: 0o700 });
|
|
208
|
+
}
|
|
209
|
+
return writer;
|
|
210
|
+
}
|
|
211
|
+
private async flush(): Promise<void> {
|
|
212
|
+
if (!this.pending.length) return;
|
|
213
|
+
await this.output!.write(this.pending.join(""));
|
|
214
|
+
this.pending = [];
|
|
215
|
+
this.pendingBytes = 0;
|
|
216
|
+
}
|
|
217
|
+
private async finishPart(): Promise<void> {
|
|
218
|
+
await this.flush();
|
|
219
|
+
if (!this.part) return;
|
|
220
|
+
await this.output!.close();
|
|
221
|
+
this.output = undefined;
|
|
222
|
+
const part = this.part;
|
|
223
|
+
const digest = part.digest.digest("hex");
|
|
224
|
+
// Output is private to this newly created artifact; no other writer shares it.
|
|
225
|
+
await rename(part.temp, objectPath(this.directory, digest));
|
|
226
|
+
this.parts.push({
|
|
227
|
+
fileId: part.fileId,
|
|
228
|
+
digest,
|
|
229
|
+
byteLength: part.bytes,
|
|
230
|
+
recordCount: part.count,
|
|
231
|
+
});
|
|
232
|
+
this.part = undefined;
|
|
233
|
+
}
|
|
234
|
+
async emit(doc: Doc): Promise<void> {
|
|
235
|
+
const data = canonical(doc),
|
|
236
|
+
bytes = Buffer.byteLength(data);
|
|
237
|
+
invariant(
|
|
238
|
+
bytes <= documentLimit,
|
|
239
|
+
`Serialized document exceeds safe limit: ${doc.id}`,
|
|
240
|
+
);
|
|
241
|
+
invariant(!this.recordHashes[doc.id], "Duplicate document ID.");
|
|
242
|
+
this.recordHashes[doc.id] = hash(data);
|
|
243
|
+
if (this.storage === "segments") {
|
|
244
|
+
if (doc.kind === "file") {
|
|
245
|
+
await this.finishPart();
|
|
246
|
+
const temp = join(this.directory, "objects", `${randomUUID()}.tmp`);
|
|
247
|
+
this.output = await open(temp, "wx", 0o600);
|
|
248
|
+
this.part = {
|
|
249
|
+
fileId: doc.id,
|
|
250
|
+
temp,
|
|
251
|
+
digest: createHash("sha256"),
|
|
252
|
+
bytes: 0,
|
|
253
|
+
count: 0,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
invariant(this.part, "Orphan artifact chunk.");
|
|
257
|
+
this.part.digest.update(data + "\n");
|
|
258
|
+
this.part.bytes += bytes + 1;
|
|
259
|
+
this.part.count++;
|
|
260
|
+
}
|
|
261
|
+
if (this.pendingBytes + bytes + 1 > bufferLimit) await this.flush();
|
|
262
|
+
if (bytes + 1 >= bufferLimit) await this.output!.write(data + "\n");
|
|
263
|
+
else {
|
|
264
|
+
this.pending.push(data + "\n");
|
|
265
|
+
this.pendingBytes += bytes + 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async retain(
|
|
269
|
+
previous: Build,
|
|
270
|
+
part: RecordPart,
|
|
271
|
+
ids: readonly string[],
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
await this.finishPart();
|
|
274
|
+
await objectDirectory(previous.directory);
|
|
275
|
+
const file = await openObject(previous.directory, part);
|
|
276
|
+
await file.close();
|
|
277
|
+
const source = objectPath(previous.directory, part.digest),
|
|
278
|
+
target = objectPath(this.directory, part.digest);
|
|
279
|
+
try {
|
|
280
|
+
await link(source, target);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (
|
|
283
|
+
!["EXDEV", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(
|
|
284
|
+
(error as NodeJS.ErrnoException).code ?? "",
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
throw error;
|
|
288
|
+
await copyFile(source, target, constants.COPYFILE_EXCL);
|
|
289
|
+
}
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
invariant(!this.recordHashes[id], "Duplicate document ID.");
|
|
292
|
+
this.recordHashes[id] = previous.recordHashes[id]!;
|
|
293
|
+
}
|
|
294
|
+
this.parts.push({ ...part });
|
|
295
|
+
}
|
|
296
|
+
async finish(): Promise<void> {
|
|
297
|
+
await this.finishPart();
|
|
298
|
+
}
|
|
299
|
+
async close(): Promise<void> {
|
|
300
|
+
await this.output?.close();
|
|
301
|
+
this.output = undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/build.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { mkdir,
|
|
2
|
-
import { createReadStream } from "node:fs";
|
|
3
|
-
import { createInterface } from "node:readline";
|
|
1
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
4
2
|
import { join, resolve } from "node:path";
|
|
5
3
|
import { Blobs, inventory, resolveCommit, type Identity } from "./git.js";
|
|
6
4
|
import { chunk, CHUNKER, tokens } from "./chunk.js";
|
|
5
|
+
import {
|
|
6
|
+
ArtifactWriter,
|
|
7
|
+
artifactRecords,
|
|
8
|
+
validateStorage,
|
|
9
|
+
type ArtifactStorage,
|
|
10
|
+
type RecordPart,
|
|
11
|
+
} from "./artifact.js";
|
|
7
12
|
import {
|
|
8
13
|
atomic,
|
|
9
14
|
canonical,
|
|
@@ -184,7 +189,8 @@ export type InventoryItem = {
|
|
|
184
189
|
skipped?: number;
|
|
185
190
|
};
|
|
186
191
|
export type Build = {
|
|
187
|
-
format: 1;
|
|
192
|
+
format: 1 | 2;
|
|
193
|
+
recordParts?: RecordPart[];
|
|
188
194
|
directory: string;
|
|
189
195
|
repoKey: string;
|
|
190
196
|
configHash: string;
|
|
@@ -212,25 +218,18 @@ export type Embedder = {
|
|
|
212
218
|
dimensions: number;
|
|
213
219
|
embed: (text: string) => Promise<number[]>;
|
|
214
220
|
};
|
|
215
|
-
export async function* records(
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
});
|
|
219
|
-
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
220
|
-
try {
|
|
221
|
-
for await (const line of lines) if (line) yield JSON.parse(line) as Doc;
|
|
222
|
-
} finally {
|
|
223
|
-
lines.close();
|
|
224
|
-
input.destroy();
|
|
225
|
-
}
|
|
221
|
+
export async function* records(build: Build | string): AsyncGenerator<Doc> {
|
|
222
|
+
const b = typeof build === "string" ? await loadBuild(build) : build;
|
|
223
|
+
yield* artifactRecords(b);
|
|
226
224
|
}
|
|
227
225
|
export async function loadBuild(directory: string): Promise<Build> {
|
|
228
226
|
const b = await json<Build>(join(directory, "build.json"));
|
|
229
227
|
invariant(
|
|
230
|
-
b.format === 1 && b.configHash === hash(b.preset),
|
|
228
|
+
(b.format === 1 || b.format === 2) && b.configHash === hash(b.preset),
|
|
231
229
|
"Invalid build configuration.",
|
|
232
230
|
);
|
|
233
231
|
b.directory = resolve(directory);
|
|
232
|
+
validateStorage(b);
|
|
234
233
|
return b;
|
|
235
234
|
}
|
|
236
235
|
export async function materialize(args: {
|
|
@@ -239,10 +238,16 @@ export async function materialize(args: {
|
|
|
239
238
|
resolvedCommit?: Awaited<ReturnType<typeof resolveCommit>>;
|
|
240
239
|
output: string;
|
|
241
240
|
previous?: Build;
|
|
241
|
+
storage?: ArtifactStorage;
|
|
242
242
|
preset?: Preset;
|
|
243
243
|
embedder?: Embedder;
|
|
244
244
|
cache?: string;
|
|
245
245
|
}): Promise<Build> {
|
|
246
|
+
const storage = args.storage ?? "jsonl";
|
|
247
|
+
invariant(
|
|
248
|
+
["jsonl", "segments"].includes(storage),
|
|
249
|
+
"Artifact storage must be jsonl or segments.",
|
|
250
|
+
);
|
|
246
251
|
const preset = args.preset ?? PRESET,
|
|
247
252
|
configHash = hash(preset),
|
|
248
253
|
prev = args.previous;
|
|
@@ -274,35 +279,16 @@ export async function materialize(args: {
|
|
|
274
279
|
args.resolvedCommit ?? (await resolveCommit(args.identity.path, args.ref));
|
|
275
280
|
const directory = resolve(args.output);
|
|
276
281
|
await mkdir(directory, { recursive: false, mode: 0o700 });
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
async function flush() {
|
|
284
|
-
if (!pending.length) return;
|
|
285
|
-
await output.write(pending.join(""));
|
|
286
|
-
pending.length = 0;
|
|
287
|
-
pendingBytes = 0;
|
|
288
|
-
}
|
|
289
|
-
async function emit(doc: Doc) {
|
|
290
|
-
const data = canonical(doc);
|
|
291
|
-
const bytes = Buffer.byteLength(data);
|
|
292
|
-
invariant(
|
|
293
|
-
bytes <= 4_800_000,
|
|
294
|
-
`Serialized document exceeds safe limit: ${doc.id}`,
|
|
295
|
-
);
|
|
296
|
-
invariant(!recordHashes[doc.id], "Duplicate document ID.");
|
|
297
|
-
// Hash the same canonical bytes written, without serializing the record twice.
|
|
298
|
-
recordHashes[doc.id] = hash(data);
|
|
299
|
-
if (pendingBytes + bytes + 1 > writeBufferBytes) await flush();
|
|
300
|
-
if (bytes + 1 >= writeBufferBytes) await output.write(data + "\n");
|
|
301
|
-
else {
|
|
302
|
-
pending.push(data + "\n");
|
|
303
|
-
pendingBytes += bytes + 1;
|
|
304
|
-
}
|
|
282
|
+
let writer: ArtifactWriter;
|
|
283
|
+
try {
|
|
284
|
+
writer = await ArtifactWriter.create(directory, storage);
|
|
285
|
+
} catch (error) {
|
|
286
|
+
await rm(directory, { recursive: true, force: true });
|
|
287
|
+
throw error;
|
|
305
288
|
}
|
|
289
|
+
const recordHashes = writer.recordHashes;
|
|
290
|
+
let writeError = false;
|
|
291
|
+
const emit = (doc: Doc) => writer.emit(doc);
|
|
306
292
|
const items: InventoryItem[] = [];
|
|
307
293
|
const blobs = new Blobs(args.identity.path);
|
|
308
294
|
const old = new Map(prev?.inventory.map((e) => [e.pathBase64, e]) ?? []);
|
|
@@ -487,21 +473,34 @@ export async function materialize(args: {
|
|
|
487
473
|
await emit(doc);
|
|
488
474
|
}
|
|
489
475
|
}
|
|
490
|
-
if (prev && reuse.size)
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
reuse.
|
|
476
|
+
if (prev && reuse.size) {
|
|
477
|
+
if (storage === "segments" && prev.format === 2) {
|
|
478
|
+
const files = new Map(prev.inventory.map((e) => [e.fileId, e]));
|
|
479
|
+
for (const part of prev.recordParts!) {
|
|
480
|
+
if (!reuse.has(part.fileId)) continue;
|
|
481
|
+
const ids = [part.fileId, ...files.get(part.fileId)!.chunkIds!];
|
|
482
|
+
await writer.retain(prev, part, ids);
|
|
483
|
+
for (const id of ids) reuse.delete(id);
|
|
495
484
|
}
|
|
485
|
+
} else
|
|
486
|
+
for await (const doc of records(prev))
|
|
487
|
+
if (reuse.has(doc.id)) {
|
|
488
|
+
await emit(doc);
|
|
489
|
+
reuse.delete(doc.id);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
496
492
|
invariant(reuse.size === 0, "Previous artifact lacks retained records.");
|
|
497
|
-
await
|
|
493
|
+
await writer.finish();
|
|
498
494
|
} catch (e) {
|
|
499
495
|
writeError = true;
|
|
500
496
|
throw e;
|
|
501
497
|
} finally {
|
|
502
498
|
blobs.close();
|
|
503
|
-
|
|
504
|
-
|
|
499
|
+
try {
|
|
500
|
+
await writer.close();
|
|
501
|
+
} finally {
|
|
502
|
+
if (writeError) await rm(directory, { recursive: true, force: true });
|
|
503
|
+
}
|
|
505
504
|
}
|
|
506
505
|
const changes: Build["changes"] = {
|
|
507
506
|
added: [],
|
|
@@ -523,7 +522,8 @@ export async function materialize(args: {
|
|
|
523
522
|
for (const e of old.values())
|
|
524
523
|
if (!now.has(e.pathBase64)) changes.deleted.push(e.path);
|
|
525
524
|
const build: Build = {
|
|
526
|
-
format: 1,
|
|
525
|
+
format: storage === "segments" ? 2 : 1,
|
|
526
|
+
...(storage === "segments" ? { recordParts: writer.parts } : {}),
|
|
527
527
|
directory,
|
|
528
528
|
repoKey: args.identity.key,
|
|
529
529
|
configHash,
|
|
@@ -587,6 +587,7 @@ export async function validateBuild(b: Build): Promise<void> {
|
|
|
587
587
|
hash(b.inventory) === b.inventoryHash,
|
|
588
588
|
"Build metadata hash mismatch.",
|
|
589
589
|
);
|
|
590
|
+
validateStorage(b);
|
|
590
591
|
const seen = new Set<string>();
|
|
591
592
|
let activeFile: Doc | undefined;
|
|
592
593
|
let end = 0,
|
|
@@ -609,7 +610,7 @@ export async function validateBuild(b: Build): Promise<void> {
|
|
|
609
610
|
completedFiles.add(activeFile.id);
|
|
610
611
|
}
|
|
611
612
|
};
|
|
612
|
-
for await (const doc of records(b
|
|
613
|
+
for await (const doc of records(b)) {
|
|
613
614
|
invariant(
|
|
614
615
|
!seen.has(doc.id) && b.recordHashes[doc.id] === hash(doc),
|
|
615
616
|
"Corrupted or unexpected artifact record.",
|
package/src/cli.ts
CHANGED
|
@@ -209,6 +209,14 @@ cli
|
|
|
209
209
|
),
|
|
210
210
|
)
|
|
211
211
|
.option("--output <directory>", "New artifact directory")
|
|
212
|
+
.addOption(
|
|
213
|
+
new Option(
|
|
214
|
+
"--artifact-storage <storage>",
|
|
215
|
+
"Experimental local artifact storage",
|
|
216
|
+
)
|
|
217
|
+
.choices(["jsonl", "segments"])
|
|
218
|
+
.default("jsonl"),
|
|
219
|
+
)
|
|
212
220
|
.option(
|
|
213
221
|
"--previous <directory>",
|
|
214
222
|
"Previous build artifact for a dry-run diff",
|
|
@@ -219,7 +227,7 @@ cli
|
|
|
219
227
|
"Resume the same pending artifact after inspecting its journal",
|
|
220
228
|
)
|
|
221
229
|
.option("--timeout <seconds>", "Validation wait deadline", integer, 120)
|
|
222
|
-
.action(async (o) => {
|
|
230
|
+
.action(async (o, command) => {
|
|
223
231
|
invariant(
|
|
224
232
|
!o.embedding || (o.dryRun && o.path),
|
|
225
233
|
"--embedding is only accepted with --dry-run --path; connected imports use the repository preset.",
|
|
@@ -242,6 +250,10 @@ cli
|
|
|
242
250
|
!(o.artifact && (o.ref || o.path || o.output || o.previous)),
|
|
243
251
|
"--artifact cannot be combined with --ref/--path/--output/--previous.",
|
|
244
252
|
);
|
|
253
|
+
invariant(
|
|
254
|
+
!o.artifact || command.getOptionValueSource("artifactStorage") !== "cli",
|
|
255
|
+
"--artifact-storage cannot be combined with --artifact; the artifact records its storage format.",
|
|
256
|
+
);
|
|
245
257
|
const buildLocal = async (
|
|
246
258
|
source: Awaited<ReturnType<typeof identity>>,
|
|
247
259
|
previous?: Build,
|
|
@@ -271,6 +283,7 @@ cli
|
|
|
271
283
|
resolvedCommit: commit,
|
|
272
284
|
output: path,
|
|
273
285
|
previous,
|
|
286
|
+
storage: o.artifactStorage,
|
|
274
287
|
preset,
|
|
275
288
|
});
|
|
276
289
|
};
|
|
@@ -300,6 +313,7 @@ cli
|
|
|
300
313
|
output({
|
|
301
314
|
status: "locally-validated",
|
|
302
315
|
artifact: b.directory,
|
|
316
|
+
artifactFormat: b.format,
|
|
303
317
|
commitOid: b.commitOid,
|
|
304
318
|
configHash: b.configHash,
|
|
305
319
|
counts: b.counts,
|
package/src/publish.ts
CHANGED
|
@@ -174,7 +174,7 @@ export async function validateCandidate(
|
|
|
174
174
|
let query: Record<string, unknown> = {
|
|
175
175
|
queryString: { query: "kind:chunk" },
|
|
176
176
|
};
|
|
177
|
-
for await (const d of records(b
|
|
177
|
+
for await (const d of records(b)) {
|
|
178
178
|
if (d.kind !== "chunk") continue;
|
|
179
179
|
// Probe an indexed chunk ID: a valid source may contain only stop words
|
|
180
180
|
// or punctuation, so text-query matches cannot establish readiness.
|
|
@@ -623,7 +623,7 @@ export async function publish(args: {
|
|
|
623
623
|
await mutate("delete", ids, () => store.delete(j!.writer, ids));
|
|
624
624
|
}
|
|
625
625
|
async function* changed() {
|
|
626
|
-
for await (const d of records(b
|
|
626
|
+
for await (const d of records(b))
|
|
627
627
|
if (oldHashes.get(d.id) !== hash(d)) yield d;
|
|
628
628
|
for (const d of metadata.slice(0, -1))
|
|
629
629
|
if (oldHashes.get(d.id) !== hash(d)) yield d;
|