@littlebigbrain/create 0.4.0 → 0.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/create",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Create a project from a little big brain starter workflow template: npm create @littlebigbrain",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -358,10 +358,23 @@ export interface MockFile {
358
358
  * files: `files.content` and `workflows.derivations.check` / `record`, the
359
359
  * shape of an `LbbClient`. `upload` puts the file's `Document` in `graph`
360
360
  * with its name, SHA-256 and media type, as the upload route does.
361
+ *
362
+ * Each upload has its `uploaded_at`, as the server's file record: an upload
363
+ * of a live file keeps it, an upload after `remove` gets a new one. A
364
+ * derivation belongs to the upload of its record. The check names the
365
+ * upload of a live file; a removed file has no upload and no derivation.
366
+ * A record that names another upload than the live one throws `LbbError`
367
+ * 409 `derivation_upload_changed`.
361
368
  */
362
369
  export class MockContent {
363
370
  readonly held = new Map<string, MockFile>();
364
371
  readonly recorded = new Map<string, Schemas["Derivation"]>();
372
+ /** The upload of each content's file, live or removed. */
373
+ readonly uploads = new Map<string, { uploadedAt: string; live: boolean }>();
374
+ /** The upload each recorded derivation belongs to. */
375
+ private readonly stamps = new Map<string, string | undefined>();
376
+ /** Every derivation record request, in order. */
377
+ readonly records: Schemas["DerivationRecordRequest"][] = [];
365
378
  /** Reads of a file's bytes. */
366
379
  reads = 0;
367
380
  /** Derivation checks. */
@@ -373,6 +386,11 @@ export class MockContent {
373
386
  upload(name: string, body: string | Uint8Array, mediaType: string): MockFile {
374
387
  const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
375
388
  const sha256 = createHash("sha256").update(bytes).digest("hex");
389
+ if (!this.uploads.get(sha256)?.live) {
390
+ this.newUploads += 1;
391
+ const at = new Date(Date.UTC(2026, 9, 15, 9) + this.newUploads * 1_000);
392
+ this.uploads.set(sha256, { uploadedAt: at.toISOString(), live: true });
393
+ }
376
394
  const doc = this.graph.entity({ type: "Document", name, key: `file:${sha256}` });
377
395
  doc.name = name;
378
396
  Object.assign(doc.properties, {
@@ -386,6 +404,22 @@ export class MockContent {
386
404
  return file;
387
405
  }
388
406
 
407
+ /** Uploads that gave a content a new `uploaded_at`. */
408
+ private newUploads = 0;
409
+
410
+ /** Delete a file, as `DELETE /v1/files`: its bytes are no longer read; its `Document` stays here. */
411
+ remove(sha256: string): void {
412
+ const upload = this.uploads.get(sha256);
413
+ if (!upload?.live) throw new Error(`no live file ${sha256}`);
414
+ this.uploads.set(sha256, { ...upload, live: false });
415
+ this.held.delete(sha256);
416
+ }
417
+
418
+ /** The `uploaded_at` of a content's file. */
419
+ uploadedAt(sha256: string): string | undefined {
420
+ return this.uploads.get(sha256)?.uploadedAt;
421
+ }
422
+
389
423
  /** The byte range of each read, `null` for a whole file, in order. */
390
424
  readonly ranges: Array<{ start: number; end: number | null } | null> = [];
391
425
  /** The bytes all reads answered. */
@@ -428,14 +462,30 @@ export class MockContent {
428
462
  derivations: {
429
463
  check: async (key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]> => {
430
464
  this.checks += 1;
431
- const derivation = this.recorded.get(derivationName(key));
432
- return derivation ? { exists: true, derivation } : { exists: false };
465
+ const file = this.uploads.get(key.sha256);
466
+ const upload = file?.live ? file.uploadedAt : undefined;
467
+ const derivation =
468
+ file && !file.live ? undefined : this.current(derivationName(key), key.sha256);
469
+ return {
470
+ exists: Boolean(derivation),
471
+ ...(derivation ? { derivation } : {}),
472
+ ...(upload ? { content_uploaded_at: upload } : {}),
473
+ };
433
474
  },
434
475
  record: async (
435
476
  request: Schemas["DerivationRecordRequest"],
436
477
  ): Promise<Schemas["DerivationRecordResponse"]> => {
478
+ this.records.push(request);
479
+ const named = request.content_uploaded_at;
480
+ const upload = this.uploads.get(request.sha256);
481
+ if (named && !(upload?.live && upload.uploadedAt === named))
482
+ throw new LbbError(409, "{}", {
483
+ code: "derivation_upload_changed",
484
+ message: `the file was deleted since upload ${named}: nothing was recorded`,
485
+ param: "content_uploaded_at",
486
+ });
437
487
  const name = derivationName(request);
438
- const kept = this.recorded.get(name);
488
+ const kept = this.current(name, request.sha256);
439
489
  if (kept) return { recorded: false, derivation: kept };
440
490
  const outputs = JSON.stringify(request.outputs ?? null);
441
491
  if (outputs.length > 16 * 1024)
@@ -453,10 +503,19 @@ export class MockContent {
453
503
  recorded_at: "2026-10-15T09:00:00Z",
454
504
  };
455
505
  this.recorded.set(name, derivation);
506
+ this.stamps.set(name, upload?.uploadedAt);
456
507
  return { recorded: true, derivation };
457
508
  },
458
509
  },
459
510
  };
511
+
512
+ /** The derivation `name` when it belongs to the current upload of its content. */
513
+ private current(name: string, sha256: string): Schemas["Derivation"] | undefined {
514
+ const derivation = this.recorded.get(name);
515
+ const upload = this.uploadedAt(sha256);
516
+ if (!derivation || (upload !== undefined && this.stamps.get(name) !== upload)) return undefined;
517
+ return derivation;
518
+ }
460
519
  }
461
520
 
462
521
  const derivationName = (key: Schemas["DerivationKey"]) =>
@@ -62,7 +62,14 @@ same offset. A cursor without `byte`, from an earlier version of the
62
62
  template, reads the file again from its start. The last turn
63
63
  records the derivation with what it wrote: the rows, the instances, the
64
64
  columns it mapped and left out, the instance the file represents, and the
65
- commits of its writes.
65
+ commits of its writes. The record names the upload the check
66
+ answered (`content_uploaded_at`). The cursor carries it from turn to turn.
67
+ When the file was deleted while the run read it, the record writes nothing
68
+ (`409 derivation_upload_changed`). The run ends and counts the file as
69
+ skipped. The run of a new upload reads the file again. Say the file was
70
+ deleted and not uploaded again. The next read of its bytes answers
71
+ `404 file_not_found`. The run ends there too, and counts the file as
72
+ skipped.
66
73
 
67
74
  The parser follows RFC 4180: fields in double quotes can hold the
68
75
  delimiter, line breaks and quotes (`""`). A row whose quoted field holds a
@@ -526,6 +526,62 @@ test("a turn that continues starts at the byte offset where the last one stopped
526
526
  assert.equal(restarted.turn.keys[0], "check");
527
527
  });
528
528
 
529
+ test("a file deleted and uploaded again while it is read records nothing; the new upload's run reads it", async () => {
530
+ const csv = customerList(10);
531
+ const settings = { ...EVERY_FILE, rowsPerTurn: 4 };
532
+ const { lbb, content, definition } = setup(settings, []);
533
+ const file = content.upload("customers-ten.csv", csv, "text/csv");
534
+ const first = content.uploadedAt(file.sha256);
535
+
536
+ // Turn 1 checks the file; the turns after it carry the upload the check named.
537
+ const one = await runTurn<RowsState>(definition, {}, contentMessage("rows", file));
538
+ type Continued = { cursor: { plan: { uploadedAt?: string } } };
539
+ const carried = one.result.continuation!.message as unknown as Continued;
540
+ assert.equal(carried.cursor.plan.uploadedAt, first);
541
+
542
+ // The file is deleted and uploaded again; the old run reads on to its end.
543
+ content.remove(file.sha256);
544
+ const again = content.upload("customers-ten.csv", csv, "text/csv");
545
+ assert.notEqual(content.uploadedAt(again.sha256), first);
546
+ const old = await runMessage<RowsState>(
547
+ definition,
548
+ one.result.continuation!.message,
549
+ one.result.state,
550
+ );
551
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, rows: 10, instances: 10, represents: 0 });
552
+ assert.equal(content.records.at(-1)?.content_uploaded_at, first);
553
+ assert.equal(content.recorded.size, 0, "nothing is recorded for the deleted upload");
554
+
555
+ // The run of the new upload checks, reads the file and records its derivation.
556
+ const fresh = await runMessage<RowsState>(definition, contentMessage("rows", again));
557
+ assert.equal(fresh.state.contents, 1);
558
+ assert.equal(fresh.state.skipped, 0);
559
+ assert.equal(content.records.at(-1)?.content_uploaded_at, content.uploadedAt(again.sha256));
560
+ assert.equal(content.recorded.size, 1);
561
+ assertEveryRow(lbb.graph, again, csv);
562
+ });
563
+
564
+ test("a file deleted while it is read, and not uploaded again, ends the run as skipped", async () => {
565
+ const csv = customerList(10);
566
+ const settings = { ...EVERY_FILE, rowsPerTurn: 4 };
567
+ const { content, definition } = setup(settings, []);
568
+ const file = content.upload("customers-ten.csv", csv, "text/csv");
569
+ const one = await runTurn<RowsState>(definition, {}, contentMessage("rows", file));
570
+ assert.equal(one.result.state.rows, 4);
571
+
572
+ // The next read of the bytes answers 404: the run ends without an error.
573
+ content.remove(file.sha256);
574
+ const old = await runMessage<RowsState>(
575
+ definition,
576
+ one.result.continuation!.message,
577
+ one.result.state,
578
+ );
579
+ assert.equal(old.turns.length, 1, "the run ends at the turn that read nothing");
580
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, rows: 4, instances: 4, represents: 0 });
581
+ assert.equal(content.records.length, 0, "no derivation is recorded");
582
+ assert.equal(content.recorded.size, 0);
583
+ });
584
+
529
585
  test("a row longer than the longest row stops the read: a quote that never closes", async () => {
530
586
  const bytes = new TextEncoder().encode(`a,b\n1,"open${"x".repeat(500)}`);
531
587
  await assert.rejects(
@@ -18,7 +18,11 @@
18
18
  // header is row 1);
19
19
  // 4. with `represents`, writes `Document REPRESENTS <instance>` when the
20
20
  // file's name names exactly one instance of that class;
21
- // 5. records the derivation.
21
+ // 5. records the derivation, for the upload its check named. When the file
22
+ // was deleted (or deleted and uploaded again) since, nothing is recorded,
23
+ // and the run of the new upload reads the file again. A file deleted and
24
+ // not uploaded again answers 404 to the next read of its bytes: the run
25
+ // ends there and counts the file as skipped.
22
26
  //
23
27
  // A turn reads at most `rowsPerTurn` rows; the rest goes to a new turn
24
28
  // (`ctx.continue`) that starts where the last one stopped. The file is read
@@ -29,6 +33,7 @@ import { createHash } from "node:crypto";
29
33
  import {
30
34
  LbbError,
31
35
  entityRef,
36
+ isDerivationUploadChanged,
32
37
  triggeredWorkflow,
33
38
  type LbbActivities,
34
39
  type Schemas,
@@ -138,7 +143,7 @@ export interface ContentPorts {
138
143
  export interface RowsState {
139
144
  /** Files read to their end. */
140
145
  contents: number;
141
- /** Files skipped: read before with these settings, another name, or no file. */
146
+ /** Files skipped: read before with these settings, another name, no file, or deleted while read. */
142
147
  skipped: number;
143
148
  rows: number;
144
149
  instances: number;
@@ -165,6 +170,8 @@ interface Plan {
165
170
  key: number | null;
166
171
  label: number | null;
167
172
  represents: string | null;
173
+ /** The upload of the file the check named: the record names it. */
174
+ uploadedAt?: string;
168
175
  }
169
176
 
170
177
  /** Where a file's next turn starts, and what its turns did so far. */
@@ -196,9 +203,11 @@ interface FileInfo {
196
203
  mediaType: string;
197
204
  }
198
205
 
199
- /** What the check of a file found: the file to read, or why it is skipped. */
206
+ /** What the check of a file found: the file to read and its upload, or why it is skipped. */
200
207
  interface Checked {
201
208
  file?: FileInfo;
209
+ /** The `content_uploaded_at` of the check: absent without a file record. */
210
+ uploadedAt?: string;
202
211
  skip?: string;
203
212
  }
204
213
 
@@ -227,6 +236,10 @@ const SCALAR: Record<string, ValueType> = {
227
236
  /** The media type without parameters, in lower case. */
228
237
  const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
229
238
 
239
+ /** Whether an error is the 404 of a file's bytes: the file was deleted and not uploaded again. */
240
+ export const fileGone = (error: unknown) =>
241
+ error instanceof LbbError && error.status === 404 && error.code === "file_not_found";
242
+
230
243
  /** A file name without its last extension. */
231
244
  export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
232
245
 
@@ -381,158 +394,178 @@ export function createWorkflow(
381
394
  delete plain.cursor;
382
395
  return ctx.continue(next, plain);
383
396
  };
384
- const document = message.instances[0];
385
- if (!document) return { state };
386
- // A cursor without a byte offset (a message of an earlier version of
387
- // this template) reads the file again from its start.
388
- let cursor = typeof message.cursor?.byte === "number" ? message.cursor : undefined;
389
- // The bytes the header read holds after the header, for the rows of
390
- // the same turn: they are not read again.
391
- let held: HeldBytes | undefined;
392
-
393
- if (!cursor) {
394
- const checked = await ctx.step("check", async (s): Promise<Checked> => {
395
- s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
396
- const file = await readDocument(message, document);
397
- if (!file) return { skip: "no file" };
398
- const named = /\.(csv|tsv)$/i.test(file.name);
399
- if (!MEDIA_TYPES.includes(file.mediaType) && !named) return { skip: "media type" };
400
- if (match && !match.test(file.name)) return { skip: "name" };
401
- const found = await ports.workflows.derivations.check(
402
- derivationKey(file.sha256, settings),
403
- );
404
- return found.exists ? { skip: "derived" } : { file };
405
- });
406
- if (!checked.file) {
407
- const next = { ...state, skipped: state.skipped + 1 };
408
- return rest(next);
397
+ try {
398
+ const document = message.instances[0];
399
+ if (!document) return { state };
400
+ // A cursor without a byte offset (a message of an earlier version of
401
+ // this template) reads the file again from its start.
402
+ let cursor = typeof message.cursor?.byte === "number" ? message.cursor : undefined;
403
+ // The bytes the header read holds after the header, for the rows of
404
+ // the same turn: they are not read again.
405
+ let held: HeldBytes | undefined;
406
+
407
+ if (!cursor) {
408
+ const checked = await ctx.step("check", async (s): Promise<Checked> => {
409
+ s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
410
+ const file = await readDocument(message, document);
411
+ if (!file) return { skip: "no file" };
412
+ const named = /\.(csv|tsv)$/i.test(file.name);
413
+ if (!MEDIA_TYPES.includes(file.mediaType) && !named) return { skip: "media type" };
414
+ if (match && !match.test(file.name)) return { skip: "name" };
415
+ const found = await ports.workflows.derivations.check(
416
+ derivationKey(file.sha256, settings),
417
+ );
418
+ if (found.exists) return { skip: "derived" };
419
+ const uploadedAt = found.content_uploaded_at ?? undefined;
420
+ return uploadedAt ? { file, uploadedAt } : { file };
421
+ });
422
+ if (!checked.file) {
423
+ const next = { ...state, skipped: state.skipped + 1 };
424
+ return rest(next);
425
+ }
426
+ const file = checked.file;
427
+ const planned = await ctx.step("plan", async (s) => {
428
+ s.progress("reading the header", { done: 0, total: 1, unit: "documents" });
429
+ const delimiter = delimiterOf(file.mediaType, file.name);
430
+ const header = await readRecords(rangeOf(file.sha256), delimiter, 0, 1, 1, windows);
431
+ held = header.held;
432
+ return plan(file, delimiter, header, checked.uploadedAt);
433
+ });
434
+ const first: number[] = [];
435
+ if (planned.plan.represents) {
436
+ const written = await lbb.writeOwned(ctx, "represents", (s) => {
437
+ s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
438
+ const p = planned.plan;
439
+ return {
440
+ subjects: [entityRef(p.document, "Document", p.name)],
441
+ relations: [REPRESENTS],
442
+ triplets: [
443
+ {
444
+ source: entityRef(p.document, "Document", p.name),
445
+ relation: REPRESENTS,
446
+ target: entityRef(
447
+ p.represents!,
448
+ settings.represents!.class,
449
+ planned.label ?? undefined,
450
+ ),
451
+ confidence: 1,
452
+ evidence: { text: p.name, source_id: p.document },
453
+ },
454
+ ],
455
+ };
456
+ });
457
+ if (written.commit_seq !== null) first.push(written.commit_seq);
458
+ }
459
+ cursor = {
460
+ byte: planned.at,
461
+ row: 2,
462
+ plan: planned.plan,
463
+ done: { rows: 0, instances: 0, keyless: 0, rejected: 0, commits: first },
464
+ };
409
465
  }
410
- const file = checked.file;
411
- const planned = await ctx.step("plan", async (s) => {
412
- s.progress("reading the header", { done: 0, total: 1, unit: "documents" });
413
- const delimiter = delimiterOf(file.mediaType, file.name);
414
- const header = await readRecords(rangeOf(file.sha256), delimiter, 0, 1, 1, windows);
415
- held = header.held;
416
- return plan(file, delimiter, header);
466
+
467
+ // The rows of this turn, read from the byte offset where the last turn
468
+ // stopped, then their writes in parts. A turn run again after a crash
469
+ // reads its rows again from the same offset.
470
+ const from = cursor;
471
+ const filePlan = from.plan;
472
+ let rows: Promise<RecordsRead> | undefined;
473
+ const turnRows = () =>
474
+ (rows ??= readRecords(
475
+ rangeOf(filePlan.sha256),
476
+ filePlan.delimiter,
477
+ from.byte,
478
+ from.row,
479
+ settings.rowsPerTurn,
480
+ windows,
481
+ held,
482
+ ));
483
+ const windowRecords = async (): Promise<CsvRecord[]> => (await turnRows()).records;
484
+ const window = await ctx.step("rows", async (s) => {
485
+ s.progress("reading rows", { done: 0, unit: "rows" });
486
+ const read = await turnRows();
487
+ const taken = read.records;
488
+ s.progress("reading rows", { done: taken.length, unit: "rows" });
489
+ const counts = { instances: 0, keyless: 0, rejected: 0 };
490
+ for (const record of taken) {
491
+ const built = rowFacts(filePlan, record);
492
+ if (built === "blank") continue;
493
+ if (built === "keyless") counts.keyless += 1;
494
+ else {
495
+ counts.instances += 1;
496
+ counts.rejected += built.rejected;
497
+ }
498
+ }
499
+ return { count: taken.length, byte: read.next, row: read.row, end: read.end, ...counts };
417
500
  });
418
- const first: number[] = [];
419
- if (planned.plan.represents) {
420
- const written = await lbb.writeOwned(ctx, "represents", (s) => {
421
- s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
422
- const p = planned.plan;
423
- return {
424
- subjects: [entityRef(p.document, "Document", p.name)],
425
- relations: [REPRESENTS],
426
- triplets: [
427
- {
428
- source: entityRef(p.document, "Document", p.name),
429
- relation: REPRESENTS,
430
- target: entityRef(
431
- p.represents!,
432
- settings.represents!.class,
433
- planned.label ?? undefined,
434
- ),
435
- confidence: 1,
436
- evidence: { text: p.name, source_id: p.document },
437
- },
438
- ],
439
- };
501
+ const commits: number[] = [];
502
+ for (let n = 0; n * PART < window.count; n++) {
503
+ const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
504
+ s.progress("writing facts", { done: n * PART, total: window.count, unit: "rows" });
505
+ const part = (await windowRecords()).slice(n * PART, (n + 1) * PART);
506
+ return partWrite(filePlan, part);
440
507
  });
441
- if (written.commit_seq !== null) first.push(written.commit_seq);
508
+ if (written.commit_seq !== null) commits.push(written.commit_seq);
442
509
  }
443
- cursor = {
444
- byte: planned.at,
445
- row: 2,
446
- plan: planned.plan,
447
- done: { rows: 0, instances: 0, keyless: 0, rejected: 0, commits: first },
510
+ const done: Done = {
511
+ rows: from.done.rows + window.count,
512
+ instances: from.done.instances + window.instances,
513
+ keyless: from.done.keyless + window.keyless,
514
+ rejected: from.done.rejected + window.rejected,
515
+ commits: [...from.done.commits, ...commits].slice(0, 256),
448
516
  };
449
- }
517
+ const counted = {
518
+ ...state,
519
+ rows: state.rows + window.count,
520
+ instances: state.instances + window.instances,
521
+ };
522
+ if (!window.end)
523
+ return ctx.continue(counted, {
524
+ ...message,
525
+ removed: [],
526
+ cursor: { byte: window.byte, row: window.row, plan: filePlan, done },
527
+ });
450
528
 
451
- // The rows of this turn, read from the byte offset where the last turn
452
- // stopped, then their writes in parts. A turn run again after a crash
453
- // reads its rows again from the same offset.
454
- const from = cursor;
455
- const filePlan = from.plan;
456
- let rows: Promise<RecordsRead> | undefined;
457
- const turnRows = () =>
458
- (rows ??= readRecords(
459
- rangeOf(filePlan.sha256),
460
- filePlan.delimiter,
461
- from.byte,
462
- from.row,
463
- settings.rowsPerTurn,
464
- windows,
465
- held,
466
- ));
467
- const windowRecords = async (): Promise<CsvRecord[]> => (await turnRows()).records;
468
- const window = await ctx.step("rows", async (s) => {
469
- s.progress("reading rows", { done: 0, unit: "rows" });
470
- const read = await turnRows();
471
- const taken = read.records;
472
- s.progress("reading rows", { done: taken.length, unit: "rows" });
473
- const counts = { instances: 0, keyless: 0, rejected: 0 };
474
- for (const record of taken) {
475
- const built = rowFacts(filePlan, record);
476
- if (built === "blank") continue;
477
- if (built === "keyless") counts.keyless += 1;
478
- else {
479
- counts.instances += 1;
480
- counts.rejected += built.rejected;
529
+ const recorded = await ctx.step("record", async (s) => {
530
+ s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
531
+ try {
532
+ const answer = await ports.workflows.derivations.record({
533
+ ...derivationKey(filePlan.sha256, settings),
534
+ run_id: ctx.messageId,
535
+ outputs: {
536
+ rows: done.rows,
537
+ instances: done.instances,
538
+ rows_without_key: done.keyless,
539
+ values_left_out: done.rejected,
540
+ columns: filePlan.columns.slice(0, 100).map((c) => `${c.header} → ${c.property}`),
541
+ columns_left_out: filePlan.left,
542
+ represents: filePlan.represents,
543
+ commits: done.commits,
544
+ },
545
+ ...(filePlan.uploadedAt ? { content_uploaded_at: filePlan.uploadedAt } : {}),
546
+ });
547
+ return answer.recorded;
548
+ } catch (error) {
549
+ // The file was deleted while this run read it: nothing is
550
+ // recorded, and the run of a new upload reads it again.
551
+ if (isDerivationUploadChanged(error)) return "upload changed";
552
+ throw error;
481
553
  }
482
- }
483
- return { count: taken.length, byte: read.next, row: read.row, end: read.end, ...counts };
484
- });
485
- const commits: number[] = [];
486
- for (let n = 0; n * PART < window.count; n++) {
487
- const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
488
- s.progress("writing facts", { done: n * PART, total: window.count, unit: "rows" });
489
- const part = (await windowRecords()).slice(n * PART, (n + 1) * PART);
490
- return partWrite(filePlan, part);
491
- });
492
- if (written.commit_seq !== null) commits.push(written.commit_seq);
493
- }
494
- const done: Done = {
495
- rows: from.done.rows + window.count,
496
- instances: from.done.instances + window.instances,
497
- keyless: from.done.keyless + window.keyless,
498
- rejected: from.done.rejected + window.rejected,
499
- commits: [...from.done.commits, ...commits].slice(0, 256),
500
- };
501
- const counted = {
502
- ...state,
503
- rows: state.rows + window.count,
504
- instances: state.instances + window.instances,
505
- };
506
- if (!window.end)
507
- return ctx.continue(counted, {
508
- ...message,
509
- removed: [],
510
- cursor: { byte: window.byte, row: window.row, plan: filePlan, done },
511
554
  });
512
-
513
- await ctx.step("record", async (s) => {
514
- s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
515
- const recorded = await ports.workflows.derivations.record({
516
- ...derivationKey(filePlan.sha256, settings),
517
- run_id: ctx.messageId,
518
- outputs: {
519
- rows: done.rows,
520
- instances: done.instances,
521
- rows_without_key: done.keyless,
522
- values_left_out: done.rejected,
523
- columns: filePlan.columns.slice(0, 100).map((c) => `${c.header} → ${c.property}`),
524
- columns_left_out: filePlan.left,
525
- represents: filePlan.represents,
526
- commits: done.commits,
527
- },
555
+ if (recorded === "upload changed")
556
+ return rest({ ...counted, skipped: counted.skipped + 1 });
557
+ return rest({
558
+ ...counted,
559
+ contents: counted.contents + 1,
560
+ represents: counted.represents + (filePlan.represents ? 1 : 0),
528
561
  });
529
- return recorded.recorded;
530
- });
531
- return rest({
532
- ...counted,
533
- contents: counted.contents + 1,
534
- represents: counted.represents + (filePlan.represents ? 1 : 0),
535
- });
562
+ } catch (error) {
563
+ // The file was deleted while this run read it, and not uploaded
564
+ // again: its bytes answer 404. Nothing is recorded, and the file
565
+ // counts as skipped.
566
+ if (!fileGone(error)) throw error;
567
+ return rest({ ...state, skipped: state.skipped + 1 });
568
+ }
536
569
  },
537
570
  });
538
571
 
@@ -557,8 +590,8 @@ export function createWorkflow(
557
590
  };
558
591
  }
559
592
 
560
- /** The plan of a file: its delimiter, the mapped columns, the key and label columns, and what it represents. */
561
- async function plan(file: FileInfo, delimiter: string, read: RecordsRead) {
593
+ /** The plan of a file: its delimiter, the mapped columns, the key and label columns, what it represents, and its upload. */
594
+ async function plan(file: FileInfo, delimiter: string, read: RecordsRead, uploadedAt?: string) {
562
595
  const first = read.records[0];
563
596
  const header = first ? first.fields.map((h) => h.trim()) : [];
564
597
  const registry = header.length ? ((await lbb.ontology()).property_defs ?? []) : [];
@@ -580,6 +613,7 @@ export function createWorkflow(
580
613
  key: key >= 0 ? key : null,
581
614
  label,
582
615
  represents: found?.iri ?? null,
616
+ ...(uploadedAt ? { uploadedAt } : {}),
583
617
  } satisfies Plan,
584
618
  };
585
619
  }
@@ -54,7 +54,14 @@ The text before the first heading, when it is not blank, is the section at
54
54
  position 0, of level 0, titled with the file's name without its extension.
55
55
  A section's lines run from its heading to its last line that is not
56
56
  blank. The last turn records the derivation: the sections, their count per
57
- level, the instance the file represents, and the commits of the writes.
57
+ level, the instance the file represents, and the commits of the writes. The record names the upload the check
58
+ answered (`content_uploaded_at`). The cursor carries it from turn to turn.
59
+ When the file was deleted while the run read it, the record writes nothing
60
+ (`409 derivation_upload_changed`). The run ends and counts the file as
61
+ skipped. The run of a new upload reads the file again. Say the file was
62
+ deleted and not uploaded again. The next read of its bytes answers
63
+ `404 file_not_found`. The run ends there too, and counts the file as
64
+ skipped.
58
65
 
59
66
  ## Settings
60
67
 
@@ -12,6 +12,7 @@ import {
12
12
  phases,
13
13
  readJson,
14
14
  runMessage,
15
+ runTurn,
15
16
  samplesDir,
16
17
  type MemoryGraph,
17
18
  type MockFile,
@@ -74,7 +75,7 @@ function setup(settings: Settings = WITH_GUIDES, guides = GUIDES.map((g) => g.na
74
75
  const definition = createWorkflow(lbb, content, settings);
75
76
  const run = (file: MockFile, state: unknown = {}) =>
76
77
  runMessage<SectionsState>(definition, contentMessage("sections", file), state);
77
- return { lbb, content, run };
78
+ return { lbb, content, run, definition };
78
79
  }
79
80
 
80
81
  /** The written sections by position. */
@@ -207,6 +208,55 @@ test("a content with a derivation for these settings is skipped, under any name"
207
208
  assert.deepEqual(again.turns[0]!.keys, ["check"]);
208
209
  });
209
210
 
211
+ test("a file deleted and uploaded again while it is read records nothing; the new upload's run reads it", async () => {
212
+ const { lbb, content, definition } = setup({ ...WITH_GUIDES, sectionsPerTurn: 3 });
213
+ const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
214
+ const first = content.uploadedAt(file.sha256);
215
+
216
+ // Turn 1 checks the file; the turns after it carry the upload the check named.
217
+ const one = await runTurn<SectionsState>(definition, {}, contentMessage("sections", file));
218
+ type Continued = { cursor: { plan: { uploadedAt?: string } } };
219
+ const carried = one.result.continuation!.message as unknown as Continued;
220
+ assert.equal(carried.cursor.plan.uploadedAt, first);
221
+
222
+ // The file is deleted and uploaded again; the old run writes on to its end.
223
+ content.remove(file.sha256);
224
+ const again = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
225
+ const old = await runMessage<SectionsState>(
226
+ definition,
227
+ one.result.continuation!.message,
228
+ one.result.state,
229
+ );
230
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, sections: 8, represents: 0 });
231
+ assert.equal(content.records.at(-1)?.content_uploaded_at, first);
232
+ assert.equal(content.recorded.size, 0, "nothing is recorded for the deleted upload");
233
+
234
+ // The run of the new upload checks, writes the sections and records.
235
+ const fresh = await runMessage<SectionsState>(definition, contentMessage("sections", again));
236
+ assert.deepEqual(fresh.state, { contents: 1, skipped: 0, sections: 8, represents: 1 });
237
+ assert.equal(content.records.at(-1)?.content_uploaded_at, content.uploadedAt(again.sha256));
238
+ assert.equal(content.recorded.size, 1);
239
+ assert.equal(written(lbb.graph).length, EXPECTED.sections.length);
240
+ });
241
+
242
+ test("a file deleted while it is read, and not uploaded again, ends the run as skipped", async () => {
243
+ const { content, definition } = setup({ ...WITH_GUIDES, sectionsPerTurn: 3 });
244
+ const file = content.upload("support-handbook.md", HANDBOOK, "text/markdown");
245
+ const one = await runTurn<SectionsState>(definition, {}, contentMessage("sections", file));
246
+ assert.equal(one.result.state.sections, 3);
247
+
248
+ // The next read of the bytes answers 404: the run ends without an error.
249
+ content.remove(file.sha256);
250
+ const old = await runMessage<SectionsState>(
251
+ definition,
252
+ one.result.continuation!.message,
253
+ one.result.state,
254
+ );
255
+ assert.equal(old.turns.length, 1, "the run ends at the turn that read nothing");
256
+ assert.deepEqual(old.state, { contents: 0, skipped: 1, sections: 3, represents: 0 });
257
+ assert.equal(content.records.length, 0, "no derivation is recorded");
258
+ });
259
+
210
260
  test("REPRESENTS needs exactly one instance whose label is the file's name", async () => {
211
261
  const counts: number[] = [];
212
262
  for (const guides of [
@@ -19,13 +19,19 @@
19
19
  // to 44);
20
20
  // 4. with `represents`, writes `Document REPRESENTS <instance>` when the
21
21
  // file's name names exactly one instance of that class;
22
- // 5. records the derivation.
22
+ // 5. records the derivation, for the upload its check named. When the file
23
+ // was deleted (or deleted and uploaded again) since, nothing is recorded,
24
+ // and the run of the new upload reads the file again. A file deleted and
25
+ // not uploaded again answers 404 to the next read of its bytes: the run
26
+ // ends there and counts the file as skipped.
23
27
  //
24
28
  // A turn writes at most `sectionsPerTurn` sections; the rest goes to a new
25
29
  // turn (`ctx.continue`).
26
30
  import { createHash } from "node:crypto";
27
31
  import {
32
+ LbbError,
28
33
  entityRef,
34
+ isDerivationUploadChanged,
29
35
  triggeredWorkflow,
30
36
  type LbbActivities,
31
37
  type Schemas,
@@ -121,7 +127,7 @@ export interface ContentPorts {
121
127
  export interface SectionsState {
122
128
  /** Files read to their end. */
123
129
  contents: number;
124
- /** Files skipped: read before with these settings, another name or type, or no file. */
130
+ /** Files skipped: read before with these settings, another name or type, no file, or deleted while read. */
125
131
  skipped: number;
126
132
  sections: number;
127
133
  represents: number;
@@ -135,9 +141,11 @@ interface FileInfo {
135
141
  mediaType: string;
136
142
  }
137
143
 
138
- /** What the check of a file found: the file to read, or why it is skipped. */
144
+ /** What the check of a file found: the file to read and its upload, or why it is skipped. */
139
145
  interface Checked {
140
146
  file?: FileInfo;
147
+ /** The `content_uploaded_at` of the check: absent without a file record. */
148
+ uploadedAt?: string;
141
149
  skip?: string;
142
150
  }
143
151
 
@@ -148,6 +156,8 @@ interface Plan {
148
156
  name: string;
149
157
  count: number;
150
158
  represents: string | null;
159
+ /** The upload of the file the check named: the record names it. */
160
+ uploadedAt?: string;
151
161
  }
152
162
 
153
163
  /** Where a file's next turn starts, and the commits of its writes so far. */
@@ -174,6 +184,10 @@ const LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
174
184
  /** The media type without parameters, in lower case. */
175
185
  const baseType = (mediaType: string) => mediaType.split(";")[0]!.trim().toLowerCase();
176
186
 
187
+ /** Whether an error is the 404 of a file's bytes: the file was deleted and not uploaded again. */
188
+ export const fileGone = (error: unknown) =>
189
+ error instanceof LbbError && error.status === 404 && error.code === "file_not_found";
190
+
177
191
  /** A file name without its last extension. */
178
192
  export const stem = (name: string) => name.replace(/\.[^./\\]*$/, "");
179
193
 
@@ -244,108 +258,133 @@ export function createWorkflow(
244
258
  delete plain.cursor;
245
259
  return ctx.continue(next, plain);
246
260
  };
247
- const document = message.instances[0];
248
- if (!document) return { state };
249
- let split: Promise<Section[]> | undefined;
250
- const sectionsOf = (sha256: string, name: string) =>
251
- (split ??= ports.files
252
- .content(sha256)
253
- .then((bytes) => markdownSections(new TextDecoder().decode(bytes), stem(name))));
254
-
255
- let cursor = message.cursor;
256
- if (!cursor) {
257
- const checked = await ctx.step("check", async (s): Promise<Checked> => {
258
- s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
259
- const file = await readDocument(message, document);
260
- if (!file) return { skip: "no file" };
261
- if (!isMarkdown(file.mediaType, file.name)) return { skip: "media type" };
262
- if (match && !match.test(file.name)) return { skip: "name" };
263
- const found = await ports.workflows.derivations.check(
264
- derivationKey(file.sha256, settings),
265
- );
266
- return found.exists ? { skip: "derived" } : { file };
267
- });
268
- if (!checked.file) return rest({ ...state, skipped: state.skipped + 1 });
269
- const file = checked.file;
270
- const planned = await ctx.step("plan", async (s) => {
271
- s.progress("reading sections", { done: 0, unit: "sections" });
272
- const count = (await sectionsOf(file.sha256, file.name)).length;
273
- s.progress("reading sections", { done: count, total: count, unit: "sections" });
274
- const found = settings.represents ? await representsOf(file.name) : null;
275
- const plan: Plan = { ...file, count, represents: found?.iri ?? null };
276
- return { plan, label: found?.label ?? null };
277
- });
278
- const commits: number[] = [];
279
- const p = planned.plan;
280
- if (p.represents) {
281
- const written = await lbb.writeOwned(ctx, "represents", (s) => {
282
- s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
283
- return {
284
- subjects: [entityRef(p.document, "Document", p.name)],
285
- relations: [REPRESENTS],
286
- triplets: [
287
- {
288
- source: entityRef(p.document, "Document", p.name),
289
- relation: REPRESENTS,
290
- target: entityRef(
291
- p.represents!,
292
- settings.represents!.class,
293
- planned.label ?? undefined,
294
- ),
295
- confidence: 1,
296
- evidence: { text: p.name, source_id: p.document },
297
- },
298
- ],
261
+ try {
262
+ const document = message.instances[0];
263
+ if (!document) return { state };
264
+ let split: Promise<Section[]> | undefined;
265
+ const sectionsOf = (sha256: string, name: string) =>
266
+ (split ??= ports.files
267
+ .content(sha256)
268
+ .then((bytes) => markdownSections(new TextDecoder().decode(bytes), stem(name))));
269
+
270
+ let cursor = message.cursor;
271
+ if (!cursor) {
272
+ const checked = await ctx.step("check", async (s): Promise<Checked> => {
273
+ s.progress("checking the file", { done: 0, total: 1, unit: "documents" });
274
+ const file = await readDocument(message, document);
275
+ if (!file) return { skip: "no file" };
276
+ if (!isMarkdown(file.mediaType, file.name)) return { skip: "media type" };
277
+ if (match && !match.test(file.name)) return { skip: "name" };
278
+ const found = await ports.workflows.derivations.check(
279
+ derivationKey(file.sha256, settings),
280
+ );
281
+ if (found.exists) return { skip: "derived" };
282
+ const uploadedAt = found.content_uploaded_at ?? undefined;
283
+ return uploadedAt ? { file, uploadedAt } : { file };
284
+ });
285
+ if (!checked.file) return rest({ ...state, skipped: state.skipped + 1 });
286
+ const file = checked.file;
287
+ const planned = await ctx.step("plan", async (s) => {
288
+ s.progress("reading sections", { done: 0, unit: "sections" });
289
+ const count = (await sectionsOf(file.sha256, file.name)).length;
290
+ s.progress("reading sections", { done: count, total: count, unit: "sections" });
291
+ const found = settings.represents ? await representsOf(file.name) : null;
292
+ const plan: Plan = {
293
+ ...file,
294
+ count,
295
+ represents: found?.iri ?? null,
296
+ ...(checked.uploadedAt ? { uploadedAt: checked.uploadedAt } : {}),
299
297
  };
298
+ return { plan, label: found?.label ?? null };
299
+ });
300
+ const commits: number[] = [];
301
+ const p = planned.plan;
302
+ if (p.represents) {
303
+ const written = await lbb.writeOwned(ctx, "represents", (s) => {
304
+ s.progress("writing facts", { done: 0, total: 1, unit: "documents" });
305
+ return {
306
+ subjects: [entityRef(p.document, "Document", p.name)],
307
+ relations: [REPRESENTS],
308
+ triplets: [
309
+ {
310
+ source: entityRef(p.document, "Document", p.name),
311
+ relation: REPRESENTS,
312
+ target: entityRef(
313
+ p.represents!,
314
+ settings.represents!.class,
315
+ planned.label ?? undefined,
316
+ ),
317
+ confidence: 1,
318
+ evidence: { text: p.name, source_id: p.document },
319
+ },
320
+ ],
321
+ };
322
+ });
323
+ if (written.commit_seq !== null) commits.push(written.commit_seq);
324
+ }
325
+ cursor = { next: 0, plan: p, commits };
326
+ }
327
+
328
+ // The sections of this turn, written in parts.
329
+ const { plan, next: from } = cursor;
330
+ const to = Math.min(plan.count, from + settings.sectionsPerTurn);
331
+ const commits = [...cursor.commits];
332
+ for (let n = 0; from + n * PART < to; n++) {
333
+ const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
334
+ s.progress("writing facts", { done: n * PART, total: to - from, unit: "sections" });
335
+ const all = await sectionsOf(plan.sha256, plan.name);
336
+ const start = from + n * PART;
337
+ return partWrite(plan, all, all.slice(start, Math.min(to, start + PART)));
300
338
  });
301
339
  if (written.commit_seq !== null) commits.push(written.commit_seq);
302
340
  }
303
- cursor = { next: 0, plan: p, commits };
304
- }
341
+ const counted = { ...state, sections: state.sections + (to - from) };
342
+ if (to < plan.count)
343
+ return ctx.continue(counted, {
344
+ ...message,
345
+ removed: [],
346
+ cursor: { next: to, plan, commits: commits.slice(0, 256) },
347
+ });
305
348
 
306
- // The sections of this turn, written in parts.
307
- const { plan, next: from } = cursor;
308
- const to = Math.min(plan.count, from + settings.sectionsPerTurn);
309
- const commits = [...cursor.commits];
310
- for (let n = 0; from + n * PART < to; n++) {
311
- const written = await lbb.writeOwned(ctx, `write-${n}`, async (s) => {
312
- s.progress("writing facts", { done: n * PART, total: to - from, unit: "sections" });
313
- const all = await sectionsOf(plan.sha256, plan.name);
314
- const start = from + n * PART;
315
- return partWrite(plan, all, all.slice(start, Math.min(to, start + PART)));
349
+ const recorded = await ctx.step("record", async (s) => {
350
+ s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
351
+ const all = plan.count ? await sectionsOf(plan.sha256, plan.name) : [];
352
+ const levels: Record<string, number> = {};
353
+ for (const section of all) levels[section.level] = (levels[section.level] ?? 0) + 1;
354
+ try {
355
+ const answer = await ports.workflows.derivations.record({
356
+ ...derivationKey(plan.sha256, settings),
357
+ run_id: ctx.messageId,
358
+ outputs: {
359
+ sections: plan.count,
360
+ levels,
361
+ represents: plan.represents,
362
+ commits: commits.slice(0, 256),
363
+ },
364
+ ...(plan.uploadedAt ? { content_uploaded_at: plan.uploadedAt } : {}),
365
+ });
366
+ return answer.recorded;
367
+ } catch (error) {
368
+ // The file was deleted while this run read it: nothing is
369
+ // recorded, and the run of a new upload reads it again.
370
+ if (isDerivationUploadChanged(error)) return "upload changed";
371
+ throw error;
372
+ }
316
373
  });
317
- if (written.commit_seq !== null) commits.push(written.commit_seq);
318
- }
319
- const counted = { ...state, sections: state.sections + (to - from) };
320
- if (to < plan.count)
321
- return ctx.continue(counted, {
322
- ...message,
323
- removed: [],
324
- cursor: { next: to, plan, commits: commits.slice(0, 256) },
374
+ if (recorded === "upload changed")
375
+ return rest({ ...counted, skipped: counted.skipped + 1 });
376
+ return rest({
377
+ ...counted,
378
+ contents: counted.contents + 1,
379
+ represents: counted.represents + (plan.represents ? 1 : 0),
325
380
  });
326
-
327
- await ctx.step("record", async (s) => {
328
- s.progress("recording the derivation", { done: 0, total: 1, unit: "documents" });
329
- const all = plan.count ? await sectionsOf(plan.sha256, plan.name) : [];
330
- const levels: Record<string, number> = {};
331
- for (const section of all) levels[section.level] = (levels[section.level] ?? 0) + 1;
332
- const recorded = await ports.workflows.derivations.record({
333
- ...derivationKey(plan.sha256, settings),
334
- run_id: ctx.messageId,
335
- outputs: {
336
- sections: plan.count,
337
- levels,
338
- represents: plan.represents,
339
- commits: commits.slice(0, 256),
340
- },
341
- });
342
- return recorded.recorded;
343
- });
344
- return rest({
345
- ...counted,
346
- contents: counted.contents + 1,
347
- represents: counted.represents + (plan.represents ? 1 : 0),
348
- });
381
+ } catch (error) {
382
+ // The file was deleted while this run read it, and not uploaded
383
+ // again: its bytes answer 404. Nothing is recorded, and the file
384
+ // counts as skipped.
385
+ if (!fileGone(error)) throw error;
386
+ return rest({ ...state, skipped: state.skipped + 1 });
387
+ }
349
388
  },
350
389
  });
351
390
 
@@ -1,3 +1,3 @@
1
1
  {
2
- "client": "0.27.0"
2
+ "client": "0.28.0"
3
3
  }