@noy-db/as-xlsx 0.2.0-pre.21 → 0.2.0-pre.24

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/dist/index.d.cts CHANGED
@@ -154,6 +154,30 @@ interface ReadXlsxResult {
154
154
  */
155
155
  declare function readXlsx(bytes: Uint8Array): Promise<ReadXlsxResult>;
156
156
 
157
+ type InferredType = 'string' | 'number' | 'boolean' | 'date';
158
+ interface InferredField {
159
+ readonly type: InferredType;
160
+ /** Target collection (sheet) name when this field looks like a foreign key. */
161
+ readonly references?: string;
162
+ }
163
+ interface InferredCollection {
164
+ readonly idField: string;
165
+ readonly fields: Record<string, InferredField>;
166
+ }
167
+ interface InferredSchema {
168
+ readonly collections: Record<string, InferredCollection>;
169
+ }
170
+ /**
171
+ * Infer a {@link InferredSchema} from an `.xlsx` byte stream. Sheets whose name
172
+ * starts with `_` (our smart-export meta sheets) are skipped; pass `skipSheets`
173
+ * to exclude more (e.g. summary sheets).
174
+ */
175
+ declare function inferSchema(bytes: Uint8Array, options?: {
176
+ skipSheets?: readonly string[];
177
+ }): Promise<InferredSchema>;
178
+ /** Emit a Zod schema snippet (guidance) from an {@link InferredSchema}. */
179
+ declare function zodSourceFor(schema: InferredSchema): string;
180
+
157
181
  /**
158
182
  * **@noy-db/as-xlsx** — Excel spreadsheet plaintext export for noy-db.
159
183
  *
@@ -244,6 +268,45 @@ interface AsXlsxSheetOptions {
244
268
  * global LANG cell via `VLOOKUP(code, …, MATCH(LANG, …))`.
245
269
  */
246
270
  readonly dictFields?: Record<string, string>;
271
+ /**
272
+ * Multi-vault export only: pull FK-referenced fields from a supporting vault
273
+ * into this sheet as extra columns. Each entry resolves `localField` on the
274
+ * primary record against the `keyField` of another vault's collection (matched
275
+ * by `from.label`/`from.collection`), and emits the `pick` field value as
276
+ * `column`. Appended AFTER the declared `columns` in declaration order.
277
+ * Unresolved FK (row not in the supporting-vault index) → empty cell.
278
+ * Ignored by single-vault `toBytes`.
279
+ */
280
+ readonly denormalize?: readonly MultiVaultDenormColumn[];
281
+ }
282
+ /**
283
+ * One denormalized column in a multi-vault export: pulls a field from a
284
+ * supporting vault into the primary sheet via an in-memory join.
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * {
289
+ * column: 'entityName', // new column header in the primary sheet
290
+ * localField: 'entityId', // FK field on the primary record
291
+ * from: { label: 'directory', collection: 'entities', keyField: 'id', pick: 'name' },
292
+ * }
293
+ * ```
294
+ */
295
+ interface MultiVaultDenormColumn {
296
+ /** Header of the new column to append to the primary sheet. */
297
+ readonly column: string;
298
+ /** Field on the primary record whose value is the FK. */
299
+ readonly localField: string;
300
+ readonly from: {
301
+ /** Label of the supporting vault entry (matches `MultiVaultXlsxEntry.label`). */
302
+ readonly label: string;
303
+ /** Collection in the supporting vault. */
304
+ readonly collection: string;
305
+ /** Field in the supporting record that is the join key (usually `'id'`). */
306
+ readonly keyField: string;
307
+ /** Field in the supporting record to copy as the denorm value. */
308
+ readonly pick: string;
309
+ };
247
310
  }
248
311
  /** One aggregate column in a smart-mode summary sheet (#414 P3). */
