@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.
@@ -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, open, rm } from "node:fs/promises";
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(directory: string): AsyncGenerator<Doc> {
216
- const input = createReadStream(join(directory, "records.jsonl"), {
217
- encoding: "utf8",
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
- const output = await open(join(directory, "records.jsonl"), "wx", 0o600);
278
- const recordHashes: Record<string, string> = {};
279
- let writeError = false;
280
- const pending: string[] = [];
281
- let pendingBytes = 0;
282
- const writeBufferBytes = 64 * 1024;
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
- for await (const doc of records(prev.directory))
492
- if (reuse.has(doc.id)) {
493
- await emit(doc);
494
- reuse.delete(doc.id);
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 flush();
493
+ await writer.finish();
498
494
  } catch (e) {
499
495
  writeError = true;
500
496
  throw e;
501
497
  } finally {
502
498
  blobs.close();
503
- await output.close();
504
- if (writeError) await rm(directory, { recursive: true, force: true });
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.directory)) {
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.directory)) {
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.directory))
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;