@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 +1 -1
- package/templates/_shared/testing.ts +62 -3
- package/templates/rows/README.md +8 -1
- package/templates/rows/workflow.test.ts +56 -0
- package/templates/rows/workflow.ts +182 -148
- package/templates/sections/README.md +8 -1
- package/templates/sections/workflow.test.ts +51 -1
- package/templates/sections/workflow.ts +137 -98
- package/templates/versions.json +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
432
|
-
|
|
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.
|
|
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"]) =>
|
package/templates/rows/README.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
const written = await lbb.writeOwned(ctx,
|
|
421
|
-
s.progress("writing facts", { done:
|
|
422
|
-
const
|
|
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)
|
|
508
|
+
if (written.commit_seq !== null) commits.push(written.commit_seq);
|
|
442
509
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
(
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 (
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
package/templates/versions.json
CHANGED