@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.
Files changed (55) hide show
  1. package/README.md +7 -2
  2. package/lib/create.mjs +28 -4
  3. package/package.json +1 -1
  4. package/templates/_shared/testing.ts +185 -5
  5. package/templates/link/README.md +203 -0
  6. package/templates/link/candidates.ts +432 -0
  7. package/templates/link/decide.test.ts +368 -0
  8. package/templates/link/decide.ts +340 -0
  9. package/templates/link/fixture.ts +727 -0
  10. package/templates/link/grade-run.ts +53 -0
  11. package/templates/link/grade.test.ts +113 -0
  12. package/templates/link/grade.ts +335 -0
  13. package/templates/link/items.ts +333 -0
  14. package/templates/link/names.test.ts +260 -0
  15. package/templates/link/names.ts +590 -0
  16. package/templates/link/reads.ts +302 -0
  17. package/templates/link/samples/gold.json +108 -0
  18. package/templates/link/samples/records.json +48 -0
  19. package/templates/link/samples.ts +64 -0
  20. package/templates/link/settings.ts +162 -0
  21. package/templates/link/setup.ts +18 -0
  22. package/templates/link/source-turn.ts +894 -0
  23. package/templates/link/sources/records.ts +429 -0
  24. package/templates/link/things-turn.ts +984 -0
  25. package/templates/link/things.test.ts +204 -0
  26. package/templates/link/things.ts +426 -0
  27. package/templates/link/try.ts +28 -0
  28. package/templates/link/worker.ts +24 -0
  29. package/templates/link/workflow.test.ts +484 -0
  30. package/templates/link/workflow.ts +202 -0
  31. package/templates/rows/README.md +150 -0
  32. package/templates/rows/csv.ts +260 -0
  33. package/templates/rows/samples/expected.json +150 -0
  34. package/templates/rows/samples/properties.json +11 -0
  35. package/templates/rows/samples/q3-customers.csv +10 -0
  36. package/templates/rows/samples/q3-orders.tsv +5 -0
  37. package/templates/rows/samples/reports.json +5 -0
  38. package/templates/rows/samples.ts +56 -0
  39. package/templates/rows/setup.ts +18 -0
  40. package/templates/rows/try.ts +32 -0
  41. package/templates/rows/worker.ts +24 -0
  42. package/templates/rows/workflow.test.ts +541 -0
  43. package/templates/rows/workflow.ts +657 -0
  44. package/templates/sections/README.md +118 -0
  45. package/templates/sections/markdown.ts +121 -0
  46. package/templates/sections/samples/expected.json +82 -0
  47. package/templates/sections/samples/guides.json +4 -0
  48. package/templates/sections/samples/support-handbook.md +34 -0
  49. package/templates/sections/samples.ts +41 -0
  50. package/templates/sections/setup.ts +18 -0
  51. package/templates/sections/try.ts +32 -0
  52. package/templates/sections/worker.ts +24 -0
  53. package/templates/sections/workflow.test.ts +265 -0
  54. package/templates/sections/workflow.ts +433 -0
  55. 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,5 @@
1
+ Order ID Customer ID Placed on Total Status
2
+ O-5001 C-1001 2026-07-03 182.40 paid
3
+ O-5002 C-1002 2026-07-19 1250,50 "on hold"
4
+ O-5003 C-1003 2026-08-02 64 paid
5
+ O-5004 C-1001 2026-09-28 n/a cancelled
@@ -0,0 +1,5 @@
1
+ [
2
+ { "name": "Q3 customers", "text": "The customer list at the end of the third quarter." },
3
+ { "name": "Q3 orders", "text": "The orders of the third quarter." },
4
+ { "name": "Q2 customers", "text": "The customer list at the end of the second quarter." }
5
+ ]
@@ -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.`);