@littlebigbrain/create 0.2.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 +7 -2
- package/lib/create.mjs +28 -4
- package/package.json +1 -1
- package/templates/_shared/testing.ts +185 -5
- package/templates/link/README.md +203 -0
- package/templates/link/candidates.ts +432 -0
- package/templates/link/decide.test.ts +368 -0
- package/templates/link/decide.ts +340 -0
- package/templates/link/fixture.ts +727 -0
- package/templates/link/grade-run.ts +53 -0
- package/templates/link/grade.test.ts +113 -0
- package/templates/link/grade.ts +335 -0
- package/templates/link/items.ts +333 -0
- package/templates/link/names.test.ts +260 -0
- package/templates/link/names.ts +590 -0
- package/templates/link/reads.ts +302 -0
- package/templates/link/samples/gold.json +108 -0
- package/templates/link/samples/records.json +48 -0
- package/templates/link/samples.ts +64 -0
- package/templates/link/settings.ts +162 -0
- package/templates/link/setup.ts +18 -0
- package/templates/link/source-turn.ts +894 -0
- package/templates/link/sources/records.ts +429 -0
- package/templates/link/things-turn.ts +984 -0
- package/templates/link/things.test.ts +204 -0
- package/templates/link/things.ts +426 -0
- package/templates/link/try.ts +28 -0
- package/templates/link/worker.ts +24 -0
- package/templates/link/workflow.test.ts +484 -0
- package/templates/link/workflow.ts +202 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
{
|
|
2
|
+
"q3-customers.csv": {
|
|
3
|
+
"settings": { "className": "Customer", "key": "Customer ID", "represents": { "class": "Report" } },
|
|
4
|
+
"rows": 8,
|
|
5
|
+
"rows_without_key": 1,
|
|
6
|
+
"values_left_out": 1,
|
|
7
|
+
"columns": [
|
|
8
|
+
"Customer ID → customer_id",
|
|
9
|
+
"Email → email",
|
|
10
|
+
"Address → address",
|
|
11
|
+
"City → city",
|
|
12
|
+
"Customer since → customer_since"
|
|
13
|
+
],
|
|
14
|
+
"columns_left_out": ["Name", "Account manager"],
|
|
15
|
+
"represents": "Q3 customers",
|
|
16
|
+
"instances": [
|
|
17
|
+
{
|
|
18
|
+
"key": "C-1001",
|
|
19
|
+
"name": "Harbor Lane Bakery",
|
|
20
|
+
"row": 2,
|
|
21
|
+
"properties": {
|
|
22
|
+
"customer_id": "C-1001",
|
|
23
|
+
"email": "orders@harborlane.example",
|
|
24
|
+
"address": "12 Quay Street",
|
|
25
|
+
"city": "Lisbon",
|
|
26
|
+
"customer_since": "2021-03-14T00:00:00Z"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"key": "C-1002",
|
|
31
|
+
"name": "Okafor, Ada",
|
|
32
|
+
"row": 3,
|
|
33
|
+
"properties": {
|
|
34
|
+
"customer_id": "C-1002",
|
|
35
|
+
"email": "ada.okafor@mail.example",
|
|
36
|
+
"address": "4 Palm Avenue",
|
|
37
|
+
"city": "Lagos",
|
|
38
|
+
"customer_since": "2019-11-02T00:00:00Z"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"key": "C-1003",
|
|
43
|
+
"name": "Studio \"Nord\"",
|
|
44
|
+
"row": 4,
|
|
45
|
+
"properties": {
|
|
46
|
+
"customer_id": "C-1003",
|
|
47
|
+
"email": "hello@studionord.example",
|
|
48
|
+
"address": "Strandgata 9",
|
|
49
|
+
"city": "Oslo",
|
|
50
|
+
"customer_since": "2022-06-30T00:00:00Z"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"key": "C-1004",
|
|
55
|
+
"name": "Meridian Tutoring",
|
|
56
|
+
"row": 5,
|
|
57
|
+
"why": "The address holds a line break inside its quotes, so the row spans two lines. 'not a date' does not take the type of customer_since and is left out.",
|
|
58
|
+
"properties": {
|
|
59
|
+
"customer_id": "C-1004",
|
|
60
|
+
"email": "info@meridian.example",
|
|
61
|
+
"address": "Rua das Flores 21\nFloor 2",
|
|
62
|
+
"city": "Porto"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"key": "C-1005",
|
|
67
|
+
"name": "Lindqvist Maja",
|
|
68
|
+
"row": 6,
|
|
69
|
+
"properties": {
|
|
70
|
+
"customer_id": "C-1005",
|
|
71
|
+
"email": "maja@lindqvist.example",
|
|
72
|
+
"address": "Kungsgatan 3",
|
|
73
|
+
"city": "Uppsala",
|
|
74
|
+
"customer_since": "2020-01-20T00:00:00Z"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"key": "C-1006",
|
|
79
|
+
"name": "Calder Repair Shop",
|
|
80
|
+
"row": 8,
|
|
81
|
+
"why": "Row 7 is blank. The email cell is empty, so the instance has no email.",
|
|
82
|
+
"properties": {
|
|
83
|
+
"customer_id": "C-1006",
|
|
84
|
+
"address": "1 Mill Lane",
|
|
85
|
+
"city": "Leeds",
|
|
86
|
+
"customer_since": "2023-09-05T00:00:00Z"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"leave_out": [{ "row": 9, "why": "The row has no Customer ID: no instance." }]
|
|
91
|
+
},
|
|
92
|
+
"q3-orders.tsv": {
|
|
93
|
+
"settings": { "className": "Order", "key": "Order ID", "represents": { "class": "Report" } },
|
|
94
|
+
"rows": 4,
|
|
95
|
+
"rows_without_key": 0,
|
|
96
|
+
"values_left_out": 1,
|
|
97
|
+
"represents": "Q3 orders",
|
|
98
|
+
"instances": [
|
|
99
|
+
{
|
|
100
|
+
"key": "O-5001",
|
|
101
|
+
"name": "O-5001",
|
|
102
|
+
"row": 2,
|
|
103
|
+
"properties": {
|
|
104
|
+
"order_id": "O-5001",
|
|
105
|
+
"customer_id": "C-1001",
|
|
106
|
+
"placed_on": "2026-07-03T00:00:00Z",
|
|
107
|
+
"total": 182.4,
|
|
108
|
+
"status": "paid"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"key": "O-5002",
|
|
113
|
+
"name": "O-5002",
|
|
114
|
+
"row": 3,
|
|
115
|
+
"why": "1250,50 has a decimal comma; the status is quoted.",
|
|
116
|
+
"properties": {
|
|
117
|
+
"order_id": "O-5002",
|
|
118
|
+
"customer_id": "C-1002",
|
|
119
|
+
"placed_on": "2026-07-19T00:00:00Z",
|
|
120
|
+
"total": 1250.5,
|
|
121
|
+
"status": "on hold"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"key": "O-5003",
|
|
126
|
+
"name": "O-5003",
|
|
127
|
+
"row": 4,
|
|
128
|
+
"properties": {
|
|
129
|
+
"order_id": "O-5003",
|
|
130
|
+
"customer_id": "C-1003",
|
|
131
|
+
"placed_on": "2026-08-02T00:00:00Z",
|
|
132
|
+
"total": 64,
|
|
133
|
+
"status": "paid"
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"key": "O-5004",
|
|
138
|
+
"name": "O-5004",
|
|
139
|
+
"row": 5,
|
|
140
|
+
"why": "n/a does not take the type of total and is left out.",
|
|
141
|
+
"properties": {
|
|
142
|
+
"order_id": "O-5004",
|
|
143
|
+
"customer_id": "C-1001",
|
|
144
|
+
"placed_on": "2026-09-28T00:00:00Z",
|
|
145
|
+
"status": "cancelled"
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[
|
|
2
|
+
{ "name": "customer_id", "value_type": "keyword" },
|
|
3
|
+
{ "name": "email", "value_type": "keyword" },
|
|
4
|
+
{ "name": "address", "value_type": "text" },
|
|
5
|
+
{ "name": "city", "value_type": "keyword" },
|
|
6
|
+
{ "name": "customer_since", "value_type": "date_time" },
|
|
7
|
+
{ "name": "order_id", "value_type": "keyword" },
|
|
8
|
+
{ "name": "placed_on", "value_type": "date_time" },
|
|
9
|
+
{ "name": "total", "value_type": "f64" },
|
|
10
|
+
{ "name": "status", "value_type": "keyword" }
|
|
11
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Customer ID,Name,Email,Address,City,Customer since,Account manager
|
|
2
|
+
C-1001,Harbor Lane Bakery,orders@harborlane.example,12 Quay Street,Lisbon,2021-03-14,R. Silva
|
|
3
|
+
C-1002,"Okafor, Ada",ada.okafor@mail.example,4 Palm Avenue,Lagos,2019-11-02,T. Bello
|
|
4
|
+
C-1003,"Studio ""Nord""",hello@studionord.example,Strandgata 9,Oslo,2022-06-30,K. Berg
|
|
5
|
+
C-1004,Meridian Tutoring,info@meridian.example,"Rua das Flores 21
|
|
6
|
+
Floor 2",Porto,not a date,R. Silva
|
|
7
|
+
C-1005,Lindqvist Maja,maja@lindqvist.example,Kungsgatan 3,Uppsala,2020-01-20,K. Berg
|
|
8
|
+
|
|
9
|
+
C-1006,Calder Repair Shop,,1 Mill Lane,Leeds,2023-09-05,T. Bello
|
|
10
|
+
,Walk-in customer,walkin@shop.example,,Leeds,2024-02-11,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The samples of rows, for `npm run try`: an invented customer list
|
|
2
|
+
// (samples/q3-customers.csv) and order table (samples/q3-orders.tsv), the
|
|
3
|
+
// properties their columns fill (samples/properties.json), and three
|
|
4
|
+
// reports (samples/reports.json) that the files' names can name.
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { LbbClient, Schemas } from "@littlebigbrain/client";
|
|
8
|
+
import { defaults } from "./workflow.js";
|
|
9
|
+
|
|
10
|
+
/** The sample files and their media types. */
|
|
11
|
+
export const SAMPLE_FILES = [
|
|
12
|
+
{ name: "q3-customers.csv", contentType: "text/csv" },
|
|
13
|
+
{ name: "q3-orders.tsv", contentType: "text/tab-separated-values" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** Add the properties and the reports, then upload the sample files to the client's graph. */
|
|
17
|
+
export async function loadSamples(client: LbbClient, dir: string): Promise<void> {
|
|
18
|
+
const properties = JSON.parse(await readFile(join(dir, "properties.json"), "utf8")) as {
|
|
19
|
+
name: string;
|
|
20
|
+
value_type: string;
|
|
21
|
+
}[];
|
|
22
|
+
const reports = JSON.parse(await readFile(join(dir, "reports.json"), "utf8")) as {
|
|
23
|
+
name: string;
|
|
24
|
+
text: string;
|
|
25
|
+
}[];
|
|
26
|
+
const reportClass = defaults.represents?.class ?? "Report";
|
|
27
|
+
const ops: Schemas["OntologyEvolveOp"][] = [
|
|
28
|
+
{ op: "add_entity_type", name: reportClass },
|
|
29
|
+
{ op: "add_property", name: "text", value_type: "text" },
|
|
30
|
+
...properties.map((p) => ({
|
|
31
|
+
op: "add_property" as const,
|
|
32
|
+
name: p.name,
|
|
33
|
+
value_type: p.value_type,
|
|
34
|
+
})),
|
|
35
|
+
];
|
|
36
|
+
await client.ontology.evolve({ ops });
|
|
37
|
+
await client.commit(
|
|
38
|
+
{
|
|
39
|
+
entity_properties: reports.map((report) => ({
|
|
40
|
+
type: reportClass,
|
|
41
|
+
name: report.name,
|
|
42
|
+
properties: { text: report.text },
|
|
43
|
+
})),
|
|
44
|
+
},
|
|
45
|
+
{ idempotencyKey: "rows-samples" },
|
|
46
|
+
);
|
|
47
|
+
console.log(`Committed ${reports.length} ${reportClass} records`);
|
|
48
|
+
for (const file of SAMPLE_FILES) {
|
|
49
|
+
const bytes = await readFile(join(dir, file.name));
|
|
50
|
+
const uploaded = await client.files.upload(new Uint8Array(bytes), {
|
|
51
|
+
name: file.name,
|
|
52
|
+
contentType: file.contentType,
|
|
53
|
+
});
|
|
54
|
+
console.log(`Uploaded ${file.name} (${uploaded.file.sha256.slice(0, 12)})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Create the workflow on your graph: add the class and the relation it
|
|
2
|
+
// writes, then create the named workflow whose content trigger sends it each
|
|
3
|
+
// CSV and TSV upload. Each call is safe to repeat.
|
|
4
|
+
//
|
|
5
|
+
// LBB_URL=... LBB_API_KEY=... LBB_GRAPH=... npm run setup
|
|
6
|
+
import { LbbClient, lbbActivities } from "@littlebigbrain/client";
|
|
7
|
+
import { ontology, trigger } from "./workflow.js";
|
|
8
|
+
|
|
9
|
+
const graph = process.env.LBB_GRAPH ?? "main";
|
|
10
|
+
const client = new LbbClient({
|
|
11
|
+
baseUrl: process.env.LBB_URL ?? "http://127.0.0.1:7400",
|
|
12
|
+
apiKey: process.env.LBB_API_KEY,
|
|
13
|
+
graph,
|
|
14
|
+
});
|
|
15
|
+
await lbbActivities(client).evolve(ontology());
|
|
16
|
+
const created = await client.workflows.named.put(trigger());
|
|
17
|
+
console.log(`Created ${created.name} on the graph ${graph}.`);
|
|
18
|
+
console.log(`Serve it with LBB_GRAPH=${graph} npm start.`);
|