@happyvertical/smrt-spreadsheets 0.0.0-stage → 0.61.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 (41) hide show
  1. package/AGENTS.md +75 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +112 -2
  5. package/dist/chunks/plan-BAV2jEzl.js +429 -0
  6. package/dist/chunks/plan-BAV2jEzl.js.map +1 -0
  7. package/dist/import/checks.d.ts +52 -0
  8. package/dist/import/checks.d.ts.map +1 -0
  9. package/dist/import/coerce.d.ts +27 -0
  10. package/dist/import/coerce.d.ts.map +1 -0
  11. package/dist/import/errors.d.ts +16 -0
  12. package/dist/import/errors.d.ts.map +1 -0
  13. package/dist/import/evaluate.d.ts +45 -0
  14. package/dist/import/evaluate.d.ts.map +1 -0
  15. package/dist/import/findings.d.ts +33 -0
  16. package/dist/import/findings.d.ts.map +1 -0
  17. package/dist/import/index.d.ts +11 -0
  18. package/dist/import/index.d.ts.map +1 -0
  19. package/dist/import/index.js +975 -0
  20. package/dist/import/index.js.map +1 -0
  21. package/dist/import/limits.d.ts +19 -0
  22. package/dist/import/limits.d.ts.map +1 -0
  23. package/dist/import/load.d.ts +35 -0
  24. package/dist/import/load.d.ts.map +1 -0
  25. package/dist/import/plan.d.ts +81 -0
  26. package/dist/import/plan.d.ts.map +1 -0
  27. package/dist/import/run.d.ts +34 -0
  28. package/dist/import/run.d.ts.map +1 -0
  29. package/dist/import/target.d.ts +38 -0
  30. package/dist/import/target.d.ts.map +1 -0
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +0 -0
  34. package/dist/manifest.json +9 -0
  35. package/dist/smrt-knowledge.json +57 -0
  36. package/dist/smrt-knowledge.okf.md +46 -0
  37. package/dist/testing.d.ts +21 -0
  38. package/dist/testing.d.ts.map +1 -0
  39. package/dist/testing.js +198 -0
  40. package/dist/testing.js.map +1 -0
  41. package/package.json +61 -3
