@happyvertical/smrt-spreadsheets 0.0.0-stage → 0.61.1
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/AGENTS.md +75 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +112 -2
- package/dist/chunks/plan-BAV2jEzl.js +429 -0
- package/dist/chunks/plan-BAV2jEzl.js.map +1 -0
- package/dist/import/checks.d.ts +52 -0
- package/dist/import/checks.d.ts.map +1 -0
- package/dist/import/coerce.d.ts +27 -0
- package/dist/import/coerce.d.ts.map +1 -0
- package/dist/import/errors.d.ts +16 -0
- package/dist/import/errors.d.ts.map +1 -0
- package/dist/import/evaluate.d.ts +45 -0
- package/dist/import/evaluate.d.ts.map +1 -0
- package/dist/import/findings.d.ts +33 -0
- package/dist/import/findings.d.ts.map +1 -0
- package/dist/import/index.d.ts +11 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +975 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/limits.d.ts +19 -0
- package/dist/import/limits.d.ts.map +1 -0
- package/dist/import/load.d.ts +35 -0
- package/dist/import/load.d.ts.map +1 -0
- package/dist/import/plan.d.ts +81 -0
- package/dist/import/plan.d.ts.map +1 -0
- package/dist/import/run.d.ts +34 -0
- package/dist/import/run.d.ts.map +1 -0
- package/dist/import/target.d.ts +38 -0
- package/dist/import/target.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +0 -0
- package/dist/manifest.json +9 -0
- package/dist/smrt-knowledge.json +57 -0
- package/dist/smrt-knowledge.okf.md +46 -0
- package/dist/testing.d.ts +21 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +198 -0
- package/dist/testing.js.map +1 -0
- package/package.json +61 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# @happyvertical/smrt-spreadsheets
|
|
2
|
+
|
|
3
|
+
smrt's spreadsheet package, starting with import: the deterministic half of
|
|
4
|
+
"build an app from a workbook" (#4969). A model, elsewhere and later, proposes an `ImportPlan`; this package validates it,
|
|
5
|
+
checks it against the workbook, loads the data through an `ImportTarget`, and
|
|
6
|
+
proves the result. Two products implement the target, so nothing here may
|
|
7
|
+
name either. No `@smrt()` objects, no tables, no Svelte.
|
|
8
|
+
|
|
9
|
+
`./export`, saved mapping and re-import are planned, not present.
|
|
10
|
+
|
|
11
|
+
## Entries
|
|
12
|
+
|
|
13
|
+
- Root (`.`): types only (`export type * from './import/index.js'`); no runtime
|
|
14
|
+
code or values. Keep it types only as new capabilities arrive.
|
|
15
|
+
- `./import`: browser-safe and pure. Imports `@happyvertical/spreadsheets` and
|
|
16
|
+
`@happyvertical/sql/query` only, never the sql root or a `node:` built-in
|
|
17
|
+
(`browser-safe.test.ts` walks and bundles the graph).
|
|
18
|
+
- `./testing`: Node-only `createSqliteReferenceTarget()`, real in-process SQLite
|
|
19
|
+
through `@happyvertical/sql`. Calculated fields are generated columns made
|
|
20
|
+
by `renderExpression`, so they run on database semantics. Never import it
|
|
21
|
+
from the root or import entry.
|
|
22
|
+
|
|
23
|
+
## Source map
|
|
24
|
+
|
|
25
|
+
Import implementation and tests live under `src/import/`; `src/import/index.ts`
|
|
26
|
+
exports the import surface. `src/index.ts` is the type-only root, and
|
|
27
|
+
`src/testing.ts` implements the Node-only target.
|
|
28
|
+
|
|
29
|
+
- `src/import/plan.ts`: `validatePlan` (closed shapes, JSON-cloned input, limits),
|
|
30
|
+
`loadOrder`. Calculated expressions may read column fields only.
|
|
31
|
+
- `src/import/coerce.ts`: `coerceCell`, the one typing rule. Nothing is guessed.
|
|
32
|
+
- `src/import/evaluate.ts`: reads and types every table; shared by `checkPlan` and
|
|
33
|
+
`planLoad`. A row that fails never reaches a record.
|
|
34
|
+
- `src/import/checks.ts`: `checkPlan` (before writing) and `checkLoaded` (after).
|
|
35
|
+
- `src/import/load.ts`: `admit` (limits) and `planLoad` (ordered, batched records).
|
|
36
|
+
- `src/import/run.ts`: `runImport`. Adapter errors propagate; it catches nothing.
|
|
37
|
+
|
|
38
|
+
## Invariants
|
|
39
|
+
|
|
40
|
+
- First import only: `runImport` refuses a target that has any planned table
|
|
41
|
+
(`target-not-empty`). Re-import matching is a later slice.
|
|
42
|
+
- `checkPlan` errors stop the run before `describe()` is called; nothing is written.
|
|
43
|
+
- `DEFAULT_MAX_CELLS` (250,000) is PROVISIONAL, owner decision pending; keep it
|
|
44
|
+
one constant. Per-table rows default to 10,000.
|
|
45
|
+
- Money is integer minor units (`value * 10^scale`, within 1e-6 of an integer or
|
|
46
|
+
refused). Integer sums are exact (BigInt); number sums are compensated.
|
|
47
|
+
- `distinct` tables skip the unmapped-column warning: they extract columns by design.
|
|
48
|
+
- Work is bounded: evaluation visits occupied rows and mapped columns only, never
|
|
49
|
+
range width x height, and spends from `maxWork` (`DEFAULT_MAX_WORK`); over it,
|
|
50
|
+
`work-over-limit`. Row indexes live for one evaluation call, never across calls:
|
|
51
|
+
a workbook is mutable and is re-read every time.
|
|
52
|
+
- Anything that does not depend on a row (field-by-key maps, reference key fields,
|
|
53
|
+
choice sets, column lists, insert SQL) is resolved once before rows are visited;
|
|
54
|
+
never `.find`/`.includes` over plan data inside a row or cell loop. Every
|
|
55
|
+
per-row, per-cell and per-reference unit is charged to one `WorkBudget` that
|
|
56
|
+
`runImport` shares across checking, planning and verifying. `work-over-limit`
|
|
57
|
+
is thrown before the first call on the target (`describe()` included) or never:
|
|
58
|
+
`runImport` plans every batch up front and reserves the read-back work
|
|
59
|
+
(`verificationWork`) before touching the target; read-back is bounded by planned
|
|
60
|
+
rows + 1, and extra rows are a `row-count-mismatch`, never a budget error.
|
|
61
|
+
- `checkLoaded` value-checks every calculated integer, money and number field
|
|
62
|
+
(`calculated-invalid`: not a whole safe integer / not finite) with or without a
|
|
63
|
+
`cachedColumn`. Integer and money comparisons are exact; tolerance is for `number`.
|
|
64
|
+
- `ok:false` after a load is a verdict, not a repair: the caller discards the draft
|
|
65
|
+
target. There is no rollback or recreation logic here.
|
|
66
|
+
- Findings keep at most 20 examples per (code, table, field) with a total `count`.
|
|
67
|
+
- Plan keys are `^[a-z][a-z0-9_]{0,62}$`; SQL keywords are fine, names that exist
|
|
68
|
+
on every object (`constructor`, `prototype`, ...) are refused. The reference
|
|
69
|
+
target quotes every identifier.
|
|
70
|
+
|
|
71
|
+
## Validation
|
|
72
|
+
|
|
73
|
+
`pnpm --filter @happyvertical/smrt-spreadsheets build|typecheck|test`.
|
|
74
|
+
Tests build workbooks in code with the SDK's `createWorkbook`/`createSheet`;
|
|
75
|
+
no binary fixtures, no containers.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright <2025> <Happy Vertical Corporation>
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,113 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @happyvertical/smrt-spreadsheets
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
smrt's spreadsheet package. Its first capability is import: turning a spreadsheet
|
|
4
|
+
into records without guessing. A plan says which sheet
|
|
5
|
+
range becomes which table, which column becomes which typed field, and which
|
|
6
|
+
fields are calculated or point at another table. This package validates the
|
|
7
|
+
plan, checks it against the workbook before anything is written, loads the data
|
|
8
|
+
in batches through a target you provide, and then reads the target back to
|
|
9
|
+
prove nothing was lost or changed.
|
|
10
|
+
|
|
11
|
+
It does not decide what the plan is (a person or a model does that) and it does
|
|
12
|
+
not know where the data goes (your target does). Nothing here is tied to a
|
|
13
|
+
product, a database or an s-m-r-t model layer.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @happyvertical/smrt-spreadsheets
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Entry points
|
|
22
|
+
|
|
23
|
+
- `.`: type-only re-exports of the import surface; no runtime code or values.
|
|
24
|
+
- `./import`: the browser-safe import API (`runImport`, plan validation, checks
|
|
25
|
+
and target contracts).
|
|
26
|
+
- `./testing`: the Node-only SQLite reference target for tests.
|
|
27
|
+
|
|
28
|
+
`./export`, saved mapping and re-import are planned capabilities, not present
|
|
29
|
+
in this version. The root stays types only as new capabilities arrive.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { getSpreadsheets } from '@happyvertical/spreadsheets';
|
|
35
|
+
import { runImport } from '@happyvertical/smrt-spreadsheets/import';
|
|
36
|
+
|
|
37
|
+
const workbook = await (await getSpreadsheets({ type: 'xlsx' })).read(bytes);
|
|
38
|
+
|
|
39
|
+
const result = await runImport({
|
|
40
|
+
plan, // validated for you; a bad plan throws ImportPlanError
|
|
41
|
+
workbook,
|
|
42
|
+
target, // your ImportTarget
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!result.ok) console.table(result.findings);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`runImport` validates the plan, refuses a workbook over the size limits, runs
|
|
49
|
+
`checkPlan`, refuses a target that already holds a planned table, builds the
|
|
50
|
+
structure, loads every batch, and runs `checkLoaded`. If `checkPlan` reports
|
|
51
|
+
errors nothing is written. Errors thrown by your target are never caught.
|
|
52
|
+
|
|
53
|
+
A row is blank only when every mapped column, reference column and calculated
|
|
54
|
+
`cachedColumn` is blank. Calculated-only tables need at least one `cachedColumn`
|
|
55
|
+
to establish their rows; otherwise `validatePlan` throws `no-row-source`.
|
|
56
|
+
|
|
57
|
+
### Typing
|
|
58
|
+
|
|
59
|
+
Every cell is typed by one rule (`coerceCell`) and refused when it does not fit:
|
|
60
|
+
integers must be safe whole numbers, money must be an exact number of minor
|
|
61
|
+
units, booleans accept true/false/yes/no/y/n/1/0, dates become `YYYY-MM-DD`,
|
|
62
|
+
choices must match exactly, and error cells are always refused with their
|
|
63
|
+
Excel text. Long digit strings and values with leading zeros in number fields
|
|
64
|
+
are reported as `digits-as-number`: they are identifiers, so map them as text.
|
|
65
|
+
|
|
66
|
+
### Checks
|
|
67
|
+
|
|
68
|
+
`checkPlan` reports findings (`coerce-failed`, `digits-as-number`,
|
|
69
|
+
`lookup-key-not-unique`, `lookup-key-blank`, `reference-unresolved`,
|
|
70
|
+
`unmapped-column`, and structural ones) with the cell and row, at most 20
|
|
71
|
+
examples per code, table and field plus a total `count`. It also returns facts:
|
|
72
|
+
rows read, skipped and planned per table, and column sums.
|
|
73
|
+
|
|
74
|
+
`checkLoaded` compares the target with those facts: row counts, column sums,
|
|
75
|
+
and, for every calculated field with a `cachedColumn`, the target's value
|
|
76
|
+
against the workbook's cached result, row by row. That is how a translated
|
|
77
|
+
formula is proven.
|
|
78
|
+
|
|
79
|
+
Integer and money sums use exact BigInt accumulation; only the final total
|
|
80
|
+
must fit a safe integer. Number comparisons reject non-finite values before
|
|
81
|
+
applying relative tolerance, including when comparing a calculated cache.
|
|
82
|
+
|
|
83
|
+
If `ok` is false after a load, the target holds a draft that failed its proof:
|
|
84
|
+
discard it. This package does not undo or recreate anything.
|
|
85
|
+
|
|
86
|
+
### Targets
|
|
87
|
+
|
|
88
|
+
Implement `ImportTarget` (`describe`, `applyStructure`, `load`, `count`, `sum`,
|
|
89
|
+
`values`). `load` resolves references by lookup key and is all-or-nothing per
|
|
90
|
+
batch. The target keeps each record's `sourceRow`.
|
|
91
|
+
`sum` returns money in minor units and preserves unsafe integer/money totals as
|
|
92
|
+
`bigint`, so verification reports a mismatch rather than losing precision.
|
|
93
|
+
|
|
94
|
+
For tests there is a reference target on real in-process SQLite:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { createSqliteReferenceTarget } from '@happyvertical/smrt-spreadsheets/testing';
|
|
98
|
+
|
|
99
|
+
const target = await createSqliteReferenceTarget();
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The `./testing` entry is Node-only; `.` and `./import` are browser-safe.
|
|
103
|
+
|
|
104
|
+
### Limits
|
|
105
|
+
|
|
106
|
+
Workbooks over 250,000 cells or tables over 10,000 rows are refused before any
|
|
107
|
+
row is read (`admit`). The cell limit is provisional. Reading is bounded by
|
|
108
|
+
occupied cells, not range size, and by a work budget (`maxWork`, refusal
|
|
109
|
+
`work-over-limit`). That refusal comes before the first call on your target,
|
|
110
|
+
`describe()` included, or not at all: `runImport` plans the whole load and reserves
|
|
111
|
+
the work for reading it back before it writes anything. Plans are bounded too:
|
|
112
|
+
200 tables, 500 fields per table, 2 MB of JSON; all can be raised through
|
|
113
|
+
`validatePlan(plan, limits)`.
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import { range } from "@happyvertical/spreadsheets";
|
|
2
|
+
import { ExpressionError, renderExpression } from "@happyvertical/sql/query";
|
|
3
|
+
//#region src/import/errors.ts
|
|
4
|
+
var ImportPlanError = class extends Error {
|
|
5
|
+
code;
|
|
6
|
+
path;
|
|
7
|
+
constructor(code, path, message) {
|
|
8
|
+
super(`${path || "plan"}: ${message}`);
|
|
9
|
+
this.name = "ImportPlanError";
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.path = path;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
var ImportRunError = class extends Error {
|
|
15
|
+
code;
|
|
16
|
+
detail;
|
|
17
|
+
constructor(code, message, detail) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ImportRunError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.detail = detail;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/import/plan.ts
|
|
26
|
+
var IMPORT_FIELD_TYPES = Object.freeze([
|
|
27
|
+
"text",
|
|
28
|
+
"number",
|
|
29
|
+
"integer",
|
|
30
|
+
"boolean",
|
|
31
|
+
"date",
|
|
32
|
+
"money",
|
|
33
|
+
"choice",
|
|
34
|
+
"reference"
|
|
35
|
+
]);
|
|
36
|
+
var DEFAULT_PLAN_LIMITS = Object.freeze({
|
|
37
|
+
maxTables: 200,
|
|
38
|
+
maxFieldsPerTable: 500,
|
|
39
|
+
maxPlanBytes: 2 * 1024 * 1024,
|
|
40
|
+
maxReports: 200,
|
|
41
|
+
maxChoices: 1e3,
|
|
42
|
+
maxSkipRows: 1e5,
|
|
43
|
+
maxExpressionNodes: 1024
|
|
44
|
+
});
|
|
45
|
+
var PLAN_KEY_PATTERN = /^[a-z][a-z0-9_]{0,62}$/;
|
|
46
|
+
var RESERVED_KEYS = /* @__PURE__ */ new Set([...Object.getOwnPropertyNames(Object.prototype), "prototype"]);
|
|
47
|
+
var MAX_LABEL = 200;
|
|
48
|
+
var MAX_COLUMN = 16384;
|
|
49
|
+
var MAX_ROW = 1048576;
|
|
50
|
+
function fail(code, path, message) {
|
|
51
|
+
throw new ImportPlanError(code, path, message);
|
|
52
|
+
}
|
|
53
|
+
function toPlainData(input, limits) {
|
|
54
|
+
let text;
|
|
55
|
+
try {
|
|
56
|
+
text = JSON.stringify(input, (_key, value) => {
|
|
57
|
+
const kind = typeof value;
|
|
58
|
+
if (kind === "function" || kind === "symbol" || kind === "bigint") throw new TypeError(`a ${kind} is not plain data`);
|
|
59
|
+
if (typeof value === "number" && !Number.isFinite(value)) throw new TypeError("a non-finite number is not plain data");
|
|
60
|
+
return value;
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
fail("plan-not-data", "", `a plan must be plain JSON data (${error instanceof Error ? error.message : "unserializable"})`);
|
|
64
|
+
}
|
|
65
|
+
if (text === void 0) fail("plan-not-data", "", "a plan must be plain JSON data");
|
|
66
|
+
if (text.length > limits.maxPlanBytes) fail("plan-too-large", "", `plan is ${text.length} bytes; the limit is ${limits.maxPlanBytes}`);
|
|
67
|
+
return JSON.parse(text);
|
|
68
|
+
}
|
|
69
|
+
function isRecord(value) {
|
|
70
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
function closedObject(value, path, allowed) {
|
|
73
|
+
if (!isRecord(value)) fail("invalid-shape", path, "expected an object");
|
|
74
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail("unknown-key", `${path}.${key}`, `unknown key "${key}"`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function arrayOf(value, path, max) {
|
|
78
|
+
if (!Array.isArray(value)) fail("invalid-shape", path, "expected an array");
|
|
79
|
+
if (value.length > max) fail("limit-exceeded", path, `at most ${max} entries; got ${value.length}`);
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
function planKey(value, path) {
|
|
83
|
+
if (typeof value !== "string" || !PLAN_KEY_PATTERN.test(value)) fail("invalid-key", path, "a key matches ^[a-z][a-z0-9_]{0,62}$");
|
|
84
|
+
if (RESERVED_KEYS.has(value)) fail("reserved-key", path, `"${value}" is a reserved key`);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
function label(value, path) {
|
|
88
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > MAX_LABEL) fail("invalid-value", path, `a non-empty string of at most ${MAX_LABEL}`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function positiveInt(value, path, max) {
|
|
92
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > max) fail("invalid-value", path, `an integer from 1 to ${max}`);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function optionalBoolean(value, path) {
|
|
96
|
+
if (value === void 0) return void 0;
|
|
97
|
+
if (typeof value !== "boolean") fail("invalid-value", path, "a boolean");
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function keyList(value, path, max) {
|
|
101
|
+
const list = arrayOf(value, path, max);
|
|
102
|
+
if (list.length === 0) fail("invalid-value", path, "at least one key");
|
|
103
|
+
const seen = /* @__PURE__ */ new Set();
|
|
104
|
+
return {
|
|
105
|
+
keys: list.map((item, index) => {
|
|
106
|
+
const key = planKey(item, `${path}[${index}]`);
|
|
107
|
+
if (seen.has(key)) fail("duplicate-key", `${path}[${index}]`, `"${key}"`);
|
|
108
|
+
seen.add(key);
|
|
109
|
+
return key;
|
|
110
|
+
}),
|
|
111
|
+
paths: list.map((_, index) => `${path}[${index}]`)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
var FIELD_KEYS = [
|
|
115
|
+
"key",
|
|
116
|
+
"label",
|
|
117
|
+
"type",
|
|
118
|
+
"required",
|
|
119
|
+
"column",
|
|
120
|
+
"calculated",
|
|
121
|
+
"cachedColumn",
|
|
122
|
+
"reference",
|
|
123
|
+
"choices",
|
|
124
|
+
"money"
|
|
125
|
+
];
|
|
126
|
+
function validateField(value, path, limits) {
|
|
127
|
+
const raw = closedObject(value, path, FIELD_KEYS);
|
|
128
|
+
const key = planKey(raw.key, `${path}.key`);
|
|
129
|
+
const fieldLabel = label(raw.label, `${path}.label`);
|
|
130
|
+
if (typeof raw.type !== "string" || !IMPORT_FIELD_TYPES.includes(raw.type)) fail("invalid-value", `${path}.type`, `one of ${IMPORT_FIELD_TYPES.join(", ")}`);
|
|
131
|
+
const type = raw.type;
|
|
132
|
+
const field = {
|
|
133
|
+
key,
|
|
134
|
+
label: fieldLabel,
|
|
135
|
+
type
|
|
136
|
+
};
|
|
137
|
+
const required = optionalBoolean(raw.required, `${path}.required`);
|
|
138
|
+
if (required !== void 0) field.required = required;
|
|
139
|
+
const sources = [
|
|
140
|
+
"column",
|
|
141
|
+
"calculated",
|
|
142
|
+
"reference"
|
|
143
|
+
].filter((name) => raw[name] !== void 0);
|
|
144
|
+
if (sources.length !== 1) fail("field-source", path, `exactly one of column, calculated or reference is required; got ${sources.length}`);
|
|
145
|
+
const source = sources[0];
|
|
146
|
+
if (source === "column") field.column = positiveInt(raw.column, `${path}.column`, MAX_COLUMN);
|
|
147
|
+
if (source === "calculated") field.calculated = raw.calculated;
|
|
148
|
+
if (raw.cachedColumn !== void 0) {
|
|
149
|
+
if (source !== "calculated") fail("field-source", `${path}.cachedColumn`, "cachedColumn belongs to calculated fields");
|
|
150
|
+
field.cachedColumn = positiveInt(raw.cachedColumn, `${path}.cachedColumn`, MAX_COLUMN);
|
|
151
|
+
}
|
|
152
|
+
if (source === "reference") {
|
|
153
|
+
if (type !== "reference") fail("field-source", `${path}.type`, "a reference field has type reference");
|
|
154
|
+
const ref = closedObject(raw.reference, `${path}.reference`, [
|
|
155
|
+
"table",
|
|
156
|
+
"by",
|
|
157
|
+
"columns"
|
|
158
|
+
]);
|
|
159
|
+
const by = keyList(ref.by, `${path}.reference.by`, 16);
|
|
160
|
+
const columnList = arrayOf(ref.columns, `${path}.reference.columns`, 16);
|
|
161
|
+
if (columnList.length !== by.keys.length) fail("reference-mismatch", `${path}.reference.columns`, "columns must pair one-to-one with by");
|
|
162
|
+
field.reference = {
|
|
163
|
+
table: planKey(ref.table, `${path}.reference.table`),
|
|
164
|
+
by: by.keys,
|
|
165
|
+
columns: columnList.map((column, index) => positiveInt(column, `${path}.reference.columns[${index}]`, MAX_COLUMN))
|
|
166
|
+
};
|
|
167
|
+
} else if (type === "reference") fail("field-source", `${path}.type`, "type reference needs a reference");
|
|
168
|
+
if (type === "choice") {
|
|
169
|
+
if (raw.choices === void 0) fail("invalid-value", `${path}.choices`, "a choice field needs choices");
|
|
170
|
+
const list = arrayOf(raw.choices, `${path}.choices`, limits.maxChoices);
|
|
171
|
+
if (list.length === 0) fail("invalid-value", `${path}.choices`, "a choice field needs choices");
|
|
172
|
+
const seen = /* @__PURE__ */ new Set();
|
|
173
|
+
field.choices = list.map((item, index) => {
|
|
174
|
+
if (typeof item !== "string" || item.length === 0 || item.length > MAX_LABEL || item !== item.trim()) fail("invalid-value", `${path}.choices[${index}]`, "a trimmed, non-empty string");
|
|
175
|
+
if (seen.has(item)) fail("duplicate-key", `${path}.choices[${index}]`, `"${item}"`);
|
|
176
|
+
seen.add(item);
|
|
177
|
+
return item;
|
|
178
|
+
});
|
|
179
|
+
} else if (raw.choices !== void 0) fail("invalid-value", `${path}.choices`, "choices belong to choice fields");
|
|
180
|
+
if (type === "money") {
|
|
181
|
+
const money = closedObject(raw.money, `${path}.money`, ["currency", "scale"]);
|
|
182
|
+
if (typeof money.currency !== "string" || !/^[A-Z]{3}$/.test(money.currency)) fail("invalid-value", `${path}.money.currency`, "a three-letter upper-case currency code");
|
|
183
|
+
if (typeof money.scale !== "number" || !Number.isInteger(money.scale) || money.scale < 0 || money.scale > 6) fail("invalid-value", `${path}.money.scale`, "an integer from 0 to 6");
|
|
184
|
+
field.money = {
|
|
185
|
+
currency: money.currency,
|
|
186
|
+
scale: money.scale
|
|
187
|
+
};
|
|
188
|
+
} else if (raw.money !== void 0) fail("invalid-value", `${path}.money`, "money belongs to money fields");
|
|
189
|
+
return field;
|
|
190
|
+
}
|
|
191
|
+
var TABLE_KEYS = [
|
|
192
|
+
"key",
|
|
193
|
+
"label",
|
|
194
|
+
"source",
|
|
195
|
+
"fields",
|
|
196
|
+
"lookupKey",
|
|
197
|
+
"titleField"
|
|
198
|
+
];
|
|
199
|
+
var SOURCE_KEYS = [
|
|
200
|
+
"sheet",
|
|
201
|
+
"range",
|
|
202
|
+
"headerRow",
|
|
203
|
+
"skipRows",
|
|
204
|
+
"distinct"
|
|
205
|
+
];
|
|
206
|
+
function validateTable(value, path, limits) {
|
|
207
|
+
const raw = closedObject(value, path, TABLE_KEYS);
|
|
208
|
+
const key = planKey(raw.key, `${path}.key`);
|
|
209
|
+
const tableLabel = label(raw.label, `${path}.label`);
|
|
210
|
+
const src = closedObject(raw.source, `${path}.source`, SOURCE_KEYS);
|
|
211
|
+
if (typeof src.sheet !== "string" || src.sheet.length === 0 || src.sheet.length > 255) fail("invalid-source", `${path}.source.sheet`, "a sheet name");
|
|
212
|
+
if (typeof src.range !== "string") fail("invalid-source", `${path}.source.range`, "an A1 range");
|
|
213
|
+
try {
|
|
214
|
+
range(src.range);
|
|
215
|
+
} catch {
|
|
216
|
+
fail("invalid-source", `${path}.source.range`, `"${src.range}" is not A1`);
|
|
217
|
+
}
|
|
218
|
+
const source = {
|
|
219
|
+
sheet: src.sheet,
|
|
220
|
+
range: src.range,
|
|
221
|
+
headerRow: positiveInt(src.headerRow, `${path}.source.headerRow`, MAX_ROW)
|
|
222
|
+
};
|
|
223
|
+
if (src.skipRows !== void 0) {
|
|
224
|
+
const rows = arrayOf(src.skipRows, `${path}.source.skipRows`, limits.maxSkipRows);
|
|
225
|
+
const seen = /* @__PURE__ */ new Set();
|
|
226
|
+
source.skipRows = rows.map((row, index) => {
|
|
227
|
+
const n = positiveInt(row, `${path}.source.skipRows[${index}]`, MAX_ROW);
|
|
228
|
+
if (seen.has(n)) fail("duplicate-key", `${path}.source.skipRows[${index}]`, `row ${n}`);
|
|
229
|
+
seen.add(n);
|
|
230
|
+
return n;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const distinct = optionalBoolean(src.distinct, `${path}.source.distinct`);
|
|
234
|
+
if (distinct !== void 0) source.distinct = distinct;
|
|
235
|
+
const fieldList = arrayOf(raw.fields, `${path}.fields`, limits.maxFieldsPerTable);
|
|
236
|
+
if (fieldList.length === 0) fail("invalid-value", `${path}.fields`, "a table needs at least one field");
|
|
237
|
+
const fields = fieldList.map((item, index) => validateField(item, `${path}.fields[${index}]`, limits));
|
|
238
|
+
if (!fields.some((field) => field.column !== void 0 || field.reference !== void 0 || field.cachedColumn !== void 0)) fail("no-row-source", `${path}.fields`, "a table needs a column, reference column or cachedColumn to establish rows");
|
|
239
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
240
|
+
fields.forEach((field, index) => {
|
|
241
|
+
if (byKey.has(field.key)) fail("duplicate-key", `${path}.fields[${index}].key`, `"${field.key}"`);
|
|
242
|
+
byKey.set(field.key, field);
|
|
243
|
+
});
|
|
244
|
+
const table = {
|
|
245
|
+
key,
|
|
246
|
+
label: tableLabel,
|
|
247
|
+
source,
|
|
248
|
+
fields
|
|
249
|
+
};
|
|
250
|
+
if (raw.lookupKey !== void 0) {
|
|
251
|
+
const list = keyList(raw.lookupKey, `${path}.lookupKey`, 16);
|
|
252
|
+
list.keys.forEach((fieldKey, index) => {
|
|
253
|
+
const field = byKey.get(fieldKey);
|
|
254
|
+
if (!field) fail("unknown-field", list.paths[index], `no field "${fieldKey}"`);
|
|
255
|
+
if (field.column === void 0) fail("field-source", list.paths[index], "a lookup key field takes its value from a column");
|
|
256
|
+
});
|
|
257
|
+
table.lookupKey = list.keys;
|
|
258
|
+
}
|
|
259
|
+
if (raw.titleField !== void 0) {
|
|
260
|
+
const title = planKey(raw.titleField, `${path}.titleField`);
|
|
261
|
+
if (!byKey.has(title)) fail("unknown-field", `${path}.titleField`, `no field "${title}"`);
|
|
262
|
+
table.titleField = title;
|
|
263
|
+
}
|
|
264
|
+
return table;
|
|
265
|
+
}
|
|
266
|
+
function expressionColumns(root, path, limits) {
|
|
267
|
+
const found = /* @__PURE__ */ new Set();
|
|
268
|
+
const stack = [root];
|
|
269
|
+
let nodes = 0;
|
|
270
|
+
while (stack.length > 0) {
|
|
271
|
+
const node = stack.pop();
|
|
272
|
+
if (Array.isArray(node)) {
|
|
273
|
+
for (const item of node) stack.push(item);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (!isRecord(node)) continue;
|
|
277
|
+
if (++nodes > limits.maxExpressionNodes) fail("invalid-expression", path, `more than ${limits.maxExpressionNodes} nodes`);
|
|
278
|
+
if (Object.hasOwn(node, "col")) {
|
|
279
|
+
if (Object.keys(node).length !== 1 || typeof node.col !== "string") fail("invalid-expression", path, "a column node is { col: string }");
|
|
280
|
+
found.add(node.col);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
for (const item of Object.values(node)) stack.push(item);
|
|
284
|
+
}
|
|
285
|
+
return [...found];
|
|
286
|
+
}
|
|
287
|
+
var EXPRESSION_COLUMN_TYPE = {
|
|
288
|
+
text: "text",
|
|
289
|
+
choice: "text",
|
|
290
|
+
number: "number",
|
|
291
|
+
integer: "number",
|
|
292
|
+
money: "number",
|
|
293
|
+
boolean: "boolean",
|
|
294
|
+
date: "date",
|
|
295
|
+
reference: "unknown"
|
|
296
|
+
};
|
|
297
|
+
var EXPECTED_RESULT = {
|
|
298
|
+
text: ["text"],
|
|
299
|
+
choice: ["text"],
|
|
300
|
+
number: ["number"],
|
|
301
|
+
integer: ["number"],
|
|
302
|
+
money: ["number"],
|
|
303
|
+
boolean: ["boolean"],
|
|
304
|
+
date: ["date"],
|
|
305
|
+
reference: []
|
|
306
|
+
};
|
|
307
|
+
function validateCalculated(table, tablePath, limits) {
|
|
308
|
+
const siblings = new Map(table.fields.map((field) => [field.key, field]));
|
|
309
|
+
table.fields.forEach((field, index) => {
|
|
310
|
+
if (field.calculated === void 0) return;
|
|
311
|
+
const path = `${tablePath}.fields[${index}].calculated`;
|
|
312
|
+
if (field.type === "reference") fail("field-source", path, "a calculated field cannot be a reference");
|
|
313
|
+
for (const column of expressionColumns(field.calculated, path, limits)) {
|
|
314
|
+
const sibling = siblings.get(column);
|
|
315
|
+
if (!sibling) fail("expression-column", path, `"${column}" is not a field of this table`);
|
|
316
|
+
if (sibling.column === void 0) fail("expression-column", path, `"${column}" is not a column field; a calculated field reads column fields only`);
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const result = renderExpression(field.calculated, {
|
|
320
|
+
dialect: "sqlite",
|
|
321
|
+
mode: "ddl",
|
|
322
|
+
resolveColumn: (name) => {
|
|
323
|
+
const sibling = siblings.get(name);
|
|
324
|
+
return {
|
|
325
|
+
name,
|
|
326
|
+
type: sibling ? EXPRESSION_COLUMN_TYPE[sibling.type] : "unknown"
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}).resultType;
|
|
330
|
+
if (result !== "unknown" && result !== "null" && !EXPECTED_RESULT[field.type].includes(result)) fail("expression-type", path, `expression yields ${result}, but the field is ${field.type}`);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
if (error instanceof ImportPlanError) throw error;
|
|
333
|
+
if (error instanceof ExpressionError) fail("invalid-expression", path, `${error.code}: ${error.message}`);
|
|
334
|
+
fail("invalid-expression", path, error instanceof Error ? error.message : String(error));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
function validateReferences(plan) {
|
|
339
|
+
const tables = new Map(plan.tables.map((table) => [table.key, table]));
|
|
340
|
+
const edges = /* @__PURE__ */ new Map();
|
|
341
|
+
plan.tables.forEach((table, tableIndex) => {
|
|
342
|
+
const out = /* @__PURE__ */ new Set();
|
|
343
|
+
edges.set(table.key, out);
|
|
344
|
+
table.fields.forEach((field, fieldIndex) => {
|
|
345
|
+
if (!field.reference) return;
|
|
346
|
+
const path = `tables[${tableIndex}].fields[${fieldIndex}].reference`;
|
|
347
|
+
const target = tables.get(field.reference.table);
|
|
348
|
+
if (!target) fail("unknown-table", `${path}.table`, `no table "${field.reference.table}"`);
|
|
349
|
+
const lookup = target.lookupKey;
|
|
350
|
+
if (!lookup || lookup.length !== field.reference.by.length || lookup.some((key, i) => key !== field.reference?.by[i])) fail("reference-mismatch", `${path}.by`, `must equal the lookupKey of "${target.key}"`);
|
|
351
|
+
out.add(target.key);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
const state = /* @__PURE__ */ new Map();
|
|
355
|
+
const visit = (key, trail) => {
|
|
356
|
+
if (state.get(key) === 2) return;
|
|
357
|
+
if (state.get(key) === 1) fail("reference-cycle", "tables", `references form a cycle: ${[...trail.slice(trail.indexOf(key)), key].join(" -> ")}`);
|
|
358
|
+
state.set(key, 1);
|
|
359
|
+
for (const next of edges.get(key) ?? []) visit(next, [...trail, key]);
|
|
360
|
+
state.set(key, 2);
|
|
361
|
+
};
|
|
362
|
+
for (const table of plan.tables) visit(table.key, []);
|
|
363
|
+
}
|
|
364
|
+
function validatePlan(input, overrides = {}) {
|
|
365
|
+
const limits = {
|
|
366
|
+
...DEFAULT_PLAN_LIMITS,
|
|
367
|
+
...overrides
|
|
368
|
+
};
|
|
369
|
+
const raw = closedObject(toPlainData(input, limits), "", [
|
|
370
|
+
"version",
|
|
371
|
+
"tables",
|
|
372
|
+
"reports"
|
|
373
|
+
]);
|
|
374
|
+
if (raw.version !== 1) fail("unsupported-version", "version", "this package reads plan version 1");
|
|
375
|
+
const tableList = arrayOf(raw.tables, "tables", limits.maxTables);
|
|
376
|
+
if (tableList.length === 0) fail("invalid-value", "tables", "a plan needs at least one table");
|
|
377
|
+
const tables = tableList.map((item, index) => validateTable(item, `tables[${index}]`, limits));
|
|
378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
379
|
+
tables.forEach((table, index) => {
|
|
380
|
+
if (seen.has(table.key)) fail("duplicate-key", `tables[${index}].key`, `"${table.key}"`);
|
|
381
|
+
seen.add(table.key);
|
|
382
|
+
});
|
|
383
|
+
const plan = {
|
|
384
|
+
version: 1,
|
|
385
|
+
tables
|
|
386
|
+
};
|
|
387
|
+
if (raw.reports !== void 0) {
|
|
388
|
+
const list = arrayOf(raw.reports, "reports", limits.maxReports);
|
|
389
|
+
const keys = /* @__PURE__ */ new Set();
|
|
390
|
+
plan.reports = list.map((item, index) => {
|
|
391
|
+
const path = `reports[${index}]`;
|
|
392
|
+
const report = closedObject(item, path, [
|
|
393
|
+
"key",
|
|
394
|
+
"label",
|
|
395
|
+
"spec"
|
|
396
|
+
]);
|
|
397
|
+
const key = planKey(report.key, `${path}.key`);
|
|
398
|
+
if (keys.has(key)) fail("duplicate-key", `${path}.key`, `"${key}"`);
|
|
399
|
+
keys.add(key);
|
|
400
|
+
return {
|
|
401
|
+
key,
|
|
402
|
+
label: label(report.label, `${path}.label`),
|
|
403
|
+
spec: report.spec
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
validateReferences(plan);
|
|
408
|
+
for (const [index, table] of tables.entries()) validateCalculated(table, `tables[${index}]`, limits);
|
|
409
|
+
return plan;
|
|
410
|
+
}
|
|
411
|
+
function loadOrder(plan) {
|
|
412
|
+
const order = [];
|
|
413
|
+
const done = /* @__PURE__ */ new Set();
|
|
414
|
+
const tables = new Map(plan.tables.map((table) => [table.key, table]));
|
|
415
|
+
const visit = (key) => {
|
|
416
|
+
if (done.has(key)) return;
|
|
417
|
+
done.add(key);
|
|
418
|
+
const table = tables.get(key);
|
|
419
|
+
if (!table) return;
|
|
420
|
+
for (const field of table.fields) if (field.reference) visit(field.reference.table);
|
|
421
|
+
order.push(key);
|
|
422
|
+
};
|
|
423
|
+
for (const table of plan.tables) visit(table.key);
|
|
424
|
+
return order;
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
export { validatePlan as a, loadOrder as i, IMPORT_FIELD_TYPES as n, ImportPlanError as o, PLAN_KEY_PATTERN as r, ImportRunError as s, DEFAULT_PLAN_LIMITS as t };
|
|
428
|
+
|
|
429
|
+
//# sourceMappingURL=plan-BAV2jEzl.js.map
|