jsonstat-io 0.1.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 (99) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +200 -0
  3. package/dist/arrow/index.cjs +60 -0
  4. package/dist/arrow/index.cjs.map +1 -0
  5. package/dist/arrow/index.d.cts +125 -0
  6. package/dist/arrow/index.d.ts +125 -0
  7. package/dist/arrow/index.js +3 -0
  8. package/dist/arrow/index.js.map +1 -0
  9. package/dist/arrowToCube-BdRWZ5oG.d.cts +29 -0
  10. package/dist/arrowToCube-Mjr79ZHM.d.ts +29 -0
  11. package/dist/chunk-2HY7NRKX.js +259 -0
  12. package/dist/chunk-2HY7NRKX.js.map +1 -0
  13. package/dist/chunk-62NIY7ML.js +191 -0
  14. package/dist/chunk-62NIY7ML.js.map +1 -0
  15. package/dist/chunk-AXUZF6O5.cjs +352 -0
  16. package/dist/chunk-AXUZF6O5.cjs.map +1 -0
  17. package/dist/chunk-EFRDFQOT.js +335 -0
  18. package/dist/chunk-EFRDFQOT.js.map +1 -0
  19. package/dist/chunk-GDKDYLZ4.cjs +263 -0
  20. package/dist/chunk-GDKDYLZ4.cjs.map +1 -0
  21. package/dist/chunk-IDTQZG4B.js +432 -0
  22. package/dist/chunk-IDTQZG4B.js.map +1 -0
  23. package/dist/chunk-KFIKGBIJ.cjs +200 -0
  24. package/dist/chunk-KFIKGBIJ.cjs.map +1 -0
  25. package/dist/chunk-L65CRXTV.js +64 -0
  26. package/dist/chunk-L65CRXTV.js.map +1 -0
  27. package/dist/chunk-PHLCWTHB.cjs +446 -0
  28. package/dist/chunk-PHLCWTHB.cjs.map +1 -0
  29. package/dist/chunk-VQCS4LVD.cjs +166 -0
  30. package/dist/chunk-VQCS4LVD.cjs.map +1 -0
  31. package/dist/chunk-VYS7DPB3.js +162 -0
  32. package/dist/chunk-VYS7DPB3.js.map +1 -0
  33. package/dist/chunk-ZEDTWRDN.cjs +70 -0
  34. package/dist/chunk-ZEDTWRDN.cjs.map +1 -0
  35. package/dist/cli/index.cjs +337 -0
  36. package/dist/cli/index.cjs.map +1 -0
  37. package/dist/cli/index.d.cts +90 -0
  38. package/dist/cli/index.d.ts +90 -0
  39. package/dist/cli/index.js +333 -0
  40. package/dist/cli/index.js.map +1 -0
  41. package/dist/cubeBuilder-BEGA4C5Y.js +4 -0
  42. package/dist/cubeBuilder-BEGA4C5Y.js.map +1 -0
  43. package/dist/cubeBuilder-ZU76VVEX.cjs +25 -0
  44. package/dist/cubeBuilder-ZU76VVEX.cjs.map +1 -0
  45. package/dist/cubeReader-7QVUF3TI.js +4 -0
  46. package/dist/cubeReader-7QVUF3TI.js.map +1 -0
  47. package/dist/cubeReader-UN2BHQWK.cjs +21 -0
  48. package/dist/cubeReader-UN2BHQWK.cjs.map +1 -0
  49. package/dist/index.cjs +172 -0
  50. package/dist/index.cjs.map +1 -0
  51. package/dist/index.d.cts +445 -0
  52. package/dist/index.d.ts +445 -0
  53. package/dist/index.js +7 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/ir-BArfNxo-.d.cts +143 -0
  56. package/dist/ir-Jae2ymov.d.ts +143 -0
  57. package/dist/jsonstat-BX4mBLci.d.cts +179 -0
  58. package/dist/jsonstat-BX4mBLci.d.ts +179 -0
  59. package/dist/sources/csv.cjs +40 -0
  60. package/dist/sources/csv.cjs.map +1 -0
  61. package/dist/sources/csv.d.cts +112 -0
  62. package/dist/sources/csv.d.ts +112 -0
  63. package/dist/sources/csv.js +3 -0
  64. package/dist/sources/csv.js.map +1 -0
  65. package/dist/sources/csvw.cjs +236 -0
  66. package/dist/sources/csvw.cjs.map +1 -0
  67. package/dist/sources/csvw.d.cts +143 -0
  68. package/dist/sources/csvw.d.ts +143 -0
  69. package/dist/sources/csvw.js +230 -0
  70. package/dist/sources/csvw.js.map +1 -0
  71. package/dist/sources/duckdb.cjs +121 -0
  72. package/dist/sources/duckdb.cjs.map +1 -0
  73. package/dist/sources/duckdb.d.cts +111 -0
  74. package/dist/sources/duckdb.d.ts +111 -0
  75. package/dist/sources/duckdb.js +115 -0
  76. package/dist/sources/duckdb.js.map +1 -0
  77. package/dist/sources/parquet.cjs +117 -0
  78. package/dist/sources/parquet.cjs.map +1 -0
  79. package/dist/sources/parquet.d.cts +83 -0
  80. package/dist/sources/parquet.d.ts +83 -0
  81. package/dist/sources/parquet.js +111 -0
  82. package/dist/sources/parquet.js.map +1 -0
  83. package/dist/sources/polars.cjs +94 -0
  84. package/dist/sources/polars.cjs.map +1 -0
  85. package/dist/sources/polars.d.cts +91 -0
  86. package/dist/sources/polars.d.ts +91 -0
  87. package/dist/sources/polars.js +87 -0
  88. package/dist/sources/polars.js.map +1 -0
  89. package/docs/api.md +322 -0
  90. package/docs/architecture.md +140 -0
  91. package/docs/cli.md +159 -0
  92. package/docs/formats/arrow.md +111 -0
  93. package/docs/formats/csv.md +115 -0
  94. package/docs/formats/csvw.md +152 -0
  95. package/docs/formats/duckdb.md +151 -0
  96. package/docs/formats/parquet.md +132 -0
  97. package/docs/formats/polars.md +111 -0
  98. package/docs/mapping.md +136 -0
  99. package/package.json +119 -0
