@littlebigbrain/create 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/lib/create.mjs +2 -0
- package/package.json +1 -1
- package/templates/_shared/testing.ts +227 -1
- package/templates/rows/README.md +157 -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 +597 -0
- package/templates/rows/workflow.ts +691 -0
- package/templates/sections/README.md +125 -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 +315 -0
- package/templates/sections/workflow.ts +472 -0
- package/templates/versions.json +1 -1
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
// rows on its samples, without a server: the files and the derivations are
|
|
2
|
+
// the mock's (MockContent), the graph is in memory, and the SPARQL reads are
|
|
3
|
+
// answered from it. The expected instances come from samples/expected.json.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { Schemas } from "@littlebigbrain/client";
|
|
9
|
+
import {
|
|
10
|
+
Crash,
|
|
11
|
+
MockActivities,
|
|
12
|
+
MockContent,
|
|
13
|
+
contentMessage,
|
|
14
|
+
phases,
|
|
15
|
+
readJson,
|
|
16
|
+
runMessage,
|
|
17
|
+
runTurn,
|
|
18
|
+
samplesDir,
|
|
19
|
+
type MemoryGraph,
|
|
20
|
+
type MockFile,
|
|
21
|
+
} from "../_shared/testing.js";
|
|
22
|
+
import { csvRecords, normalizeName, readRecords, type ReadRange } from "./csv.js";
|
|
23
|
+
import {
|
|
24
|
+
READ_FROM,
|
|
25
|
+
createWorkflow,
|
|
26
|
+
defaults,
|
|
27
|
+
derivationKey,
|
|
28
|
+
labelKey,
|
|
29
|
+
typed,
|
|
30
|
+
type ReadOptions,
|
|
31
|
+
type RowsState,
|
|
32
|
+
type Settings,
|
|
33
|
+
} from "./workflow.js";
|
|
34
|
+
|
|
35
|
+
const SAMPLES = samplesDir(import.meta.url, "rows");
|
|
36
|
+
const CUSTOMERS = readFileSync(join(SAMPLES, "q3-customers.csv"));
|
|
37
|
+
const ORDERS = readFileSync(join(SAMPLES, "q3-orders.tsv"));
|
|
38
|
+
const PROPERTIES = readJson<{ name: string; value_type: string }[]>(
|
|
39
|
+
join(SAMPLES, "properties.json"),
|
|
40
|
+
);
|
|
41
|
+
const REPORTS = readJson<{ name: string }[]>(join(SAMPLES, "reports.json"));
|
|
42
|
+
interface ExpectedFile {
|
|
43
|
+
settings: Partial<Settings>;
|
|
44
|
+
rows: number;
|
|
45
|
+
rows_without_key: number;
|
|
46
|
+
values_left_out: number;
|
|
47
|
+
columns?: string[];
|
|
48
|
+
columns_left_out?: string[];
|
|
49
|
+
represents: string;
|
|
50
|
+
instances: { key: string; name: string; row: number; properties: Record<string, unknown> }[];
|
|
51
|
+
}
|
|
52
|
+
const EXPECTED = readJson<Record<string, ExpectedFile>>(join(SAMPLES, "expected.json"));
|
|
53
|
+
|
|
54
|
+
/** The SPARQL reads of the workflow, answered from the graph in memory. */
|
|
55
|
+
function sparql(query: string, graph: MemoryGraph): Record<string, string>[] {
|
|
56
|
+
if (query.startsWith("# rows: document")) {
|
|
57
|
+
const iri = /VALUES \?doc \{ <([^>]+)> \}/.exec(query)![1]!;
|
|
58
|
+
const doc = graph.entities.get(iri);
|
|
59
|
+
if (!doc) return [];
|
|
60
|
+
const p = doc.properties;
|
|
61
|
+
return [{ name: String(p.name), sha: String(p.file_sha256), type: String(p.content_type) }];
|
|
62
|
+
}
|
|
63
|
+
if (query.startsWith("# rows: represents")) {
|
|
64
|
+
const cls = decodeURIComponent(
|
|
65
|
+
/<https:\/\/littlebigbrain\.com\/class\/([^>]+)>/.exec(query)![1]!,
|
|
66
|
+
);
|
|
67
|
+
const wanted = JSON.parse(/= ("(?:[^"\\]|\\.)*")\)/.exec(query)![1]!) as string;
|
|
68
|
+
return [...graph.entities.values()]
|
|
69
|
+
.filter((e) => e.type.toLowerCase() === cls && labelKey(e.name) === wanted)
|
|
70
|
+
.map((e) => ({ s: e.iri, label: e.name }))
|
|
71
|
+
.slice(0, 2);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`unexpected query: ${query.slice(0, 60)}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ONTOLOGY = {
|
|
77
|
+
graph: { tenant: "samples", graph: "samples" },
|
|
78
|
+
ontology_version: 1,
|
|
79
|
+
entity_types: [],
|
|
80
|
+
relations: [],
|
|
81
|
+
property_defs: PROPERTIES.map((p) => ({ ...p, required: false })),
|
|
82
|
+
} as unknown as Schemas["OntologyView"];
|
|
83
|
+
|
|
84
|
+
/** A run of the workflow on a graph with the sample reports. */
|
|
85
|
+
function setup(
|
|
86
|
+
settings: Settings = defaults,
|
|
87
|
+
reports = REPORTS.map((r) => r.name),
|
|
88
|
+
reading: ReadOptions = {},
|
|
89
|
+
) {
|
|
90
|
+
const lbb = new MockActivities({ sparql, ontology: ONTOLOGY });
|
|
91
|
+
for (const name of reports) lbb.graph.entity({ type: "Report", name });
|
|
92
|
+
const content = new MockContent(lbb.graph);
|
|
93
|
+
const definition = createWorkflow(lbb, content, settings, reading);
|
|
94
|
+
const run = (file: MockFile, state: unknown = {}) =>
|
|
95
|
+
runMessage<RowsState>(definition, contentMessage("rows", file), state);
|
|
96
|
+
return { lbb, content, run, definition };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const instancesOf = (graph: MemoryGraph, type: string) =>
|
|
100
|
+
graph.ofType(type).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
101
|
+
|
|
102
|
+
/** The instances, edges and REPRESENTS of a file's run, checked against its answer key. */
|
|
103
|
+
function assertMatchesKey(graph: MemoryGraph, file: MockFile, key: ExpectedFile, type: string) {
|
|
104
|
+
const written = graph.ofType(type);
|
|
105
|
+
assert.equal(written.length, key.instances.length, "one instance per data row with a key");
|
|
106
|
+
for (const want of key.instances) {
|
|
107
|
+
const got = written.find((e) => e.name === want.name);
|
|
108
|
+
assert.ok(got, `an instance named ${want.name}`);
|
|
109
|
+
assert.deepEqual(got.properties, want.properties, want.name);
|
|
110
|
+
const edge = graph.edges.find((e) => e.source === got.iri && e.relation === READ_FROM);
|
|
111
|
+
assert.ok(edge, `${want.name} is READ_FROM its file`);
|
|
112
|
+
assert.equal(edge.target, file.document);
|
|
113
|
+
const evidence = edge.evidence as { text: string; source_id: string };
|
|
114
|
+
assert.equal(evidence.source_id, `${file.document}#row=${want.row}`);
|
|
115
|
+
const record = [
|
|
116
|
+
...csvRecords(new TextDecoder().decode(file.bytes), type === "Order" ? "\t" : ","),
|
|
117
|
+
].find((r) => r.row === want.row)!;
|
|
118
|
+
assert.equal(evidence.text, record.text, `the evidence of ${want.name} is its row's text`);
|
|
119
|
+
}
|
|
120
|
+
const represents = graph.edgesOf("REPRESENTS");
|
|
121
|
+
assert.equal(represents.length, 1);
|
|
122
|
+
assert.equal(represents[0]!.source, file.document);
|
|
123
|
+
assert.equal(graph.entities.get(represents[0]!.target)!.name, key.represents);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("the customer list gives the instances of the answer key, each READ_FROM its row", async () => {
|
|
127
|
+
const { lbb, content, run } = setup();
|
|
128
|
+
const file = content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
129
|
+
const { state, turns } = await run(file);
|
|
130
|
+
const key = EXPECTED["q3-customers.csv"]!;
|
|
131
|
+
|
|
132
|
+
assertMatchesKey(lbb.graph, file, key, "Customer");
|
|
133
|
+
assert.deepEqual(state, { contents: 1, skipped: 0, rows: 8, instances: 6, represents: 1 });
|
|
134
|
+
assert.equal(turns.length, 1);
|
|
135
|
+
const derivation = content.recorded.get(
|
|
136
|
+
`rows@v1/${derivationKey(file.sha256, defaults).settings_digest}/${file.sha256}`,
|
|
137
|
+
);
|
|
138
|
+
assert.ok(derivation, "the derivation is recorded");
|
|
139
|
+
const outputs = derivation.outputs as Record<string, unknown>;
|
|
140
|
+
assert.equal(outputs.rows, key.rows);
|
|
141
|
+
assert.equal(outputs.rows_without_key, key.rows_without_key);
|
|
142
|
+
assert.equal(outputs.values_left_out, key.values_left_out);
|
|
143
|
+
assert.deepEqual(outputs.columns, key.columns);
|
|
144
|
+
assert.deepEqual(outputs.columns_left_out, key.columns_left_out);
|
|
145
|
+
assert.equal(derivation.run_id, "starter-test-1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a TSV order table reads by tabs, with a quoted field and a decimal comma", async () => {
|
|
149
|
+
const settings: Settings = {
|
|
150
|
+
className: "Order",
|
|
151
|
+
key: "Order ID",
|
|
152
|
+
represents: { class: "Report" },
|
|
153
|
+
rowsPerTurn: 5_000,
|
|
154
|
+
};
|
|
155
|
+
const { lbb, content, run } = setup(settings);
|
|
156
|
+
const file = content.upload("q3-orders.tsv", ORDERS, "text/tab-separated-values");
|
|
157
|
+
const { state } = await run(file);
|
|
158
|
+
|
|
159
|
+
assertMatchesKey(lbb.graph, file, EXPECTED["q3-orders.tsv"]!, "Order");
|
|
160
|
+
assert.equal(state.instances, 4);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a file whose name the settings do not match is skipped without a derivation", async () => {
|
|
164
|
+
const { lbb, content, run } = setup();
|
|
165
|
+
const file = content.upload("q3-orders.tsv", ORDERS, "text/tab-separated-values");
|
|
166
|
+
const { state } = await run(file);
|
|
167
|
+
|
|
168
|
+
assert.deepEqual(state, { contents: 0, skipped: 1, rows: 0, instances: 0, represents: 0 });
|
|
169
|
+
assert.equal(lbb.graph.commits, 0);
|
|
170
|
+
assert.equal(content.recorded.size, 0);
|
|
171
|
+
assert.equal(content.reads, 0, "the bytes are not read");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a content with a derivation for these settings is skipped; new settings read it again", async () => {
|
|
175
|
+
const { lbb, content, run } = setup();
|
|
176
|
+
const file = content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
177
|
+
const first = await run(file);
|
|
178
|
+
const commits = lbb.graph.commits;
|
|
179
|
+
|
|
180
|
+
// The same bytes again, and under a second name: one derivation, no new write.
|
|
181
|
+
const again = await run(file, first.state);
|
|
182
|
+
const renamed = content.upload("customers-copy.csv", CUSTOMERS, "text/csv");
|
|
183
|
+
assert.equal(renamed.sha256, file.sha256);
|
|
184
|
+
const twice = await run(renamed, again.state);
|
|
185
|
+
assert.equal(twice.state.skipped, 2);
|
|
186
|
+
assert.equal(twice.state.contents, 1);
|
|
187
|
+
assert.equal(lbb.graph.commits, commits, "a skipped content writes nothing");
|
|
188
|
+
assert.equal(content.reads, 1, "a skipped content is not read");
|
|
189
|
+
assert.equal(content.recorded.size, 1);
|
|
190
|
+
|
|
191
|
+
// Other settings are another key: the content is read again.
|
|
192
|
+
const other = setup({ ...defaults, rowsPerTurn: 100 });
|
|
193
|
+
const copy = other.content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
194
|
+
for (const [k, v] of content.recorded) other.content.recorded.set(k, v);
|
|
195
|
+
await other.run(copy);
|
|
196
|
+
assert.equal(other.content.recorded.size, 2);
|
|
197
|
+
assert.notEqual(
|
|
198
|
+
derivationKey(file.sha256, defaults).settings_digest,
|
|
199
|
+
derivationKey(file.sha256, { ...defaults, rowsPerTurn: 100 }).settings_digest,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("a file of more rows than a turn reads continues where the last turn stopped", async () => {
|
|
204
|
+
const { lbb, content, run } = setup({ ...defaults, rowsPerTurn: 3 });
|
|
205
|
+
const file = content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
206
|
+
const { state, turns } = await run(file);
|
|
207
|
+
|
|
208
|
+
// 8 rows after the header: 3 + 3 + 2.
|
|
209
|
+
assert.equal(turns.length, 3);
|
|
210
|
+
assert.deepEqual(turns[0]!.keys, ["check", "plan", "represents", "rows", "write-0"]);
|
|
211
|
+
assert.deepEqual(turns[1]!.keys, ["rows", "write-0"]);
|
|
212
|
+
assert.deepEqual(turns[2]!.keys, ["rows", "write-0", "record"]);
|
|
213
|
+
assert.deepEqual(state, { contents: 1, skipped: 0, rows: 8, instances: 6, represents: 1 });
|
|
214
|
+
assertMatchesKey(lbb.graph, file, EXPECTED["q3-customers.csv"]!, "Customer");
|
|
215
|
+
assert.equal(content.recorded.size, 1);
|
|
216
|
+
const outputs = [...content.recorded.values()][0]!.outputs as { commits: number[] };
|
|
217
|
+
assert.equal(outputs.commits.length, 4, "the REPRESENTS write and one write per turn");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a turn writes its rows in parts of at most 400", async () => {
|
|
221
|
+
const lines = ["Customer ID,Email"];
|
|
222
|
+
for (let n = 1; n <= 1_000; n++) lines.push(`C-${n},c${n}@shop.example`);
|
|
223
|
+
const { lbb, content, run } = setup({ ...defaults, match: undefined, represents: undefined });
|
|
224
|
+
const file = content.upload("customers-large.csv", lines.join("\r\n"), "text/csv");
|
|
225
|
+
const { state, turns } = await run(file);
|
|
226
|
+
|
|
227
|
+
assert.equal(state.instances, 1_000);
|
|
228
|
+
assert.deepEqual(turns[0]!.keys, [
|
|
229
|
+
"check",
|
|
230
|
+
"plan",
|
|
231
|
+
"rows",
|
|
232
|
+
"write-0",
|
|
233
|
+
"write-1",
|
|
234
|
+
"write-2",
|
|
235
|
+
"record",
|
|
236
|
+
]);
|
|
237
|
+
assert.equal(lbb.graph.ofType("Customer").length, 1_000);
|
|
238
|
+
const last = lbb.graph.edges.find((e) =>
|
|
239
|
+
(e.evidence as { text: string }).text.startsWith("C-1000,"),
|
|
240
|
+
);
|
|
241
|
+
assert.equal((last!.evidence as { source_id: string }).source_id, `${file.document}#row=1001`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a header-only file and an empty file write no instance and record a derivation", async () => {
|
|
245
|
+
const { lbb, content, run } = setup({ ...defaults, match: undefined, represents: undefined });
|
|
246
|
+
const header = content.upload("customers-none.csv", "Customer ID,Email\r\n", "text/csv");
|
|
247
|
+
const empty = content.upload("empty.csv", "", "text/csv");
|
|
248
|
+
const one = await run(header);
|
|
249
|
+
const two = await run(empty, one.state);
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(two.state, { contents: 2, skipped: 0, rows: 0, instances: 0, represents: 0 });
|
|
252
|
+
assert.equal(lbb.graph.ofType("Customer").length, 0);
|
|
253
|
+
assert.equal(content.recorded.size, 2);
|
|
254
|
+
for (const derivation of content.recorded.values())
|
|
255
|
+
assert.equal((derivation.outputs as { rows: number }).rows, 0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("REPRESENTS needs exactly one instance whose label is the file's name", async () => {
|
|
259
|
+
const counts: number[] = [];
|
|
260
|
+
for (const reports of [["Q2 customers"], ["Q3 customers"], ["Q3 customers", "q3_Customers"]]) {
|
|
261
|
+
const { lbb, content, run } = setup(defaults, reports);
|
|
262
|
+
const file = content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
263
|
+
const { state, turns } = await run(file);
|
|
264
|
+
counts.push(lbb.graph.edgesOf("REPRESENTS").length);
|
|
265
|
+
assert.equal(state.represents, lbb.graph.edgesOf("REPRESENTS").length);
|
|
266
|
+
assert.equal(turns[0]!.keys.includes("represents"), counts.at(-1) === 1);
|
|
267
|
+
assert.equal(lbb.reviews.size, 0, "nothing is proposed");
|
|
268
|
+
}
|
|
269
|
+
assert.deepEqual(counts, [0, 1, 0]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("without a key column the key is the content and the row; settings columns map by name", async () => {
|
|
273
|
+
const settings: Settings = {
|
|
274
|
+
className: "Customer",
|
|
275
|
+
columns: { "E-Mail": "email", Town: "city", Phone: "phone" },
|
|
276
|
+
rowsPerTurn: 100,
|
|
277
|
+
};
|
|
278
|
+
const { lbb, content, run } = setup(settings);
|
|
279
|
+
const csv = "E-Mail,Town,Phone\nana@shop.example,Faro,+351 1\nbo@shop.example,Bergen,+47 2\n";
|
|
280
|
+
const file = content.upload("contacts.csv", csv, "text/csv");
|
|
281
|
+
await run(file);
|
|
282
|
+
|
|
283
|
+
const written = instancesOf(lbb.graph, "Customer");
|
|
284
|
+
assert.equal(written.length, 2);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
written.map((e) => e.name),
|
|
287
|
+
["contacts row 2", "contacts row 3"],
|
|
288
|
+
);
|
|
289
|
+
assert.deepEqual(written[0]!.properties, { email: "ana@shop.example", city: "Faro" });
|
|
290
|
+
const outputs = [...content.recorded.values()][0]!.outputs as Record<string, unknown>;
|
|
291
|
+
assert.deepEqual(outputs.columns, ["E-Mail → email", "Town → city"]);
|
|
292
|
+
assert.deepEqual(outputs.columns_left_out, ["Phone"], "phone is no property of the graph");
|
|
293
|
+
// The key of a row without a key column: the same row reads to the same instance.
|
|
294
|
+
const again = setup(settings);
|
|
295
|
+
const copy = again.content.upload("contacts.csv", csv, "text/csv");
|
|
296
|
+
await again.run(copy);
|
|
297
|
+
assert.ok([...again.lbb.graph.entities.keys()].every((iri) => lbb.graph.entities.has(iri)));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("each step reports its phase in the progress convention", async () => {
|
|
301
|
+
const { content, run } = setup();
|
|
302
|
+
const file = content.upload("q3-customers.csv", CUSTOMERS, "text/csv");
|
|
303
|
+
const { turns } = await run(file);
|
|
304
|
+
assert.deepEqual(phases(turns), [
|
|
305
|
+
"checking the file",
|
|
306
|
+
"reading the header",
|
|
307
|
+
"writing facts",
|
|
308
|
+
"reading rows",
|
|
309
|
+
"recording the derivation",
|
|
310
|
+
]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("RFC 4180: quotes, delimiters and line breaks inside quotes, and the row numbers", () => {
|
|
314
|
+
const text =
|
|
315
|
+
'a,b,c\r\n"x, y","say ""hi""","two\r\nlines"\r\n\r\nplain,"",end\rlast,row,"no break"';
|
|
316
|
+
const records = [...csvRecords(text)];
|
|
317
|
+
assert.deepEqual(
|
|
318
|
+
records.map((r) => [r.row, r.fields]),
|
|
319
|
+
[
|
|
320
|
+
[1, ["a", "b", "c"]],
|
|
321
|
+
[2, ["x, y", 'say "hi"', "two\r\nlines"]],
|
|
322
|
+
[3, [""]],
|
|
323
|
+
[4, ["plain", "", "end"]],
|
|
324
|
+
[5, ["last", "row", "no break"]],
|
|
325
|
+
],
|
|
326
|
+
);
|
|
327
|
+
assert.equal(records[1]!.text, '"x, y","say ""hi""","two\r\nlines"');
|
|
328
|
+
// A turn that starts at a record's offset reads on from there.
|
|
329
|
+
assert.deepEqual(
|
|
330
|
+
[...csvRecords(text, ",", records[3]!.start, 4)].map((r) => r.row),
|
|
331
|
+
[4, 5],
|
|
332
|
+
);
|
|
333
|
+
assert.deepEqual([...csvRecords("")], []);
|
|
334
|
+
assert.deepEqual(
|
|
335
|
+
[...csvRecords('a\tb\n1\t"2\t3"\n', "\t")].map((r) => r.fields),
|
|
336
|
+
[
|
|
337
|
+
["a", "b"],
|
|
338
|
+
["1", "2\t3"],
|
|
339
|
+
],
|
|
340
|
+
);
|
|
341
|
+
// A quote inside an unquoted field stays.
|
|
342
|
+
assert.deepEqual([...csvRecords('5" pipe,x')][0]!.fields, ['5" pipe', "x"]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("values take their property's type; names compare by letters and digits", () => {
|
|
346
|
+
assert.equal(typed(" 42 ", "i64"), 42);
|
|
347
|
+
assert.equal(typed("4.2", "i64"), null);
|
|
348
|
+
assert.equal(typed("1250,50", "f64"), 1250.5);
|
|
349
|
+
assert.equal(typed("yes", "bool"), true);
|
|
350
|
+
assert.equal(typed("2026-07-03", "date_time"), "2026-07-03T00:00:00Z");
|
|
351
|
+
assert.equal(typed("2026-07-03T10:00:00+02:00", "date_time"), "2026-07-03T08:00:00Z");
|
|
352
|
+
assert.equal(typed("July", "date_time"), null);
|
|
353
|
+
assert.equal(typed("", "text"), null);
|
|
354
|
+
assert.equal(normalizeName("Customer ID"), normalizeName("customer_id"));
|
|
355
|
+
assert.equal(normalizeName("Straße"), "straße");
|
|
356
|
+
assert.equal(labelKey("q3-customers"), labelKey("Q3 customers"));
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ---- reading by range ------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/** Settings that read every file and write no REPRESENTS. */
|
|
362
|
+
const EVERY_FILE: Settings = { ...defaults, match: undefined, represents: undefined };
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A customer list of `n` data rows with `eol` after each record: every third
|
|
366
|
+
* name is quoted and holds a comma and letters beyond ASCII, every fifth
|
|
367
|
+
* holds a line break (CRLF) and quotes inside its quotes.
|
|
368
|
+
*/
|
|
369
|
+
function customerList(n: number, eol = "\r\n"): string {
|
|
370
|
+
const lines = ["Customer ID,Name,City"];
|
|
371
|
+
for (let i = 1; i <= n; i++) {
|
|
372
|
+
const name =
|
|
373
|
+
i % 5 === 0
|
|
374
|
+
? `"Line ${i}\r\nsecond ""part"""`
|
|
375
|
+
: i % 3 === 0
|
|
376
|
+
? `"Müller, Jürgen ${i}"`
|
|
377
|
+
: `Ana ${i}`;
|
|
378
|
+
lines.push(`C-${i},${name},Zürich`);
|
|
379
|
+
}
|
|
380
|
+
return lines.join(eol) + eol;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** A read of `bytes` as the server answers a range: fewer at the end, none from the end on. */
|
|
384
|
+
const rangeOver =
|
|
385
|
+
(bytes: Uint8Array): ReadRange =>
|
|
386
|
+
async (start, end) =>
|
|
387
|
+
bytes.slice(start, Math.min(end + 1, bytes.length));
|
|
388
|
+
|
|
389
|
+
/** Each data row of `csv` has its instance, whose edge cites its exact row and text. */
|
|
390
|
+
function assertEveryRow(graph: MemoryGraph, file: MockFile, csv: string) {
|
|
391
|
+
const rows = [...csvRecords(csv)].slice(1);
|
|
392
|
+
assert.equal(graph.ofType("Customer").length, rows.length);
|
|
393
|
+
for (const record of rows) {
|
|
394
|
+
const edge = graph.edges.find(
|
|
395
|
+
(e) =>
|
|
396
|
+
(e.evidence as { source_id: string }).source_id === `${file.document}#row=${record.row}`,
|
|
397
|
+
);
|
|
398
|
+
assert.ok(edge, `row ${record.row} has its edge`);
|
|
399
|
+
assert.equal((edge.evidence as { text: string }).text, record.text, `row ${record.row}`);
|
|
400
|
+
const instance = graph.entities.get(edge.source)!;
|
|
401
|
+
assert.equal(instance.name, record.fields[1]!.trim(), `row ${record.row}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test("a file larger than one window is read once, window by window, with exact row numbers", async () => {
|
|
406
|
+
const csv = customerList(200);
|
|
407
|
+
const { lbb, content, run } = setup(EVERY_FILE, [], { window: 256 });
|
|
408
|
+
const file = content.upload("customers-big.csv", csv, "text/csv");
|
|
409
|
+
assert.ok(file.bytes.length > 256 * 20, "the file holds many windows");
|
|
410
|
+
const { state, turns } = await run(file);
|
|
411
|
+
|
|
412
|
+
assert.equal(turns.length, 1);
|
|
413
|
+
assert.equal(state.rows, 200);
|
|
414
|
+
assertEveryRow(lbb.graph, file, csv);
|
|
415
|
+
// The header's window goes on into the rows: each read asks for the next
|
|
416
|
+
// window, and each byte is read once.
|
|
417
|
+
content.ranges.forEach((range, n) =>
|
|
418
|
+
assert.deepEqual(range, { start: n * 256, end: n * 256 + 255 }, `read ${n}`),
|
|
419
|
+
);
|
|
420
|
+
assert.equal(content.bytesRead, file.bytes.length);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("a quoted field and a letter beyond ASCII across a window edge read as one row", async () => {
|
|
424
|
+
// Every window size from 64 to 200 bytes, so window edges fall inside
|
|
425
|
+
// quoted fields, between the bytes of `ü`, and between CR and LF; with the
|
|
426
|
+
// three line breaks, and in turns of 3 records.
|
|
427
|
+
for (const eol of ["\r\n", "\n", "\r"]) {
|
|
428
|
+
const bytes = new TextEncoder().encode(customerList(40, eol));
|
|
429
|
+
const whole = [...csvRecords(bytes)];
|
|
430
|
+
assert.equal(whole.length, 41);
|
|
431
|
+
for (let window = 64; window <= 200; window++) {
|
|
432
|
+
const options = { window, maxRecord: 1 << 20 };
|
|
433
|
+
const read = await readRecords(rangeOver(bytes), ",", 0, 1, 10_000, options);
|
|
434
|
+
assert.deepEqual(read.records, whole, `window ${window}`);
|
|
435
|
+
assert.equal(read.end, true);
|
|
436
|
+
const turns = [];
|
|
437
|
+
let at = 0;
|
|
438
|
+
let row = 1;
|
|
439
|
+
for (let end = false; !end;) {
|
|
440
|
+
const turn = await readRecords(rangeOver(bytes), ",", at, row, 3, options);
|
|
441
|
+
turns.push(...turn.records);
|
|
442
|
+
({ next: at, row, end } = turn);
|
|
443
|
+
}
|
|
444
|
+
assert.deepEqual(turns, whole, `window ${window} in turns of 3`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The workflow, on a window that ends inside the quoted field of row 6.
|
|
449
|
+
const csv = customerList(12);
|
|
450
|
+
const edge = Buffer.from(csv).indexOf("Line 5") + 3;
|
|
451
|
+
assert.ok(edge > 64);
|
|
452
|
+
const { lbb, content, run } = setup(EVERY_FILE, [], { window: edge });
|
|
453
|
+
const file = content.upload("customers-quoted.csv", csv, "text/csv");
|
|
454
|
+
await run(file);
|
|
455
|
+
assertEveryRow(lbb.graph, file, csv);
|
|
456
|
+
const quoted = lbb.graph.ofType("Customer").find((e) => e.name.startsWith("Line 5"));
|
|
457
|
+
assert.equal(quoted?.name, 'Line 5\r\nsecond "part"');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("a CRLF file whose window ends between CR and LF keeps one row per line", async () => {
|
|
461
|
+
const csv = customerList(30);
|
|
462
|
+
// A window that ends after the CR of the line break that ends row 4.
|
|
463
|
+
const bytes = Buffer.from(csv);
|
|
464
|
+
const cr = bytes.indexOf("\r\nC-4,") + 1;
|
|
465
|
+
const rowEnd = bytes.indexOf("\r\n", cr + 1) + 1;
|
|
466
|
+
const { lbb, content, run } = setup(EVERY_FILE, [], { window: rowEnd });
|
|
467
|
+
const file = content.upload("customers-crlf.csv", csv, "text/csv");
|
|
468
|
+
const { state } = await run(file);
|
|
469
|
+
assert.equal(content.ranges[0]!.end, rowEnd - 1, "the first window ends with the CR");
|
|
470
|
+
assert.equal(bytes[rowEnd - 1], 0x0d);
|
|
471
|
+
assert.equal(state.rows, 30, "no blank row between CR and LF");
|
|
472
|
+
assertEveryRow(lbb.graph, file, csv);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("a turn that continues starts at the byte offset where the last one stopped", async () => {
|
|
476
|
+
const csv = customerList(10);
|
|
477
|
+
const whole = [...csvRecords(csv)];
|
|
478
|
+
const settings = { ...EVERY_FILE, rowsPerTurn: 4 };
|
|
479
|
+
const { lbb, content, definition } = setup(settings, [], { window: 64 });
|
|
480
|
+
const file = content.upload("customers-ten.csv", csv, "text/csv");
|
|
481
|
+
|
|
482
|
+
// Turn 1 reads the header and rows 2 to 5, and continues at row 6.
|
|
483
|
+
let message = contentMessage("rows", file);
|
|
484
|
+
const first = await runTurn<RowsState>(definition, {}, message);
|
|
485
|
+
type Continued = typeof message & { cursor: { byte: number; row: number } };
|
|
486
|
+
message = first.result.continuation!.message;
|
|
487
|
+
const cursor = (message as Continued).cursor;
|
|
488
|
+
assert.deepEqual([cursor.byte, cursor.row], [whole[5]!.start, 6]);
|
|
489
|
+
|
|
490
|
+
// Turn 2 stops after its rows (a crash) and runs again: each time its
|
|
491
|
+
// first read starts at the cursor's byte, and it writes rows 6 to 9.
|
|
492
|
+
const reads = content.ranges.length;
|
|
493
|
+
const stopped = await runTurn<RowsState>(definition, first.result.state, message, {
|
|
494
|
+
crashAfter: 1,
|
|
495
|
+
}).then(
|
|
496
|
+
() => assert.fail("the turn should stop"),
|
|
497
|
+
(error: unknown) => error,
|
|
498
|
+
);
|
|
499
|
+
assert.ok(stopped instanceof Crash);
|
|
500
|
+
assert.deepEqual(stopped.turn.keys, ["rows"]);
|
|
501
|
+
assert.equal(content.ranges[reads]!.start, cursor.byte);
|
|
502
|
+
const again = content.ranges.length;
|
|
503
|
+
const second = await runTurn<RowsState>(definition, first.result.state, message, {
|
|
504
|
+
saved: stopped.turn,
|
|
505
|
+
});
|
|
506
|
+
assert.deepEqual(second.turn.keys, ["rows", "write-0"]);
|
|
507
|
+
assert.equal(content.ranges[again]!.start, cursor.byte);
|
|
508
|
+
const next = (second.result.continuation!.message as Continued).cursor;
|
|
509
|
+
assert.deepEqual([next.byte, next.row], [whole[9]!.start, 10]);
|
|
510
|
+
|
|
511
|
+
// Turn 3 writes rows 10 and 11 and records the derivation.
|
|
512
|
+
const third = await runTurn<RowsState>(
|
|
513
|
+
definition,
|
|
514
|
+
second.result.state,
|
|
515
|
+
second.result.continuation!.message,
|
|
516
|
+
);
|
|
517
|
+
assert.deepEqual(third.turn.keys, ["rows", "write-0", "record"]);
|
|
518
|
+
assert.equal(third.result.continuation, undefined);
|
|
519
|
+
assert.equal(third.result.state.rows, 10);
|
|
520
|
+
assertEveryRow(lbb.graph, file, csv);
|
|
521
|
+
|
|
522
|
+
// A cursor without a byte offset (an earlier version of the template)
|
|
523
|
+
// reads the file again from its start.
|
|
524
|
+
const old = { ...message, cursor: { at: 120, row: 6 } } as unknown as typeof message;
|
|
525
|
+
const restarted = await runTurn<RowsState>(definition, {}, old);
|
|
526
|
+
assert.equal(restarted.turn.keys[0], "check");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("a file deleted and uploaded again while it is read records nothing; the new upload's run reads it", async () => {
|
|
530
|
+
const csv = customerList(10);
|
|
531
|
+
const settings = { ...EVERY_FILE, rowsPerTurn: 4 };
|
|
532
|
+
const { lbb, content, definition } = setup(settings, []);
|
|
533
|
+
const file = content.upload("customers-ten.csv", csv, "text/csv");
|
|
534
|
+
const first = content.uploadedAt(file.sha256);
|
|
535
|
+
|
|
536
|
+
// Turn 1 checks the file; the turns after it carry the upload the check named.
|
|
537
|
+
const one = await runTurn<RowsState>(definition, {}, contentMessage("rows", file));
|
|
538
|
+
type Continued = { cursor: { plan: { uploadedAt?: string } } };
|
|
539
|
+
const carried = one.result.continuation!.message as unknown as Continued;
|
|
540
|
+
assert.equal(carried.cursor.plan.uploadedAt, first);
|
|
541
|
+
|
|
542
|
+
// The file is deleted and uploaded again; the old run reads on to its end.
|
|
543
|
+
content.remove(file.sha256);
|
|
544
|
+
const again = content.upload("customers-ten.csv", csv, "text/csv");
|
|
545
|
+
assert.notEqual(content.uploadedAt(again.sha256), first);
|
|
546
|
+
const old = await runMessage<RowsState>(
|
|
547
|
+
definition,
|
|
548
|
+
one.result.continuation!.message,
|
|
549
|
+
one.result.state,
|
|
550
|
+
);
|
|
551
|
+
assert.deepEqual(old.state, { contents: 0, skipped: 1, rows: 10, instances: 10, represents: 0 });
|
|
552
|
+
assert.equal(content.records.at(-1)?.content_uploaded_at, first);
|
|
553
|
+
assert.equal(content.recorded.size, 0, "nothing is recorded for the deleted upload");
|
|
554
|
+
|
|
555
|
+
// The run of the new upload checks, reads the file and records its derivation.
|
|
556
|
+
const fresh = await runMessage<RowsState>(definition, contentMessage("rows", again));
|
|
557
|
+
assert.equal(fresh.state.contents, 1);
|
|
558
|
+
assert.equal(fresh.state.skipped, 0);
|
|
559
|
+
assert.equal(content.records.at(-1)?.content_uploaded_at, content.uploadedAt(again.sha256));
|
|
560
|
+
assert.equal(content.recorded.size, 1);
|
|
561
|
+
assertEveryRow(lbb.graph, again, csv);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("a file deleted while it is read, and not uploaded again, ends the run as skipped", async () => {
|
|
565
|
+
const csv = customerList(10);
|
|
566
|
+
const settings = { ...EVERY_FILE, rowsPerTurn: 4 };
|
|
567
|
+
const { content, definition } = setup(settings, []);
|
|
568
|
+
const file = content.upload("customers-ten.csv", csv, "text/csv");
|
|
569
|
+
const one = await runTurn<RowsState>(definition, {}, contentMessage("rows", file));
|
|
570
|
+
assert.equal(one.result.state.rows, 4);
|
|
571
|
+
|
|
572
|
+
// The next read of the bytes answers 404: the run ends without an error.
|
|
573
|
+
content.remove(file.sha256);
|
|
574
|
+
const old = await runMessage<RowsState>(
|
|
575
|
+
definition,
|
|
576
|
+
one.result.continuation!.message,
|
|
577
|
+
one.result.state,
|
|
578
|
+
);
|
|
579
|
+
assert.equal(old.turns.length, 1, "the run ends at the turn that read nothing");
|
|
580
|
+
assert.deepEqual(old.state, { contents: 0, skipped: 1, rows: 4, instances: 4, represents: 0 });
|
|
581
|
+
assert.equal(content.records.length, 0, "no derivation is recorded");
|
|
582
|
+
assert.equal(content.recorded.size, 0);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("a row longer than the longest row stops the read: a quote that never closes", async () => {
|
|
586
|
+
const bytes = new TextEncoder().encode(`a,b\n1,"open${"x".repeat(500)}`);
|
|
587
|
+
await assert.rejects(
|
|
588
|
+
readRecords(rangeOver(bytes), ",", 4, 2, 10, { window: 64, maxRecord: 200 }),
|
|
589
|
+
/row 2 is longer than 200 bytes from byte 4/,
|
|
590
|
+
);
|
|
591
|
+
// Within the limit, the open quote runs to the end of the file, as RFC 4180 parsers read it.
|
|
592
|
+
const read = await readRecords(rangeOver(bytes), ",", 4, 2, 10, { window: 64, maxRecord: 1_000 });
|
|
593
|
+
assert.deepEqual(
|
|
594
|
+
read.records.map((r) => [r.row, r.fields[0], r.fields[1]!.length]),
|
|
595
|
+
[[2, "1", 504]],
|
|
596
|
+
);
|
|
597
|
+
});
|