@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 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
@@ -22,6 +22,8 @@ export const TEMPLATES = [
22
22
  "values-extract",
23
23
  "ontology-fit",
24
24
  "link",
25
+ "rows",
26
+ "sections",
25
27
  ];
26
28
 
27
29
  const PACKAGE = resolve(dirname(fileURLToPath(import.meta.url)), "..");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/create",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {
@@ -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 = JSON.stringify(output ?? null);
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
+ }