package/docs/api.md ADDED
@@ -0,0 +1,322 @@
1
+ # API Reference
2
+
3
+ The main entry `jsonstat-io` re-exports the always-loaded layers: model types, core engine, Arrow hub, sink, and isomorphic utils. Format adapters that need optional peers live behind subpath exports. Every subpath is **bidirectional** — it exports both an import function (`xToCube` / `xToDataset`) and an export function (`cubeToX`).
4
+
5
+ ## High-level dispatchers
6
+
7
+ ### `importToCube(source, options?)`
8
+
9
+ ```ts
10
+ import { importToCube } from "jsonstat-io";
11
+
12
+ const obs = await importToCube("./sales.parquet");
13
+ ```
14
+
15
+ Loads a source (file path, URL, `"-"` for stdin, `Uint8Array`, or `Blob`), auto-detects the format, and converts it to the [`Observations`](./mapping.md#observations-ir) IR. Dispatches to the right source adapter and funnels through the Arrow hub.
16
+
17
+ **Parameters:**
18
+ - `source: string | Uint8Array | Blob` — the input.
19
+ - `options?: ImportOptions` — detection and conversion hints.
20
+
21
+ **Returns:** `Promise<Observations>`
22
+
23
+ ### `importToDataset(source, options?)`
24
+
25
+ ```ts
26
+ import { importToDataset } from "jsonstat-io";
27
+
28
+ const dataset = await importToDataset("./sales.parquet", {
29
+ measure: "amount",
30
+ roles: { time: ["year"], geo: ["country"] },
31
+ build: { valueForm: "sparse" },
32
+ });
33
+ ```
34
+
35
+ Like `importToCube` but also builds the final [`JsonStatDataset`](./mapping.md) via `buildDataset`.
36
+
37
+ **Returns:** `Promise<JsonStatDataset>`
38
+
39
+ ### `ImportOptions`
40
+
41
+ Extends `ArrowToCubeOptions`:
42
+
43
+ | Field | Type | Default | Description |
44
+ |-------------------|-----------------------------------|-------------|-------------|
45
+ | `from` | `SourceFormat \| "auto"` | `"auto"` | Force a format instead of auto-detecting |
46
+ | `build` | `BuildOptions` | — | Passed to `buildDataset` |
47
+ | `csvwMetadata` | `unknown` | — | CSVW metadata object (when `from: "csvw"`) |
48
+ | `delimiter` | `string` | `","` | CSV delimiter |
49
+ | `measure` | `string` | *(detected)*| Measure column name |
50
+ | `dimensions` | `string[]` | *(detected)*| Dimension column names |
51
+ | `status` | `string` | *(detected)*| Status column name |
52
+ | `roles` | `RoleMap` | *(inferred)*| Role assignments |
53
+ | `valueForm` | `"auto" \| "dense" \| "sparse"` | `"auto"` | Value form hint |
54
+
55
+ ### `importToCube`
56
+
57
+ Thrown by the dispatchers when a format cannot be detected, a required peer dependency is missing, or the source produces no data.
58
+
59
+ ---
60
+
61
+ ## Export dispatchers
62
+
63
+ ### `exportDataset(dataset, options)`
64
+
65
+ ```ts
66
+ import { exportDataset } from "jsonstat-io";
67
+
68
+ // JSON-stat dataset → Arrow Table
69
+ const table = await exportDataset(dataset, { to: "arrow" });
70
+
71
+ // → Parquet bytes (needs parquet-wasm)
72
+ const bytes = await exportDataset(dataset, { to: "parquet" });
73
+
74
+ // → CSV text
75
+ const csv = await exportDataset(dataset, { to: "csv" });
76
+
77
+ // → CSV text + CSVW metadata object
78
+ const { csv, metadata } = await exportDataset(dataset, { to: "csvw" });
79
+ ```
80
+
81
+ Flattens a JSON-stat dataset to the Observations IR via `readDataset`, then converts to the requested target format. Arrow-native targets (parquet) funnel through `cubeToArrow`; text targets (csv, csvw) serialize directly from the IR.
82
+
83
+ **Parameters:**
84
+ - `dataset: JsonStatDataset` — the input JSON-stat dataset.
85
+ - `options: ExportOptions` — `{ to, ...format-specific options }`.
86
+
87
+ **Returns:** `Promise<ExportResult>` — an Arrow `Table` (`to: "arrow"`), a `Uint8Array` (`to: "parquet"`), a `string` (`to: "csv"`), or a `{ csv, metadata }` object (`to: "csvw"`).
88
+
89
+ ### `ExportOptions`
90
+
91
+ | Field | Type | Description |
92
+ |----------|-------------------------------------|-------------|
93
+ | `to` | `ExportTarget` | `"arrow"` \| `"parquet"` \| `"csv"` \| `"csvw"` |
94
+ | *(format-specific)* | varies | Passed through to the target adapter (e.g. `compression` for parquet, `delimiter` for csv) |
95
+
96
+ ---
97
+
98
+ ## Core engine
99
+
100
+ ### `buildDataset(obs, options?)`
101
+
102
+ ```ts
103
+ import { buildDataset } from "jsonstat-io";
104
+
105
+ const { dataset, size, valueForm } = buildDataset(obs, { valueForm: "sparse" });
106
+ ```
107
+
108
+ Converts the `Observations` IR into a `JsonStatDataset`. Resolves dimension categories, scatters values into row-major positions, decides dense/sparse, and emits status.
109
+
110
+ **Returns:** `BuildResult { dataset, size, valueForm }`
111
+
112
+ ### `toDataset(obs)`
113
+
114
+ Convenience wrapper: `buildDataset(obs).dataset`.
115
+
116
+ ### `readDataset(dataset, options?)`
117
+
118
+ ```ts
119
+ import { readDataset } from "jsonstat-io";
120
+
121
+ const obs = readDataset(jsonStatDataset, { dropNulls: false });
122
+ ```
123
+
124
+ The inverse of `buildDataset`: flattens a JSON-stat cube back into the `Observations` IR. Powers round-trip tests, the JSON-stat-as-input path, and **all export** operations (`exportDataset` calls `readDataset` first).
125
+
126
+ ### `BuildOptions`
127
+
128
+ | Field | Type | Default | Description |
129
+ |--------------------|---------------------------------------------------|---------|-------------|
130
+ | `valueForm` | `"auto" \| "dense" \| "sparse"` | `"auto"`| Override value form |
131
+ | `sparseThreshold` | `number` | `0.5` | Null ratio threshold for auto sparse |
132
+ | `statusForm` | `"auto" \| "array" \| "string" \| "object" \| "none"` | `"auto"`| Status emission form |
133
+ | `meta` | `DatasetMeta` | — | Dataset label/source/updated |
134
+
135
+ ### Stride utilities
136
+
137
+ ```ts
138
+ import { strides, totalCells, flatPosition, multiIndex, enumerateCells } from "jsonstat-io";
139
+ ```
140
+
141
+ - `strides(size: number[]): number[]` — stride of each dimension.
142
+ - `totalCells(size: number[]): number` — product of all sizes.
143
+ - `flatPosition(indices: number[], size: number[]): number` — multi-index → flat position.
144
+ - `multiIndex(pos: number, size: number[]): number[]` — flat position → multi-index.
145
+ - `enumerateCells(size: number[]): number[][]` — all cell addresses.
146
+
147
+ ---
148
+
149
+ ## Arrow hub
150
+
151
+ ### `arrowToCube(table, options?)`
152
+
153
+ ```ts
154
+ import { arrowToCube } from "jsonstat-io";
155
+ import { tableFromIPC } from "apache-arrow";
156
+
157
+ const table = tableFromIPC(bytes);
158
+ const obs = arrowToCube(table, { measure: "amount" });
159
+ ```
160
+
161
+ Converts an Apache Arrow `Table` to the `Observations` IR. Detects dimensions (dictionary columns), measures (numeric columns), and status from the schema, resolving roles from `jsonstat.*` metadata or heuristics.
162
+
163
+ ### `arrowToDataset(table, options?)`
164
+
165
+ Async convenience: `buildDataset(arrowToCube(table, options)).dataset`.
166
+
167
+ ### `cubeToArrow(obs)`
168
+
169
+ The export hub: converts `Observations` IR → Arrow `Table` with dictionary-encoded dimensions and a Float64 measure, annotating fields with `jsonstat.*` metadata. This is what `exportDataset({ to: "arrow" })`, `cubeToParquet`, `cubeToDuckdb`, and `cubeToPolars` all build upon.
170
+
171
+ ### Schema metadata helpers
172
+
173
+ ```ts
174
+ import { buildFieldMeta, readSchemaMeta, buildSchemaMeta, getFieldRole } from "jsonstat-io";
175
+ ```
176
+
177
+ - `buildFieldMeta(hints)` — construct `Field.metadata` entries from role/label/category hints.
178
+ - `readSchemaMeta(schema)` — read `jsonstat.*` schema-level metadata.
179
+ - `buildSchemaMeta(hints)` — construct `Schema.metadata` entries.
180
+ - `getFieldRole(field)` — extract the role from a field's metadata.
181
+
182
+ ---
183
+
184
+ ## Source adapters (subpath exports)
185
+
186
+ Each subpath is bidirectional — it exports import (`xToCube` / `xToDataset`) and export (`cubeToX`) functions.
187
+
188
+ ### `jsonstat-io/parquet`
189
+
190
+ ```ts
191
+ import { parquetToCube, parquetToDataset, cubeToParquet } from "jsonstat-io/parquet";
192
+
193
+ const obs = await parquetToCube(bytes, { init: () => parquetWasm.init() });
194
+
195
+ // Export: IR → Arrow → Parquet bytes
196
+ const parquetBytes = await cubeToParquet(obs, { init: () => parquetWasm.init() });
197
+ ```
198
+
199
+ Requires `parquet-wasm`. See [`formats/parquet.md`](./formats/parquet.md).
200
+
201
+ ### `jsonstat-io/duckdb`
202
+
203
+ ```ts
204
+ import { duckdbToCube, openDuckdbNode, cubeToDuckdb } from "jsonstat-io/duckdb";
205
+
206
+ const conn = await openDuckdbNode("./data.duckdb");
207
+ const obs = await duckdbToCube(conn, "SELECT year, country, amount FROM sales");
208
+
209
+ // Export: register an Arrow-backed view (or table) in the connection
210
+ await cubeToDuckdb(conn, obs, { tableName: "sales", mode: "view" });
211
+ ```
212
+
213
+ Requires `duckdb-async` (Node) or `@duckdb/duckdb-wasm` (browser). See [`formats/duckdb.md`](./formats/duckdb.md).
214
+
215
+ ### `jsonstat-io/polars`
216
+
217
+ ```ts
218
+ import { polarsToCube, loadPolars, cubeToPolars } from "jsonstat-io/polars";
219
+
220
+ const pl = await loadPolars();
221
+ const df = pl.readCSV("data.csv");
222
+ const obs = await polarsToCube(df);
223
+
224
+ // Export: IR → Arrow → Polars DataFrame
225
+ const dfOut = await cubeToPolars(obs);
226
+ ```
227
+
228
+ Requires `nodejs-polars`. Node only. See [`formats/polars.md`](./formats/polars.md).
229
+
230
+ ### `jsonstat-io/csvw`
231
+
232
+ ```ts
233
+ import { csvwToCube, parseCsvwMetadata, cubeToCsvw } from "jsonstat-io/csvw";
234
+
235
+ const meta = parseCsvwMetadata(metadataJson);
236
+ const obs = csvwToCube(csvText, meta, { dimensions: ["year", "country"] });
237
+
238
+ // Export: IR → CSV text + CSVW metadata object
239
+ const { csv, metadata } = cubeToCsvw(obs);
240
+ ```
241
+
242
+ No dependencies. See [`formats/csvw.md`](./formats/csvw.md).
243
+
244
+ ### `jsonstat-io/csv`
245
+
246
+ ```ts
247
+ import { csvToCube, cubeToCsv } from "jsonstat-io/csv";
248
+
249
+ const obs = csvToCube(csvText, { measure: "amount", delimiter: ";" });
250
+
251
+ // Export: IR → CSV text (dimensions + measure + optional status)
252
+ const csv = cubeToCsv(obs);
253
+ ```
254
+
255
+ No dependencies. Heuristic measure/dimension inference on import.
256
+
257
+ ---
258
+
259
+ ## Sink
260
+
261
+ ### `serialize(dataset, options?)`
262
+
263
+ ```ts
264
+ import { serialize } from "jsonstat-io";
265
+
266
+ const json = serialize(dataset, { pretty: true, canonicalKeys: true });
267
+ ```
268
+
269
+ Produces a canonical JSON string. Optionally reorders top-level keys for diff-stable output.
270
+
271
+ ### `serializeToBytes(dataset, options?)`
272
+
273
+ Returns `Uint8Array` instead of a string.
274
+
275
+ ---
276
+
277
+ ## Utilities
278
+
279
+ ### Format detection
280
+
281
+ ```ts
282
+ import { detectFormat, detectFromBytes, detectFromExtension } from "jsonstat-io";
283
+
284
+ detectFromBytes(bytes); // "parquet" | "arrow" | "jsonstat" | "unknown"
285
+ detectFromExtension("parquet"); // "parquet"
286
+ detectFormat("data.csv", bytes); // tries extension first, then bytes
287
+ ```
288
+
289
+ ### Input loading
290
+
291
+ ```ts
292
+ import { loadInput } from "jsonstat-io";
293
+
294
+ const { bytes, source } = await loadInput("./sales.parquet"); // Node: reads file
295
+ const { bytes } = await loadInput(blob); // Browser: reads Blob
296
+ ```
297
+
298
+ ### Density decision
299
+
300
+ ```ts
301
+ import { decideDensity } from "jsonstat-io";
302
+
303
+ decideDensity(0.6, 0.5); // { form: "sparse", nullRatio: 0.6, threshold: 0.5 }
304
+ ```
305
+
306
+ ---
307
+
308
+ ## Error classes
309
+
310
+ | Class | Thrown by |
311
+ |-----------------------|-----------|
312
+ | `ImporterError` | `importToCube`, `importToDataset`, `exportDataset` |
313
+ | `ArrowConversionError`| `arrowToCube`, `cubeToArrow` |
314
+ | `CubeBuilderError` | `buildDataset` |
315
+ | `CubeReaderError` | `readDataset` |
316
+ | `ParquetSourceError` | `parquetToCube` |
317
+ | `DuckdbSourceError` | `duckdbToCube` |
318
+ | `PolarsSourceError` | `polarsToCube` |
319
+ | `CsvwSourceError` | `csvwToCube` |
320
+ | `CsvSourceError` | `csvToCube` |
321
+
322
+ All extend `Error` and set `this.name` to the class name.
@@ -0,0 +1,140 @@
1
+ # Architecture
2
+
3
+ This document explains the layered design of `jsonstat-io` and the rationale behind the two key decisions: the **Arrow hub** and the **Observations IR**. Both import (columnar → JSON-stat) and export (JSON-stat → columnar) are fully supported and share the same IR.
4
+
5
+ ## The core problem
6
+
7
+ JSON-stat 2.0 is a *dense or sparse* multidimensional cube model: values are laid out in **row-major order** ("what does not change first") across the cross-product of dimension categories. Every statistical office, every data portal, and increasingly every lakehouse produces *tabular* data instead — Parquet, Arrow, CSV, DataFrames. These two worlds speak different shapes:
8
+
9
+ | JSON-stat cube | Columnar / tabular |
10
+ |-----------------------|-------------------------|
11
+ | N-dimensional array | 2D table (rows × cols) |
12
+ | Row-major value order | Columnar or row order |
13
+ | Dimensions × size | Column names × values |
14
+ | Implicit cell address | Explicit row |
15
+
16
+ `jsonstat-io` bridges this gap in both directions. The challenge is not just *reading* a format — it's **faithfully reconstructing the full JSON-stat model** (roles, labels, units, coordinates, hierarchies, status, sparse/dense) from a source that carries only raw values unless you annotate it, and reversing that reconstruction losslessly on export.
17
+
18
+ ## The Arrow-hub insight
19
+
20
+ Apache Arrow is the *de facto* in-memory standard of the columnar data stack. Critically, the major lakehouse engines all emit Arrow tables natively:
21
+
22
+ - **Parquet** → Arrow via `parquet-wasm` (or DuckDB/Polars, which read Parquet)
23
+ - **DuckDB** → Arrow via `.arrow()` / `.arrowResult()`
24
+ - **Polars** → Arrow via `toArrow()` / `toIPC()`
25
+ - **Arrow IPC** → Arrow via `tableFromIPC()` (it already *is* Arrow)
26
+
27
+ This means a single converter pair — `arrowToCube` (import) and `cubeToArrow` (export) — handles four of the five major formats in both directions. Adding a new Arrow-native source is a ~30-line adapter that calls `arrowToCube` on import and `cubeToArrow` on export. The mapping logic (dimension detection, role resolution, value scattering, status emission) lives in exactly one place per direction and is tested once.
28
+
29
+ ```
30
+ IMPORT
31
+ N sources ──▶ Arrow Table ──▶ arrowToCube ──▶ Observations IR ──▶ buildDataset ──▶ JSON-stat
32
+ (one shape) (one converter) (one IR) (one builder) (one format)
33
+
34
+ EXPORT
35
+ JSON-stat ──▶ readDataset ──▶ Observations IR ──▶ cubeToArrow ──▶ Arrow Table ──▶ N targets
36
+ (one IR) (one converter) (one shape)
37
+ ```
38
+
39
+ CSVW and plain CSV do not produce Arrow natively in this package, so they build the IR directly on import and serialize from the IR directly on export — but they share the *same* IR type and the *same* builder/reader, so the downstream path is identical.
40
+
41
+ ## Layered architecture
42
+
43
+ The package is organized in strict layers with one-way dependencies:
44
+
45
+ ```
46
+ ┌─────────────────────────────────────────────────────────┐
47
+ │ Public API (index.ts) + CLI (cli/) │ ← what consumers call
48
+ ├─────────────────────────────────────────────────────────┤
49
+ │ Sources (parquet, duckdb, polars, csvw, csv) │ ← format adapters
50
+ ├─────────────────────────────────────────────────────────┤
51
+ │ Arrow hub (arrowToCube, arrowFromCube, schemaMeta) │ ← the hub
52
+ ├─────────────────────────────────────────────────────────┤
53
+ │ Core engine (strides, cubeBuilder, cubeReader) │ ← the math
54
+ ├─────────────────────────────────────────────────────────┤
55
+ │ Model (jsonstat types, Observations IR) │ ← pure types
56
+ ├─────────────────────────────────────────────────────────┤
57
+ │ Sink (serialize) + Utils (detect, fetch, density) │ ← isomorphic helpers
58
+ └─────────────────────────────────────────────────────────┘
59
+ ```
60
+
61
+ Each layer depends only on the layers below it. The model layer is pure TypeScript types with zero runtime cost. The core engine is pure logic with no I/O. The Arrow hub depends on `apache-arrow` (a hard dependency). Source adapters depend on optional peers, imported lazily.
62
+
63
+ ### Layer 1: Model (`src/model/`)
64
+
65
+ Two parallel type definitions:
66
+
67
+ - **`jsonstat.ts`** — the JSON-stat 2.0 wire types: `JsonStatDataset`, `JsonStatDimension`, `JsonStatCategory`, `JsonStatRole`, `JsonStatUnit`, etc. Plus type guards (`isDataset`, `isCollection`).
68
+ - **`ir.ts`** — the internal `Observations` IR: a tidy long table with `DimensionColumn[]`, one `MeasureColumn`, an optional `StatusColumn`, a `RoleMap`, `DatasetMeta`, and a `valueForm` hint.
69
+
70
+ No runtime logic here except `observationCount()`. This is the contract every layer agrees on.
71
+
72
+ ### Layer 2: Core engine (`src/core/`)
73
+
74
+ The mathematical heart:
75
+
76
+ - **`strides.ts`** — row-major stride arithmetic. `strides(size)` computes the stride of each dimension; `flatPosition(indices, size)` maps a multi-index to a flat array position; `multiIndex` is the inverse; `enumerateCells` iterates all cell addresses.
77
+ - **`cubeBuilder.ts`** — `buildDataset(obs, options)` resolves dimension categories (explicit order or first-seen), scatters the measure values into their row-major positions, decides dense vs sparse, and emits status. This is the "long → wide" pivot.
78
+ - **`cubeReader.ts`** — `readDataset(dataset)` is the inverse: it takes a JSON-stat cube and *flattens* it back into the Observations IR. This powers round-trip tests and the JSON-stat-as-input path.
79
+
80
+ The core has **zero I/O dependencies** — it operates on plain arrays and objects. This is what makes it isomorphic and testable.
81
+
82
+ ### Layer 3: Arrow hub (`src/arrow/`)
83
+
84
+ - **`schemaMeta.ts`** — defines the `jsonstat.*` metadata key contract on Arrow `Field` and `Schema` objects. This is how producers annotate Arrow tables with JSON-stat semantics (roles, labels, units, coordinates, hierarchies). Without metadata, `arrowToCube` falls back to heuristics.
85
+ - **`arrowToCube.ts`** — `arrowToCube(table, options)` reads an Arrow `Table`, detects dimensions (dictionary columns), measures (numeric columns), and status, resolves roles from metadata + options, and produces the `Observations` IR. This is the single converter that Parquet/DuckDB/Polars all funnel through.
86
+ - **`arrowFromCube.ts`** — `cubeToArrow(obs)` is the Phase 2 seam: IR → Arrow Table. It builds dictionary-encoded dimension columns and a Float64 measure column, annotating fields with `jsonstat.*` metadata for lossless round-trips.
87
+
88
+ ### Layer 4: Source adapters (`src/sources/`)
89
+
90
+ Each adapter is bidirectional — a thin wrapper that, on import, gets bytes (or a live connection/DataFrame) into an Arrow Table then calls `arrowToCube`, and on export, takes the Arrow Table (or IR) from `cubeToArrow` and writes it to the target format:
91
+
92
+ - **`parquet.ts`** — `parquetToCube(bytes)` / `cubeToParquet(obs)` via `parquet-wasm` (lazy import). Browser + Node.
93
+ - **`duckdb.ts`** — `duckdbToCube(conn, query)` / `cubeToDuckdb(conn, obs)` runs SQL on import, registers an Arrow view/table on export. Supports `duckdb-async` (Node) and `@duckdb/duckdb-wasm` (browser).
94
+ - **`polars.ts`** — `polarsToCube(df)` / `cubeToPolars(obs)` via `toArrow()`/`fromArrow()`. Node only.
95
+ - **`csvw.ts`** — `csvwToCube(text, metadata)` / `cubeToCsvw(obs)` uses CSVW metadata for lossless mapping. No deps.
96
+ - **`csv.ts`** — `csvToCube(text)` / `cubeToCsv(obs)` infers measure/dimensions heuristically on import; serializes dimensions+measure+status columns on export. No deps.
97
+
98
+ All optional peer imports are **lazy** (`await import(...)`) so they never enter a bundle that doesn't use them.
99
+
100
+ ### Layer 5: Sink + Utils (`src/sink/`, `src/util/`)
101
+
102
+ - **`serialize.ts`** — `serialize(dataset, options)` produces canonical JSON. Optionally reorders top-level keys for diff-stable output. `serializeToBytes` returns `Uint8Array`.
103
+ - **`detect.ts`** — `detectFormat(source, bytes)` sniffs the format from magic bytes (Parquet `PAR1`, Arrow `ARROW1`) and file extension. Used by the high-level dispatchers.
104
+ - **`fetch.ts`** — `loadInput(source)` normalizes file paths, URLs, stdin, `Uint8Array`, and `Blob` into a uniform `LoadedInput { bytes, source }`. Uses `fetch` in the browser, `node:fs` in Node.
105
+ - **`density.ts`** — `decideDensity(nullRatio, threshold)` implements the sparse/dense auto-decision.
106
+
107
+ ## The Observations IR
108
+
109
+ The `Observations` IR is the keystone. It is a **tidy long table**:
110
+
111
+ ```ts
112
+ interface Observations {
113
+ dimensions: Record<string, DimensionColumn>; // { values: string[] }
114
+ measure: MeasureColumn; // { values: (number|null)[] }
115
+ status?: StatusColumn; // { values: string[] }
116
+ roles?: RoleMap; // { time?: string[], geo?: string[], metric?: string[] }
117
+ meta?: DatasetMeta; // { label?, source?, updated? }
118
+ valueForm?: "auto" | "dense" | "sparse";
119
+ }
120
+ ```
121
+
122
+ Every source produces this shape. `buildDataset` is the only consumer that pivots it into the row-major cube. This separation means:
123
+
124
+ 1. **Adding a source** = producing `Observations`. You never touch the cube math.
125
+ 2. **Testing the cube math** = construct `Observations` directly, no file I/O.
126
+ 3. **Round-tripping** = `readDataset` (cube → IR) + `buildDataset` (IR → cube).
127
+
128
+ ## Bidirectional scope
129
+
130
+ Both directions are fully implemented and tested:
131
+
132
+ - **Import** (columnar → JSON-stat): `arrowToCube` handles Arrow-native sources; `csvToCube` / `csvwToCube` handle text formats; `buildDataset` scatters into the cube. Exposed via [`importToDataset`](./api.md#importtodatasetsource-options).
133
+ - **Export** (JSON-stat → columnar): `readDataset` flattens the cube to the IR; `cubeToArrow` handles Arrow-native targets (Parquet, DuckDB, Polars); `cubeToCsv` / `cubeToCsvw` handle text targets. Exposed via [`exportDataset`](./api.md#exportdatasetdataset-options).
134
+ - **CLI:** the `--to` flag drives direction — `jsonstat` (default) imports; `arrow|parquet|csv|csvw` exports.
135
+
136
+ The same Observations IR sits at the center of both directions, guaranteeing that `export → import` round-trips losslessly.
137
+
138
+ ## The Rust/Wasm accelerator seam
139
+
140
+ The performance-critical path is `buildDataset`'s value scattering and `cubeToArrow`'s vector building — the nested-loop stride multiplication. The public API signatures (`buildDataset`, `arrowToCube`, `cubeToArrow`, `exportDataset`) are stable and framework-agnostic. An accelerator can replace the internals behind the same types, yielding a drop-in speedup with no consumer changes.
package/docs/cli.md ADDED
@@ -0,0 +1,159 @@
1
+ # CLI Reference
2
+
3
+ ```sh
4
+ npx jsonstat-io [input] [options]
5
+ ```
6
+
7
+ ## Synopsis
8
+
9
+ ```
10
+ jsonstat-io [input] [options]
11
+
12
+ Convert between columnar data (Arrow, Parquet, DuckDB, Polars, CSVW, CSV) and JSON-stat 2.0 cubes.
13
+
14
+ Arguments:
15
+ input Input file path, URL, or "-" for stdin (default: "-").
16
+ Supports .parquet, .arrow/.ipc, .csv, .csvw, .json/.jsonstat.
17
+
18
+ Options:
19
+ -V, --version Print version and exit.
20
+ -h, --help Print this help and exit.
21
+
22
+ Format:
23
+ -f, --from <format> Source format override. One of:
24
+ auto (default) — sniff from magic bytes + extension
25
+ parquet — needs parquet-wasm
26
+ arrow — Arrow IPC stream/file
27
+ csv — plain CSV (heuristic mapping)
28
+ csvw — CSV with metadata
29
+ jsonstat, json — JSON-stat input (round-trip)
30
+ -t, --to <format> Output format / direction. One of:
31
+ jsonstat (default) — IMPORT: columnar → JSON-stat
32
+ arrow — EXPORT: JSON-stat → Arrow IPC
33
+ parquet — EXPORT: JSON-stat → Parquet (needs parquet-wasm)
34
+ csv — EXPORT: JSON-stat → CSV
35
+ csvw — EXPORT: JSON-stat → CSV + CSVW metadata
36
+
37
+ Column mapping:
38
+ --measure <column> Name of the measure column (overrides detection).
39
+ --dimensions <a,b,c> Comma-separated dimension column names, in order.
40
+ --role <assigns> Role assignments: time=<col>,geo=<col>,metric=<col>
41
+ (comma-separated). Example: --role time=year,geo=country
42
+ --status <column> Name of the status column.
43
+
44
+ Value form:
45
+ --sparse Force sparse (object) value form.
46
+ --dense Force dense (array) value form.
47
+ --auto Auto-decide value form by null ratio (default).
48
+ --threshold <n> Sparse threshold: null ratio 0–1 (default 0.5).
49
+
50
+ Status form:
51
+ --status-form <form> Status emission. One of:
52
+ auto (default) — string if uniform, else array
53
+ array — always per-cell array
54
+ string — always a single string
55
+ object — explicit {position: code}
56
+ none — omit status entirely
57
+
58
+ Dataset metadata:
59
+ --label <text> Dataset label.
60
+ --source <text> Dataset source.
61
+ --updated <date> Dataset last-updated date (ISO 8601).
62
+
63
+ Output:
64
+ -o, --output <file> Write to file instead of stdout.
65
+ --pretty Pretty-print JSON (default: true).
66
+ --no-pretty Compact JSON output (single line).
67
+ --canonical-keys Reorder top-level keys canonically (default: true).
68
+ --no-canonical-keys Preserve insertion key order.
69
+ --validate Validate output with jsonstat-validator (if installed).
70
+
71
+ CSV/CSVW:
72
+ --csvw-metadata <json> Inline CSVW metadata as a JSON string.
73
+ --delimiter <char> CSV delimiter (default: ",").
74
+ ```
75
+
76
+ ## Exit codes
77
+
78
+ | Code | Meaning |
79
+ |------|---------|
80
+ | 0 | Success |
81
+ | 1 | Import, validation, or write error |
82
+ | 2 | Invalid CLI arguments |
83
+
84
+ ## Examples
85
+
86
+ ### IMPORT: Parquet → JSON-stat file
87
+
88
+ ```sh
89
+ npx jsonstat-io ./sales.parquet -o sales.jsonstat.json --label "Sales 2024"
90
+ ```
91
+
92
+ ### IMPORT: CSV on stdin with explicit roles
93
+
94
+ ```sh
95
+ cat data.csv | npx jsonstat-io - --measure amount --role time=year,geo=country
96
+ ```
97
+
98
+ ### IMPORT: Arrow IPC from a URL, forced sparse
99
+
100
+ ```sh
101
+ npx jsonstat-io https://example.com/data.arrow --sparse --threshold 0.3
102
+ ```
103
+
104
+ ### IMPORT: CSVW with inline metadata
105
+
106
+ ```sh
107
+ npx jsonstat-io ./data.csv --from csvw \
108
+ --csvw-metadata '{"tableSchema":{"columns":[{"titles":"year","datatype":"string"},{"titles":"value","datatype":"decimal"}],"primaryKey":["year"]}}'
109
+ ```
110
+
111
+ ### IMPORT: JSON-stat round-trip (re-emit with canonical keys)
112
+
113
+ ```sh
114
+ npx jsonstat-io ./input.jsonstat --from jsonstat --canonical-keys -o canonical.jsonstat.json
115
+ ```
116
+
117
+ ### EXPORT: JSON-stat → Parquet
118
+
119
+ ```sh
120
+ npx jsonstat-io ./sales.jsonstat.json --to parquet -o sales.parquet
121
+ ```
122
+
123
+ ### EXPORT: JSON-stat → Arrow IPC
124
+
125
+ ```sh
126
+ npx jsonstat-io ./sales.jsonstat.json --to arrow -o sales.arrow
127
+ ```
128
+
129
+ ### EXPORT: JSON-stat → CSV (+ sibling CSVW metadata)
130
+
131
+ ```sh
132
+ npx jsonstat-io ./sales.jsonstat.json --to csv -o sales.csv
133
+ # writes sales.csv and sales-metadata.json
134
+ ```
135
+
136
+ ### EXPORT: JSON-stat → CSVW (with explicit CSVW metadata)
137
+
138
+ ```sh
139
+ npx jsonstat-io ./sales.jsonstat.json --to csvw -o sales.csvw
140
+ ```
141
+
142
+ ### Validate the output
143
+
144
+ ```sh
145
+ npm i -g jsonstat-validator
146
+ npx jsonstat-io ./sales.parquet --validate
147
+ ```
148
+
149
+ If `jsonstat-validator` is not installed, `--validate` prints a hint instead of failing.
150
+
151
+ ## How detection works
152
+
153
+ When `--from` is omitted (or `auto`), the CLI:
154
+
155
+ 1. Reads magic bytes: `PAR1` → Parquet, `ARROW1` → Arrow IPC.
156
+ 2. Falls back to the file extension: `.parquet`, `.arrow`/`.ipc`, `.csv`, `.json`/`.jsonstat`.
157
+ 3. For `.csv`, tries to load a sibling `*-metadata.json` (CSVW convention). If found, uses CSVW; otherwise plain CSV.
158
+
159
+ DuckDB and Polars require a live connection or DataFrame object and are **not** available via the CLI — use the programmatic API ([`docs/formats/duckdb.md`](./formats/duckdb.md), [`docs/formats/polars.md`](./formats/polars.md)).