@noy-db/hub 0.7.0-pre.11 → 0.7.0-pre.13

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 (98) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/as/index.js +3 -2
  3. package/dist/as/index.js.map +1 -1
  4. package/dist/{backup-VASRVLDZ.js → backup-R6A4VZRX.js} +4 -4
  5. package/dist/cargo/index.js +2 -2
  6. package/dist/chunk-66TQOPFM.js +37 -0
  7. package/dist/chunk-66TQOPFM.js.map +1 -0
  8. package/dist/{chunk-OEA5NVSU.js → chunk-AJQY44CE.js} +86 -6
  9. package/dist/chunk-AJQY44CE.js.map +1 -0
  10. package/dist/{chunk-S5AMPTNE.js → chunk-BMELYFHU.js} +2 -2
  11. package/dist/{chunk-T6PSWLAR.js → chunk-BUE2D7PX.js} +25 -2
  12. package/dist/chunk-BUE2D7PX.js.map +1 -0
  13. package/dist/chunk-BWXGSSFX.js +1589 -0
  14. package/dist/chunk-BWXGSSFX.js.map +1 -0
  15. package/dist/{chunk-HN4FNA4X.js → chunk-COTPDJR5.js} +8 -8
  16. package/dist/{chunk-E3EBMC4G.js → chunk-E2MYJGSG.js} +2 -2
  17. package/dist/{chunk-JJR2JIN4.js → chunk-FFBYRWDJ.js} +2 -2
  18. package/dist/{chunk-WQF3QABE.js → chunk-IHFXR2VQ.js} +9 -79
  19. package/dist/chunk-IHFXR2VQ.js.map +1 -0
  20. package/dist/chunk-MGOXJIBV.js +176 -0
  21. package/dist/chunk-MGOXJIBV.js.map +1 -0
  22. package/dist/chunk-PDD75NUZ.js +87 -0
  23. package/dist/chunk-PDD75NUZ.js.map +1 -0
  24. package/dist/chunk-RTBJ5IOO.js +87 -0
  25. package/dist/chunk-RTBJ5IOO.js.map +1 -0
  26. package/dist/chunk-RXLYKVTF.js +17 -0
  27. package/dist/chunk-RXLYKVTF.js.map +1 -0
  28. package/dist/{chunk-ESVREVWN.js → chunk-SXCYYMVS.js} +2 -2
  29. package/dist/chunk-TFMM5DBB.js +20 -0
  30. package/dist/chunk-TFMM5DBB.js.map +1 -0
  31. package/dist/chunk-TPHAGLUH.js +30 -0
  32. package/dist/chunk-TPHAGLUH.js.map +1 -0
  33. package/dist/{chunk-THNJ3IKU.js → chunk-ULUMAZ5S.js} +2 -2
  34. package/dist/{chunk-NO272T7Q.js → chunk-V4PXLYWM.js} +2 -31
  35. package/dist/chunk-V4PXLYWM.js.map +1 -0
  36. package/dist/{chunk-A5WZEPXJ.js → chunk-VGICZPVE.js} +5 -3
  37. package/dist/{chunk-A5WZEPXJ.js.map → chunk-VGICZPVE.js.map} +1 -1
  38. package/dist/{chunk-KHKKD62N.js → chunk-VYYM3MEN.js} +2 -16
  39. package/dist/chunk-VYYM3MEN.js.map +1 -0
  40. package/dist/{chunk-SWVNZQVR.js → chunk-YPXNAP72.js} +2 -169
  41. package/dist/chunk-YPXNAP72.js.map +1 -0
  42. package/dist/{chunk-I6DGKPTY.js → chunk-ZVCC36XQ.js} +101 -1273
  43. package/dist/chunk-ZVCC36XQ.js.map +1 -0
  44. package/dist/{collection-facade-MGGHQ3HU.js → collection-facade-4ACKIDDY.js} +4 -3
  45. package/dist/derivations/index.js +21 -6
  46. package/dist/dispatch-MSTR5VBI.js +255 -0
  47. package/dist/dispatch-MSTR5VBI.js.map +1 -0
  48. package/dist/{dispatch-44J4TL72.js → dispatch-PDP5TA73.js} +5 -5
  49. package/dist/{executor-KSPGNVDO.js → executor-YDDC5XGY.js} +4 -3
  50. package/dist/index.d.ts +2 -2
  51. package/dist/index.js +39 -35
  52. package/dist/index.js.map +1 -1
  53. package/dist/introspection/index.js +3 -2
  54. package/dist/kernel/collection.d.ts +11 -6
  55. package/dist/kernel/match-pairs.d.ts +30 -0
  56. package/dist/lazy/index.js +3 -2
  57. package/dist/materialized-views/index.js +4 -3
  58. package/dist/{noydb-QJFZAH34.js → noydb-2C6QQJ5S.js} +17 -12
  59. package/dist/pod/index.js +17 -12
  60. package/dist/registry-D2YXKRYC.js +33 -0
  61. package/dist/search/index.js +1 -1
  62. package/dist/{stale-KSPG6LFX.js → stale-2CHKZTMK.js} +2 -2
  63. package/dist/{walk-G3CZIL4L.js → walk-P3E6QSNC.js} +4 -3
  64. package/dist/with-formula/derivations/dispatch.d.ts +16 -1
  65. package/dist/with-formula/derivations/registry.d.ts +20 -6
  66. package/dist/with-formula/derivations/trigger-match.d.ts +52 -0
  67. package/dist/with-formula/derivations/types.d.ts +23 -0
  68. package/dist/with-shape/introspection/behaviors.d.ts +7 -0
  69. package/dist/with-shape/introspection/describe.d.ts +30 -0
  70. package/package.json +3 -3
  71. package/dist/chunk-I6DGKPTY.js.map +0 -1
  72. package/dist/chunk-KHKKD62N.js.map +0 -1
  73. package/dist/chunk-NO272T7Q.js.map +0 -1
  74. package/dist/chunk-OEA5NVSU.js.map +0 -1
  75. package/dist/chunk-SWVNZQVR.js.map +0 -1
  76. package/dist/chunk-T6PSWLAR.js.map +0 -1
  77. package/dist/chunk-UB5CRTFF.js +0 -344
  78. package/dist/chunk-UB5CRTFF.js.map +0 -1
  79. package/dist/chunk-WQF3QABE.js.map +0 -1
  80. package/dist/chunk-YPMW26SL.js +0 -81
  81. package/dist/chunk-YPMW26SL.js.map +0 -1
  82. package/dist/dispatch-IM52HDXT.js +0 -194
  83. package/dist/dispatch-IM52HDXT.js.map +0 -1
  84. package/dist/registry-FZXPQRAB.js +0 -18
  85. /package/dist/{backup-VASRVLDZ.js.map → backup-R6A4VZRX.js.map} +0 -0
  86. /package/dist/{chunk-S5AMPTNE.js.map → chunk-BMELYFHU.js.map} +0 -0
  87. /package/dist/{chunk-HN4FNA4X.js.map → chunk-COTPDJR5.js.map} +0 -0
  88. /package/dist/{chunk-E3EBMC4G.js.map → chunk-E2MYJGSG.js.map} +0 -0
  89. /package/dist/{chunk-JJR2JIN4.js.map → chunk-FFBYRWDJ.js.map} +0 -0
  90. /package/dist/{chunk-ESVREVWN.js.map → chunk-SXCYYMVS.js.map} +0 -0
  91. /package/dist/{chunk-THNJ3IKU.js.map → chunk-ULUMAZ5S.js.map} +0 -0
  92. /package/dist/{collection-facade-MGGHQ3HU.js.map → collection-facade-4ACKIDDY.js.map} +0 -0
  93. /package/dist/{dispatch-44J4TL72.js.map → dispatch-PDP5TA73.js.map} +0 -0
  94. /package/dist/{executor-KSPGNVDO.js.map → executor-YDDC5XGY.js.map} +0 -0
  95. /package/dist/{noydb-QJFZAH34.js.map → noydb-2C6QQJ5S.js.map} +0 -0
  96. /package/dist/{registry-FZXPQRAB.js.map → registry-D2YXKRYC.js.map} +0 -0
  97. /package/dist/{stale-KSPG6LFX.js.map → stale-2CHKZTMK.js.map} +0 -0
  98. /package/dist/{walk-G3CZIL4L.js.map → walk-P3E6QSNC.js.map} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,147 @@
1
1
  # Changelog — hub
2
2
 