@@ -0,0 +1,975 @@
1
+ import { a as validatePlan, i as loadOrder, n as IMPORT_FIELD_TYPES, o as ImportPlanError, r as PLAN_KEY_PATTERN, s as ImportRunError, t as DEFAULT_PLAN_LIMITS } from "../chunks/plan-BAV2jEzl.js";
2
+ import { address, range } from "@happyvertical/spreadsheets";
3
+ //#region src/import/coerce.ts
4
+ var EMPTY = {
5
+ value: null,
6
+ type: "empty"
7
+ };
8
+ var DECIMAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/;
9
+ var ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
10
+ function plainDecimal(value) {
11
+ const text = String(value);
12
+ const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/.exec(text);
13
+ if (!match) return text;
14
+ const [, sign, whole, fraction = "", exp] = match;
15
+ const digits = whole + fraction;
16
+ const point = whole.length + Number(exp);
17
+ if (point <= 0) return `${sign}0.${"0".repeat(-point)}${digits}`;
18
+ if (point >= digits.length) return sign + digits + "0".repeat(point - digits.length);
19
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
20
+ }
21
+ function failure(code, detail) {
22
+ return {
23
+ ok: false,
24
+ code,
25
+ detail
26
+ };
27
+ }
28
+ function isoDate(date) {
29
+ return date.toISOString().slice(0, 10);
30
+ }
31
+ function describeCell(cell) {
32
+ if (!cell || cell.type === "empty" || cell.value === null) return cell?.type === "error" ? cell.error ?? "#VALUE!" : "";
33
+ const value = cell.value;
34
+ const text = value instanceof Date ? value.toISOString() : typeof value === "number" ? cell.originalText ?? plainDecimal(value) : String(value);
35
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
36
+ }
37
+ function isBlankCell(cell, trim = true) {
38
+ if (!cell || cell.type === "empty" || cell.value === null) return cell?.type !== "error";
39
+ if (cell.type === "error") return false;
40
+ if (typeof cell.value === "string") return trim ? cell.value.trim().length === 0 : cell.value.length === 0;
41
+ return false;
42
+ }
43
+ function numberText(cell) {
44
+ return cell.originalText ?? plainDecimal(cell.value);
45
+ }
46
+ function digitCount(text) {
47
+ return text.replace(/[^0-9]/g, "").length;
48
+ }
49
+ function hasLeadingZeros(text) {
50
+ return /^[+-]?0\d/.test(text.trim());
51
+ }
52
+ function decimalOf(cell, code) {
53
+ if (cell.type === "number" && typeof cell.value === "number") {
54
+ if (!Number.isFinite(cell.value)) return {
55
+ ok: false,
56
+ result: failure(code, "not a finite number")
57
+ };
58
+ return {
59
+ ok: true,
60
+ value: cell.value
61
+ };
62
+ }
63
+ if (cell.type === "text" && typeof cell.value === "string") {
64
+ const text = cell.value.trim();
65
+ if (!DECIMAL.test(text)) return {
66
+ ok: false,
67
+ result: failure(code, `"${text}" is not a number`)
68
+ };
69
+ if (hasLeadingZeros(text)) return {
70
+ ok: false,
71
+ result: failure(code, `"${text}" has leading zeros; keep it as text`)
72
+ };
73
+ if (digitCount(text) >= 16) return {
74
+ ok: false,
75
+ result: failure(code === "not-integer" ? "unsafe-integer" : "unsafe-number", `"${text}" has too many digits to hold as a number`)
76
+ };
77
+ const value = Number(text);
78
+ if (!Number.isFinite(value)) return {
79
+ ok: false,
80
+ result: failure(code, "not a finite number")
81
+ };
82
+ return {
83
+ ok: true,
84
+ value
85
+ };
86
+ }
87
+ return {
88
+ ok: false,
89
+ result: failure(code, `a ${cell.type} cell is not a number`)
90
+ };
91
+ }
92
+ var TRUE_WORDS = /* @__PURE__ */ new Set([
93
+ "true",
94
+ "yes",
95
+ "y",
96
+ "1"
97
+ ]);
98
+ var FALSE_WORDS = /* @__PURE__ */ new Set([
99
+ "false",
100
+ "no",
101
+ "n",
102
+ "0"
103
+ ]);
104
+ function validDate(year, month, day) {
105
+ const probe = new Date(Date.UTC(year, month - 1, day));
106
+ return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
107
+ }
108
+ function coerceCell(cell, field, choiceSet) {
109
+ const c = cell ?? EMPTY;
110
+ if (c.type === "error") return failure("cell-error", c.error ?? "#VALUE!");
111
+ if (field.type === "reference") return failure("not-coercible", "reference values resolve through a lookup");
112
+ if (isBlankCell(c, field.type !== "text")) return {
113
+ ok: true,
114
+ value: null
115
+ };
116
+ switch (field.type) {
117
+ case "text":
118
+ if (typeof c.value === "string") return {
119
+ ok: true,
120
+ value: c.value
121
+ };
122
+ if (c.type === "number") return {
123
+ ok: true,
124
+ value: numberText(c)
125
+ };
126
+ if (c.value instanceof Date) return {
127
+ ok: true,
128
+ value: isoDate(c.value)
129
+ };
130
+ return {
131
+ ok: true,
132
+ value: String(c.value)
133
+ };
134
+ case "number": {
135
+ const parsed = decimalOf(c, "not-number");
136
+ return parsed.ok ? {
137
+ ok: true,
138
+ value: parsed.value + 0
139
+ } : parsed.result;
140
+ }
141
+ case "integer": {
142
+ const parsed = decimalOf(c, "not-integer");
143
+ if (!parsed.ok) return parsed.result;
144
+ if (!Number.isInteger(parsed.value)) return failure("not-integer", `${plainDecimal(parsed.value)} is not whole`);
145
+ if (!Number.isSafeInteger(parsed.value)) return failure("unsafe-integer", "beyond the safe integer range");
146
+ return {
147
+ ok: true,
148
+ value: parsed.value + 0
149
+ };
150
+ }
151
+ case "boolean": {
152
+ if (c.type === "boolean") return {
153
+ ok: true,
154
+ value: c.value
155
+ };
156
+ const word = c.type === "number" && typeof c.value === "number" ? String(c.value) : typeof c.value === "string" ? c.value.trim().toLowerCase() : "";
157
+ if (TRUE_WORDS.has(word)) return {
158
+ ok: true,
159
+ value: true
160
+ };
161
+ if (FALSE_WORDS.has(word)) return {
162
+ ok: true,
163
+ value: false
164
+ };
165
+ return failure("not-boolean", `"${describeCell(c)}" is not yes/no`);
166
+ }
167
+ case "date":
168
+ if (c.value instanceof Date) return {
169
+ ok: true,
170
+ value: isoDate(c.value)
171
+ };
172
+ if (c.type === "text" && typeof c.value === "string") {
173
+ const match = ISO_DATE.exec(c.value.trim());
174
+ if (match && validDate(+match[1], +match[2], +match[3])) return {
175
+ ok: true,
176
+ value: `${match[1]}-${match[2]}-${match[3]}`
177
+ };
178
+ }
179
+ return failure("not-date", `"${describeCell(c)}" is not a date`);
180
+ case "money": {
181
+ const parsed = decimalOf(c, "not-money");
182
+ if (!parsed.ok) return parsed.result;
183
+ const scale = field.money?.scale ?? 0;
184
+ const minor = parsed.value * 10 ** scale;
185
+ const rounded = Math.round(minor);
186
+ if (!Number.isFinite(minor) || Math.abs(minor - rounded) > 1e-6 || !Number.isSafeInteger(rounded)) return failure("not-money", `${plainDecimal(parsed.value)} does not fit ${scale} decimal places`);
187
+ return {
188
+ ok: true,
189
+ value: rounded + 0
190
+ };
191
+ }
192
+ case "choice": {
193
+ const text = typeof c.value === "string" ? c.value.trim() : c.type === "number" ? numberText(c) : String(c.value);
194
+ if (choiceSet ? choiceSet.has(text) : field.choices?.includes(text)) return {
195
+ ok: true,
196
+ value: text
197
+ };
198
+ return failure("not-choice", `"${text}" is not one of the choices`);
199
+ }
200
+ }
201
+ }
202
+ //#endregion
203
+ //#region src/import/limits.ts
204
+ var DEFAULT_MAX_CELLS = 25e4;
205
+ var DEFAULT_MAX_ROWS_PER_TABLE = 1e4;
206
+ var DEFAULT_MAX_WORK = 2e7;
207
+ var DEFAULT_IMPORT_LIMITS = Object.freeze({
208
+ maxCells: DEFAULT_MAX_CELLS,
209
+ maxRowsPerTable: DEFAULT_MAX_ROWS_PER_TABLE,
210
+ maxWork: DEFAULT_MAX_WORK
211
+ });
212
+ //#endregion
213
+ //#region src/import/evaluate.ts
214
+ function lastOccupiedRow(sheet) {
215
+ let last = 0;
216
+ for (const [row] of sheet.cells.rows()) last = Math.max(last, row);
217
+ return last;
218
+ }
219
+ var WorkBudget = class {
220
+ constructor(maxWork) {
221
+ this.maxWork = maxWork;
222
+ }
223
+ maxWork;
224
+ used = 0;
225
+ charge(units) {
226
+ this.used += units;
227
+ if (this.used > this.maxWork) throw new ImportRunError("work-over-limit", `Reading the workbook needs more than ${this.maxWork} units of work.`, { maxWork: this.maxWork });
228
+ }
229
+ };
230
+ var Evaluation = class {
231
+ constructor(plan, budget) {
232
+ this.budget = budget;
233
+ for (const table of plan.tables) {
234
+ budget.charge(1 + table.fields.length);
235
+ const byKey = /* @__PURE__ */ new Map();
236
+ for (const field of table.fields) {
237
+ byKey.set(field.key, field);
238
+ if (field.choices) {
239
+ budget.charge(field.choices.length);
240
+ this.choiceSets.set(field, new Set(field.choices));
241
+ }
242
+ }
243
+ this.fields.set(table.key, byKey);
244
+ }
245
+ }
246
+ budget;
247
+ rows = /* @__PURE__ */ new Map();
248
+ /** table key -> field key -> field. */
249
+ fields = /* @__PURE__ */ new Map();
250
+ /** Fields with choices -> their choices as a set. */
251
+ choiceSets = /* @__PURE__ */ new Map();
252
+ charge(units) {
253
+ this.budget.charge(units);
254
+ }
255
+ occupied(sheet) {
256
+ let known = this.rows.get(sheet);
257
+ if (!known) {
258
+ this.charge(sheet.cells.size);
259
+ known = [...sheet.cells.rows()];
260
+ this.rows.set(sheet, known);
261
+ }
262
+ return known;
263
+ }
264
+ };
265
+ function lowerBound(rows, row) {
266
+ let low = 0;
267
+ let high = rows.length;
268
+ while (low < high) {
269
+ const mid = low + high >> 1;
270
+ if (rows[mid][0] < row) low = mid + 1;
271
+ else high = mid;
272
+ }
273
+ return low;
274
+ }
275
+ function fieldColumns(field) {
276
+ const columns = [];
277
+ if (field.column !== void 0) columns.push(field.column);
278
+ if (field.reference) columns.push(...field.reference.columns);
279
+ if (field.cachedColumn !== void 0) columns.push(field.cachedColumn);
280
+ return columns;
281
+ }
282
+ function digitsAsNumber(cell) {
283
+ if (!cell) return void 0;
284
+ let text;
285
+ if (cell.type === "text" && typeof cell.value === "string") text = cell.value.trim();
286
+ else if (cell.type === "number" && typeof cell.value === "number") {
287
+ text = cell.originalText;
288
+ if (text === void 0) return Number.isInteger(cell.value) && Math.abs(cell.value) >= 0x38d7ea4c68000 ? String(cell.value) : void 0;
289
+ }
290
+ if (text === void 0 || !/^[+-]?\d+$/.test(text)) return void 0;
291
+ if (text.replace(/^[+-]/, "").length >= 16) return text;
292
+ if (hasLeadingZeros(text)) return text;
293
+ }
294
+ function keyText(values) {
295
+ return JSON.stringify(values);
296
+ }
297
+ function evaluatePlan(plan, workbook, sink, budget = new WorkBudget(DEFAULT_MAX_WORK)) {
298
+ const evaluation = new Evaluation(plan, budget);
299
+ const result = /* @__PURE__ */ new Map();
300
+ for (const table of plan.tables) result.set(table.key, evaluateTable(table, workbook, sink, evaluation));
301
+ return result;
302
+ }
303
+ function evaluateTable(table, workbook, sink, evaluation) {
304
+ const out = {
305
+ table,
306
+ readable: false,
307
+ rows: [],
308
+ read: 0,
309
+ skipped: 0,
310
+ rejected: 0
311
+ };
312
+ const { sheet: sheetName, range: a1, headerRow } = table.source;
313
+ const sheet = workbook.sheets.find((candidate) => candidate.name === sheetName);
314
+ if (!sheet) {
315
+ sink.add({
316
+ code: "sheet-missing",
317
+ severity: "error",
318
+ table: table.key,
319
+ sheet: sheetName,
320
+ message: `Sheet "${sheetName}" is not in the workbook.`
321
+ });
322
+ return out;
323
+ }
324
+ let box;
325
+ try {
326
+ box = range(a1);
327
+ } catch {
328
+ sink.add({
329
+ code: "range-invalid",
330
+ severity: "error",
331
+ table: table.key,
332
+ sheet: sheetName,
333
+ message: `Range "${a1}" is not a valid A1 range.`
334
+ });
335
+ return out;
336
+ }
337
+ let structural = false;
338
+ if (headerRow < box.start.row || headerRow > box.end.row) {
339
+ structural = true;
340
+ sink.add({
341
+ code: "header-outside-range",
342
+ severity: "error",
343
+ table: table.key,
344
+ sheet: sheetName,
345
+ row: headerRow,
346
+ message: `Header row ${headerRow} is outside ${a1}.`
347
+ });
348
+ }
349
+ for (const field of table.fields) for (const column of fieldColumns(field)) if (column < box.start.col || column > box.end.col) {
350
+ structural = true;
351
+ sink.add({
352
+ code: "column-outside-range",
353
+ severity: "error",
354
+ table: table.key,
355
+ field: field.key,
356
+ sheet: sheetName,
357
+ column,
358
+ message: `Column ${column} of field "${field.key}" is outside ${a1}.`
359
+ });
360
+ }
361
+ if (structural) return out;
362
+ out.readable = true;
363
+ const skip = new Set(table.source.skipRows ?? []);
364
+ const first = Math.max(headerRow + 1, box.start.row);
365
+ const occupiedRows = evaluation.occupied(sheet);
366
+ const lastRow = occupiedRows.length > 0 ? occupiedRows[occupiedRows.length - 1][0] : 0;
367
+ const last = Math.min(box.end.row, lastRow);
368
+ const total = Math.max(0, last - first + 1);
369
+ const mapped = new Set(table.fields.flatMap(fieldColumns));
370
+ const readFields = table.fields.filter((field) => field.column !== void 0 || field.reference);
371
+ const sources = [...mapped];
372
+ evaluation.charge(1 + table.fields.length + mapped.size);
373
+ const keyFieldsOf = /* @__PURE__ */ new Map();
374
+ for (const field of readFields) {
375
+ if (!field.reference) continue;
376
+ const target = evaluation.fields.get(field.reference.table);
377
+ keyFieldsOf.set(field, field.reference.by.map((key) => target?.get(key)));
378
+ }
379
+ const seen = /* @__PURE__ */ new Set();
380
+ const unmapped = /* @__PURE__ */ new Map();
381
+ let occupied = 0;
382
+ for (let at = lowerBound(occupiedRows, first); at < occupiedRows.length && occupiedRows[at][0] <= last; at++) {
383
+ const [row, rowCells] = occupiedRows[at];
384
+ occupied++;
385
+ evaluation.charge(1 + rowCells.size + sources.length);
386
+ if (skip.has(row)) {
387
+ out.skipped++;
388
+ continue;
389
+ }
390
+ for (const [column, cell] of rowCells) {
391
+ if (table.source.distinct || mapped.has(column) || column < box.start.col || column > box.end.col) continue;
392
+ if (isBlankCell(cell)) continue;
393
+ const entry = unmapped.get(column) ?? {
394
+ count: 0,
395
+ formula: false
396
+ };
397
+ entry.count++;
398
+ if (cell.formula) entry.formula = true;
399
+ unmapped.set(column, entry);
400
+ }
401
+ if (sources.every((column) => isBlankCell(rowCells.get(column)))) {
402
+ out.skipped++;
403
+ continue;
404
+ }
405
+ const record = {
406
+ sourceRow: row,
407
+ values: {},
408
+ references: {}
409
+ };
410
+ let refused = false;
411
+ const refuse = (code, field, column, message, expected) => {
412
+ refused = true;
413
+ const cell = sheet.cells.get(row, column);
414
+ sink.add({
415
+ code,
416
+ severity: "error",
417
+ table: table.key,
418
+ field: field.key,
419
+ sheet: sheetName,
420
+ cell: address({
421
+ row,
422
+ col: column
423
+ }),
424
+ row,
425
+ expected,
426
+ actual: describeCell(cell),
427
+ message
428
+ });
429
+ };
430
+ for (const field of readFields) {
431
+ if (field.reference) {
432
+ const parts = [];
433
+ let blanks = 0;
434
+ let failed = false;
435
+ const keyFields = keyFieldsOf.get(field) ?? [];
436
+ field.reference.columns.forEach((column2, index) => {
437
+ const keyField = keyFields[index];
438
+ const cell2 = rowCells.get(column2);
439
+ if (!keyField) {
440
+ failed = true;
441
+ return;
442
+ }
443
+ const coerced2 = coerceCell(cell2, keyField, evaluation.choiceSets.get(keyField));
444
+ if (!coerced2.ok) {
445
+ failed = true;
446
+ refuse("coerce-failed", field, column2, `Row ${row}: ${coerced2.detail} [${coerced2.code}]`, keyField.type);
447
+ return;
448
+ }
449
+ if (coerced2.value === null) blanks++;
450
+ parts.push(coerced2.value);
451
+ });
452
+ if (failed) continue;
453
+ if (blanks === parts.length) {
454
+ if (field.required) refuse("required-blank", field, field.reference.columns[0], `Row ${row}: "${field.key}" is required.`, "a value");
455
+ continue;
456
+ }
457
+ if (blanks > 0) {
458
+ refuse("reference-unresolved", field, field.reference.columns[0], `Row ${row}: the key for "${field.key}" is partly blank.`, field.reference.table);
459
+ continue;
460
+ }
461
+ record.references[field.key] = {
462
+ table: field.reference.table,
463
+ key: parts
464
+ };
465
+ continue;
466
+ }
467
+ const column = field.column;
468
+ const cell = rowCells.get(column);
469
+ if (field.type === "number" || field.type === "integer") {
470
+ const digits = digitsAsNumber(cell);
471
+ if (digits !== void 0) {
472
+ refuse("digits-as-number", field, column, `Row ${row}: "${digits}" looks like an identifier; map "${field.key}" as text.`, "text");
473
+ continue;
474
+ }
475
+ }
476
+ const coerced = coerceCell(cell, field, evaluation.choiceSets.get(field));
477
+ if (!coerced.ok) {
478
+ refuse("coerce-failed", field, column, `Row ${row}: ${coerced.detail} [${coerced.code}]`, field.type);
479
+ continue;
480
+ }
481
+ if (coerced.value === null && field.required) {
482
+ refuse("required-blank", field, column, `Row ${row}: "${field.key}" is required.`, "a value");
483
+ continue;
484
+ }
485
+ record.values[field.key] = coerced.value;
486
+ }
487
+ if (refused) {
488
+ out.rejected++;
489
+ continue;
490
+ }
491
+ if (table.source.distinct) {
492
+ const identity = keyText(readFields.map((field) => field.reference ? record.references[field.key]?.key ?? null : record.values[field.key] ?? null));
493
+ if (seen.has(identity)) continue;
494
+ seen.add(identity);
495
+ }
496
+ out.rows.push(record);
497
+ }
498
+ evaluation.charge(unmapped.size);
499
+ out.read = total;
500
+ out.skipped += total - occupied;
501
+ const headerLabel = (column) => describeCell(sheet.cells.get(headerRow, column));
502
+ for (const [column, entry] of [...unmapped].sort(([a], [b]) => a - b)) {
503
+ if (entry.formula) continue;
504
+ sink.add({
505
+ code: "unmapped-column",
506
+ severity: "warning",
507
+ table: table.key,
508
+ sheet: sheetName,
509
+ cell: address({
510
+ row: headerRow,
511
+ col: column
512
+ }),
513
+ column,
514
+ actual: headerLabel(column),
515
+ message: `Column ${column} ("${headerLabel(column)}") holds ${entry.count} values that no field uses.`
516
+ });
517
+ }
518
+ return out;
519
+ }
520
+ //#endregion
521
+ //#region src/import/findings.ts
522
+ var MAX_EXAMPLES = 20;
523
+ var FindingSink = class {
524
+ groups = /* @__PURE__ */ new Map();
525
+ add(finding) {
526
+ const key = `${finding.code}\0${finding.table ?? ""}\0${finding.field ?? ""}`;
527
+ let group = this.groups.get(key);
528
+ if (!group) {
529
+ group = {
530
+ total: 0,
531
+ items: []
532
+ };
533
+ this.groups.set(key, group);
534
+ }
535
+ group.total++;
536
+ if (group.items.length < 20) group.items.push(finding);
537
+ }
538
+ /** Insertion order of first occurrence, then row order within a group. */
539
+ list() {
540
+ const out = [];
541
+ for (const group of this.groups.values()) for (const item of group.items) out.push({
542
+ ...item,
543
+ count: group.total
544
+ });
545
+ return out;
546
+ }
547
+ hasErrors() {
548
+ for (const group of this.groups.values()) if (group.items.some((item) => item.severity === "error")) return true;
549
+ return false;
550
+ }
551
+ };
552
+ function hasErrors(findings) {
553
+ return findings.some((finding) => finding.severity === "error");
554
+ }
555
+ //#endregion
556
+ //#region src/import/checks.ts
557
+ var SUMMED = /* @__PURE__ */ new Set([
558
+ "number",
559
+ "integer",
560
+ "money"
561
+ ]);
562
+ function compensatedSum(values) {
563
+ let sum = 0;
564
+ let compensation = 0;
565
+ for (const value of values) {
566
+ const next = sum + value;
567
+ compensation += Math.abs(sum) >= Math.abs(value) ? sum - next + value : value - next + sum;
568
+ sum = next;
569
+ }
570
+ return sum + compensation;
571
+ }
572
+ function columnSum(rows, field) {
573
+ if (field.type === "number") return {
574
+ value: compensatedSum(rows.map((row) => Number(row.values[field.key] ?? 0))),
575
+ safe: true
576
+ };
577
+ let total = 0n;
578
+ for (const row of rows) {
579
+ const value = row.values[field.key];
580
+ if (typeof value === "number") total += BigInt(value);
581
+ }
582
+ const safe = total <= BigInt(Number.MAX_SAFE_INTEGER) && total >= BigInt(Number.MIN_SAFE_INTEGER);
583
+ return {
584
+ value: Number(total),
585
+ safe
586
+ };
587
+ }
588
+ function crossChecks(tables, sink, budget) {
589
+ const keyIndex = /* @__PURE__ */ new Map();
590
+ for (const evaluated of tables.values()) {
591
+ const { table } = evaluated;
592
+ if (!table.lookupKey) continue;
593
+ const keys = /* @__PURE__ */ new Set();
594
+ const firstRow = /* @__PURE__ */ new Map();
595
+ const columnOf = new Map(table.fields.map((f) => [f.key, f.column]));
596
+ budget.charge(1 + table.fields.length + evaluated.rows.length * table.lookupKey.length);
597
+ for (const row of evaluated.rows) {
598
+ const parts = table.lookupKey.map((key) => row.values[key] ?? null);
599
+ const blankIndex = parts.indexOf(null);
600
+ if (blankIndex >= 0) {
601
+ const field = table.lookupKey[blankIndex];
602
+ const column = columnOf.get(field);
603
+ sink.add({
604
+ code: "lookup-key-blank",
605
+ severity: "error",
606
+ table: table.key,
607
+ field,
608
+ sheet: table.source.sheet,
609
+ row: row.sourceRow,
610
+ ...column !== void 0 ? { cell: address({
611
+ row: row.sourceRow,
612
+ col: column
613
+ }) } : {},
614
+ message: `Row ${row.sourceRow}: lookup key field "${field}" is blank.`
615
+ });
616
+ continue;
617
+ }
618
+ const text = keyText(parts);
619
+ const earlier = firstRow.get(text);
620
+ if (earlier !== void 0) {
621
+ sink.add({
622
+ code: "lookup-key-not-unique",
623
+ severity: "error",
624
+ table: table.key,
625
+ field: table.lookupKey.join(","),
626
+ sheet: table.source.sheet,
627
+ row: row.sourceRow,
628
+ actual: parts.join(" | "),
629
+ message: `Row ${row.sourceRow}: lookup key (${parts.join(", ")}) already appears on row ${earlier}.`
630
+ });
631
+ continue;
632
+ }
633
+ firstRow.set(text, row.sourceRow);
634
+ keys.add(text);
635
+ }
636
+ keyIndex.set(table.key, keys);
637
+ }
638
+ for (const evaluated of tables.values()) {
639
+ const { table } = evaluated;
640
+ for (const field of table.fields) {
641
+ if (!field.reference) continue;
642
+ const known = keyIndex.get(field.reference.table) ?? /* @__PURE__ */ new Set();
643
+ budget.charge(1 + evaluated.rows.length);
644
+ for (const row of evaluated.rows) {
645
+ const ref = row.references[field.key];
646
+ if (!ref || known.has(keyText(ref.key))) continue;
647
+ const column = field.reference.columns[0];
648
+ sink.add({
649
+ code: "reference-unresolved",
650
+ severity: "error",
651
+ table: table.key,
652
+ field: field.key,
653
+ sheet: table.source.sheet,
654
+ cell: address({
655
+ row: row.sourceRow,
656
+ col: column
657
+ }),
658
+ row: row.sourceRow,
659
+ expected: field.reference.table,
660
+ actual: ref.key.join(" | "),
661
+ message: `Row ${row.sourceRow}: no ${field.reference.table} row has key (${ref.key.join(", ")}).`
662
+ });
663
+ }
664
+ }
665
+ }
666
+ }
667
+ function checkPlan(plan, workbook, _profile, limits = {}, budget = new WorkBudget(limits.maxWork ?? DEFAULT_IMPORT_LIMITS.maxWork)) {
668
+ const sink = new FindingSink();
669
+ const tables = evaluatePlan(plan, workbook, sink, budget);
670
+ crossChecks(tables, sink, budget);
671
+ const facts = {
672
+ tables: {},
673
+ sums: {}
674
+ };
675
+ for (const [key, evaluated] of tables) {
676
+ facts.tables[key] = {
677
+ read: evaluated.read,
678
+ skipped: evaluated.skipped,
679
+ rejected: evaluated.rejected,
680
+ planned: evaluated.rows.length
681
+ };
682
+ const sums = {};
683
+ for (const field of evaluated.table.fields) {
684
+ if (field.column === void 0 || !SUMMED.has(field.type)) continue;
685
+ budget.charge(1 + evaluated.rows.length);
686
+ const sum = columnSum(evaluated.rows, field);
687
+ if (!sum.safe) sink.add({
688
+ code: "sum-unsafe",
689
+ severity: "error",
690
+ table: key,
691
+ field: field.key,
692
+ message: `The column sum of "${field.key}" exceeds the safe integer range.`
693
+ });
694
+ sums[field.key] = sum.value;
695
+ }
696
+ facts.sums[key] = sums;
697
+ }
698
+ const findings = sink.list();
699
+ return {
700
+ ok: !hasErrors(findings),
701
+ findings,
702
+ facts,
703
+ work: budget.used
704
+ };
705
+ }
706
+ function relativelyEqual(a, b, tolerance = 1e-9) {
707
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
708
+ if (a === b) return true;
709
+ return Math.abs(a - b) <= tolerance * Math.max(Math.abs(a), Math.abs(b));
710
+ }
711
+ function asNumber(value) {
712
+ if (typeof value === "number") return value;
713
+ if (typeof value === "bigint") return Number(value);
714
+ if (typeof value === "string" && value.trim() !== "") {
715
+ const parsed = Number(value);
716
+ return Number.isFinite(parsed) ? parsed : void 0;
717
+ }
718
+ }
719
+ function asBoolean(value) {
720
+ if (typeof value === "boolean") return value;
721
+ if (value === 1 || value === "1") return true;
722
+ if (value === 0 || value === "0") return false;
723
+ }
724
+ function asIsoDate(value) {
725
+ if (value instanceof Date) return isoDate(value);
726
+ if (typeof value === "string") return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : void 0;
727
+ }
728
+ function invalidCalculated(field, actual) {
729
+ if (actual === null || actual === void 0) return void 0;
730
+ const whole = field.type === "integer" || field.type === "money";
731
+ if (!whole && field.type !== "number") return void 0;
732
+ if (typeof actual === "bigint") return actual <= BigInt(Number.MAX_SAFE_INTEGER) && actual >= BigInt(Number.MIN_SAFE_INTEGER) ? void 0 : `${actual} is beyond the safe integer range`;
733
+ const n = actual;
734
+ if (typeof n !== "number" || !Number.isFinite(n)) return `${String(actual)} is not a finite number`;
735
+ if (whole && !Number.isInteger(n)) return `${n} is not a whole number`;
736
+ if (whole && !Number.isSafeInteger(n)) return `${n} is beyond the safe integer range`;
737
+ }
738
+ function cachedAgrees(field, cached, actual) {
739
+ if (isBlankCell(cached, false) || cached?.type === "error") return actual === null || actual === void 0;
740
+ if (actual === null || actual === void 0) return false;
741
+ const value = cached?.value;
742
+ switch (field.type) {
743
+ case "number": {
744
+ const a = asNumber(actual);
745
+ return typeof value === "number" && a !== void 0 && relativelyEqual(value, a);
746
+ }
747
+ case "integer": {
748
+ const a = asNumber(actual);
749
+ return typeof value === "number" && a !== void 0 && value === a;
750
+ }
751
+ case "money": {
752
+ const a = asNumber(actual);
753
+ if (typeof value !== "number" || a === void 0) return false;
754
+ const minor = value * 10 ** (field.money?.scale ?? 0);
755
+ const rounded = Math.round(minor);
756
+ return Math.abs(minor - rounded) <= 1e-6 && rounded === a;
757
+ }
758
+ case "boolean": return typeof value === "boolean" && asBoolean(actual) === value;
759
+ case "date": return value instanceof Date && asIsoDate(actual) === isoDate(value);
760
+ default: return typeof value === "string" ? value === String(actual) : describeCell(cached) === String(actual);
761
+ }
762
+ }
763
+ function verificationWork(plan, facts) {
764
+ let work = 0;
765
+ for (const table of plan.tables) {
766
+ const planned = facts.tables[table.key]?.planned ?? 0;
767
+ const calculated = table.fields.filter((f) => f.calculated !== void 0);
768
+ const summed = table.fields.filter((f) => f.column !== void 0 && SUMMED.has(f.type));
769
+ work += 1 + summed.length;
770
+ if (calculated.length > 0) work += (planned + 1) * (1 + calculated.length);
771
+ }
772
+ return work;
773
+ }
774
+ async function checkLoaded(plan, workbook, target, facts, budget = new WorkBudget(Number.POSITIVE_INFINITY)) {
775
+ const sink = new FindingSink();
776
+ for (const table of plan.tables) {
777
+ const planned = facts.tables[table.key]?.planned ?? 0;
778
+ const count = await target.count(table.key);
779
+ if (count !== planned) sink.add({
780
+ code: "row-count-mismatch",
781
+ severity: "error",
782
+ table: table.key,
783
+ expected: planned,
784
+ actual: count,
785
+ message: `"${table.key}" holds ${count} rows; the workbook plans ${planned}.`
786
+ });
787
+ for (const field of table.fields) {
788
+ if (field.column === void 0 || !SUMMED.has(field.type)) continue;
789
+ const expected = facts.sums[table.key]?.[field.key];
790
+ if (expected === void 0) continue;
791
+ const actual = Number(await target.sum(table.key, field.key));
792
+ if (!(field.type === "number" ? relativelyEqual(expected, actual) : expected === actual)) sink.add({
793
+ code: "sum-mismatch",
794
+ severity: "error",
795
+ table: table.key,
796
+ field: field.key,
797
+ expected,
798
+ actual,
799
+ message: `"${table.key}.${field.key}" sums to ${actual}; the workbook sums to ${expected}.`
800
+ });
801
+ }
802
+ await checkCalculated(table, workbook, target, sink, budget, planned + 1);
803
+ }
804
+ const findings = sink.list();
805
+ return {
806
+ ok: !hasErrors(findings),
807
+ findings
808
+ };
809
+ }
810
+ async function checkCalculated(table, workbook, target, sink, budget, maxRows) {
811
+ const sheet = workbook.sheets.find((s) => s.name === table.source.sheet);
812
+ const calculated = table.fields.filter((f) => f.calculated !== void 0);
813
+ if (calculated.length === 0) return;
814
+ let seen = 0;
815
+ for await (const row of target.values(table.key, calculated.map((f) => f.key))) {
816
+ if (++seen > maxRows) break;
817
+ budget.charge(1 + calculated.length);
818
+ for (const field of calculated) {
819
+ const column = field.cachedColumn;
820
+ const actual = row.values[field.key];
821
+ const shown = actual === null || actual === void 0 ? null : typeof actual === "object" ? JSON.stringify(actual) : typeof actual === "bigint" ? String(actual) : actual;
822
+ const invalid = invalidCalculated(field, actual);
823
+ if (invalid !== void 0) {
824
+ sink.add({
825
+ code: "calculated-invalid",
826
+ severity: "error",
827
+ table: table.key,
828
+ field: field.key,
829
+ row: row.sourceRow,
830
+ actual: shown,
831
+ message: `Row ${row.sourceRow}: "${field.key}" holds ${invalid}.`
832
+ });
833
+ continue;
834
+ }
835
+ if (column === void 0 || !sheet) continue;
836
+ const cached = sheet.cells.get(row.sourceRow, column);
837
+ if (cachedAgrees(field, cached, actual)) continue;
838
+ sink.add({
839
+ code: "calculated-mismatch",
840
+ severity: "error",
841
+ table: table.key,
842
+ field: field.key,
843
+ sheet: table.source.sheet,
844
+ cell: address({
845
+ row: row.sourceRow,
846
+ col: column
847
+ }),
848
+ row: row.sourceRow,
849
+ expected: describeCell(cached),
850
+ actual: shown,
851
+ message: `Row ${row.sourceRow}: "${field.key}" is ${String(actual)} in the target; the workbook cached ${describeCell(cached)}.`
852
+ });
853
+ }
854
+ }
855
+ }
856
+ //#endregion
857
+ //#region src/import/load.ts
858
+ var DEFAULT_BATCH_SIZE = 500;
859
+ function admit(workbook, limits = {}, plan) {
860
+ const { maxCells, maxRowsPerTable } = {
861
+ ...DEFAULT_IMPORT_LIMITS,
862
+ ...limits
863
+ };
864
+ let cells = 0;
865
+ for (const sheet of workbook.sheets) {
866
+ cells += sheet.cells.size;
867
+ if (cells > maxCells) throw new ImportRunError("cells-over-limit", `The workbook holds more than ${maxCells} cells.`, { maxCells });
868
+ }
869
+ const over = (what, rows) => {
870
+ throw new ImportRunError("rows-over-limit", `${what} holds ${rows} rows; the limit is ${maxRowsPerTable}.`, {
871
+ maxRowsPerTable,
872
+ rows
873
+ });
874
+ };
875
+ const lastRows = /* @__PURE__ */ new Map();
876
+ const lastRowOf = (sheet) => {
877
+ let known = lastRows.get(sheet);
878
+ if (known === void 0) {
879
+ known = lastOccupiedRow(sheet);
880
+ lastRows.set(sheet, known);
881
+ }
882
+ return known;
883
+ };
884
+ if (plan) {
885
+ const sheets = new Map(workbook.sheets.map((sheet) => [sheet.name, sheet]));
886
+ for (const table of plan.tables) {
887
+ const sheet = sheets.get(table.source.sheet);
888
+ if (!sheet) continue;
889
+ let box;
890
+ try {
891
+ box = range(table.source.range);
892
+ } catch {
893
+ continue;
894
+ }
895
+ const first = Math.max(table.source.headerRow + 1, box.start.row);
896
+ const rows = Math.min(box.end.row, lastRowOf(sheet)) - first + 1;
897
+ if (rows > maxRowsPerTable) over(`Table "${table.key}"`, rows);
898
+ }
899
+ return;
900
+ }
901
+ for (const sheet of workbook.sheets) {
902
+ const rows = lastRowOf(sheet);
903
+ if (rows > maxRowsPerTable) over(`Sheet "${sheet.name}"`, rows);
904
+ }
905
+ }
906
+ function planLoad(plan, workbook, options = {}) {
907
+ const batchSize = options.batchSize ?? 500;
908
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new RangeError("batchSize must be a positive integer");
909
+ const sink = new FindingSink();
910
+ const tables = evaluatePlan(plan, workbook, sink, options.budget ?? new WorkBudget(options.limits?.maxWork ?? DEFAULT_IMPORT_LIMITS.maxWork));
911
+ const unreadable = [...tables.values()].filter((t) => !t.readable);
912
+ if (unreadable.length > 0) throw new ImportRunError("source-invalid", `The source of ${unreadable.map((t) => `"${t.table.key}"`).join(", ")} cannot be read; run checkPlan.`, { findings: sink.list().filter((f) => f.severity === "error") });
913
+ const order = loadOrder(plan);
914
+ return {
915
+ order,
916
+ *batches() {
917
+ for (const key of order) {
918
+ const evaluated = tables.get(key);
919
+ for (let at = 0, index = 0; at < evaluated.rows.length; at += batchSize) yield {
920
+ table: key,
921
+ index: index++,
922
+ records: evaluated.rows.slice(at, at + batchSize)
923
+ };
924
+ }
925
+ }
926
+ };
927
+ }
928
+ //#endregion
929
+ //#region src/import/run.ts
930
+ async function runImport(options) {
931
+ const plan = validatePlan(options.plan);
932
+ admit(options.workbook, options.limits, plan);
933
+ const budget = new WorkBudget(options.limits?.maxWork ?? DEFAULT_IMPORT_LIMITS.maxWork);
934
+ const planned = checkPlan(plan, options.workbook, void 0, options.limits, budget);
935
+ if (!planned.ok) return {
936
+ ok: false,
937
+ findings: planned.findings,
938
+ facts: planned.facts,
939
+ loaded: {},
940
+ plan,
941
+ work: budget.used
942
+ };
943
+ const load = planLoad(plan, options.workbook, {
944
+ batchSize: options.batchSize,
945
+ limits: options.limits,
946
+ budget
947
+ });
948
+ const batches = [...load.batches()];
949
+ budget.charge(verificationWork(plan, planned.facts));
950
+ const structure = await options.target.describe();
951
+ const existing = new Set(structure.tables.map((table) => table.key));
952
+ const clash = plan.tables.filter((table) => existing.has(table.key));
953
+ if (clash.length > 0) throw new ImportRunError("target-not-empty", `The target already has ${clash.map((t) => `"${t.key}"`).join(", ")}; this import is first-time only.`, { tables: clash.map((table) => table.key) });
954
+ await options.target.applyStructure(plan);
955
+ const loaded = {};
956
+ for (const table of load.order) loaded[table] = 0;
957
+ for (const batch of batches) {
958
+ const result = await options.target.load(batch);
959
+ loaded[batch.table] += result.loaded;
960
+ }
961
+ const verified = await checkLoaded(plan, options.workbook, options.target, planned.facts);
962
+ const findings = [...planned.findings, ...verified.findings];
963
+ return {
964
+ ok: !hasErrors(findings),
965
+ findings,
966
+ facts: planned.facts,
967
+ loaded,
968
+ plan,
969
+ work: budget.used
970
+ };
971
+ }
972
+ //#endregion
973
+ export { DEFAULT_BATCH_SIZE, DEFAULT_IMPORT_LIMITS, DEFAULT_MAX_CELLS, DEFAULT_MAX_ROWS_PER_TABLE, DEFAULT_MAX_WORK, DEFAULT_PLAN_LIMITS, IMPORT_FIELD_TYPES, ImportPlanError, ImportRunError, MAX_EXAMPLES, PLAN_KEY_PATTERN, WorkBudget, admit, checkLoaded, checkPlan, coerceCell, compensatedSum, loadOrder, planLoad, runImport, validatePlan };
974
+
975
+ //# sourceMappingURL=index.js.map