@littlebigbrain/create 0.3.0 → 0.4.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/README.md +5 -2
- package/lib/create.mjs +2 -0
- package/package.json +1 -1
- package/templates/_shared/testing.ts +168 -1
- package/templates/rows/README.md +150 -0
- package/templates/rows/csv.ts +260 -0
- package/templates/rows/samples/expected.json +150 -0
- package/templates/rows/samples/properties.json +11 -0
- package/templates/rows/samples/q3-customers.csv +10 -0
- package/templates/rows/samples/q3-orders.tsv +5 -0
- package/templates/rows/samples/reports.json +5 -0
- package/templates/rows/samples.ts +56 -0
- package/templates/rows/setup.ts +18 -0
- package/templates/rows/try.ts +32 -0
- package/templates/rows/worker.ts +24 -0
- package/templates/rows/workflow.test.ts +541 -0
- package/templates/rows/workflow.ts +657 -0
- package/templates/sections/README.md +118 -0
- package/templates/sections/markdown.ts +121 -0
- package/templates/sections/samples/expected.json +82 -0
- package/templates/sections/samples/guides.json +4 -0
- package/templates/sections/samples/support-handbook.md +34 -0
- package/templates/sections/samples.ts +41 -0
- package/templates/sections/setup.ts +18 -0
- package/templates/sections/try.ts +32 -0
- package/templates/sections/worker.ts +24 -0
- package/templates/sections/workflow.test.ts +265 -0
- package/templates/sections/workflow.ts +433 -0
- package/templates/versions.json +1 -1
package/README.md
CHANGED
|
@@ -21,9 +21,12 @@ npm test
|
|
|
21
21
|
| `documents-map` | Maps the links of `documents-link` into the graph's own ontology, as fact proposals |
|
|
22
22
|
| `review` | Writes the fact proposals at or above a gate as facts and gives the others to a person |
|
|
23
23
|
| `link` | Connects the mentions of one thing across the records of a graph into one thing, and proposes records of one thing as an identity |
|
|
24
|
+
| `rows` | Reads each CSV or TSV file into one instance per row, each with an edge to the file whose evidence is the row |
|
|
25
|
+
| `sections` | Reads each Markdown file into its sections, each with an edge to its parent whose evidence is its lines |
|
|
24
26
|
|
|
25
27
|
A template id is the id of its starter workflow, with dashes for dots:
|
|
26
|
-
`documents-link` is the code of `documents.link`.
|
|
28
|
+
`documents-link` is the code of `documents.link`. `link`, `rows` and
|
|
29
|
+
`sections` have no LBB-run version.
|
|
27
30
|
|
|
28
31
|
## The project
|
|
29
32
|
|
|
@@ -32,7 +35,7 @@ A template id is the id of its starter workflow, with dashes for dots:
|
|
|
32
35
|
| `src/workflow.ts` | The workflow, its settings, the triggered workflow that sends it the instances (`trigger()`) and the ontology it writes (`ontology()`) |
|
|
33
36
|
| `src/*.ts` | The template's helpers and `samples.ts`, which puts the samples on a graph |
|
|
34
37
|
| `src/workflow.test.ts`, `src/testing.ts` | The workflow on its samples, with mock models and no server |
|
|
35
|
-
| `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try`; a template that has its own (`link`) keeps them |
|
|
38
|
+
| `src/worker.ts`, `src/setup.ts`, `src/try.ts` | `npm start`, `npm run setup` and `npm run try`; a template that has its own (`link`, `rows`, `sections`) keeps them |
|
|
36
39
|
| `src/grade-run.ts` | `npm run grade`, in a template that grades a run against a gold file (`link`) |
|
|
37
40
|
| `samples/` | The samples and their answer key |
|
|
38
41
|
|
package/lib/create.mjs
CHANGED
package/package.json
CHANGED
|
@@ -342,6 +342,150 @@ export class MockActivities implements LbbActivities {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
344
|
|
|
345
|
+
// ---- files and derivations ---------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** A file the mock holds: its bytes, name, media type and `Document` IRI. */
|
|
348
|
+
export interface MockFile {
|
|
349
|
+
sha256: string;
|
|
350
|
+
name: string;
|
|
351
|
+
mediaType: string;
|
|
352
|
+
document: string;
|
|
353
|
+
bytes: Uint8Array;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The files and the derivations of a server, for the templates that read
|
|
358
|
+
* files: `files.content` and `workflows.derivations.check` / `record`, the
|
|
359
|
+
* shape of an `LbbClient`. `upload` puts the file's `Document` in `graph`
|
|
360
|
+
* with its name, SHA-256 and media type, as the upload route does.
|
|
361
|
+
*/
|
|
362
|
+
export class MockContent {
|
|
363
|
+
readonly held = new Map<string, MockFile>();
|
|
364
|
+
readonly recorded = new Map<string, Schemas["Derivation"]>();
|
|
365
|
+
/** Reads of a file's bytes. */
|
|
366
|
+
reads = 0;
|
|
367
|
+
/** Derivation checks. */
|
|
368
|
+
checks = 0;
|
|
369
|
+
|
|
370
|
+
constructor(readonly graph: MemoryGraph) {}
|
|
371
|
+
|
|
372
|
+
/** Hold a file and write its `Document`. */
|
|
373
|
+
upload(name: string, body: string | Uint8Array, mediaType: string): MockFile {
|
|
374
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
375
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
376
|
+
const doc = this.graph.entity({ type: "Document", name, key: `file:${sha256}` });
|
|
377
|
+
doc.name = name;
|
|
378
|
+
Object.assign(doc.properties, {
|
|
379
|
+
name,
|
|
380
|
+
file_sha256: sha256,
|
|
381
|
+
content_type: mediaType,
|
|
382
|
+
file_bytes: bytes.length,
|
|
383
|
+
});
|
|
384
|
+
const file = { sha256, name, mediaType, document: doc.iri, bytes };
|
|
385
|
+
this.held.set(sha256, file);
|
|
386
|
+
return file;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The byte range of each read, `null` for a whole file, in order. */
|
|
390
|
+
readonly ranges: Array<{ start: number; end: number | null } | null> = [];
|
|
391
|
+
/** The bytes all reads answered. */
|
|
392
|
+
bytesRead = 0;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* A file's bytes, or with `range` bytes `start` to `end` (both included),
|
|
396
|
+
* as `GET /v1/files/content` answers: a range past the end ends at the
|
|
397
|
+
* end, and a range from the end on (a file without bytes too) throws
|
|
398
|
+
* `LbbError` 416 `range_not_satisfiable`.
|
|
399
|
+
*/
|
|
400
|
+
readonly files = {
|
|
401
|
+
content: async (
|
|
402
|
+
sha256: string,
|
|
403
|
+
options: { range?: { start: number; end?: number } } = {},
|
|
404
|
+
): Promise<Uint8Array> => {
|
|
405
|
+
this.reads += 1;
|
|
406
|
+
const file = this.held.get(sha256);
|
|
407
|
+
if (!file)
|
|
408
|
+
throw new LbbError(404, "{}", { code: "file_not_found", message: `no file ${sha256}` });
|
|
409
|
+
const range = options.range;
|
|
410
|
+
this.ranges.push(range ? { start: range.start, end: range.end ?? null } : null);
|
|
411
|
+
if (!range) {
|
|
412
|
+
this.bytesRead += file.bytes.length;
|
|
413
|
+
return file.bytes;
|
|
414
|
+
}
|
|
415
|
+
if (range.start >= file.bytes.length)
|
|
416
|
+
throw new LbbError(416, "{}", {
|
|
417
|
+
code: "range_not_satisfiable",
|
|
418
|
+
message: `no byte of the file from ${range.start} on`,
|
|
419
|
+
});
|
|
420
|
+
const end = Math.min(range.end ?? file.bytes.length - 1, file.bytes.length - 1);
|
|
421
|
+
const bytes = file.bytes.slice(range.start, end + 1);
|
|
422
|
+
this.bytesRead += bytes.length;
|
|
423
|
+
return bytes;
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
readonly workflows = {
|
|
428
|
+
derivations: {
|
|
429
|
+
check: async (key: Schemas["DerivationKey"]): Promise<Schemas["DerivationCheckResponse"]> => {
|
|
430
|
+
this.checks += 1;
|
|
431
|
+
const derivation = this.recorded.get(derivationName(key));
|
|
432
|
+
return derivation ? { exists: true, derivation } : { exists: false };
|
|
433
|
+
},
|
|
434
|
+
record: async (
|
|
435
|
+
request: Schemas["DerivationRecordRequest"],
|
|
436
|
+
): Promise<Schemas["DerivationRecordResponse"]> => {
|
|
437
|
+
const name = derivationName(request);
|
|
438
|
+
const kept = this.recorded.get(name);
|
|
439
|
+
if (kept) return { recorded: false, derivation: kept };
|
|
440
|
+
const outputs = JSON.stringify(request.outputs ?? null);
|
|
441
|
+
if (outputs.length > 16 * 1024)
|
|
442
|
+
throw new LbbError(400, "{}", {
|
|
443
|
+
code: "derivation_invalid",
|
|
444
|
+
message: "outputs over 16 KiB",
|
|
445
|
+
});
|
|
446
|
+
const derivation = {
|
|
447
|
+
sha256: request.sha256,
|
|
448
|
+
workflow_type: request.workflow_type,
|
|
449
|
+
version: request.version,
|
|
450
|
+
settings_digest: request.settings_digest,
|
|
451
|
+
run_id: request.run_id ?? null,
|
|
452
|
+
outputs: JSON.parse(outputs) as unknown,
|
|
453
|
+
recorded_at: "2026-10-15T09:00:00Z",
|
|
454
|
+
};
|
|
455
|
+
this.recorded.set(name, derivation);
|
|
456
|
+
return { recorded: true, derivation };
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const derivationName = (key: Schemas["DerivationKey"]) =>
|
|
463
|
+
`${key.workflow_type}@${key.version}/${key.settings_digest}/${key.sha256}`;
|
|
464
|
+
|
|
465
|
+
/** The message a content trigger sends for one upload: its `Document`, with origin `content`. */
|
|
466
|
+
export function contentMessage(
|
|
467
|
+
workflow: string,
|
|
468
|
+
file: Pick<MockFile, "sha256" | "mediaType" | "document">,
|
|
469
|
+
): WorkflowTriggerMessage {
|
|
470
|
+
return {
|
|
471
|
+
type: "instances",
|
|
472
|
+
workflow,
|
|
473
|
+
graph: "samples",
|
|
474
|
+
as_of_commit_seq: 7,
|
|
475
|
+
reason: "changed",
|
|
476
|
+
instances: [file.document],
|
|
477
|
+
removed: [],
|
|
478
|
+
origin: {
|
|
479
|
+
kind: "content",
|
|
480
|
+
trigger: workflow,
|
|
481
|
+
sha256: file.sha256,
|
|
482
|
+
media_type: file.mediaType,
|
|
483
|
+
uploaded_at_ms: 1791967651158,
|
|
484
|
+
requests: [`file-upload:${file.sha256}`],
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
345
489
|
function assertNever(what: string): never {
|
|
346
490
|
throw new Error(`the test gives the mock no ${what} rule`);
|
|
347
491
|
}
|
|
@@ -395,6 +539,27 @@ export class Crash extends Error {
|
|
|
395
539
|
|
|
396
540
|
const STEP_KEY = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
397
541
|
|
|
542
|
+
/**
|
|
543
|
+
* A workflow value as JSON, as the SDK's worker writes it: a value JSON
|
|
544
|
+
* cannot hold (undefined, a function, a number that is not finite) fails
|
|
545
|
+
* the turn there, so it fails here too.
|
|
546
|
+
*/
|
|
547
|
+
function workflowJson(value: unknown, where: string): string {
|
|
548
|
+
return JSON.stringify(value, (key, v: unknown) => {
|
|
549
|
+
const bad =
|
|
550
|
+
v === undefined ||
|
|
551
|
+
typeof v === "function" ||
|
|
552
|
+
typeof v === "symbol" ||
|
|
553
|
+
typeof v === "bigint" ||
|
|
554
|
+
(typeof v === "number" && !Number.isFinite(v));
|
|
555
|
+
if (bad)
|
|
556
|
+
throw new Error(
|
|
557
|
+
`Workflow values must be JSON serializable (use null for no result): ${where}${key ? `.${key}` : ""} is ${typeof v}`,
|
|
558
|
+
);
|
|
559
|
+
return v;
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
398
563
|
/**
|
|
399
564
|
* Run one turn of `definition` as the workflow engine does: each step runs
|
|
400
565
|
* once and its output is saved; with `saved`, the saved steps replay without
|
|
@@ -457,7 +622,7 @@ export async function runTurn<S>(
|
|
|
457
622
|
heartbeat: async () => {},
|
|
458
623
|
progress: (phase, counts) => turn.progress.push(progressCall(key, phase, counts)),
|
|
459
624
|
});
|
|
460
|
-
const json =
|
|
625
|
+
const json = workflowJson(output, `step "${key}"`);
|
|
461
626
|
if (json.length > 64 * 1024)
|
|
462
627
|
throw new Error(`step ${key} saves ${json.length} bytes, more than 64 KiB`);
|
|
463
628
|
turn.bytes += json.length;
|
|
@@ -481,6 +646,8 @@ export async function runTurn<S>(
|
|
|
481
646
|
}),
|
|
482
647
|
} as unknown as WorkflowContext;
|
|
483
648
|
const result = (await definition.execute(ctx, state, message)) as TurnResult<S>;
|
|
649
|
+
workflowJson(result.state, "state");
|
|
650
|
+
if (result.continuation) workflowJson(result.continuation, "continuation");
|
|
484
651
|
return { result, turn };
|
|
485
652
|
}
|
|
486
653
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# rows
|
|
2
|
+
|
|
3
|
+
Reads each CSV or TSV file of a graph into one instance per row. A
|
|
4
|
+
customer list gives one `Customer` per row, with the values of its
|
|
5
|
+
columns. Each instance has an edge `READ_FROM` to the file's `Document`.
|
|
6
|
+
The edge's evidence is the row's text, and its source is the file's IRI
|
|
7
|
+
with the row: `https://littlebigbrain.com/e/41ab…#row=5`
|
|
8
|
+
([evidence.md](../../../docs/reference/evidence.md#evidence-from-a-file)).
|
|
9
|
+
|
|
10
|
+
The template has no LBB-run version. It is a named workflow with a
|
|
11
|
+
**content trigger**: LBB sends it each upload of `text/csv` or
|
|
12
|
+
`text/tab-separated-values` within seconds, without a wait for a
|
|
13
|
+
publication ([workflows.md](../../../docs/reference/workflows.md#content-triggers)).
|
|
14
|
+
|
|
15
|
+
## How it runs
|
|
16
|
+
|
|
17
|
+
Each message holds one file. The first turn of a file:
|
|
18
|
+
|
|
19
|
+
1. **Check.** Reads the file's name with a strong read, and the
|
|
20
|
+
derivation of the content for `rows@v1` and the settings
|
|
21
|
+
([Derivations](../../../docs/reference/workflows.md#derivations)). A
|
|
22
|
+
content with a derivation is skipped, so the same bytes under a second
|
|
23
|
+
name are read once. So is a file whose name the settings' `match` does
|
|
24
|
+
not match. Other settings are another derivation, and read the file
|
|
25
|
+
again.
|
|
26
|
+
2. **Plan.** Reads the header row: the first window of the file, by byte
|
|
27
|
+
range (`client.files.content(sha256, { range })`). Each column maps to
|
|
28
|
+
a property: the settings' `columns`, else each
|
|
29
|
+
column whose header is a property of the graph by its normalized name
|
|
30
|
+
(`Customer ID` and `customer_id` match). A property must be in the
|
|
31
|
+
graph's property registry with a scalar type. The key column keys each
|
|
32
|
+
instance; a column named `name`, `title` or `label` names it.
|
|
33
|
+
3. **Represents.** With `represents`, when the file's name without its
|
|
34
|
+
extension is the label of exactly one instance of that class, writes
|
|
35
|
+
`Document REPRESENTS <instance>`. `q3-customers.csv` represents the
|
|
36
|
+
report `Q3 customers`: the labels compare in lower case, with `-`, `_`
|
|
37
|
+
and spaces as one space. No match, or more than one, writes nothing and
|
|
38
|
+
proposes nothing.
|
|
39
|
+
|
|
40
|
+
Then each turn reads at most `rowsPerTurn` rows and writes them in owned
|
|
41
|
+
writes of 400 rows. A turn reads from the byte offset where the last one
|
|
42
|
+
stopped, a window of 4 MiB per read, and parses only complete rows. A row
|
|
43
|
+
that a window does not end, such as a quoted field with a line break across
|
|
44
|
+
the window's edge, is read on in the next window. The first turn reads on
|
|
45
|
+
from the bytes of its header read, so a file is read about once in all. A
|
|
46
|
+
row is one instance of the settings' class:
|
|
47
|
+
|
|
48
|
+
- its key: the key column's value, else `<sha256>:<row>`. A row whose key
|
|
49
|
+
column is empty is left out;
|
|
50
|
+
- its values, each in its property's type. A value that does not take the
|
|
51
|
+
type is left out and counted. An empty cell removes the property, as the
|
|
52
|
+
owned write replaces the mapped properties of the instance;
|
|
53
|
+
- the edge `READ_FROM` to the `Document`, with the evidence
|
|
54
|
+
`{text: <the row's text>, source_id: "<Document IRI>#row=<n>"}`.
|
|
55
|
+
|
|
56
|
+
Rows count as RFC 7111 counts them: from 1, and the header is row 1. A
|
|
57
|
+
blank row counts and writes nothing. When the file has more rows, the turn
|
|
58
|
+
continues (`ctx.continue`): the message's cursor keeps the byte offset of
|
|
59
|
+
the next row (`byte`) and its row number (`row`), so the row numbers stay
|
|
60
|
+
exact. A turn that runs again after a crash reads its rows again from the
|
|
61
|
+
same offset. A cursor without `byte`, from an earlier version of the
|
|
62
|
+
template, reads the file again from its start. The last turn
|
|
63
|
+
records the derivation with what it wrote: the rows, the instances, the
|
|
64
|
+
columns it mapped and left out, the instance the file represents, and the
|
|
65
|
+
commits of its writes.
|
|
66
|
+
|
|
67
|
+
The parser follows RFC 4180: fields in double quotes can hold the
|
|
68
|
+
delimiter, line breaks and quotes (`""`). A row whose quoted field holds a
|
|
69
|
+
line break spans two lines and keeps one row number. TSV takes the same
|
|
70
|
+
quotes, with a tab as the delimiter. A byte order mark is dropped. The
|
|
71
|
+
parser reads bytes: the delimiter, the quote and the line breaks are ASCII,
|
|
72
|
+
so a window's edge inside a character of UTF-8 or between CR and LF changes
|
|
73
|
+
no row.
|
|
74
|
+
|
|
75
|
+
## Settings
|
|
76
|
+
|
|
77
|
+
`defaults` in `workflow.ts`, which fit the samples:
|
|
78
|
+
|
|
79
|
+
| Setting | Default | Meaning |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `className` | `Customer` | The class of the instances, one per data row |
|
|
82
|
+
| `key` | `Customer ID` | The column whose value keys each instance. Without it, the key is `<sha256>:<row>` |
|
|
83
|
+
| `columns` | none | Column to property. Without it, each column whose header is a property of the graph |
|
|
84
|
+
| `represents` | `{class: "Report"}` | Write `REPRESENTS` to the one instance of this class that the file's name names |
|
|
85
|
+
| `match` | `customers` | A regular expression on the file's name. Without it, every CSV and TSV file |
|
|
86
|
+
| `rowsPerTurn` | 5,000 | Rows one turn reads, 1 to 20,000 |
|
|
87
|
+
|
|
88
|
+
`trigger(settings)` names the workflow `rows`, with the content trigger
|
|
89
|
+
`rows` for the two media types. It owns the class, `READ_FROM`,
|
|
90
|
+
`REPRESENTS` when `represents` is set, and the properties of `columns`.
|
|
91
|
+
`ontology(settings)` adds the class and `READ_FROM`. The properties come
|
|
92
|
+
from the graph: add them with `lbb.evolve` before the first file.
|
|
93
|
+
|
|
94
|
+
The settings digest is the SHA-256 of the settings' canonical JSON: a
|
|
95
|
+
change of any setting reads every file again.
|
|
96
|
+
|
|
97
|
+
Two content workflows own one relation when no upload can reach both:
|
|
98
|
+
`rows` and `sections`, both with `represents`, go on one graph, because no
|
|
99
|
+
media type of one meets a media type of the other. A second workflow that
|
|
100
|
+
writes `REPRESENTS` for a type that meets one of these, such as `text/*`, is
|
|
101
|
+
refused with `409 workflow_owns_conflict`
|
|
102
|
+
([starter-workflows.md](../../../docs/reference/starter-workflows.md#cycles-and-conflicts)).
|
|
103
|
+
|
|
104
|
+
## Commands
|
|
105
|
+
|
|
106
|
+
`setup.ts`, `worker.ts` and `try.ts` are the template's own:
|
|
107
|
+
`npm run setup` creates the named workflow with
|
|
108
|
+
`client.workflows.named.put(trigger())`, and `npm start` serves
|
|
109
|
+
`createWorkflow(lbbActivities(client), client)`: the client reads the
|
|
110
|
+
files' bytes and the derivations. A fourth argument sets how the workflow
|
|
111
|
+
reads: `{ window, maxRecord }`, the bytes of one read (default 4 MiB, at
|
|
112
|
+
least 64) and of the longest row (default 16 MiB). They change no output,
|
|
113
|
+
so they are not settings and enter no settings digest. `npm run try` creates the graph
|
|
114
|
+
`try-rows`, adds the sample properties and reports, uploads the two sample
|
|
115
|
+
files and creates the workflow. A content trigger reads the uploads from
|
|
116
|
+
before its creation too.
|
|
117
|
+
|
|
118
|
+
## Limits
|
|
119
|
+
|
|
120
|
+
- A turn holds at most one window and the longest row in memory. A row
|
|
121
|
+
longer than 16 MiB, such as a quote that never closes, stops the file:
|
|
122
|
+
the turn fails with an error that names the row and its byte offset.
|
|
123
|
+
- A turn that stops at `rowsPerTurn` in the middle of a window reads the
|
|
124
|
+
rest of that window again in the next turn.
|
|
125
|
+
- A file deleted later keeps the instances it gave; its `READ_FROM` edges
|
|
126
|
+
go with its `Document`.
|
|
127
|
+
- A message that comes again while a large file is still in progress (a
|
|
128
|
+
run now) reads the file again. The writes are the same.
|
|
129
|
+
|
|
130
|
+
## Samples and test
|
|
131
|
+
|
|
132
|
+
`samples/q3-customers.csv` is an invented customer list of 8 data rows. It
|
|
133
|
+
holds a quoted name with a comma, a name with quotes, an address with a
|
|
134
|
+
line break, a value that is not a date, a blank row, an empty cell and a
|
|
135
|
+
row without a key. `samples/q3-orders.tsv` is an invented order table.
|
|
136
|
+
`samples/properties.json` lists the graph properties the columns fill, and
|
|
137
|
+
`samples/reports.json` three reports the file names can name.
|
|
138
|
+
`samples/expected.json` gives the instances a correct run writes.
|
|
139
|
+
|
|
140
|
+
`workflow.test.ts` runs the workflow without a server (`MockContent` holds
|
|
141
|
+
the files and the derivations). It checks the answer key for both files,
|
|
142
|
+
the evidence of each row, the skip by derivation and by name, a file of
|
|
143
|
+
more rows than a turn reads, parts of 400 rows, a header-only file and an
|
|
144
|
+
empty file, `represents` with zero, one and two matching instances, keys
|
|
145
|
+
without a key column, the `columns` setting, the phases, and the parser
|
|
146
|
+
on RFC 4180's cases. The reads by range: a file of many windows read once
|
|
147
|
+
with exact row numbers, every window size from 64 to 200 bytes over
|
|
148
|
+
quoted fields, letters beyond ASCII and the three line breaks, a CRLF file
|
|
149
|
+
whose window ends between CR and LF, a turn that continues from its byte
|
|
150
|
+
offset (also after a crash), and a row longer than the longest row.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The records of a CSV or TSV file, as RFC 4180 defines them: fields split by
|
|
2
|
+
// the delimiter, records by a line break (CRLF, LF or CR), and a field in
|
|
3
|
+
// double quotes may hold the delimiter, line breaks and quotes (written `""`).
|
|
4
|
+
// Each record keeps its row number as RFC 7111 counts rows (from 1, the
|
|
5
|
+
// header is row 1) and its text in the file, the quote of its evidence.
|
|
6
|
+
//
|
|
7
|
+
// The parser reads bytes. The delimiter, the quote and the line breaks are
|
|
8
|
+
// ASCII, and no byte of a UTF-8 character other than ASCII is ASCII, so a
|
|
9
|
+
// record boundary is never inside a character. `readRecords` reads a file
|
|
10
|
+
// in windows of bytes (`client.files.content(sha256, { range })`) and keeps a
|
|
11
|
+
// record that a window does not end for the next window.
|
|
12
|
+
|
|
13
|
+
/** One record: its fields, its row from 1, its text without the line break, and where it starts and ends. */
|
|
14
|
+
export interface CsvRecord {
|
|
15
|
+
fields: string[];
|
|
16
|
+
row: number;
|
|
17
|
+
text: string;
|
|
18
|
+
/** The byte offset of the record's first byte in the file. */
|
|
19
|
+
start: number;
|
|
20
|
+
/** The byte offset after the record's line break: where the next record starts. */
|
|
21
|
+
next: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where the bytes given to `csvRecords` are in the file, and whether the file ends with them. */
|
|
25
|
+
export interface CsvPart {
|
|
26
|
+
/** The byte offset of the first byte given. Default 0. */
|
|
27
|
+
base?: number;
|
|
28
|
+
/**
|
|
29
|
+
* The file ends with these bytes (default). With `false` the file goes on:
|
|
30
|
+
* a record the bytes do not end is not given, nor one that ends in a
|
|
31
|
+
* carriage return or a quote as the last byte (a line feed or a second
|
|
32
|
+
* quote can follow). The next read starts at the `next` of the last
|
|
33
|
+
* record given.
|
|
34
|
+
*/
|
|
35
|
+
final?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const QUOTE = 0x22;
|
|
39
|
+
const CR = 0x0d;
|
|
40
|
+
const LF = 0x0a;
|
|
41
|
+
const decoder = new TextDecoder();
|
|
42
|
+
|
|
43
|
+
/** The delimiter of a file: a tab for TSV (`text/tab-separated-values` or a `.tsv` name), else a comma. */
|
|
44
|
+
export function delimiterOf(mediaType: string, name: string): string {
|
|
45
|
+
return mediaType === "text/tab-separated-values" || /\.tsv$/i.test(name) ? "\t" : ",";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The records of `input` (bytes, or text as its UTF-8 bytes) from the byte
|
|
50
|
+
* offset `at` of the file, numbered from `row`. A byte order mark at the
|
|
51
|
+
* start of the file is dropped. A blank line is a record of one empty field,
|
|
52
|
+
* so the row numbers stay those of RFC 7111. A quote inside an unquoted
|
|
53
|
+
* field, and text after a closing quote, are kept as they are. A line break
|
|
54
|
+
* at the end of the file ends the last record. `part` says where the bytes
|
|
55
|
+
* are in the file and whether the file ends with them.
|
|
56
|
+
*/
|
|
57
|
+
export function* csvRecords(
|
|
58
|
+
input: string | Uint8Array,
|
|
59
|
+
delimiter = ",",
|
|
60
|
+
at = 0,
|
|
61
|
+
row = 1,
|
|
62
|
+
part: CsvPart = {},
|
|
63
|
+
): Generator<CsvRecord, void, undefined> {
|
|
64
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
65
|
+
const base = part.base ?? 0;
|
|
66
|
+
const final = part.final ?? true;
|
|
67
|
+
const split = delimiter.charCodeAt(0);
|
|
68
|
+
const n = bytes.length;
|
|
69
|
+
const text = (from: number, to: number) => decoder.decode(bytes.subarray(from, to));
|
|
70
|
+
let i = at - base;
|
|
71
|
+
if (at === 0 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) i = 3;
|
|
72
|
+
while (i < n) {
|
|
73
|
+
const start = i;
|
|
74
|
+
const fields: string[] = [];
|
|
75
|
+
let field = "";
|
|
76
|
+
let from = i;
|
|
77
|
+
let quoted = false;
|
|
78
|
+
let atFieldStart = true;
|
|
79
|
+
let end = -1;
|
|
80
|
+
let next = -1;
|
|
81
|
+
while (i < n) {
|
|
82
|
+
const c = bytes[i]!;
|
|
83
|
+
if (quoted) {
|
|
84
|
+
if (c === QUOTE) {
|
|
85
|
+
if (i + 1 >= n && !final) return;
|
|
86
|
+
if (bytes[i + 1] === QUOTE) {
|
|
87
|
+
field += `${text(from, i)}"`;
|
|
88
|
+
i += 2;
|
|
89
|
+
} else {
|
|
90
|
+
field += text(from, i);
|
|
91
|
+
quoted = false;
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
from = i;
|
|
95
|
+
} else i += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === QUOTE && atFieldStart) {
|
|
99
|
+
quoted = true;
|
|
100
|
+
atFieldStart = false;
|
|
101
|
+
i += 1;
|
|
102
|
+
from = i;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (c === split) {
|
|
106
|
+
fields.push(field + text(from, i));
|
|
107
|
+
field = "";
|
|
108
|
+
atFieldStart = true;
|
|
109
|
+
i += 1;
|
|
110
|
+
from = i;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (c === CR || c === LF) {
|
|
114
|
+
if (c === CR && i + 1 >= n && !final) return;
|
|
115
|
+
end = i;
|
|
116
|
+
next = c === CR && bytes[i + 1] === LF ? i + 2 : i + 1;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
atFieldStart = false;
|
|
120
|
+
i += 1;
|
|
121
|
+
}
|
|
122
|
+
if (end < 0) {
|
|
123
|
+
if (!final) return;
|
|
124
|
+
end = n;
|
|
125
|
+
next = n;
|
|
126
|
+
}
|
|
127
|
+
fields.push(field + text(from, end));
|
|
128
|
+
yield { fields, row, text: text(start, end), start: base + start, next: base + next };
|
|
129
|
+
row += 1;
|
|
130
|
+
i = next;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Bytes `start` to `end` of a file, both included: fewer at its end, none from its end on. */
|
|
135
|
+
export type ReadRange = (start: number, end: number) => Promise<Uint8Array>;
|
|
136
|
+
|
|
137
|
+
/** Bytes a read holds that its records did not take: they start at `base`. */
|
|
138
|
+
export interface HeldBytes {
|
|
139
|
+
base: number;
|
|
140
|
+
bytes: Uint8Array;
|
|
141
|
+
/** The file ends with these bytes. */
|
|
142
|
+
eof: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What `readRecords` read. */
|
|
146
|
+
export interface RecordsRead {
|
|
147
|
+
records: CsvRecord[];
|
|
148
|
+
/** The byte offset after the last record: where the next read starts. */
|
|
149
|
+
next: number;
|
|
150
|
+
/** The row number of the record at `next`. */
|
|
151
|
+
row: number;
|
|
152
|
+
/** No byte of the file is left from `next` on. */
|
|
153
|
+
end: boolean;
|
|
154
|
+
/** The bytes read from the file. */
|
|
155
|
+
bytesRead: number;
|
|
156
|
+
/** The bytes read from `next` on: a later read of the same turn starts with them. */
|
|
157
|
+
held: HeldBytes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** How `readRecords` reads. */
|
|
161
|
+
export interface WindowOptions {
|
|
162
|
+
/** Bytes one read asks for. */
|
|
163
|
+
window: number;
|
|
164
|
+
/** The longest record: a record that is not complete after this many bytes stops the read with an error. */
|
|
165
|
+
maxRecord: number;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Bytes one read asks for by default: 4 MiB. */
|
|
169
|
+
export const WINDOW_BYTES = 4 * 1024 * 1024;
|
|
170
|
+
/** The longest record by default: 16 MiB, as a quote that never closes makes the rest of a file one record. */
|
|
171
|
+
export const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
172
|
+
|
|
173
|
+
function joined(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
174
|
+
if (!a.length) return b;
|
|
175
|
+
if (!b.length) return a;
|
|
176
|
+
const out = new Uint8Array(a.length + b.length);
|
|
177
|
+
out.set(a);
|
|
178
|
+
out.set(b, a.length);
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* At most `limit` complete records of a file from the byte offset `at`,
|
|
184
|
+
* numbered from `row`, read with `read` in windows of `options.window`
|
|
185
|
+
* bytes. A record that a window does not end is read on in the next
|
|
186
|
+
* window, so a quoted field can span windows and row numbers stay exact.
|
|
187
|
+
* `held` gives bytes an earlier read of the same turn holds from `at` on,
|
|
188
|
+
* which are not read again. Memory stays within the window and the longest
|
|
189
|
+
* record.
|
|
190
|
+
*/
|
|
191
|
+
export async function readRecords(
|
|
192
|
+
read: ReadRange,
|
|
193
|
+
delimiter: string,
|
|
194
|
+
at: number,
|
|
195
|
+
row: number,
|
|
196
|
+
limit: number,
|
|
197
|
+
options: WindowOptions,
|
|
198
|
+
held?: HeldBytes,
|
|
199
|
+
): Promise<RecordsRead> {
|
|
200
|
+
const records: CsvRecord[] = [];
|
|
201
|
+
let base = at;
|
|
202
|
+
let buffer: Uint8Array = new Uint8Array(0);
|
|
203
|
+
let eof = false;
|
|
204
|
+
let bytesRead = 0;
|
|
205
|
+
if (held && held.base <= at && at <= held.base + held.bytes.length) {
|
|
206
|
+
buffer = held.bytes.subarray(at - held.base);
|
|
207
|
+
eof = held.eof;
|
|
208
|
+
}
|
|
209
|
+
let first = true;
|
|
210
|
+
while (records.length < limit) {
|
|
211
|
+
if (!eof && !(first && buffer.length)) {
|
|
212
|
+
const from = base + buffer.length;
|
|
213
|
+
const chunk = await read(from, from + options.window - 1);
|
|
214
|
+
bytesRead += chunk.length;
|
|
215
|
+
eof = chunk.length < options.window;
|
|
216
|
+
buffer = joined(buffer, chunk);
|
|
217
|
+
}
|
|
218
|
+
first = false;
|
|
219
|
+
let taken = base;
|
|
220
|
+
for (const record of csvRecords(buffer, delimiter, base, row, { base, final: eof })) {
|
|
221
|
+
records.push(record);
|
|
222
|
+
taken = record.next;
|
|
223
|
+
row = record.row + 1;
|
|
224
|
+
if (records.length >= limit) break;
|
|
225
|
+
}
|
|
226
|
+
buffer = buffer.subarray(taken - base);
|
|
227
|
+
base = taken;
|
|
228
|
+
if (eof) break;
|
|
229
|
+
if (buffer.length > options.maxRecord)
|
|
230
|
+
throw new RangeError(
|
|
231
|
+
`row ${row} is longer than ${options.maxRecord} bytes from byte ${base}: a quote that never closes?`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
records,
|
|
236
|
+
next: base,
|
|
237
|
+
row,
|
|
238
|
+
end: eof && !buffer.length,
|
|
239
|
+
bytesRead,
|
|
240
|
+
held: { base, bytes: buffer, eof },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A record of empty fields only: a blank line, or a row of delimiters. */
|
|
245
|
+
export function isBlank(record: Pick<CsvRecord, "fields">): boolean {
|
|
246
|
+
return record.fields.every((field) => !field.trim());
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A header or property name for matching: letters and digits of any script,
|
|
251
|
+
* in lower case, without marks and without spaces or punctuation.
|
|
252
|
+
* `Customer ID`, `customer_id` and `CustomerId` are all `customerid`.
|
|
253
|
+
*/
|
|
254
|
+
export function normalizeName(name: string): string {
|
|
255
|
+
return name
|
|
256
|
+
.normalize("NFKD")
|
|
257
|
+
.replace(/\p{M}/gu, "")
|
|
258
|
+
.toLowerCase()
|
|
259
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
260
|
+
}
|