249
312
  interface AsXlsxSummaryAggregate {
@@ -274,6 +337,13 @@ interface AsXlsxOptions {
274
337
  readonly sheets: readonly AsXlsxSheetOptions[];
275
338
  /** Smart mode only: groupBy summary sheets (live SUMIFS/COUNTIFS/AVERAGEIFS). */
276
339
  readonly summaries?: readonly AsXlsxSummarySpec[];
340
+ /**
341
+ * Smart mode only: summary formula dialect. `'excel'` (default) emits
342
+ * cross-compatible per-row SUMIFS/COUNTIFS/AVERAGEIFS. `'sheets'` emits a
343
+ * single Google-Sheets `QUERY` formula per summary — **Sheets-only** (QUERY
344
+ * errors in Excel); use when the target is Google Sheets.
345
+ */
346
+ readonly dialect?: 'excel' | 'sheets';
277
347
  /**
278
348
  * Smart-workbook mode (#414). Emits a relational workbook instead of a flat
279
349
  * dump:
@@ -320,6 +390,61 @@ declare function download(vault: Vault, options: AsXlsxDownloadOptions): Promise
320
390
  * plaintext xlsx persists past the process (Tier 3 egress).
321
391
  */
322
392
  declare function write(vault: Vault, path: string, options: AsXlsxWriteOptions): Promise<void>;
393
+ /**
394
+ * One vault entry in a multi-vault export. Supplies the pre-opened
395
+ * vault, the sheet specs to render for it, and an optional closure
396
+ * (per-collection id allowlist computed externally by the orchestrator).
397
+ */
398
+ interface MultiVaultXlsxEntry {
399
+ readonly vault: Vault;
400
+ readonly sheets: readonly AsXlsxSheetOptions[];
401
+ /**
402
+ * Optional per-collection id allowlist (e.g. from walkCrossVaultClosure).
403
+ * When set, only rows whose `id` appears in the set are exported for that
404
+ * collection. Omit to export all rows.
405
+ */
406
+ readonly closure?: ReadonlyMap<string, ReadonlySet<string>>;
407
+ /**
408
+ * Optional display label for sheet-name prefixing.
409
+ * Defaults to `vault.name`.
410
+ */
411
+ readonly label?: string;
412
+ }
413
+ /** Options for {@link toBytesMultiVault}. */
414
+ interface MultiVaultXlsxOptions {
415
+ /**
416
+ * Separator inserted between the vault label and the sheet name when
417
+ * building tab names. Default `'_'`. Names are then truncated to 31 chars
418
+ * (Excel limit) by `writeXlsx`.
419
+ */
420
+ readonly sheetSeparator?: string;
421
+ }
422
+ /**
423
+ * Build an `.xlsx` byte stream spanning **multiple vaults**. Each entry
424
+ * supplies a pre-opened vault with its sheet specs and an optional
425
+ * per-collection id-closure (rows filtered to exactly those ids). A
426
+ * `_manifest` sheet is prepended that lists every vault-collection pair
427
+ * and its exported record count.
428
+ *
429
+ * ## Auth
430
+ * Every vault in `entries` must independently hold `assertCanExport('plaintext','xlsx')`.
431
+ * The check fires fail-fast before any rows are materialised.
432
+ *
433
+ * ## Architecture
434
+ * This function is **edge-pure** — it takes pre-opened vaults and a
435
+ * pre-computed closure; it performs no cross-vault FK walk itself.
436
+ * Cross-vault orchestration lives in `@klum-db/lobby` (allowed direction).
437
+ *
438
+ * ## Two-pass execution (when `denormalize` is declared)
439
+ * **Pass 1** — load and closure-filter ALL entries' rows, building a
440
+ * `Map<"${label}/${collection}", Map<keyValue, row>>` index keyed by each
441
+ * sheet's `keyField` (default `'id'`). Only closure-filtered rows are indexed,
442
+ * so an FK pointing outside the closure yields an empty cell (correct: that
443
+ * row was not referenced / not exported).
444
+ * **Pass 2** — emit each sheet; for sheets with `denormalize`, append each
445
+ * denorm column AFTER the declared columns by resolving the index lookup.
446
+ */
447
+ declare function toBytesMultiVault(entries: readonly MultiVaultXlsxEntry[], options?: MultiVaultXlsxOptions): Promise<Uint8Array>;
323
448
 
324
449
  type ImportPolicy = 'merge' | 'replace' | 'insert-only';
325
450
  /**
@@ -372,6 +497,14 @@ interface AsXlsxImportOptions {
372
497
  * Unknown labels (no match in any locale) pass through as-is.
373
498
  */
374
499
  readonly dicts?: Readonly<Record<string, readonly DictEntry[]>>;
500
+ /**
501
+ * Read a sheet produced by smart export (#414 P4). Reverses the smart layout:
502
+ * reconstructs i18n fields from their per-locale columns (`<f>__<loc>` →
503
+ * `{ loc: value }`), and drops derived columns — the i18n display column and
504
+ * every `<f>__label` (FK/dict) formula column. Code columns (the real values)
505
+ * pass through. Maps onto the existing collection schema (Mode A).
506
+ */
507
+ readonly smart?: boolean;
375
508
  }
376
509
  interface AsXlsxImportPlan {
377
510
  readonly plan: VaultDiff;
@@ -402,4 +535,4 @@ interface AsXlsxImportPlan {
402
535
  */
403
536
  declare function fromBytes(vault: Vault, bytes: Uint8Array, options: AsXlsxImportOptions): Promise<AsXlsxImportPlan>;
404
537
 
405
- export { type AsXlsxDownloadOptions, type AsXlsxImportOptions, type AsXlsxImportPlan, type AsXlsxOptions, type AsXlsxSheetOptions, type AsXlsxSummaryAggregate, type AsXlsxSummarySpec, type AsXlsxWriteOptions, type ImportPolicy, type ReadXlsxResult, type ReadXlsxRow, type ReadXlsxSheet, XlsxDictAmbiguityError, type XlsxFormulaCell, type XlsxRow, type XlsxSheet, type XlsxStyledCell, type XlsxValidation, colLetter, download, formula, fromBytes, readXlsx, styled, toBytes, toBytesFromCollection, write, writeXlsx };
538
+ export { type AsXlsxDownloadOptions, type AsXlsxImportOptions, type AsXlsxImportPlan, type AsXlsxOptions, type AsXlsxSheetOptions, type AsXlsxSummaryAggregate, type AsXlsxSummarySpec, type AsXlsxWriteOptions, type ImportPolicy, type InferredCollection, type InferredField, type InferredSchema, type InferredType, type MultiVaultDenormColumn, type MultiVaultXlsxEntry, type MultiVaultXlsxOptions, type ReadXlsxResult, type ReadXlsxRow, type ReadXlsxSheet, XlsxDictAmbiguityError, type XlsxFormulaCell, type XlsxRow, type XlsxSheet, type XlsxStyledCell, type XlsxValidation, colLetter, download, formula, fromBytes, inferSchema, readXlsx, styled, toBytes, toBytesFromCollection, toBytesMultiVault, write, writeXlsx, zodSourceFor };
package/dist/index.d.ts CHANGED
@@ -154,6 +154,30 @@ interface ReadXlsxResult {
154
154
  */
155
155
  declare function readXlsx(bytes: Uint8Array): Promise<ReadXlsxResult>;
156
156
 
157
+ type InferredType = 'string' | 'number' | 'boolean' | 'date';
158
+ interface InferredField {
159
+ readonly type: InferredType;
160
+ /** Target collection (sheet) name when this field looks like a foreign key. */
161
+ readonly references?: string;
162
+ }
163
+ interface InferredCollection {
164
+ readonly idField: string;
165
+ readonly fields: Record<string, InferredField>;
166
+ }
167
+ interface InferredSchema {
168
+ readonly collections: Record<string, InferredCollection>;
169
+ }
170
+ /**
171
+ * Infer a {@link InferredSchema} from an `.xlsx` byte stream. Sheets whose name
172
+ * starts with `_` (our smart-export meta sheets) are skipped; pass `skipSheets`
173
+ * to exclude more (e.g. summary sheets).
174
+ */
175
+ declare function inferSchema(bytes: Uint8Array, options?: {
176
+ skipSheets?: readonly string[];
177
+ }): Promise<InferredSchema>;
178
+ /** Emit a Zod schema snippet (guidance) from an {@link InferredSchema}. */
179
+ declare function zodSourceFor(schema: InferredSchema): string;
180
+
157
181
  /**
158
182
  * **@noy-db/as-xlsx** — Excel spreadsheet plaintext export for noy-db.
159
183
  *
@@ -244,6 +268,45 @@ interface AsXlsxSheetOptions {
244
268
  * global LANG cell via `VLOOKUP(code, …, MATCH(LANG, …))`.
245
269
  */
246
270
  readonly dictFields?: Record<string, string>;
271
+ /**
272
+ * Multi-vault export only: pull FK-referenced fields from a supporting vault
273
+ * into this sheet as extra columns. Each entry resolves `localField` on the
274
+ * primary record against the `keyField` of another vault's collection (matched
275
+ * by `from.label`/`from.collection`), and emits the `pick` field value as
276
+ * `column`. Appended AFTER the declared `columns` in declaration order.
277
+ * Unresolved FK (row not in the supporting-vault index) → empty cell.
278
+ * Ignored by single-vault `toBytes`.
279
+ */
280
+ readonly denormalize?: readonly MultiVaultDenormColumn[];
281
+ }
282
+ /**
283
+ * One denormalized column in a multi-vault export: pulls a field from a
284
+ * supporting vault into the primary sheet via an in-memory join.
285
+ *
286
+ * @example
287
+ * ```ts
288
+ * {
289
+ * column: 'entityName', // new column header in the primary sheet
290
+ * localField: 'entityId', // FK field on the primary record
291
+ * from: { label: 'directory', collection: 'entities', keyField: 'id', pick: 'name' },
292
+ * }
293
+ * ```
294
+ */
295
+ interface MultiVaultDenormColumn {
296
+ /** Header of the new column to append to the primary sheet. */
297
+ readonly column: string;
298
+ /** Field on the primary record whose value is the FK. */
299
+ readonly localField: string;
300
+ readonly from: {
301
+ /** Label of the supporting vault entry (matches `MultiVaultXlsxEntry.label`). */
302
+ readonly label: string;
303
+ /** Collection in the supporting vault. */
304
+ readonly collection: string;
305
+ /** Field in the supporting record that is the join key (usually `'id'`). */
306
+ readonly keyField: string;
307
+ /** Field in the supporting record to copy as the denorm value. */
308
+ readonly pick: string;
309
+ };
247
310
  }
248
311
  /** One aggregate column in a smart-mode summary sheet (#414 P3). */
249
312
  interface AsXlsxSummaryAggregate {
@@ -274,6 +337,13 @@ interface AsXlsxOptions {
274
337
  readonly sheets: readonly AsXlsxSheetOptions[];
275
338
  /** Smart mode only: groupBy summary sheets (live SUMIFS/COUNTIFS/AVERAGEIFS). */
276
339
  readonly summaries?: readonly AsXlsxSummarySpec[];
340
+ /**
341
+ * Smart mode only: summary formula dialect. `'excel'` (default) emits
342
+ * cross-compatible per-row SUMIFS/COUNTIFS/AVERAGEIFS. `'sheets'` emits a
343
+ * single Google-Sheets `QUERY` formula per summary — **Sheets-only** (QUERY
344
+ * errors in Excel); use when the target is Google Sheets.
345
+ */
346
+ readonly dialect?: 'excel' | 'sheets';
277
347
  /**
278
348
  * Smart-workbook mode (#414). Emits a relational workbook instead of a flat
279
349
  * dump:
@@ -320,6 +390,61 @@ declare function download(vault: Vault, options: AsXlsxDownloadOptions): Promise
320
390
  * plaintext xlsx persists past the process (Tier 3 egress).
321
391
  */
322
392
  declare function write(vault: Vault, path: string, options: AsXlsxWriteOptions): Promise<void>;
393
+ /**
394
+ * One vault entry in a multi-vault export. Supplies the pre-opened
395
+ * vault, the sheet specs to render for it, and an optional closure
396
+ * (per-collection id allowlist computed externally by the orchestrator).
397
+ */
398
+ interface MultiVaultXlsxEntry {
399
+ readonly vault: Vault;
400
+ readonly sheets: readonly AsXlsxSheetOptions[];
401
+ /**
402
+ * Optional per-collection id allowlist (e.g. from walkCrossVaultClosure).
403
+ * When set, only rows whose `id` appears in the set are exported for that
404
+ * collection. Omit to export all rows.
405
+ */
406
+ readonly closure?: ReadonlyMap<string, ReadonlySet<string>>;
407
+ /**
408
+ * Optional display label for sheet-name prefixing.
409
+ * Defaults to `vault.name`.
410
+ */
411
+ readonly label?: string;
412
+ }
413
+ /** Options for {@link toBytesMultiVault}. */
414
+ interface MultiVaultXlsxOptions {
415
+ /**
416
+ * Separator inserted between the vault label and the sheet name when
417
+ * building tab names. Default `'_'`. Names are then truncated to 31 chars
418
+ * (Excel limit) by `writeXlsx`.
419
+ */
420
+ readonly sheetSeparator?: string;
421
+ }
422
+ /**
423
+ * Build an `.xlsx` byte stream spanning **multiple vaults**. Each entry
424
+ * supplies a pre-opened vault with its sheet specs and an optional
425
+ * per-collection id-closure (rows filtered to exactly those ids). A
426
+ * `_manifest` sheet is prepended that lists every vault-collection pair
427
+ * and its exported record count.
428
+ *
429
+ * ## Auth
430
+ * Every vault in `entries` must independently hold `assertCanExport('plaintext','xlsx')`.
431
+ * The check fires fail-fast before any rows are materialised.
432
+ *
433
+ * ## Architecture
434
+ * This function is **edge-pure** — it takes pre-opened vaults and a
435
+ * pre-computed closure; it performs no cross-vault FK walk itself.
436
+ * Cross-vault orchestration lives in `@klum-db/lobby` (allowed direction).
437
+ *
438
+ * ## Two-pass execution (when `denormalize` is declared)
439
+ * **Pass 1** — load and closure-filter ALL entries' rows, building a
440
+ * `Map<"${label}/${collection}", Map<keyValue, row>>` index keyed by each
441
+ * sheet's `keyField` (default `'id'`). Only closure-filtered rows are indexed,
442
+ * so an FK pointing outside the closure yields an empty cell (correct: that
443
+ * row was not referenced / not exported).
444
+ * **Pass 2** — emit each sheet; for sheets with `denormalize`, append each
445
+ * denorm column AFTER the declared columns by resolving the index lookup.
446
+ */
447
+ declare function toBytesMultiVault(entries: readonly MultiVaultXlsxEntry[], options?: MultiVaultXlsxOptions): Promise<Uint8Array>;
323
448
 
324
449
  type ImportPolicy = 'merge' | 'replace' | 'insert-only';
325
450
  /**
@@ -372,6 +497,14 @@ interface AsXlsxImportOptions {
372
497
  * Unknown labels (no match in any locale) pass through as-is.
373
498
  */
374
499
  readonly dicts?: Readonly<Record<string, readonly DictEntry[]>>;
500
+ /**
501
+ * Read a sheet produced by smart export (#414 P4). Reverses the smart layout:
502
+ * reconstructs i18n fields from their per-locale columns (`<f>__<loc>` →
503
+ * `{ loc: value }`), and drops derived columns — the i18n display column and
504
+ * every `<f>__label` (FK/dict) formula column. Code columns (the real values)
505
+ * pass through. Maps onto the existing collection schema (Mode A).
506
+ */
507
+ readonly smart?: boolean;
375
508
  }
376
509
  interface AsXlsxImportPlan {
377
510
  readonly plan: VaultDiff;
@@ -402,4 +535,4 @@ interface AsXlsxImportPlan {
402
535
  */
403
536
  declare function fromBytes(vault: Vault, bytes: Uint8Array, options: AsXlsxImportOptions): Promise<AsXlsxImportPlan>;
404
537
 
405
- export { type AsXlsxDownloadOptions, type AsXlsxImportOptions, type AsXlsxImportPlan, type AsXlsxOptions, type AsXlsxSheetOptions, type AsXlsxSummaryAggregate, type AsXlsxSummarySpec, type AsXlsxWriteOptions, type ImportPolicy, type ReadXlsxResult, type ReadXlsxRow, type ReadXlsxSheet, XlsxDictAmbiguityError, type XlsxFormulaCell, type XlsxRow, type XlsxSheet, type XlsxStyledCell, type XlsxValidation, colLetter, download, formula, fromBytes, readXlsx, styled, toBytes, toBytesFromCollection, write, writeXlsx };
538
+ export { type AsXlsxDownloadOptions, type AsXlsxImportOptions, type AsXlsxImportPlan, type AsXlsxOptions, type AsXlsxSheetOptions, type AsXlsxSummaryAggregate, type AsXlsxSummarySpec, type AsXlsxWriteOptions, type ImportPolicy, type InferredCollection, type InferredField, type InferredSchema, type InferredType, type MultiVaultDenormColumn, type MultiVaultXlsxEntry, type MultiVaultXlsxOptions, type ReadXlsxResult, type ReadXlsxRow, type ReadXlsxSheet, XlsxDictAmbiguityError, type XlsxFormulaCell, type XlsxRow, type XlsxSheet, type XlsxStyledCell, type XlsxValidation, colLetter, download, formula, fromBytes, inferSchema, readXlsx, styled, toBytes, toBytesFromCollection, toBytesMultiVault, write, writeXlsx, zodSourceFor };
package/dist/index.js CHANGED
@@ -419,6 +419,92 @@ function stringAttr(raw) {
419
419
  return "";
420
420
  }
421
421
 
422
+ // src/infer.ts
423
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/;
424
+ function inferType(vals) {
425
+ if (vals.length === 0) return "string";
426
+ if (vals.every((v) => typeof v === "boolean")) return "boolean";
427
+ if (vals.every((v) => typeof v === "number")) return "number";
428
+ if (vals.every((v) => typeof v === "string" && ISO_DATE.test(v))) return "date";
429
+ return "string";
430
+ }
431
+ async function inferSchema(bytes, options = {}) {
432
+ const skip = new Set(options.skipSheets ?? []);
433
+ const decoded = await readXlsx(bytes);
434
+ const sheets = [];
435
+ for (const sh of decoded.sheets) {
436
+ if (sh.name.startsWith("_") || skip.has(sh.name)) continue;
437
+ const headerRow = sh.rows[0] ?? {};
438
+ const colToField = /* @__PURE__ */ new Map();
439
+ const fields = [];
440
+ for (const [letter, val] of Object.entries(headerRow)) {
441
+ const name = typeof val === "string" ? val.trim() : typeof val === "number" || typeof val === "boolean" ? String(val) : "";
442
+ if (!name) continue;
443
+ colToField.set(letter, name);
444
+ fields.push(name);
445
+ }
446
+ const records = sh.rows.slice(1).map((row) => {
447
+ const rec = {};
448
+ for (const [letter, val] of Object.entries(row)) {
449
+ const f = colToField.get(letter);
450
+ if (f !== void 0) rec[f] = val;
451
+ }
452
+ return rec;
453
+ });
454
+ sheets.push({ name: sh.name, fields, records });
455
+ }
456
+ const valuesOf = (s, f) => s.records.map((r) => r[f]).filter((v) => v != null && v !== "");
457
+ const idFieldOf = (s) => {
458
+ if (s.fields.includes("id")) return "id";
459
+ for (const f of s.fields) {
460
+ const vals = valuesOf(s, f);
461
+ if (vals.length > 0 && new Set(vals.map((v) => String(v))).size === vals.length) return f;
462
+ }
463
+ return s.fields[0] ?? "id";
464
+ };
465
+ const meta = sheets.map((s) => ({ s, idField: idFieldOf(s) }));
466
+ const idValuesByName = /* @__PURE__ */ new Map();
467
+ for (const { s, idField } of meta) {
468
+ idValuesByName.set(s.name, new Set(valuesOf(s, idField).map((v) => String(v))));
469
+ }
470
+ const collections = {};
471
+ for (const { s, idField } of meta) {
472
+ const fields = {};
473
+ for (const f of s.fields) {
474
+ const vals = valuesOf(s, f);
475
+ const type = inferType(vals);
476
+ let references;
477
+ if (f !== idField && type === "string" && vals.length > 0) {
478
+ const distinct = new Set(vals.map((v) => String(v)));
479
+ for (const { s: other } of meta) {
480
+ if (other.name === s.name) continue;
481
+ const ids = idValuesByName.get(other.name);
482
+ if (ids && ids.size > 0 && [...distinct].every((v) => ids.has(v))) {
483
+ references = other.name;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ fields[f] = references ? { type, references } : { type };
489
+ }
490
+ collections[s.name] = { idField, fields };
491
+ }
492
+ return { collections };
493
+ }
494
+ function zodSourceFor(schema) {
495
+ const zType = (t) => t === "number" ? "z.number()" : t === "boolean" ? "z.boolean()" : t === "date" ? "z.string().datetime()" : "z.string()";
496
+ const blocks = [`import { z } from 'zod'`];
497
+ for (const [name, c] of Object.entries(schema.collections)) {
498
+ const lines = Object.entries(c.fields).map(
499
+ ([f, d]) => ` ${f}: ${zType(d.type)},${d.references ? ` // \u2192 ${d.references}` : ""}`
500
+ );
501
+ blocks.push(`export const ${name}Schema = z.object({
502
+ ${lines.join("\n")}
503
+ })`);
504
+ }
505
+ return blocks.join("\n\n");
506
+ }
507
+
422
508
  // src/index.ts
423
509
  import { diffVault } from "@noy-db/hub";
424
510
  async function toBytesFromCollection(vault, collectionName) {
@@ -478,6 +564,95 @@ async function write(vault, path, options) {
478
564
  const { writeFile } = await import("fs/promises");
479
565
  await writeFile(path, bytes);
480
566
  }
567
+ async function toBytesMultiVault(entries, options = {}) {
568
+ const sep = options.sheetSeparator ?? "_";
569
+ for (const entry of entries) {
570
+ entry.vault.assertCanExport("plaintext", "xlsx");
571
+ }
572
+ const loaded = [];
573
+ const denormIndex = /* @__PURE__ */ new Map();
574
+ for (const entry of entries) {
575
+ const prefix = entry.label ?? entry.vault.name;
576
+ for (const s of entry.sheets) {
577
+ const all = await entry.vault.collection(s.collection).list();
578
+ const records = [];
579
+ for (const item of all) {
580
+ const r = item;
581
+ const allow = entry.closure?.get(s.collection);
582
+ if (allow && !allow.has(String(r.id))) continue;
583
+ if (s.filter && !s.filter(r)) continue;
584
+ records.push(r);
585
+ }
586
+ loaded.push({ entry, prefix, sheetOpt: s, records });
587
+ const indexKey = `${prefix}/${s.collection}`;
588
+ const collectionIndex = denormIndex.get(indexKey) ?? /* @__PURE__ */ new Map();
589
+ denormIndex.set(indexKey, collectionIndex);
590
+ for (const r of records) {
591
+ const idVal = r.id;
592
+ if (idVal != null) collectionIndex.set(safeStringify(idVal), r);
593
+ }
594
+ }
595
+ }
596
+ for (const ls of loaded) {
597
+ for (const d of ls.sheetOpt.denormalize ?? []) {
598
+ if (d.from.keyField === "id") continue;
599
+ const indexKey = `${d.from.label}/${d.from.collection}`;
600
+ const idx = denormIndex.get(indexKey);
601
+ if (!idx) continue;
602
+ const srcLoaded = loaded.find(
603
+ (l) => l.prefix === d.from.label && l.sheetOpt.collection === d.from.collection
604
+ );
605
+ if (!srcLoaded) continue;
606
+ for (const r of srcLoaded.records) {
607
+ const kv = r[d.from.keyField];
608
+ if (kv != null) idx.set(safeStringify(kv), r);
609
+ }
610
+ }
611
+ }
612
+ const allSheets = [];
613
+ const manifestRows = [];
614
+ for (const { prefix, sheetOpt: s, records } of loaded) {
615
+ const baseColumns = s.columns ?? inferColumns(records);
616
+ const denormDefs = s.denormalize ?? [];
617
+ if (denormDefs.length === 0) {
618
+ const sheetName2 = `${prefix}${sep}${s.name}`;
619
+ allSheets.push(buildFlatSheet(sheetName2, baseColumns, records));
620
+ manifestRows.push([prefix, s.collection, records.length]);
621
+ continue;
622
+ }
623
+ const allColumns = [...baseColumns, ...denormDefs.map((d) => d.column)];
624
+ const sheetName = `${prefix}${sep}${s.name}`;
625
+ const rows = records.map((r) => {
626
+ const baseCells = baseColumns.map((c) => r[c] ?? null);
627
+ const denormCells = denormDefs.map((d) => {
628
+ const idxKey = `${d.from.label}/${d.from.collection}`;
629
+ const idx = denormIndex.get(idxKey);
630
+ if (!idx) return "";
631
+ const fkVal = r[d.localField];
632
+ if (fkVal == null) return "";
633
+ const supporting = idx.get(safeStringify(fkVal));
634
+ if (!supporting) return "";
635
+ return supporting[d.from.pick] ?? "";
636
+ });
637
+ return [...baseCells, ...denormCells];
638
+ });
639
+ allSheets.push({ name: sheetName, header: allColumns, rows });
640
+ manifestRows.push([prefix, s.collection, records.length]);
641
+ }
642
+ allSheets.unshift({
643
+ name: "_manifest",
644
+ header: ["Vault", "Collection", "Records"],
645
+ rows: manifestRows
646
+ });
647
+ return writeXlsx(allSheets);
648
+ }
649
+ function buildFlatSheet(name, columns, records) {
650
+ return {
651
+ name,
652
+ header: [...columns],
653
+ rows: records.map((r) => columns.map((c) => r[c] ?? null))
654
+ };
655
+ }
481
656
  function safeStringify(v) {
482
657
  if (typeof v === "string") return v;
483
658
  if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
@@ -636,6 +811,7 @@ async function buildSmartSheets(vault, options) {
636
811
  return [m.opt.name, m.records.length, Object.entries(refs).map(([f, r]) => `${f}\u2192${r.target}`).join(", ")];
637
812
  })
638
813
  };
814
+ const dialect = options.dialect ?? "excel";
639
815
  const summarySheets = [];
640
816
  for (const spec of options.summaries ?? []) {
641
817
  const src = sheetMeta.get(spec.from);
@@ -643,6 +819,26 @@ async function buildSmartSheets(vault, options) {
643
819
  const gCol = src?.colIndex.get(spec.groupBy);
644
820
  if (!src || !srcMat || gCol === void 0) continue;
645
821
  const gLetter = colLetter(gCol);
822
+ if (dialect === "sheets") {
823
+ const selects = [];
824
+ const labels = [];
825
+ for (const a of spec.aggregates) {
826
+ if (a.op === "count") {
827
+ selects.push(`COUNT(${gLetter})`);
828
+ labels.push(`COUNT(${gLetter}) '${a.label}'`);
829
+ continue;
830
+ }
831
+ const vIdx = a.field ? src.colIndex.get(a.field) : void 0;
832
+ if (vIdx === void 0) continue;
833
+ const vL = colLetter(vIdx);
834
+ const fn = a.op === "sum" ? "SUM" : "AVG";
835
+ selects.push(`${fn}(${vL})`);
836
+ labels.push(`${fn}(${vL}) '${a.label}'`);
837
+ }
838
+ const q = `QUERY('${src.name}'!A:ZZ, "SELECT ${gLetter}, ${selects.join(", ")} GROUP BY ${gLetter} LABEL ${labels.join(", ")}", 1)`;
839
+ summarySheets.push({ name: spec.name, rows: [[formula(q)]] });
840
+ continue;
841
+ }
646
842
  const seen = /* @__PURE__ */ new Set();
647
843
  const groups = [];
648
844
  for (const r of srcMat.records) {
@@ -772,6 +968,13 @@ async function fromBytes(vault, bytes, options) {
772
968
  } catch {
773
969
  }
774
970
  }
971
+ const i18nBases = /* @__PURE__ */ new Set();
972
+ if (options.smart) {
973
+ for (const field of colToField.values()) {
974
+ const m = /^(.+)__(.+)$/.exec(field);
975
+ if (m && m[2] !== "label") i18nBases.add(m[1]);
976
+ }
977
+ }
775
978
  const records = [];
776
979
  for (let i = headerRowIdx + 1; i < allRows.length; i++) {
777
980
  const row = allRows[i];
@@ -780,6 +983,22 @@ async function fromBytes(vault, bytes, options) {
780
983
  for (const [col, value] of Object.entries(row)) {
781
984
  const field = colToField.get(col);
782
985
  if (field === void 0) continue;
986
+ if (options.smart) {
987
+ if (field.endsWith("__label")) continue;
988
+ const lm = /^(.+)__(.+)$/.exec(field);
989
+ if (lm && lm[2] !== "label" && i18nBases.has(lm[1])) {
990
+ const base = lm[1];
991
+ const coerced2 = coerceXlsxCell(value, types[base]);
992
+ if (coerced2 !== void 0 && coerced2 !== "") {
993
+ const map = record[base] ?? {};
994
+ map[lm[2]] = coerced2;
995
+ record[base] = map;
996
+ hasAny = true;
997
+ }
998
+ continue;
999
+ }
1000
+ if (i18nBases.has(field)) continue;
1001
+ }
783
1002
  const coerced = coerceXlsxCell(value, types[field]);
784
1003
  if (coerced !== void 0) {
785
1004
  const invMap = invertMaps.get(field);
@@ -888,11 +1107,14 @@ export {
888
1107
  download,
889
1108
  formula,
890
1109
  fromBytes,
1110
+ inferSchema,
891
1111
  readXlsx,
892
1112
  styled,
893
1113
  toBytes,
894
1114
  toBytesFromCollection,
1115
+ toBytesMultiVault,
895
1116
  write,
896
- writeXlsx
1117
+ writeXlsx,
1118
+ zodSourceFor
897
1119
  };
898
1120
  //# sourceMappingURL=index.js.map