3
+ ## 0.7.0-pre.13
4
+
5
+ ### Patch Changes
6
+
7
+ - A FAILED non-strict lazy re-derive no longer clears the stale flag (#1258).
8
+
9
+ `resolveStaleOnRead` consumes the pending flag before reading the source (a
10
+ recursion guard for self-write outputs). A STRICT failure throws and the catch
11
+ restores it; a NON-STRICT failure warned and continued, leaving the flag
12
+ consumed — so the record was served as fresh and never retried, permanently,
13
+ because nothing would mark it stale again.
14
+
15
+ The decision this needed was what `strict: false` means. It means "a failed
16
+ derivation must not break the read", NOT "report this output as current". The
17
+ record is still served, the warning still fires, and the flag now survives so
18
+ the next read retries — strict-mode behaviour minus the throw.
19
+
20
+ Deliberate cost, stated so it is not later mistaken for a bug: a
21
+ permanently-failing non-strict derivation now re-runs on every read of that id
22
+ rather than once. That is louder and more expensive than silently serving a
23
+ stale value forever, and it is the trade made everywhere else here — a degraded
24
+ state must not render as a healthy one.
25
+
26
+ - A rollup child that RE-PARENTS now recomputes the old parent as well as the new
27
+ one (#1257).
28
+
29
+ The dispatcher read the rollup key from the incoming record only, so moving a
30
+ child from parent A to parent B recomputed B and left A's aggregate holding a
31
+ number that was correct before the move. That is the dangerous shape: a stale
32
+ total reads as data, not as an error, so nothing prompts anyone to re-check it.
33
+
34
+ Same old-value class as the `triggerBy` update fan-out fixed in #1249 — and it
35
+ is a small change rather than a new mechanism only because that fix added
36
+ prior-record capture to the write path, which this reuses.
37
+
38
+ Consistent with the rest of the derivation surface: a write that does not thread
39
+ a prior record (a sync-applied wave write, a tiers restore) degrades to
40
+ new-parent-only, the previous behaviour, rather than guessing at the old key.
41
+
42
+ - `schemaFieldKeys` now unwraps `z.preprocess()` on both Zod majors, so the two
43
+ field-typo guards (#1253's `fieldMeta`, #1249's `triggerBy` match) see through
44
+ it (#1262, reported by the pilot).
45
+
46
+ The `0.7.0-pre.12` fix followed refinement effects only and put `preprocess` on
47
+ the carve-out side with `transform`. That was wrong: `z.preprocess(fn, inner)`
48
+ rewrites the INPUT and then parses with `inner`, so the parsed record's keys ARE
49
+ `inner`'s keys — measured, `preprocess({a,b})` parses to `['a','b']` while
50
+ `transform` parses to `['c']`.
51
+
52
+ Scope, measured rather than assumed: this left the **sync** `describe()` path
53
+ unguarded for wrapped schemas. The async path was never affected —
54
+ `derivePersistedSchema` sees through `preprocess` on its own, so
55
+ `buildDescription` gets a populated field map and never reaches the
56
+ `schemaFieldKeys` fallback. The sync path is public, is what tooling reaches
57
+ for first, and is the only path some Zod 3 consumers can reach at all (the
58
+ async path needs the `zod-to-json-schema` peer), so the gap was real — but it
59
+ was one path, not both.
60
+
61
+ Replaced the effect-kind allowlist with one rule that covers every wrapper on
62
+ both majors: **follow the output side.** These keys describe the parsed record,
63
+ so the only question a wrapper raises is whether it changes the parsed shape.
64
+ Zod 3 `ZodEffects` follows `_def.schema` for `refinement` and `preprocess`;
65
+ Zod 4 wraps both `z.preprocess()` and `.transform()` as a `ZodPipe` and
66
+ following `_def.out` resolves them with no effect-kind test at all — preprocess
67
+ reaches the object, `.transform()` reaches a shapeless `ZodTransform` and stays
68
+ silent on its own. **This also fixes Zod 4 `z.preprocess()`, which the narrower
69
+ one-line widening would have missed.**
70
+
71
+ `.transform()` remains deliberately silent on both majors: it replaces the
72
+ output, so the inner keys would describe a record that is never stored. The two
73
+ are one string apart and mean opposite things, so tests pin them against each
74
+ other on both majors.
75
+
76
+ - `triggerBy` match targets are now checked against where the field actually
77
+ LIVES, not just whether it is declared (#1266, reported by the pilot).
78
+
79
+ A derivation matcher reads STORED records. A `mode: 'virtual'` computed field is
80
+ evaluated on the read path and never persisted — but it appears in `computed:`
81
+ (and in `via(computed(...))`) exactly like a materialized one, so registration
82
+ accepted it and the fan-out then matched nothing, forever. That is the precise
83
+ failure the #1249 match guard exists to prevent, reached THROUGH the guard
84
+ rather than around it, and it is the first thing a consumer reaches for when a
85
+ match target is not already stored.
86
+
87
+ Refused at registration rather than supported: matching a virtual field means
88
+ running user code for every candidate row, turning an indexed narrow into a full
89
+ scan. `mode: 'materialized'` is stored, already works, and is what the error
90
+ names. Both sides are refused — `to` reads the source record and `from` reads
91
+ the written record, and both are the stored shape. (The report covered `to`;
92
+ `from` had the identical defect.)
93
+
94
+ The virtual check runs even when the schema's field list is unreadable, unlike
95
+ the typo guard beside it. The typo guard needs a field list to compare against
96
+ and stays silent without one; this one does not — "declared, but never stored"
97
+ is provable from the declaration alone.
98
+
99
+ Second defect fixed in the same change: `viaFields` was missing from the guard's
100
+ key set entirely, so a `via()`-declared MATERIALIZED field — a perfectly valid
101
+ match target — was rejected as an undeclared typo. A guard that refuses valid
102
+ configurations is how people learn to stop trusting it, so both directions ship
103
+ together, each with a test that fails without its half of the fix.
104
+
105
+ ## 0.7.0-pre.12
106
+
107
+ ### Minor Changes
108
+
109
+ - `withDerivation`'s `triggerBy` accepts a multi-field `match` form (#1249):
110
+ `{ collection, match: [{ from, to }] }` fans a write out to every source
111
+ record where ALL pairs satisfy `String(source[to]) === String(written[from])`.
112
+ `from: 'id'` reads the written record's id, making the existing `on` form the
113
+ single-pair special case (it is unchanged and stays supported). This makes
114
+ shared-key ("reverse") relationships and composite keys like
115
+ `(clientId, cycle)` expressible without denormalising a synthetic key.
116
+
117
+ Also, for BOTH forms:
118
+
119
+ - a LOCAL UPDATE that changes any matched field fans out on old-match ∪
120
+ new-match, so records addressed by the previous value no longer go
121
+ silently stale; a sync-applied wave write or a tiers restore, which don't
122
+ thread the prior record, fan out on the new tuple only;
123
+ - a parent DELETE now fans out using the tombstoned record's values —
124
+ previously deletes fired no triggers at all, leaving matched sources
125
+ stale. The fan-out runs after the delete commits, so cap/strict errors
126
+ can surface from `delete()` (same as the existing rollup-on-delete
127
+ precedent);
128
+ - match fields are validated against the collections' enumerable field sets at
129
+ `vault.collection()` (the #1253 pattern): a provable typo throws instead of
130
+ silently matching nothing forever; TS-generic collections stay unguarded by
131
+ design.
132
+
133
+ `maxFanout` caps the unioned matched set per written event.
134
+
135
+ ### Patch Changes
136
+
137
+ - `schemaFieldKeys` now unwraps Zod 3 `ZodEffects` created by object-level
138
+ `.refine()`/`.superRefine()` (reported by the pilot): the wrapper hides
139
+ `.shape`, so both field-typo guards — #1253's `fieldMeta` validation and
140
+ #1249's `triggerBy` match validation — were silent for exactly the schemas
141
+ most worth guarding. Unwrapping follows refinement effects only: a
142
+ `.transform()`/`.preprocess()` changes the output shape, so those stay
143
+ silent by design. Zod 4 keeps `.shape` through `.refine()` and is unaffected.
144
+
3
145
  ## 0.7.0-pre.11
4
146
 
5
147
  ### Patch Changes
package/dist/as/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import "../chunk-UBSFPGVJ.js";
2
- import "../chunk-A5WZEPXJ.js";
2
+ import "../chunk-VGICZPVE.js";
3
3
  import {
4
4
  applyListProjection
5
5
  } from "../chunk-VG2ZSFYD.js";
6
- import "../chunk-KHKKD62N.js";
6
+ import "../chunk-RXLYKVTF.js";
7
+ import "../chunk-VYYM3MEN.js";
7
8
  import "../chunk-URHRIAV6.js";
8
9
  import "../chunk-Y2NBBT6K.js";
9
10
  import "../chunk-WWNYSV7S.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/port/as/active.ts"],"sourcesContent":["/**\n * `withFormats()` — the service that consumes the `as-*` port.\n *\n * Everything with a correctness or security consequence lives here rather\n * than in each format: the capability gate, the vault read, and redaction.\n * A {@link NoydbFormat} receives records and returns bytes.\n *\n * That is the whole point of the inversion (ADR 0004). The conformance kit it\n * replaces could only check implementors who ran it; a format that cannot\n * reach a vault is gated whether its author cooperated or not.\n *\n * @module\n */\nimport type { NoydbFormat, ImportPlan } from './types.js'\nimport type {\n FormatsStrategy,\n FormatsContext,\n FormatExportOptions,\n FormatImportOptions,\n} from '../with/formats-strategy.js'\nimport type { ExportChunk } from '../../kernel/types.js'\nimport type { Vault } from '../../kernel/vault.js'\nimport type { ImportPolicy } from './types.js'\nimport { applyListProjection } from '../../with-shape/introspection/index.js'\nimport type { CollectionDescription, ListProjectionOptions } from '../../with-shape/introspection/index.js'\nimport { ValidationError } from '../../kernel/errors.js'\n\n/** Apply the redaction projection hub owns, so a format never asks for it. */\nfunction redactChunk(\n chunk: ExportChunk,\n ctx: FormatsContext,\n redact: FormatExportOptions['redact'],\n): ExportChunk {\n if (redact === undefined) return chunk\n const described = ctx.describe(chunk.collection) as CollectionDescription | undefined\n if (!described) {\n throw new Error(\n `vault.export: redaction was requested but '${chunk.collection}' has no description to redact against. ` +\n 'Open the collection with its classifiedFields / fieldMeta options first.',\n )\n }\n const opts: ListProjectionOptions | undefined =\n redact === true ? undefined : { sensitivity: redact.sensitivity as never }\n const records = chunk.records.map((r) =>\n applyListProjection(described, r as Record<string, unknown>, opts),\n )\n return { ...chunk, records: records as typeof chunk.records }\n}\n\n/**\n * Derive the narrow, format-facing capability from the vault.\n *\n * Lives here rather than on `Vault` because `kernel-surface` said so, and it\n * was right: this is service capability, not spine. The spine keeps two\n * delegating methods and nothing else.\n */\nfunction contextFor(vault: Vault): FormatsContext {\n return {\n // The empty-id guards are NOT defensive padding. The\n // plaintext tier is gated per-format, so a missing id means the calling\n // binding is broken — and without this it would reach the gate, miss the\n // allowlist, and surface as `ExportCapabilityError`: \"the owner did not\n // grant this\", which sends the reader to the keyring instead of to the\n // format. A loud TypeError names the real fault.\n assertCanExport: (tier, format) => {\n if (tier === 'bundle') return vault.assertCanExport('bundle')\n if (!format) throw new TypeError(\"assertCanExport('plaintext') requires a non-empty format id\")\n vault.assertCanExport('plaintext', format)\n },\n assertCanImport: (tier, format) => {\n if (tier === 'bundle') return vault.assertCanImport('bundle')\n if (!format) throw new TypeError(\"assertCanImport('plaintext') requires a non-empty format id\")\n vault.assertCanImport('plaintext', format)\n },\n // ⚠️ Filtered HERE, not passed to exportStream — `ExportStreamOptions`\n // has no `collections` field, so the option I first passed was silently\n // dropped and hub read every collection. Caught by an as-json test\n // expecting ['invoices'] and getting ['invoices','payments'].\n //\n // Post-read filtering is correct but not free: hub still DECRYPTS the\n // collections the caller excluded. Pushing the filter down into\n // exportStream is the real fix and is a hub change beyond this port.\n chunks: async function* (collections) {\n const wanted = collections ? new Set(collections) : null\n for await (const chunk of vault.exportStream({ granularity: 'collection' })) {\n if (wanted && !wanted.has(chunk.collection)) continue\n yield chunk\n }\n },\n // NOT swallowed. A caller who asked for redaction and silently got\n // unredacted output is the exact failure this port exists to remove — a\n // security duty that fails quiet. If the description cannot be read, the\n // export fails instead.\n describe: (collection) => vault.collection(collection).describe() as never,\n plan: async (records, policy: ImportPolicy, formatId: string, idKey?: string) => {\n const { diffVault } = await import('../../with-cargo/vault-diff.js')\n const plan = await diffVault(vault, records as never, idKey ? { idKey } : {})\n return {\n plan,\n policy,\n // The apply walk every as-* package had independently: added always,\n // modified unless insert-only, deleted only on replace — in one\n // transaction so a partial failure rolls back. Owned once, here.\n apply: async () => {\n await vault.noydb.transaction((tx) => {\n const txVault = tx.vault(vault.name)\n const reason = `import:${formatId}`\n for (const e of plan.added) {\n txVault.collection(e.collection).put(e.id, e.record as never, { reason })\n }\n if (policy !== 'insert-only') {\n for (const e of plan.modified) {\n txVault.collection(e.collection).put(e.id, e.record as never, { reason })\n }\n }\n if (policy === 'replace') {\n for (const e of plan.deleted) txVault.collection(e.collection).delete(e.id)\n }\n })\n },\n }\n },\n }\n}\n\n/**\n * Enable `vault.export()` / `vault.import()`.\n *\n * ```ts\n * const db = await createNoydb({ store, user, secret, formatsStrategy: withFormats() })\n * const csv = await vault.export(asCsv(), { collections: ['invoices'] })\n * ```\n */\nexport function withFormats(): FormatsStrategy {\n return {\n enabled: true,\n\n async exportWith<Out extends string | Uint8Array>(\n vault: Vault,\n format: NoydbFormat<Out>,\n options: FormatExportOptions = {},\n ): Promise<Out> {\n const ctx = contextFor(vault)\n // FIRST, and before a single record is read. The format is not in scope\n // yet and could not reach the vault if it were.\n if (format.tier === 'bundle') ctx.assertCanExport('bundle')\n else ctx.assertCanExport('plaintext', format.id)\n\n const chunks: ExportChunk[] = []\n for await (const chunk of ctx.chunks(options.collections)) {\n chunks.push(redactChunk(chunk, ctx, options.redact))\n }\n return format.encode(chunks)\n },\n\n async importWith<Out extends string | Uint8Array>(\n vault: Vault,\n format: NoydbFormat<Out>,\n input: Out,\n options: FormatImportOptions = {},\n ): Promise<ImportPlan> {\n const ctx = contextFor(vault)\n // Mirrors the export side: the import gate is two-tier as well, and a\n // bundle format importing under the plaintext tier would be a silent\n // downgrade in the other direction.\n if (format.tier === 'bundle') ctx.assertCanImport('bundle')\n else ctx.assertCanImport('plaintext', format.id)\n if (!format.decode) {\n throw new ValidationError(\n `vault.import: the '${format.id}' format is export-only — it declares no decode(). ` +\n 'Pick a format that round-trips, or write the records directly.',\n )\n }\n const decoded = await format.decode(input)\n const candidate: Record<string, readonly unknown[]> = {}\n for (const chunk of decoded) {\n const target = chunk.collection || options.collection\n if (!target) {\n throw new ValidationError(\n `vault.import: the '${format.id}' input names no collection and none was supplied. ` +\n 'Pass { collection } for formats whose payload is collection-less.',\n )\n }\n candidate[target] = [...(candidate[target] ?? []), ...chunk.records]\n }\n return ctx.plan(candidate, options.policy ?? 'merge', format.id, options.idKey)\n },\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/port/as/active.ts"],"sourcesContent":["/**\n * `withFormats()` — the service that consumes the `as-*` port.\n *\n * Everything with a correctness or security consequence lives here rather\n * than in each format: the capability gate, the vault read, and redaction.\n * A {@link NoydbFormat} receives records and returns bytes.\n *\n * That is the whole point of the inversion (ADR 0004). The conformance kit it\n * replaces could only check implementors who ran it; a format that cannot\n * reach a vault is gated whether its author cooperated or not.\n *\n * @module\n */\nimport type { NoydbFormat, ImportPlan } from './types.js'\nimport type {\n FormatsStrategy,\n FormatsContext,\n FormatExportOptions,\n FormatImportOptions,\n} from '../with/formats-strategy.js'\nimport type { ExportChunk } from '../../kernel/types.js'\nimport type { Vault } from '../../kernel/vault.js'\nimport type { ImportPolicy } from './types.js'\nimport { applyListProjection } from '../../with-shape/introspection/index.js'\nimport type { CollectionDescription, ListProjectionOptions } from '../../with-shape/introspection/index.js'\nimport { ValidationError } from '../../kernel/errors.js'\n\n/** Apply the redaction projection hub owns, so a format never asks for it. */\nfunction redactChunk(\n chunk: ExportChunk,\n ctx: FormatsContext,\n redact: FormatExportOptions['redact'],\n): ExportChunk {\n if (redact === undefined) return chunk\n const described = ctx.describe(chunk.collection) as CollectionDescription | undefined\n if (!described) {\n throw new Error(\n `vault.export: redaction was requested but '${chunk.collection}' has no description to redact against. ` +\n 'Open the collection with its classifiedFields / fieldMeta options first.',\n )\n }\n const opts: ListProjectionOptions | undefined =\n redact === true ? undefined : { sensitivity: redact.sensitivity as never }\n const records = chunk.records.map((r) =>\n applyListProjection(described, r as Record<string, unknown>, opts),\n )\n return { ...chunk, records: records as typeof chunk.records }\n}\n\n/**\n * Derive the narrow, format-facing capability from the vault.\n *\n * Lives here rather than on `Vault` because `kernel-surface` said so, and it\n * was right: this is service capability, not spine. The spine keeps two\n * delegating methods and nothing else.\n */\nfunction contextFor(vault: Vault): FormatsContext {\n return {\n // The empty-id guards are NOT defensive padding. The\n // plaintext tier is gated per-format, so a missing id means the calling\n // binding is broken — and without this it would reach the gate, miss the\n // allowlist, and surface as `ExportCapabilityError`: \"the owner did not\n // grant this\", which sends the reader to the keyring instead of to the\n // format. A loud TypeError names the real fault.\n assertCanExport: (tier, format) => {\n if (tier === 'bundle') return vault.assertCanExport('bundle')\n if (!format) throw new TypeError(\"assertCanExport('plaintext') requires a non-empty format id\")\n vault.assertCanExport('plaintext', format)\n },\n assertCanImport: (tier, format) => {\n if (tier === 'bundle') return vault.assertCanImport('bundle')\n if (!format) throw new TypeError(\"assertCanImport('plaintext') requires a non-empty format id\")\n vault.assertCanImport('plaintext', format)\n },\n // ⚠️ Filtered HERE, not passed to exportStream — `ExportStreamOptions`\n // has no `collections` field, so the option I first passed was silently\n // dropped and hub read every collection. Caught by an as-json test\n // expecting ['invoices'] and getting ['invoices','payments'].\n //\n // Post-read filtering is correct but not free: hub still DECRYPTS the\n // collections the caller excluded. Pushing the filter down into\n // exportStream is the real fix and is a hub change beyond this port.\n chunks: async function* (collections) {\n const wanted = collections ? new Set(collections) : null\n for await (const chunk of vault.exportStream({ granularity: 'collection' })) {\n if (wanted && !wanted.has(chunk.collection)) continue\n yield chunk\n }\n },\n // NOT swallowed. A caller who asked for redaction and silently got\n // unredacted output is the exact failure this port exists to remove — a\n // security duty that fails quiet. If the description cannot be read, the\n // export fails instead.\n describe: (collection) => vault.collection(collection).describe() as never,\n plan: async (records, policy: ImportPolicy, formatId: string, idKey?: string) => {\n const { diffVault } = await import('../../with-cargo/vault-diff.js')\n const plan = await diffVault(vault, records as never, idKey ? { idKey } : {})\n return {\n plan,\n policy,\n // The apply walk every as-* package had independently: added always,\n // modified unless insert-only, deleted only on replace — in one\n // transaction so a partial failure rolls back. Owned once, here.\n apply: async () => {\n await vault.noydb.transaction((tx) => {\n const txVault = tx.vault(vault.name)\n const reason = `import:${formatId}`\n for (const e of plan.added) {\n txVault.collection(e.collection).put(e.id, e.record as never, { reason })\n }\n if (policy !== 'insert-only') {\n for (const e of plan.modified) {\n txVault.collection(e.collection).put(e.id, e.record as never, { reason })\n }\n }\n if (policy === 'replace') {\n for (const e of plan.deleted) txVault.collection(e.collection).delete(e.id)\n }\n })\n },\n }\n },\n }\n}\n\n/**\n * Enable `vault.export()` / `vault.import()`.\n *\n * ```ts\n * const db = await createNoydb({ store, user, secret, formatsStrategy: withFormats() })\n * const csv = await vault.export(asCsv(), { collections: ['invoices'] })\n * ```\n */\nexport function withFormats(): FormatsStrategy {\n return {\n enabled: true,\n\n async exportWith<Out extends string | Uint8Array>(\n vault: Vault,\n format: NoydbFormat<Out>,\n options: FormatExportOptions = {},\n ): Promise<Out> {\n const ctx = contextFor(vault)\n // FIRST, and before a single record is read. The format is not in scope\n // yet and could not reach the vault if it were.\n if (format.tier === 'bundle') ctx.assertCanExport('bundle')\n else ctx.assertCanExport('plaintext', format.id)\n\n const chunks: ExportChunk[] = []\n for await (const chunk of ctx.chunks(options.collections)) {\n chunks.push(redactChunk(chunk, ctx, options.redact))\n }\n return format.encode(chunks)\n },\n\n async importWith<Out extends string | Uint8Array>(\n vault: Vault,\n format: NoydbFormat<Out>,\n input: Out,\n options: FormatImportOptions = {},\n ): Promise<ImportPlan> {\n const ctx = contextFor(vault)\n // Mirrors the export side: the import gate is two-tier as well, and a\n // bundle format importing under the plaintext tier would be a silent\n // downgrade in the other direction.\n if (format.tier === 'bundle') ctx.assertCanImport('bundle')\n else ctx.assertCanImport('plaintext', format.id)\n if (!format.decode) {\n throw new ValidationError(\n `vault.import: the '${format.id}' format is export-only — it declares no decode(). ` +\n 'Pick a format that round-trips, or write the records directly.',\n )\n }\n const decoded = await format.decode(input)\n const candidate: Record<string, readonly unknown[]> = {}\n for (const chunk of decoded) {\n const target = chunk.collection || options.collection\n if (!target) {\n throw new ValidationError(\n `vault.import: the '${format.id}' input names no collection and none was supplied. ` +\n 'Pass { collection } for formats whose payload is collection-less.',\n )\n }\n candidate[target] = [...(candidate[target] ?? []), ...chunk.records]\n }\n return ctx.plan(candidate, options.policy ?? 'merge', format.id, options.idKey)\n },\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  MANIFEST_COLLECTION
3
3
  } from "./chunk-EPFF7LOX.js";
4
- import {
5
- SCHEMAS_COLLECTION
6
- } from "./chunk-WWNYSV7S.js";
7
4
  import {
8
5
  SEQUENCE_COLLECTION
9
6
  } from "./chunk-MMOZQTXV.js";
7
+ import {
8
+ SCHEMAS_COLLECTION
9
+ } from "./chunk-WWNYSV7S.js";
10
10
  import {
11
11
  PERIODS_COLLECTION,
12
12
  PERIOD_ARCHIVES_COLLECTION,
@@ -262,4 +262,4 @@ export {
262
262
  loadVault,
263
263
  verifyBackupIntegrity
264
264
  };
265
- //# sourceMappingURL=backup-VASRVLDZ.js.map
265
+ //# sourceMappingURL=backup-R6A4VZRX.js.map
@@ -5,7 +5,7 @@ import {
5
5
  describeExtraction,
6
6
  unsealDeks,
7
7
  withCargo
8
- } from "../chunk-ESVREVWN.js";
8
+ } from "../chunk-SXCYYMVS.js";
9
9
  import {
10
10
  extractPartition,
11
11
  walkClosure
@@ -29,10 +29,10 @@ import {
29
29
  isQuorum,
30
30
  runDrainBarrier
31
31
  } from "../chunk-VTFVLCUH.js";
32
- import "../chunk-WWNYSV7S.js";
33
32
  import {
34
33
  CustodyApi
35
34
  } from "../chunk-Y4MJQ2UG.js";
35
+ import "../chunk-WWNYSV7S.js";
36
36
  import "../chunk-PKJ43CMD.js";
37
37
  import "../chunk-MLO75SFJ.js";
38
38
  import {
@@ -0,0 +1,37 @@
1
+ import {
2
+ Lru,
3
+ parseBytes
4
+ } from "./chunk-V4PXLYWM.js";
5
+
6
+ // src/port/with/lazy-strategy.ts
7
+ function buildLazyCache(collection, cache) {
8
+ if (!cache || cache.maxRecords === void 0 && cache.maxBytes === void 0) {
9
+ throw new Error(
10
+ `Collection "${collection}": lazy mode (prefetch: false) requires a cache option with maxRecords and/or maxBytes. An unbounded lazy cache defeats the purpose.`
11
+ );
12
+ }
13
+ const lruOptions = {};
14
+ if (cache.maxRecords !== void 0) lruOptions.maxRecords = cache.maxRecords;
15
+ if (cache.maxBytes !== void 0) lruOptions.maxBytes = parseBytes(cache.maxBytes);
16
+ return new Lru(lruOptions);
17
+ }
18
+ var implicitLazyWarned = false;
19
+ var IMPLICIT_LAZY = {
20
+ createCache(collection, cache) {
21
+ if (!implicitLazyWarned) {
22
+ implicitLazyWarned = true;
23
+ if (!(typeof process !== "undefined" && process.env["NODE_ENV"] === "test")) {
24
+ console.warn(
25
+ `[noy-db] Collection "${collection}": lazy mode (prefetch: false) without the lazy service is deprecated \u2014 pass \`lazyStrategy: withLazy()\` (from "@noy-db/hub/lazy") to createNoydb(). The implicit path will be removed at 1.0.`
26
+ );
27
+ }
28
+ }
29
+ return buildLazyCache(collection, cache);
30
+ }
31
+ };
32
+
33
+ export {
34
+ buildLazyCache,
35
+ IMPLICIT_LAZY
36
+ };
37
+ //# sourceMappingURL=chunk-66TQOPFM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/port/with/lazy-strategy.ts"],"sourcesContent":["/**\n * Lazy-mode capability contract (#267 Track A tail — lazy-mode promoted out\n * of `routing` into its own `lazy` service). Lives on the `/with` port (the\n * one seam the kernel spine may import statically) so `Collection` can hold\n * the back-compat default without a spine→service static import.\n *\n * Lazy mode (`prefetch: false`) skips bulk hydration: reads go per-id\n * against the store and land in a bounded LRU working set. The strategy owns\n * constructing that LRU:\n *\n * - `withLazy()` (`with-store/lazy/active.ts`, subpath `@noy-db/hub/lazy`)\n * is the explicit catalog opt-in — silent, forward-stable.\n * - {@link IMPLICIT_LAZY} is the floor default reached when a collection\n * declares `prefetch: false` WITHOUT `lazyStrategy: withLazy()`. It keeps\n * the pre-#267 behavior byte-for-byte (back-compat delegation, pre-1.0)\n * but emits a one-time deprecation warn outside test env. It will become\n * a throwing stub at 1.0, letting the LRU leave the floor bundle\n * entirely once the per-record-CEK cache stops pinning `Lru` there.\n *\n * The on-demand read/write branches themselves stay kernel-resident (they\n * are `if (this.lazy)` forks on the hot get/put/scan paths); the service\n * seam owns cache construction + budget validation, which is where the\n * opt-in and the future tree-shake win live.\n * @internal\n */\nimport { Lru, parseBytes } from '../../kernel/cache/index.js'\nimport type { CacheOptions } from '../../kernel/collection.js'\n\nexport interface LazyStrategy {\n /**\n * Build the bounded LRU working-set cache for a lazy collection.\n * MUST throw when `cache` declares neither `maxRecords` nor `maxBytes` —\n * an unbounded lazy cache defeats the purpose.\n */\n createCache<V>(collection: string, cache: CacheOptions | undefined): Lru<string, V>\n}\n\n/**\n * Shared budget validation + LRU construction — the one implementation both\n * {@link IMPLICIT_LAZY} and `withLazy()` run, so the two paths cannot drift.\n * @internal\n */\nexport function buildLazyCache<V>(collection: string, cache: CacheOptions | undefined): Lru<string, V> {\n if (!cache || (cache.maxRecords === undefined && cache.maxBytes === undefined)) {\n throw new Error(\n `Collection \"${collection}\": lazy mode (prefetch: false) requires a cache option ` +\n `with maxRecords and/or maxBytes. An unbounded lazy cache defeats the purpose.`,\n )\n }\n const lruOptions: { maxRecords?: number; maxBytes?: number } = {}\n if (cache.maxRecords !== undefined) lruOptions.maxRecords = cache.maxRecords\n if (cache.maxBytes !== undefined) lruOptions.maxBytes = parseBytes(cache.maxBytes)\n return new Lru<string, V>(lruOptions)\n}\n\nlet implicitLazyWarned = false\n\n/**\n * Back-compat default (deprecated): `prefetch: false` without\n * `lazyStrategy: withLazy()`. Identical behavior to the explicit opt-in,\n * plus a one-time deprecation warn (suppressed under NODE_ENV=test,\n * mirroring the listPage fallback warn). @internal\n */\nexport const IMPLICIT_LAZY: LazyStrategy = {\n createCache<V>(collection: string, cache: CacheOptions | undefined): Lru<string, V> {\n if (!implicitLazyWarned) {\n implicitLazyWarned = true\n if (!(typeof process !== 'undefined' && process.env['NODE_ENV'] === 'test')) {\n console.warn(\n `[noy-db] Collection \"${collection}\": lazy mode (prefetch: false) without the lazy ` +\n `service is deprecated — pass \\`lazyStrategy: withLazy()\\` (from \"@noy-db/hub/lazy\") ` +\n `to createNoydb(). The implicit path will be removed at 1.0.`,\n )\n }\n }\n return buildLazyCache<V>(collection, cache)\n },\n}\n"],"mappings":";;;;;;AA0CO,SAAS,eAAkB,YAAoB,OAAiD;AACrG,MAAI,CAAC,SAAU,MAAM,eAAe,UAAa,MAAM,aAAa,QAAY;AAC9E,UAAM,IAAI;AAAA,MACR,eAAe,UAAU;AAAA,IAE3B;AAAA,EACF;AACA,QAAM,aAAyD,CAAC;AAChE,MAAI,MAAM,eAAe,OAAW,YAAW,aAAa,MAAM;AAClE,MAAI,MAAM,aAAa,OAAW,YAAW,WAAW,WAAW,MAAM,QAAQ;AACjF,SAAO,IAAI,IAAe,UAAU;AACtC;AAEA,IAAI,qBAAqB;AAQlB,IAAM,gBAA8B;AAAA,EACzC,YAAe,YAAoB,OAAiD;AAClF,QAAI,CAAC,oBAAoB;AACvB,2BAAqB;AACrB,UAAI,EAAE,OAAO,YAAY,eAAe,QAAQ,IAAI,UAAU,MAAM,SAAS;AAC3E,gBAAQ;AAAA,UACN,wBAAwB,UAAU;AAAA,QAGpC;AAAA,MACF;AAAA,IACF;AACA,WAAO,eAAkB,YAAY,KAAK;AAAA,EAC5C;AACF;","names":[]}
@@ -1,12 +1,18 @@
1
1
  import {
2
- ViaGraph
3
- } from "./chunk-UB5CRTFF.js";
2
+ normalizeTriggerBy
3
+ } from "./chunk-TPHAGLUH.js";
4
+ import {
5
+ ViaGraph,
6
+ computedEntryParts,
7
+ schemaFieldKeys
8
+ } from "./chunk-BWXGSSFX.js";
4
9
  import {
5
10
  sha256Hex
6
11
  } from "./chunk-SL3JAAKN.js";
7
12
  import {
8
13
  DerivationCycleError,
9
- DuplicateBehaviorNameError
14
+ DuplicateBehaviorNameError,
15
+ ValidationError
10
16
  } from "./chunk-7O3Y4PPD.js";
11
17
 
12
18
  // src/with-formula/derivations/strategy-hash.ts
@@ -30,6 +36,23 @@ function stripArtifactSuffix(displayId) {
30
36
  const suffix = `.${WHOLE_RECORD}`;
31
37
  return displayId.endsWith(suffix) ? displayId.slice(0, -suffix.length) : displayId;
32
38
  }
39
+ function matchTargetFieldNames(opts) {
40
+ const declared = [];
41
+ const virtual = [];
42
+ for (const [field, entry] of Object.entries(opts.computed ?? {})) {
43
+ declared.push(field);
44
+ const parts = computedEntryParts(entry);
45
+ if (parts.mode === "virtual") virtual.push(field);
46
+ }
47
+ for (const [field, spec] of Object.entries(opts.viaFields ?? {})) {
48
+ declared.push(field);
49
+ const descriptors = spec.descriptors ?? [];
50
+ for (const d of descriptors) {
51
+ if (d._viaBrand === "computed" && d.mode === "virtual") virtual.push(field);
52
+ }
53
+ }
54
+ return { declared, virtual };
55
+ }
33
56
  var DerivationRegistry = class {
34
57
  _bySource = /* @__PURE__ */ new Map();
35
58
  _byOutput = /* @__PURE__ */ new Map();
@@ -46,7 +69,8 @@ var DerivationRegistry = class {
46
69
  }
47
70
  const outputKeys = Object.keys(spec.outputs);
48
71
  const strategyHash = await computeStrategyHash(spec.source, outputKeys, spec.derive, spec.sources);
49
- const reg = { spec, strategyHash };
72
+ const triggers = normalizeTriggerBy(spec.triggerBy);
73
+ const reg = { spec, strategyHash, triggers };
50
74
  if (spec.name !== void 0) this._byName.set(spec.name, reg);
51
75
  const fromSource = this._bySource.get(spec.source);
52
76
  if (fromSource) fromSource.push(reg);
@@ -56,7 +80,7 @@ var DerivationRegistry = class {
56
80
  if (fromExtra) fromExtra.push(reg);
57
81
  else this._bySource.set(extra, [reg]);
58
82
  }
59
- for (const t of spec.triggerBy ?? []) {
83
+ for (const t of triggers) {
60
84
  const fromTrigger = this._bySource.get(t.collection);
61
85
  if (fromTrigger) fromTrigger.push(reg);
62
86
  else this._bySource.set(t.collection, [reg]);
@@ -81,6 +105,62 @@ var DerivationRegistry = class {
81
105
  strategiesProducingOutput(collection) {
82
106
  return this._byOutput.get(collection) ?? [];
83
107
  }
108
+ /**
109
+ * The #1253-pattern typo guard for match fields (#1249): a misspelt
110
+ * `to`/`from` silently matches nothing forever, so validate against the
111
+ * collection's enumerable field set at the earliest point it exists.
112
+ * `schemaFieldKeys(schema) === undefined` (TS-generic collection,
113
+ * unreadable validator) is DELIBERATELY silent — those fields are real
114
+ * and unenumerable. `configKeys` folds in the collection's other
115
+ * non-schema field declarations (fieldMeta/moneyFields/dictKeyFields/
116
+ * refs/computed); `denormExempt` is derived here from this registry's
117
+ * own strategies rather than taken as a parameter — a field this
118
+ * collection's own derivations write via `denorm` is never a typo.
119
+ */
120
+ validateFieldsFor(collectionName, schema, configKeys, viaSources = void 0) {
121
+ const targets = matchTargetFieldNames(viaSources ?? {});
122
+ const virtual = new Set(targets.virtual);
123
+ const shapeKeys = schemaFieldKeys(schema);
124
+ if (shapeKeys === void 0 && virtual.size === 0) return;
125
+ const keys = shapeKeys === void 0 ? void 0 : /* @__PURE__ */ new Set([...shapeKeys, ...configKeys, ...targets.declared]);
126
+ const denormExempt = /* @__PURE__ */ new Set();
127
+ const regs = new Set([...this._bySource.values()].flat());
128
+ for (const reg of regs) {
129
+ if (reg.spec.source !== collectionName) continue;
130
+ for (const out of Object.values(reg.spec.outputs)) {
131
+ if (out.collection === collectionName) for (const d of out.denorm ?? []) denormExempt.add(d);
132
+ }
133
+ }
134
+ const unknown = (f) => keys !== void 0 && !keys.has(f) && !denormExempt.has(f);
135
+ for (const reg of regs) {
136
+ const name = reg.spec.name ?? reg.spec.source;
137
+ for (const t of reg.triggers) {
138
+ for (const p of t.match) {
139
+ if (reg.spec.source === collectionName) {
140
+ if (virtual.has(p.to)) throw new ValidationError(this._virtualMatchMessage(name, collectionName, p.to, "to"));
141
+ if (unknown(p.to)) {
142
+ throw new ValidationError(
143
+ `derivation "${name}": triggerBy match names source field "${p.to}", which "${collectionName}" does not declare \u2014 a typo here silently matches nothing forever`
144
+ );
145
+ }
146
+ }
147
+ if (t.collection === collectionName && p.from !== "id") {
148
+ if (virtual.has(p.from)) throw new ValidationError(this._virtualMatchMessage(name, collectionName, p.from, "from"));
149
+ if (unknown(p.from)) {
150
+ throw new ValidationError(
151
+ `derivation "${name}": triggerBy match reads "${p.from}" from written "${collectionName}" records, which that collection does not declare \u2014 a typo here silently matches nothing forever`
152
+ );
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ }
159
+ /** #1266 — one message shape for both match sides; see {@link matchTargetFieldNames}. */
160
+ _virtualMatchMessage(name, collectionName, field, side) {
161
+ const where = side === "to" ? `names source field "${field}", which "${collectionName}"` : `reads "${field}" from written "${collectionName}" records, which that collection`;
162
+ return `derivation "${name}": triggerBy match ${where} declares as a VIRTUAL computed field \u2014 virtual fields are evaluated on read and never stored, so the matcher would read every candidate record and match nothing, forever. Declare it as \`mode: 'materialized'\` (stored, indexable) or match on a stored field.`;
163
+ }
84
164
  /**
85
165
  * All registered strategies as a flat, deduplicated array.
86
166
  * Each strategy is indexed once per source (not once per output key),
@@ -172,4 +252,4 @@ var DerivationRegistry = class {
172
252
  export {
173
253
  DerivationRegistry
174
254
  };
175
- //# sourceMappingURL=chunk-OEA5NVSU.js.map
255
+ //# sourceMappingURL=chunk-AJQY44CE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-formula/derivations/strategy-hash.ts","../src/with-formula/derivations/registry.ts"],"sourcesContent":["/**\n * Deterministic hash of a derivation strategy's \"shape\": source\n * collection, declared sibling sources, output keys, derive function\n * source. Used to detect\n * strategy drift: a record whose `_derivedFrom.strategyHash` doesn't\n * match the current strategy is considered stale.\n *\n * Web Crypto SHA-256 — no extra deps.\n */\nimport { sha256Hex } from '../../kernel/enclave/index.js'\n\nexport async function computeStrategyHash(\n source: string,\n outputKeys: readonly string[],\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n derive: (...args: any[]) => any,\n sources?: ReadonlyArray<string>,\n): Promise<string> {\n const canonical = JSON.stringify({\n source,\n outputs: [...outputKeys].sort(),\n derive: derive.toString(),\n // Declared sibling sources — adding/removing a trigger\n // collection invalidates cached derived records. Omitted when empty\n // so strategies without siblings keep their existing hash.\n ...(sources?.length ? { sources: [...sources].sort() } : {}),\n })\n const bytes = new TextEncoder().encode(canonical)\n return sha256Hex(bytes)\n}\n","import { computedEntryParts, type ComputedEntryParts } from '../../kernel/collection-config.js'\nimport { DerivationCycleError, DuplicateBehaviorNameError, ValidationError } from '../../kernel/errors.js'\nimport { ViaGraph, type FieldRef, type EdgeKind, type Grain } from '../../kernel/via/graph.js'\nimport { schemaFieldKeys } from '../../with-shape/introspection/describe.js'\nimport { computeStrategyHash } from './strategy-hash.js'\nimport { normalizeTriggerBy, type NormalizedTrigger } from './trigger-match.js'\nimport type { DerivationSpec } from './types.js'\n\n/**\n * Whole-record artifact-grain field marker (#638 Task 2). A derivation's\n * `derive()`/a rollup's `compute()` reads the WHOLE source record — there is\n * no single declared field to point at — so a trigger/output collection is\n * modelled as one artifact node per collection (`kernel/via/graph.ts`'s\n * \"Artifact-grain targets... modelled as a field node whose field is the\n * artifact key\" convention). MUST match `materialized-views/registry.ts`'s\n * and `vault.ts`'s overlay-edge marker so cross-registry edges (a derivation\n * feeding into / out of an MV or overlay collection) resolve to the SAME\n * graph node.\n */\nconst WHOLE_RECORD = '*'\n\n/** Strip this registry's `.${WHOLE_RECORD}` artifact suffix off a graph cycle\n * path entry, recovering the bare collection name — preserves the exact\n * pre-#638 `DerivationCycleError` message shape (behavior lock) for a pure\n * record-shape derivation cycle. A rollup's real-field target (not suffixed\n * `.${WHOLE_RECORD}`) is left untouched. */\nfunction stripArtifactSuffix(displayId: string): string {\n const suffix = `.${WHOLE_RECORD}`\n return displayId.endsWith(suffix) ? displayId.slice(0, -suffix.length) : displayId\n}\n\ninterface RegisteredStrategy {\n // Type-erased to allow the registry to hold heterogeneous strategies.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n spec: DerivationSpec<any, any>\n strategyHash: string\n readonly triggers: ReadonlyArray<NormalizedTrigger>\n}\n\n/**\n * Vault-internal registry of derivation strategies. Owned by `Vault`;\n * not exported.\n *\n * @internal\n */\n/**\n * Field names a `triggerBy` match may NOT target, and the ones it may (#1266).\n *\n * Lives HERE, not in `kernel/collection-config.ts` where computed-mode logic\n * otherwise sits, because that module is in the FLOOR bundle: putting twenty\n * lines there cost every consumer 5.2% of the floor whether or not they use\n * derivations. Registry code is already behind the derivations opt-in.\n *\n * A derivation matcher reads the STORED record, so a match target must be a\n * field that is stored. `mode: 'virtual'` computed fields are evaluated on the\n * READ path and never persisted — but they appear in `computed:` (and in\n * `via(computed(...))`) exactly like materialized ones, so the registration\n * guard accepted them and the fan-out then matched nothing, forever: the guard's\n * own stated failure mode, reached through the guard rather than around it.\n *\n * Rejected rather than supported. Matching a virtual field means running user\n * code for every candidate row, which turns an indexed narrow into a full scan\n * of the collection; `mode: 'materialized'` is stored, already works, and is\n * what the error points the caller at.\n *\n * `declared` also folds in `viaFields`, which the guard's key set previously\n * omitted entirely — a `via()`-declared MATERIALIZED field is a perfectly good\n * match target and was being rejected as a typo. An over-firing guard teaches\n * people to stop trusting it, so both directions are fixed together.\n */\nfunction matchTargetFieldNames(opts: {\n // Deliberately `unknown`-valued: the caller is generic over the record type\n // (`ComputedFields<T>`), and this reads only `mode`, so narrowing the value\n // type here would force a variance cast at every call site instead of one\n // here. Both shapes are duck-checked below.\n readonly computed?: Readonly<Record<string, unknown>> | undefined\n readonly viaFields?: Readonly<Record<string, unknown>> | undefined\n}): { readonly declared: readonly string[]; readonly virtual: readonly string[] } {\n const declared: string[] = []\n const virtual: string[] = []\n for (const [field, entry] of Object.entries(opts.computed ?? {})) {\n declared.push(field)\n const parts: ComputedEntryParts = computedEntryParts(entry as Parameters<typeof computedEntryParts>[0])\n if (parts.mode === 'virtual') virtual.push(field)\n }\n for (const [field, spec] of Object.entries(opts.viaFields ?? {})) {\n declared.push(field)\n const descriptors = (spec as { descriptors?: readonly unknown[] }).descriptors ?? []\n for (const d of descriptors) {\n if ((d as { _viaBrand?: unknown })._viaBrand === 'computed'\n && (d as { mode?: unknown }).mode === 'virtual') virtual.push(field)\n }\n }\n return { declared, virtual }\n}\n\nexport class DerivationRegistry {\n private readonly _bySource = new Map<string, RegisteredStrategy[]>()\n private readonly _byOutput = new Map<string, RegisteredStrategy[]>()\n private readonly _byName = new Map<string, RegisteredStrategy>()\n\n /**\n * Register a derivation strategy. If `spec.name` is given and already\n * registered by another derivation in this vault, throws\n * {@link DuplicateBehaviorNameError} before indexing.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n async register(spec: DerivationSpec<any, any>): Promise<void> {\n if (spec.name !== undefined && this._byName.has(spec.name)) {\n throw new DuplicateBehaviorNameError(spec.name, 'derivation')\n }\n\n const outputKeys = Object.keys(spec.outputs)\n const strategyHash = await computeStrategyHash(spec.source, outputKeys, spec.derive, spec.sources)\n const triggers = normalizeTriggerBy(spec.triggerBy)\n const reg: RegisteredStrategy = { spec, strategyHash, triggers }\n\n if (spec.name !== undefined) this._byName.set(spec.name, reg)\n\n const fromSource = this._bySource.get(spec.source)\n if (fromSource) fromSource.push(reg)\n else this._bySource.set(spec.source, [reg])\n\n // Declared sibling sources index the SAME `reg` under each\n // extra collection so `strategiesForSource(extra)` returns it and a\n // sibling write re-fires the derivation. Sibling keys also enter\n // `_bySource`, so `validate()`'s cycle DFS walks them automatically.\n for (const extra of spec.sources ?? []) {\n const fromExtra = this._bySource.get(extra)\n if (fromExtra) fromExtra.push(reg)\n else this._bySource.set(extra, [reg])\n }\n\n // FK triggers index the SAME `reg` under each parent collection so\n // a parent write re-fires the derivation (fanned out to matching source\n // records in `dispatchDerivations`). Like sources[], these keys enter\n // `_bySource` so the cycle DFS walks the trigger→output edge.\n for (const t of triggers) {\n const fromTrigger = this._bySource.get(t.collection)\n if (fromTrigger) fromTrigger.push(reg)\n else this._bySource.set(t.collection, [reg])\n }\n\n // Rollup: a write to the child `from` collection recomputes\n // the parent (= spec.source = into) at id child[key]. Index under `from`\n // so a child write fires it; spec.source (into) is already indexed above,\n // so a parent write also recomputes its own aggregate (covers the\n // parent-created-after-children case). The from→into edge enters the cycle\n // DFS automatically.\n if (spec.rollup) {\n const fromRollup = this._bySource.get(spec.rollup.from)\n if (fromRollup) fromRollup.push(reg)\n else this._bySource.set(spec.rollup.from, [reg])\n }\n\n for (const key of outputKeys) {\n const output = spec.outputs[key]\n if (!output) continue\n const outputCollection = output.collection\n const arr = this._byOutput.get(outputCollection)\n if (arr) arr.push(reg)\n else this._byOutput.set(outputCollection, [reg])\n }\n }\n\n strategiesForSource(source: string): ReadonlyArray<RegisteredStrategy> {\n return this._bySource.get(source) ?? []\n }\n\n strategiesProducingOutput(collection: string): ReadonlyArray<RegisteredStrategy> {\n return this._byOutput.get(collection) ?? []\n }\n\n /**\n * The #1253-pattern typo guard for match fields (#1249): a misspelt\n * `to`/`from` silently matches nothing forever, so validate against the\n * collection's enumerable field set at the earliest point it exists.\n * `schemaFieldKeys(schema) === undefined` (TS-generic collection,\n * unreadable validator) is DELIBERATELY silent — those fields are real\n * and unenumerable. `configKeys` folds in the collection's other\n * non-schema field declarations (fieldMeta/moneyFields/dictKeyFields/\n * refs/computed); `denormExempt` is derived here from this registry's\n * own strategies rather than taken as a parameter — a field this\n * collection's own derivations write via `denorm` is never a typo.\n */\n validateFieldsFor(\n collectionName: string,\n schema: unknown,\n configKeys: ReadonlyArray<string>,\n viaSources: {\n readonly computed?: Readonly<Record<string, unknown>> | undefined\n readonly viaFields?: Readonly<Record<string, unknown>> | undefined\n } | undefined = undefined,\n ): void {\n const targets = matchTargetFieldNames(viaSources ?? {})\n const virtual = new Set(targets.virtual)\n const shapeKeys = schemaFieldKeys(schema)\n // The two checks have DIFFERENT preconditions, which is why one loop runs\n // both rather than an early return covering both. The typo check needs a\n // readable field list and must stay silent without one — a false \"unknown\n // field\" on a TS-generic collection would be worse than no check. The\n // virtual check needs nothing but the declaration in hand: \"declared, and\n // never stored\" is provable from the declaration alone (#1266).\n if (shapeKeys === undefined && virtual.size === 0) return\n const keys = shapeKeys === undefined ? undefined\n : new Set([...shapeKeys, ...configKeys, ...targets.declared])\n const denormExempt = new Set<string>()\n const regs = new Set([...this._bySource.values()].flat())\n for (const reg of regs) {\n if (reg.spec.source !== collectionName) continue\n for (const out of Object.values(reg.spec.outputs) as Array<{ collection?: string; denorm?: readonly string[] }>) {\n if (out.collection === collectionName) for (const d of out.denorm ?? []) denormExempt.add(d)\n }\n }\n const unknown = (f: string): boolean =>\n keys !== undefined && !keys.has(f) && !denormExempt.has(f)\n for (const reg of regs) {\n const name = reg.spec.name ?? reg.spec.source\n for (const t of reg.triggers) {\n for (const p of t.match) {\n // `to` reads the SOURCE record; `from` reads the WRITTEN record.\n // Both are the stored shape, so both refuse a virtual target.\n if (reg.spec.source === collectionName) {\n if (virtual.has(p.to)) throw new ValidationError(this._virtualMatchMessage(name, collectionName, p.to, 'to'))\n if (unknown(p.to)) {\n throw new ValidationError(\n `derivation \"${name}\": triggerBy match names source field \"${p.to}\", which \"${collectionName}\" does not declare — a typo here silently matches nothing forever`)\n }\n }\n if (t.collection === collectionName && p.from !== 'id') {\n if (virtual.has(p.from)) throw new ValidationError(this._virtualMatchMessage(name, collectionName, p.from, 'from'))\n if (unknown(p.from)) {\n throw new ValidationError(\n `derivation \"${name}\": triggerBy match reads \"${p.from}\" from written \"${collectionName}\" records, which that collection does not declare — a typo here silently matches nothing forever`)\n }\n }\n }\n }\n }\n }\n\n /** #1266 — one message shape for both match sides; see {@link matchTargetFieldNames}. */\n private _virtualMatchMessage(name: string, collectionName: string, field: string, side: 'to' | 'from'): string {\n const where = side === 'to'\n ? `names source field \"${field}\", which \"${collectionName}\"`\n : `reads \"${field}\" from written \"${collectionName}\" records, which that collection`\n return `derivation \"${name}\": triggerBy match ${where} declares as a VIRTUAL computed field — virtual fields are evaluated on read and never stored, so the matcher would read every candidate record and match nothing, forever. Declare it as \\`mode: 'materialized'\\` (stored, indexable) or match on a stored field.`\n }\n\n /**\n * All registered strategies as a flat, deduplicated array.\n * Each strategy is indexed once per source (not once per output key),\n * so iterating `_bySource.values()` naturally yields each strategy\n * exactly once per source — deduplication is handled by flattening\n * the per-source arrays and collecting into a Set by identity.\n *\n * Used by `dumpSchema()` / `describeDerivations()` in the introspection\n * walker to populate the derivations map.\n */\n all(): ReadonlyArray<RegisteredStrategy> {\n const seen = new Set<RegisteredStrategy>()\n for (const strategies of this._bySource.values()) {\n for (const s of strategies) seen.add(s)\n }\n return [...seen]\n }\n\n /**\n * Graph edges for #638 Task 2: one `'derivation'`/`'record'` edge per\n * (non-self-write) output key — target = the output collection (a\n * `WHOLE_RECORD` artifact node), sources = every collection that can\n * trigger this strategy (`source`, `sources[]`, `triggerBy[].collection`,\n * and — for a rollup — `rollup.from`), mirroring EXACTLY the trigger keys\n * `register()` indexes under `_bySource`. The self-write reverse-denorm\n * output (`output.collection === source` with `denorm` declared) is\n * skipped — the SAME condition the old local DFS used; it is not a cycle\n * (see the comment `validate()` used to carry, now on this skip).\n * Rollups ADDITIONALLY get a dedicated `'rollup'`/`'aggregate'` edge\n * targeting the real `rollup.field` on the parent (`source`) — the\n * self-write output above only covers the cycle-DFS skip, not\n * dispatch/taint (Tasks 3/4/6), which need the real field.\n */\n edges(): ReadonlyArray<{ readonly target: FieldRef; readonly sources: readonly FieldRef[]; readonly kind: EdgeKind; readonly grain: Grain }> {\n const out: Array<{ target: FieldRef; sources: FieldRef[]; kind: EdgeKind; grain: Grain }> = []\n for (const reg of this.all()) {\n const spec = reg.spec\n const triggerCollections = [\n spec.source,\n ...(spec.sources ?? []),\n ...(spec.triggerBy ?? []).map((t) => t.collection),\n ...(spec.rollup ? [spec.rollup.from] : []),\n ]\n const sources: FieldRef[] = triggerCollections.map((c) => ({ collection: c, field: WHOLE_RECORD }))\n for (const key of Object.keys(spec.outputs)) {\n const output = spec.outputs[key]\n if (!output) continue\n if (output.shape === 'record' && output.collection === spec.source && output.denorm !== undefined) continue\n out.push({ target: { collection: output.collection, field: WHOLE_RECORD }, sources, kind: 'derivation', grain: 'record' })\n }\n if (spec.rollup) {\n out.push({\n target: { collection: spec.source, field: spec.rollup.field },\n sources: [{ collection: spec.rollup.from, field: WHOLE_RECORD }],\n kind: 'rollup',\n grain: 'aggregate',\n })\n }\n }\n return out\n }\n\n /**\n * Cycle detection, delegated to `ViaGraph.assertAcyclic()` (#638 Task 2 —\n * retires the local DFS). Call after all `register()` calls complete (i.e.\n * at vault open). Throws `DerivationCycleError` on the first cycle found —\n * SAME class/timing as before; the message/path is byte-identical to the\n * pre-#638 shape for a pure record-shape derivation cycle (the\n * `WHOLE_RECORD` artifact suffix is stripped back to a bare collection\n * name before re-throwing).\n *\n * `graph` is the caller's shared per-vault graph, ALREADY carrying this\n * registry's `edges()` (the caller registers them — see `Vault._initDerivations`)\n * — omit it (e.g. this registry's own unit tests) to validate this\n * registry's edges in isolation against a throwaway graph.\n */\n validate(graph?: ViaGraph): void {\n const g = graph ?? new ViaGraph()\n if (!graph) {\n for (const edge of this.edges()) g.registerDerived(edge.target, edge.sources, edge.kind, edge.grain)\n }\n try {\n g.assertAcyclic()\n } catch (e) {\n if (e instanceof DerivationCycleError) throw new DerivationCycleError(e.path.map(stripArtifactSuffix))\n throw e\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createNoydb
3
- } from "./chunk-I6DGKPTY.js";
3
+ } from "./chunk-ZVCC36XQ.js";
4
4
  import {
5
5
  deriveSchemaManifest
6
6
  } from "./chunk-BBWVXF57.js";
@@ -286,4 +286,4 @@ export {
286
286
  followRedirects,
287
287
  open
288
288
  };
289
- //# sourceMappingURL=chunk-S5AMPTNE.js.map
289
+ //# sourceMappingURL=chunk-BMELYFHU.js.map
@@ -38,11 +38,34 @@ function withDerivation(spec) {
38
38
  `withDerivation: triggerBy.collection must not equal the source "${spec.source}" (use sources[] for same-id triggers)`
39
39
  );
40
40
  }
41
- if (typeof t.on !== "string" || t.on.length === 0) {
41
+ const hasOn = typeof t.on === "string";
42
+ const hasMatch = Array.isArray(t.match);
43
+ if (hasOn === hasMatch) {
44
+ throw new ValidationError(
45
+ `withDerivation: triggerBy on "${t.collection}" needs exactly one of \`on\` or \`match\``
46
+ );
47
+ }
48
+ if (hasOn && t.on.length === 0) {
42
49
  throw new ValidationError(
43
50
  `withDerivation: triggerBy on "${t.collection}" needs a non-empty \`on\` (the FK field on the source)`
44
51
  );
45
52
  }
53
+ if (hasMatch) {
54
+ const match = t.match;
55
+ if (match.length === 0) {
56
+ throw new ValidationError(`withDerivation: triggerBy match on "${t.collection}" must be non-empty`);
57
+ }
58
+ const seen = /* @__PURE__ */ new Set();
59
+ for (const p of match) {
60
+ if (typeof p?.from !== "string" || p.from.length === 0 || typeof p?.to !== "string" || p.to.length === 0) {
61
+ throw new ValidationError(`withDerivation: triggerBy match on "${t.collection}" needs non-empty \`from\` and \`to\` in every pair`);
62
+ }
63
+ if (seen.has(p.to)) {
64
+ throw new ValidationError(`withDerivation: triggerBy match on "${t.collection}" repeats \`to: "${p.to}"\` \u2014 two pairs constraining one source field is a contradiction, not a wider match`);
65
+ }
66
+ seen.add(p.to);
67
+ }
68
+ }
46
69
  if (t.maxFanout !== void 0 && (!Number.isInteger(t.maxFanout) || t.maxFanout < 1)) {
47
70
  throw new ValidationError(
48
71
  `withDerivation: triggerBy maxFanout on "${t.collection}" must be a positive integer (got ${String(t.maxFanout)}).`
@@ -126,4 +149,4 @@ export {
126
149
  withDerivation,
127
150
  withRollup
128
151
  };
129
- //# sourceMappingURL=chunk-T6PSWLAR.js.map
152
+ //# sourceMappingURL=chunk-BUE2D7PX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-formula/derivations/with-derivation.ts","../src/with-formula/derivations/with-rollup.ts"],"sourcesContent":["import { ValidationError } from '../../kernel/errors.js'\nimport type { DerivationSpec, DerivationStrategy } from './types.js'\n\n/**\n * Register a deterministic derivation: one source collection → one or\n * more typed outputs, computed by the user's `derive` function on\n * plaintext after DEK unwrap. Outputs are encrypted with the same DEK\n * as the source and written via the standard `Collection.put` path.\n *\n * See design-history/2026-05-01-dim14-derivation-v1-design.md.\n */\nexport function withDerivation<\n TSource extends Record<string, unknown>,\n TOutputs extends Record<string, Record<string, unknown> | ReadonlyArray<Record<string, unknown>>>,\n>(spec: DerivationSpec<TSource, TOutputs>): DerivationStrategy {\n if (!spec.source || spec.source.length === 0) {\n throw new ValidationError('withDerivation: source collection name is required')\n }\n if (!spec.outputs || Object.keys(spec.outputs).length === 0) {\n throw new ValidationError('withDerivation: outputs map must declare at least one output')\n }\n if (spec.deterministic !== true) {\n throw new ValidationError('withDerivation: v1 only supports deterministic derivations')\n }\n if (typeof spec.derive !== 'function') {\n throw new ValidationError('withDerivation: derive must be a function')\n }\n\n // Validate declared sibling sources. Each must be a non-empty\n // string and must differ from the primary source — a self-reference\n // would double-register the strategy under the same `_bySource` key.\n if (spec.sources !== undefined) {\n for (const extra of spec.sources) {\n if (typeof extra !== 'string' || extra.length === 0) {\n throw new ValidationError('withDerivation: each entry in sources[] must be a non-empty string')\n }\n if (extra === spec.source) {\n throw new ValidationError(\n `withDerivation: sources[] must not contain the primary source \"${spec.source}\"`,\n )\n }\n }\n }\n\n // Validate FK triggers. Each `collection` must be a non-empty\n // string differing from the primary source, and `on` a non-empty field.\n if (spec.triggerBy !== undefined) {\n for (const t of spec.triggerBy) {\n if (typeof t?.collection !== 'string' || t.collection.length === 0) {\n throw new ValidationError('withDerivation: each triggerBy entry needs a non-empty `collection`')\n }\n if (t.collection === spec.source) {\n throw new ValidationError(\n `withDerivation: triggerBy.collection must not equal the source \"${spec.source}\" (use sources[] for same-id triggers)`,\n )\n }\n const hasOn = typeof (t as { on?: unknown }).on === 'string'\n const hasMatch = Array.isArray((t as { match?: unknown }).match)\n if (hasOn === hasMatch) {\n throw new ValidationError(\n `withDerivation: triggerBy on \"${t.collection}\" needs exactly one of \\`on\\` or \\`match\\``,\n )\n }\n if (hasOn && (t as { on: string }).on.length === 0) {\n throw new ValidationError(\n `withDerivation: triggerBy on \"${t.collection}\" needs a non-empty \\`on\\` (the FK field on the source)`,\n )\n }\n if (hasMatch) {\n const match = (t as { match: ReadonlyArray<{ from: string; to: string }> }).match\n if (match.length === 0) {\n throw new ValidationError(`withDerivation: triggerBy match on \"${t.collection}\" must be non-empty`)\n }\n const seen = new Set<string>()\n for (const p of match) {\n if (typeof p?.from !== 'string' || p.from.length === 0 || typeof p?.to !== 'string' || p.to.length === 0) {\n throw new ValidationError(`withDerivation: triggerBy match on \"${t.collection}\" needs non-empty \\`from\\` and \\`to\\` in every pair`)\n }\n if (seen.has(p.to)) {\n throw new ValidationError(`withDerivation: triggerBy match on \"${t.collection}\" repeats \\`to: \"${p.to}\"\\` — two pairs constraining one source field is a contradiction, not a wider match`)\n }\n seen.add(p.to)\n }\n }\n if (t.maxFanout !== undefined && (!Number.isInteger(t.maxFanout) || t.maxFanout < 1)) {\n throw new ValidationError(\n `withDerivation: triggerBy maxFanout on \"${t.collection}\" must be a positive integer (got ${String(t.maxFanout)}).`,\n )\n }\n }\n }\n\n // Validate array-shape outputs.\n const lifecycleMode = typeof spec.lifecycle === 'string' ? spec.lifecycle : spec.lifecycle.mode\n for (const [outputKey, outputSpec] of Object.entries(spec.outputs)) {\n // Self-write output (collection === source): reverse-denorm must declare\n // `denorm` (the fields it owns) — field-level provenance.\n if (outputSpec.shape === 'record' && outputSpec.collection === spec.source) {\n if (!outputSpec.denorm || outputSpec.denorm.length === 0) {\n throw new ValidationError(\n `withDerivation: self-write output \"${outputKey}\" (collection === source \"${spec.source}\") `\n + 'must declare `denorm: [...]` naming the fields it maintains.',\n )\n }\n }\n if (outputSpec.shape === 'array') {\n if (lifecycleMode !== 'eager') {\n throw new ValidationError(\n `withDerivation: shape 'array' supports lifecycle 'eager' only in this release `\n + `Output \"${outputKey}\" declared lifecycle '${lifecycleMode}'. `\n + 'Switch to `lifecycle: \"eager\"` or use shape: \"record\".',\n )\n }\n if (typeof outputSpec.key !== 'function') {\n throw new ValidationError(\n `withDerivation: shape 'array' output \"${outputKey}\" requires \\`key: (out) => string\\`.`,\n )\n }\n if (outputSpec.maxFanout !== undefined) {\n if (!Number.isInteger(outputSpec.maxFanout) || outputSpec.maxFanout < 1) {\n throw new ValidationError(\n `withDerivation: maxFanout for output \"${outputKey}\" must be a positive integer `\n + `(got ${String(outputSpec.maxFanout)}).`,\n )\n }\n }\n }\n }\n\n return {\n __noydb_strategy: 'derivation',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n spec: spec as DerivationSpec<any, any>,\n }\n}\n","import { ValidationError } from '../../kernel/errors.js'\nimport type { DerivationSpec, DerivationStrategy } from './types.js'\n\n/** Options for {@link withRollup} (#844b — was an inline literal, so unnameable). */\nexport interface WithRollupOptions<\n TChild extends Record<string, unknown> = Record<string, unknown>,\n TParent extends Record<string, unknown> = Record<string, unknown>,\n> {\n /** Child collection — the trigger. */\n readonly from: string\n /** FK field on the child pointing at the parent id. */\n readonly key: keyof TChild & string\n /** Parent collection carrying the maintained summary. */\n readonly into: string\n /** Field on the parent to maintain. */\n readonly field: keyof TParent & string\n /** Reduces the matching children to the value written at `field`. */\n readonly compute: (children: TChild[]) => unknown\n}\n\n/**\n * `withRollup` — aggregate many child records onto a single field of their\n * parent. The reverse of a join: instead of reading children\n * on demand, the parent carries a maintained summary.\n *\n * ```ts\n * withRollup<Sale, Buyer>({\n * from: 'sales', // child collection (the trigger)\n * key: 'buyerId', // FK on the child → parent id\n * into: 'buyers', // parent collection\n * field: 'revenueByYear', // field on the parent to maintain\n * compute: (sales) => groupSumByYear(sales, 'total'),\n * })\n * ```\n *\n * On every write OR delete of a `from` record, the parent at id `child[key]`\n * is recomputed: `compute(allChildren where child[key] === parentId)` is\n * patched onto `parent[field]`. A parent write also recomputes its own\n * aggregate (so a parent created after its children still fills in). Only the\n * `field` is touched — the rest of the parent record is never clobbered — and\n * a value-equality guard suppresses no-op writes. The aggregate is gap-free\n * with respect to child inserts, updates, and deletes.\n *\n * Desugars to a `withDerivation` strategy carrying a `rollup` marker; dispatch\n * handles it without invoking the executor. Eager-only in this slice.\n */\nexport function withRollup<\n TChild extends Record<string, unknown> = Record<string, unknown>,\n TParent extends Record<string, unknown> = Record<string, unknown>,\n>(opts: WithRollupOptions<TChild, TParent>): DerivationStrategy {\n const { from, key, into, field, compute } = opts\n if (!from || from.length === 0) {\n throw new ValidationError('withRollup: `from` (child collection) is required')\n }\n if (!into || into.length === 0) {\n throw new ValidationError('withRollup: `into` (parent collection) is required')\n }\n if (from === into) {\n throw new ValidationError('withRollup: `from` and `into` must be different collections')\n }\n if (!key || key.length === 0) {\n throw new ValidationError('withRollup: `key` (FK field on the child) is required')\n }\n if (!field || field.length === 0) {\n throw new ValidationError('withRollup: `field` (target field on the parent) is required')\n }\n if (typeof compute !== 'function') {\n throw new ValidationError('withRollup: `compute` must be a function')\n }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const spec: DerivationSpec<any, any> = {\n source: into, // the parent record is what carries the rolled-up field\n deterministic: true,\n rollup: { from, key, field, compute: compute as (children: unknown[]) => unknown },\n // Synthetic self-write output for registry / cycle bookkeeping. Dispatch\n // patches `field` directly (value-equality guarded); the executor is not run.\n outputs: { value: { shape: 'record', collection: into, denorm: [field] } },\n derive: () => ({ value: {} }), // never invoked for a rollup strategy\n lifecycle: 'eager',\n }\n\n return { __noydb_strategy: 'derivation', spec }\n}\n"],"mappings":"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","names":[]}