@noy-db/hub 0.6.0-pre.7 → 0.6.0-pre.9

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 (48) hide show
  1. package/dist/cargo/index.js +1 -1
  2. package/dist/{chunk-YM75S6N6.js → chunk-2GAJGUNM.js} +139 -11
  3. package/dist/chunk-2GAJGUNM.js.map +1 -0
  4. package/dist/{chunk-PXCBVDFY.js → chunk-5O5HRRTV.js} +35 -2
  5. package/dist/chunk-5O5HRRTV.js.map +1 -0
  6. package/dist/{chunk-XBRNBG4Z.js → chunk-BEFVYAR5.js} +2 -2
  7. package/dist/{chunk-J7FM3RAU.js → chunk-BRVW2GS5.js} +2 -2
  8. package/dist/{chunk-4R55OPT2.js → chunk-BWVKEOPE.js} +2 -2
  9. package/dist/{chunk-ZMSE2ZZ4.js → chunk-GAG7QOVO.js} +6 -6
  10. package/dist/chunk-GAG7QOVO.js.map +1 -0
  11. package/dist/{chunk-JXIDGFKS.js → chunk-HPLWFNG4.js} +3 -1
  12. package/dist/chunk-HPLWFNG4.js.map +1 -0
  13. package/dist/{chunk-GOLN6LQ5.js → chunk-N35NC4JH.js} +2 -2
  14. package/dist/{chunk-WNFZI3I5.js → chunk-RV3ARMPN.js} +2 -2
  15. package/dist/{chunk-NNYENE7N.js → chunk-RXYYFSBB.js} +36 -2
  16. package/dist/chunk-RXYYFSBB.js.map +1 -0
  17. package/dist/{dispatch-4NSDG6EP.js → dispatch-FZEAWKVM.js} +5 -5
  18. package/dist/{executor-HYQBNG6J.js → executor-YIQB2S6N.js} +3 -3
  19. package/dist/index.js +10 -10
  20. package/dist/kernel/vault.d.ts +8 -2
  21. package/dist/materialized-views/index.js +3 -3
  22. package/dist/money/index.js +2 -2
  23. package/dist/{noydb-LYIAD4CW.js → noydb-ASEXNJS6.js} +4 -4
  24. package/dist/periods/index.js +8 -2
  25. package/dist/pod/index.js +4 -4
  26. package/dist/{stale-C27ICSB4.js → stale-ARHFVL5U.js} +2 -2
  27. package/dist/sync/index.js +2 -2
  28. package/dist/via/money/normalize.d.ts +20 -0
  29. package/dist/with-audit/periods/index.d.ts +3 -3
  30. package/dist/with-audit/periods/periods.d.ts +81 -1
  31. package/dist/with-audit/periods/vault-facade.d.ts +54 -1
  32. package/dist/with-audit/periods/window.d.ts +10 -0
  33. package/dist/with-shape/introspection/index.d.ts +1 -0
  34. package/package.json +3 -3
  35. package/dist/chunk-JXIDGFKS.js.map +0 -1
  36. package/dist/chunk-NNYENE7N.js.map +0 -1
  37. package/dist/chunk-PXCBVDFY.js.map +0 -1
  38. package/dist/chunk-YM75S6N6.js.map +0 -1
  39. package/dist/chunk-ZMSE2ZZ4.js.map +0 -1
  40. /package/dist/{chunk-XBRNBG4Z.js.map → chunk-BEFVYAR5.js.map} +0 -0
  41. /package/dist/{chunk-J7FM3RAU.js.map → chunk-BRVW2GS5.js.map} +0 -0
  42. /package/dist/{chunk-4R55OPT2.js.map → chunk-BWVKEOPE.js.map} +0 -0
  43. /package/dist/{chunk-GOLN6LQ5.js.map → chunk-N35NC4JH.js.map} +0 -0
  44. /package/dist/{chunk-WNFZI3I5.js.map → chunk-RV3ARMPN.js.map} +0 -0
  45. /package/dist/{dispatch-4NSDG6EP.js.map → dispatch-FZEAWKVM.js.map} +0 -0
  46. /package/dist/{executor-HYQBNG6J.js.map → executor-YIQB2S6N.js.map} +0 -0
  47. /package/dist/{noydb-LYIAD4CW.js.map → noydb-ASEXNJS6.js.map} +0 -0
  48. /package/dist/{stale-C27ICSB4.js.map → stale-ARHFVL5U.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  PERIODS_COLLECTION
3
- } from "./chunk-JXIDGFKS.js";
3
+ } from "./chunk-HPLWFNG4.js";
4
4
  import {
5
5
  canonicalJson,
6
6
  sha256Hex
@@ -14,6 +14,35 @@ import {
14
14
  } from "./chunk-HNGSV2YC.js";
15
15
 
16
16
  // src/with-audit/periods/periods.ts
17
+ function resolveReopenState(events) {
18
+ let lastReopen;
19
+ let reclosedAfter;
20
+ let reopenCount = 0;
21
+ for (const e of events) {
22
+ if (e.op === "reopen") {
23
+ lastReopen = e;
24
+ reclosedAfter = void 0;
25
+ reopenCount++;
26
+ } else if (lastReopen !== void 0) {
27
+ reclosedAfter = e.at;
28
+ }
29
+ }
30
+ if (!lastReopen) return { reopenCount };
31
+ return {
32
+ reopenedAt: lastReopen.at,
33
+ reopenedBy: lastReopen.by,
34
+ ...lastReopen.until !== void 0 && { reopenedUntil: lastReopen.until },
35
+ ...lastReopen.reason !== void 0 && { reopenReason: lastReopen.reason },
36
+ ...reclosedAfter !== void 0 && { reclosedAt: reclosedAfter },
37
+ reopenCount
38
+ };
39
+ }
40
+ function isEffectivelyReopened(period, nowIso) {
41
+ if (period.reopenedAt === void 0) return false;
42
+ if (period.reclosedAt !== void 0 && period.reclosedAt >= period.reopenedAt) return false;
43
+ if (period.reopenedUntil !== void 0 && nowIso > period.reopenedUntil) return false;
44
+ return true;
45
+ }
17
46
  function resolvePeriodKey(name, partition) {
18
47
  if (!partition || partition.length === 0) return name;
19
48
  const parts = partition.map((p) => {
@@ -59,8 +88,10 @@ function assertTsWritable(existing, incomingRecord, closedPeriods, scope) {
59
88
  const existingRecord = existing?.record ?? null;
60
89
  const existingPartition = partitionOf(existingRecord);
61
90
  const incomingPartition = partitionOf(incomingRecord);
91
+ const now = (/* @__PURE__ */ new Date()).toISOString();
62
92
  for (const p of closedPeriods) {
63
93
  if (p.kind !== "closed") continue;
94
+ if (isEffectivelyReopened(p, now)) continue;
64
95
  if (p.dateField) {
65
96
  const checkRecord = (label, r, recordPartition) => {
66
97
  if (!r) return;
@@ -155,6 +186,8 @@ var NO_PERIODS = {
155
186
 
156
187
  export {
157
188
  NO_PERIODS,
189
+ resolveReopenState,
190
+ isEffectivelyReopened,
158
191
  resolvePeriodKey,
159
192
  samePartition,
160
193
  loadPeriods,
@@ -165,4 +198,4 @@ export {
165
198
  purgeMarkersOn,
166
199
  withPeriods
167
200
  };
168
- //# sourceMappingURL=chunk-PXCBVDFY.js.map
201
+ //# sourceMappingURL=chunk-5O5HRRTV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-audit/periods/periods.ts","../src/with-audit/periods/active.ts","../src/with-audit/periods/strategy.ts"],"sourcesContent":["/**\n * Accounting-period closure + opening.\n *\n * A closed period seals every record whose envelope `_ts` is at or\n * before the period's `endDate`: further writes (`put` / `delete`)\n * against such records throw {@link PeriodClosedError}. The period\n * itself is stored as a record in the reserved `_periods` collection\n * and written through the normal ledger-instrumented path, so every\n * closure appends a tamper-evident entry to the vault's hash chain.\n *\n * ## Closure model\n *\n * ```\n * vault.closePeriod({ name: 'FY2026-Q1', endDate: '2026-03-31' })\n * └─► PeriodRecord written to _periods/<name>\n * ├─ priorPeriodName / priorPeriodHash — chain to last close\n * ├─ closedAt / closedBy — provenance\n * └─ normal ledger append fires (LedgerStore.append)\n * ```\n *\n * Enforcement (`assertTsWritable`) is vault-local: the Vault caches\n * the list of closed periods on first read and consults that cache in\n * the `Collection.put` / `.delete` path via the `periodGuard` hook.\n *\n * ## Opening model\n *\n * ```\n * vault.openPeriod({\n * name: 'FY2026-Q2',\n * startDate: '2026-04-01',\n * fromPeriod: 'FY2026-Q1',\n * carryForward: async (priorView) => Record<string, Record<string, unknown>>,\n * })\n * ```\n *\n * `carryForward` receives a read-only `VaultInstant` anchored at the\n * prior period's `endDate` (built via `vault.at(endDate)`) so the\n * callback can compute closing aggregates from the sealed state. The\n * returned `{ [collectionName]: { [id]: record } }` map is written\n * before the new `PeriodRecord` lands — opening balances materialise\n * as normal records with fresh timestamps that fall outside every\n * closed period.\n *\n * ## Freeze\n *\n * ```\n * vault.freezePeriod('FY2026-Q1')\n * └─► physically purges delete markers whose write-time falls inside\n * the closed period's window (via the #589 `_purgeDeleteMarkers`\n * seam), then records the fact:\n * ├─ PeriodFreezeRecord written to _period_freezes/<name>\n * └─ normal ledger append fires (LedgerStore.append)\n * ```\n *\n * The chained `_periods/<name>` record is never mutated — `frozenAt` /\n * `frozenBy` / `purgedMarkerCount` are merged onto the returned\n * `PeriodRecord` at read time from the companion, so a tamper with the\n * freeze can never break the inter-period hash chain. Freezing is\n * terminal (a closed period, once frozen, stays frozen) and idempotent\n * (a second call is a no-op that returns the same merged record without\n * re-purging or re-appending a ledger entry). Freeze does NOT purge\n * forget-tombstones (GDPR crypto-shred erasure evidence), `_history`\n * versions, or live records — the delete-markers-only seam leaves all\n * three untouched by construction.\n *\n * Freeze purges the LOCAL adapter only. On a synced vault, markers already\n * pushed to sync targets survive there, and a later pull re-imports them\n * (benign — they still read deleted, but the space isn't reclaimed). A\n * re-imported marker keeps its original `_ts` (inside the already-frozen\n * period's window), so — like any late-booked delete — it is reclaimed by\n * the NEXT period's freeze, whose window covers it; freeze stays terminal\n * and does NOT re-purge an already-frozen period (#611). Sweeping the sync\n * targets themselves is a cross-target-purge concern deferred to the\n * cold-archival spec. Purging re-opens the #589 resurrection window for a\n * peer offline since before the cutoff, which is why the closed period is\n * the operator-asserted safe-point that gates the call.\n *\n * A period whose purge window has not fully elapsed cannot be frozen —\n * `freezePeriod` throws rather than purge markers for deletes that may not\n * have converged yet (#610).\n *\n * ## Archive\n *\n * ```\n * vault.archivePeriod('FY2026-Q1')\n * └─► relocates the closed period's in-window records (those with\n * `_ts < periodExclusiveUpperBound(endDate)`) from the hot store to\n * the configured cold tier (routeStore's `cold` route), then records:\n * ├─ PeriodArchiveRecord written to _period_archives/<name>\n * └─ a ledger entry attributed to _period_archives\n * ```\n *\n * Archival is NON-DESTRUCTIVE: routeStore reads fall through to the cold\n * tier on a hot miss, so an archived record still reads normally. It is\n * therefore gated only on `closed` (not `frozen`) — it does not re-open the\n * #589 resurrection window and needs no convergence safe-point. Freeze\n * (purge markers) and archive (relocate records) are independent and compose\n * in either order. Like freeze, archival keeps the chained `_periods/<name>`\n * record byte-immutable (state lives in the companion) and is idempotent.\n *\n * Bounds by write-time `_ts`, NOT business date: the store tier sees only\n * encrypted envelopes. A record with an in-period business date but a later\n * `_ts` (late-booked) archives at the NEXT period's archive — the same rule\n * freeze uses for late-booked delete markers. Requires a `routeStore` with a\n * cold route (`age: { cold }`); throws otherwise.\n *\n * Read cost: with `withLazy()` (per-id reads) archived records are truly\n * cold — fetched from cold only on access. In the default hydrated mode,\n * `loadAll` merges the cold store, so archived records still load into RAM\n * on vault open (hot-tier STORAGE is reclaimed; RAM is not). Summaries\n * (`_`-prefixed) always stay hot.\n *\n * ## Target-purge\n *\n * ```\n * vault.purgePeriodTargets('FY2026-Q1')\n * └─► sweeps delete markers (`_ts < periodExclusiveUpperBound(endDate)`) off\n * the vault's PUSH-ONLY sync targets (backup/archive roles), then records:\n * ├─ PeriodTargetPurgeRecord written to _period_target_purges/<name>\n * └─ a ledger entry attributed to _period_target_purges\n * ```\n *\n * Extends freeze's local marker purge to the vault's own remote sinks.\n * `sync-peer` (bidirectional) targets are SKIPPED: purging a marker there\n * re-opens the #589 resurrection window for a client offline before the\n * cutoff, an assertion no single vault can verify. Backup/archive targets are\n * push-only — never pulled from into convergence — so sweeping their markers\n * is safe. Requires the period be frozen first (closed → frozen →\n * target-purged) so the local safe-point is already established. Idempotent\n * once run; a vault with no push-only targets writes no companion and is\n * re-runnable (so a target added later is still swept). Single-vault only —\n * fleet-wide purge across sovereign vaults is klum's concern over\n * `@noy-db/hub/cargo`.\n *\n * ## Not covered\n *\n * - Partial re-opening of a closed period. If an auditor needs to\n * make a correction inside a sealed period, the sanctioned path is\n * a compensating entry in the NEW period, not an unlock of the\n * old one.\n * - Automatic period rollover. `closePeriod` / `openPeriod` are\n * deliberately explicit operator calls so the caller decides when\n * the boundary lands.\n *\n * @module\n */\n\nimport type { NoydbStore, EncryptedEnvelope } from '../../kernel/types.js'\nimport type { LedgerStore } from '../../with-commit/history/ledger/index.js'\nimport { sha256Hex, canonicalJson } from '../../with-commit/history/ledger/index.js'\nimport { isDeleteMarker } from '../../kernel/enclave/index.js'\nimport { PeriodClosedError, ValidationError } from '../../kernel/errors.js'\n\n// The reserved collection names + `periodExclusiveUpperBound` moved to the\n// dependency-light `window.ts` so the sync engine's period-scoped pull (#807)\n// can import them without this module's ledger imports; re-exported here so\n// every existing import path keeps working.\nexport {\n PERIODS_COLLECTION,\n PERIOD_FREEZES_COLLECTION,\n PERIOD_ARCHIVES_COLLECTION,\n PERIOD_TARGET_PURGES_COLLECTION,\n PERIOD_REOPENS_COLLECTION,\n periodExclusiveUpperBound,\n} from './window.js'\nimport { PERIODS_COLLECTION } from './window.js'\n\n/**\n * Companion record recording that a closed period was frozen (its delete\n * markers physically purged). Stored in {@link PERIOD_FREEZES_COLLECTION},\n * keyed by period name — kept OFF the hash-chained `_periods/<name>` record so\n * freeze never alters the inter-period chain.\n */\nexport interface PeriodFreezeRecord {\n readonly period: string\n readonly frozenAt: string\n readonly frozenBy: string\n readonly purgedMarkerCount: number\n}\n\n/**\n * Companion record noting that a closed period was cold-archived (its\n * in-window records physically relocated hot → cold). Stored in\n * {@link PERIOD_ARCHIVES_COLLECTION}, keyed by period name — kept OFF the\n * hash-chained `_periods/<name>` record so archive never alters the chain.\n */\nexport interface PeriodArchiveRecord {\n readonly period: string\n readonly archivedAt: string\n readonly archivedBy: string\n readonly archivedRecordCount: number\n}\n\n/** Per-target count of delete markers purged off one push-only sync target (#615). */\nexport interface TargetPurgeCount {\n readonly label?: string\n readonly role: 'backup' | 'archive'\n readonly purgedCount: number\n}\n\n/**\n * One entry in a period's append-only reopen/reclose log (#1022).\n *\n * Real accounting close is a three-state lifecycle — open / closed / reopened\n * — not a one-way door. A month gets closed, a missing invoice arrives or a\n * filing is rejected, the month is reopened, corrected, and reclosed. The audit\n * value is not the ability to write again; it is the chain being able to say\n * *closed at T1, reopened at T2 by U, reclosed at T3*.\n */\nexport interface PeriodReopenEvent {\n readonly op: 'reopen' | 'reclose'\n /** ISO timestamp the event was recorded. */\n readonly at: string\n /** userId of the keyring that performed it. */\n readonly by: string\n /**\n * `reopen` only — ISO instant after which the period re-seals on its own,\n * with nobody acting. Absent means the window stays open until an explicit\n * `reclosePeriod`.\n */\n readonly until?: string\n /** Free-text justification, carried verbatim into the audit trail. */\n readonly reason?: string\n}\n\n/**\n * Companion holding a period's reopen/reclose history (#1022). Stored in\n * {@link PERIOD_REOPENS_COLLECTION}, keyed by the period's storage key — kept\n * OFF the hash-chained `_periods/<name>` record for the same reason freeze and\n * archive are: reopening must never rewrite the close, or the chain that proves\n * the close happened is the very thing the reopen destroys.\n *\n * `events` is APPEND-ONLY. Where the other companions are single-shot and\n * idempotent, this one accumulates, because the cycle repeats.\n */\nexport interface PeriodReopenRecord {\n readonly period: string\n readonly partition?: PeriodPartition\n readonly events: readonly PeriodReopenEvent[]\n}\n\n/**\n * Collapse an append-only reopen log into the return-only fields merged onto a\n * {@link PeriodRecord} on read.\n *\n * Expiry is deliberately NOT resolved here: `reopenedUntil` is carried through\n * verbatim and compared against the clock at write-guard time, so a bounded\n * window re-seals on its own without anything having to run.\n *\n * @internal\n */\nexport function resolveReopenState(events: readonly PeriodReopenEvent[]): {\n reopenedAt?: string\n reopenedBy?: string\n reopenedUntil?: string\n reopenReason?: string\n reclosedAt?: string\n reopenCount: number\n} {\n let lastReopen: PeriodReopenEvent | undefined\n let reclosedAfter: string | undefined\n let reopenCount = 0\n for (const e of events) {\n if (e.op === 'reopen') {\n lastReopen = e\n reclosedAfter = undefined\n reopenCount++\n } else if (lastReopen !== undefined) {\n reclosedAfter = e.at\n }\n }\n if (!lastReopen) return { reopenCount }\n return {\n reopenedAt: lastReopen.at,\n reopenedBy: lastReopen.by,\n ...(lastReopen.until !== undefined && { reopenedUntil: lastReopen.until }),\n ...(lastReopen.reason !== undefined && { reopenReason: lastReopen.reason }),\n ...(reclosedAfter !== undefined && { reclosedAt: reclosedAfter }),\n reopenCount,\n }\n}\n\n/**\n * Is this period writable right now on account of a reopen? (#1022)\n *\n * Three ways to be sealed again: never reopened, explicitly reclosed after the\n * last reopen, or a bounded window that has elapsed. The clock is read by the\n * caller and passed in, so the guard and any diagnostic agree on one instant.\n *\n * @internal\n */\nexport function isEffectivelyReopened(period: PeriodRecord, nowIso: string): boolean {\n if (period.reopenedAt === undefined) return false\n if (period.reclosedAt !== undefined && period.reclosedAt >= period.reopenedAt) return false\n if (period.reopenedUntil !== undefined && nowIso > period.reopenedUntil) return false\n return true\n}\n\n/**\n * Companion record noting that a closed+frozen period's delete markers were\n * swept off the vault's push-only sync targets (#615). Stored in\n * {@link PERIOD_TARGET_PURGES_COLLECTION}, keyed by period name — kept OFF the\n * hash-chained `_periods/<name>` record so target-purge never alters the chain.\n */\nexport interface PeriodTargetPurgeRecord {\n readonly period: string\n readonly purgedAt: string\n readonly purgedBy: string\n readonly targets: readonly TargetPurgeCount[]\n}\n\n/**\n * Scope tuple for a period timeline (#1005).\n *\n * Identical in shape and semantics to `SequenceOptions.partition`: a\n * partitioned timeline is always disjoint from any unpartitioned one, and from\n * every other tuple. `['acme', 'vat']` and `['acme', 'wht']` are two\n * independent close calendars for the same subject — which is the whole point,\n * since sub-ledgers for one legal entity and one month routinely close on\n * different statutory deadlines.\n */\nexport type PeriodPartition = readonly (string | number)[]\n\n/**\n * Resolve the `_periods` storage key for a (name, partition) pair.\n *\n * Deliberately the same encoding as `resolveSequenceKey`: `name` verbatim when\n * unpartitioned, else `${name}\\x00${parts}` with each component\n * `encodeURIComponent`d and `'/'`-joined. The null-byte separator cannot occur\n * in a period name, so a partitioned key never collides with an unpartitioned\n * one; URI-encoding keeps `['a/b']` distinct from `['a','b']`.\n *\n * @throws {ValidationError} on an empty component or a non-finite number.\n * @internal\n */\nexport function resolvePeriodKey(name: string, partition?: PeriodPartition): string {\n if (!partition || partition.length === 0) return name\n const parts = partition.map((p) => {\n if (typeof p === 'number' && !Number.isFinite(p)) {\n throw new ValidationError(`period partition component must be a finite number, got ${p}`)\n }\n const s = String(p)\n if (s === '') {\n throw new ValidationError('period partition component must not be empty')\n }\n return encodeURIComponent(s)\n })\n return `${name}\\x00${parts.join('/')}`\n}\n\n/**\n * Do two partitions denote the same timeline? Absent and empty both mean \"the\n * unpartitioned timeline\", so they compare equal.\n *\n * @internal\n */\nexport function samePartition(a?: PeriodPartition, b?: PeriodPartition): boolean {\n const x = a ?? []\n const y = b ?? []\n if (x.length !== y.length) return false\n return x.every((v, i) => String(v) === String(y[i]))\n}\n\n/**\n * Resolves a record to the timeline that governs it. Supplied by\n * `withPeriods({ subjects })`; returns `undefined` for any collection with no\n * mapping, which is what keeps an unconfigured vault on the single vault-wide\n * timeline it has always had.\n *\n * @internal\n */\nexport type PartitionResolver = (\n collection: string,\n record: Record<string, unknown>,\n) => PeriodPartition | undefined\n\n/**\n * Stored record for one closed or opened accounting period. One entry\n * per period, keyed by `name` in the reserved `_periods` collection.\n *\n * The hash chain between periods is computed at read time by\n * `loadPeriods()` — each record carries the name + hash of its\n * predecessor so a tamper with any period's record breaks the chain\n * into the next one, the same way the ledger's `prevHash` works.\n */\nexport interface PeriodRecord {\n /**\n * Human-readable name (e.g., `'FY2026-Q1'`). Unique per PARTITION — two\n * timelines may each carry a `'2026-06'`, which is the normal case when one\n * vault serves several subjects (#1005). Unique per vault when unpartitioned.\n */\n readonly name: string\n /**\n * The timeline this period belongs to. Absent = the vault-wide timeline.\n * Two periods with the same `name` and different `partition` are unrelated:\n * separate hash chains, separate close state, and the write guard applies\n * each only to records that resolve to its own tuple.\n */\n readonly partition?: PeriodPartition\n /**\n * Role discriminator. A period is `'closed'` from the moment its\n * `closedAt` is recorded; `'opened'` marks a period whose opening\n * entries have been carried forward via {@link openPeriod}. Many\n * workflows will produce one opened period per closed period (the\n * opened one is the SUCCESSOR — its `startDate` equals the prior\n * `endDate + 1 day`).\n */\n readonly kind: 'closed' | 'opened'\n /** ISO date — inclusive upper bound for records belonging to this period. */\n readonly endDate: string\n /** ISO date — lower bound (present on opened periods only). */\n readonly startDate?: string\n /**\n * Record field carrying the business date (e.g. `'date'` on an\n * invoice, `'paidAt'` on a payment). The guard compares\n * `record[dateField]` against `endDate` — NOT the envelope `_ts`.\n * Accounting entries booked late (business date `2026-01-15`,\n * write-time `2026-04-22`) still get sealed when Q1 closes at\n * `2026-03-31` because the comparison uses the business date.\n *\n * Optional for backwards compat. When absent, the guard falls back\n * to envelope `_ts` — that's a write-time seal, appropriate for\n * content that doesn't carry a logical business date (e.g. system\n * settings) but almost never right for accounting ledgers.\n */\n readonly dateField?: string\n /** ISO timestamp recorded at `closePeriod()` / `openPeriod()` call time. */\n readonly closedAt: string\n /** userId of the keyring that invoked the close/open. */\n readonly closedBy: string\n /** Name of the prior period this one chains to, if any. */\n readonly priorPeriodName?: string\n /** sha256(canonicalJson(priorPeriod)) — empty for the first period. */\n readonly priorPeriodHash: string\n /**\n * Opened periods only — the names of the collections whose\n * carry-forward aggregates were written by {@link openPeriod}.\n * Recorded for auditability so a future `verifyPeriodChain()` can\n * cross-check the opening balances against the closing snapshot.\n */\n readonly openingCollections?: readonly string[]\n /** #604 return-only — merged from the `_period_freezes/<name>` companion on\n * read; NEVER written into the stored `_periods/<name>` record (would break\n * the hash chain). Absent = not yet frozen. */\n readonly frozenAt?: string\n readonly frozenBy?: string\n readonly purgedMarkerCount?: number\n /** #613 return-only — merged from the `_period_archives/<name>` companion on\n * read; NEVER written into the stored `_periods/<name>` record. Absent = not\n * yet archived. */\n readonly archivedAt?: string\n readonly archivedBy?: string\n readonly archivedRecordCount?: number\n /** #615 return-only — merged from the `_period_target_purges/<name>` companion\n * on read; NEVER written into the stored `_periods/<name>` record. Absent =\n * target-purge not yet run (or the vault has no push-only targets). */\n readonly targetsPurgedAt?: string\n readonly targetsPurgedBy?: string\n readonly targetsPurged?: readonly TargetPurgeCount[]\n /** #1022 return-only — collapsed from the `_period_reopens/<key>` append-only\n * log on read; NEVER written into the stored `_periods/<name>` record, so a\n * reopen cannot disturb the inter-period hash chain. Absent = never reopened.\n * `reclosedAt` present (and >= `reopenedAt`) means the window was closed\n * again explicitly; `reopenedUntil` in the past means it lapsed on its own.\n * Use {@link isEffectivelyReopened} rather than reading these directly. */\n readonly reopenedAt?: string\n readonly reopenedBy?: string\n readonly reopenedUntil?: string\n readonly reopenReason?: string\n readonly reclosedAt?: string\n /** How many times this period has been reopened, ever. */\n readonly reopenCount?: number\n}\n\n/** Options for `vault.closePeriod()`. */\nexport interface ClosePeriodOptions {\n /** Human-readable name. Must not collide with an existing period. */\n readonly name: string\n /**\n * Inclusive upper cutoff. A record is sealed when its\n * `record[dateField]` (or, if absent, the envelope `_ts`) is at or\n * before this ISO timestamp.\n */\n readonly endDate: string\n /**\n * Record field carrying the business date used for period\n * membership. Recommended for accounting workflows — e.g. an\n * invoice booked late (write-time after close) is still sealed\n * when its `invoice.date` falls inside the closed period.\n *\n * Omit to use envelope `_ts` (write-time seal). This fallback\n * rarely matches real-world accounting semantics; prefer passing\n * an explicit `dateField`.\n */\n readonly dateField?: string\n /**\n * Close only this timeline (#1005). Omit for the vault-wide timeline.\n *\n * ```ts\n * vault.closePeriod({\n * name: '2026-06', endDate: '2026-06-30', dateField: 'issuedAt',\n * partition: [clientId, 'vat'],\n * })\n * ```\n *\n * Which records the resulting seal applies to is decided by the\n * `subjects` map passed to `withPeriods()` — without one, no record ever\n * resolves to a partition and a partitioned close seals nothing.\n */\n readonly partition?: PeriodPartition\n}\n\n/** Options for `vault.openPeriod()`. */\nexport interface OpenPeriodOptions<TCollections = Record<string, Record<string, unknown>>> {\n /** Human-readable name for the new period. Must be unique. */\n readonly name: string\n /**\n * The timeline to open in. Must match the partition of `fromPeriod` — a\n * period cannot chain across timelines, since each carries its own hash\n * chain (#1005).\n */\n readonly partition?: PeriodPartition\n /** ISO lower bound of the new period (usually prior `endDate + 1 day`). */\n readonly startDate: string\n /**\n * Name of the prior CLOSED period this one chains from. The prior\n * period's record is verified to exist and to be `kind: 'closed'`;\n * its `endDate` is made available to the `carryForward` callback.\n */\n readonly fromPeriod: string\n /**\n * Receives a read-only facade over the vault's CURRENT state,\n * plus the prior period's `endDate`. Accounting semantics: after\n * a period closes, records with `record[dateField] <= endDate`\n * are frozen — current state equals closing state, so a caller\n * can compute closing balances by querying the live collection\n * with a `where('date', '<=', priorEndDate)` filter.\n *\n * Returns opening-balance records keyed by collection name.\n * Example:\n *\n * ```ts\n * carryForward: async (ctx) => {\n * const closing = await ctx.collection<Journal>('journal')\n * .query().where('date', '<=', ctx.priorEndDate).toArray()\n * const opening: Record<string, Journal> = {}\n * for (const entry of closing) {\n * opening[`OB-${entry.id}`] = { ...entry, date: '2026-04-01' }\n * }\n * return { journal: opening }\n * }\n * ```\n */\n readonly carryForward: (\n ctx: CarryForwardContext,\n ) => Promise<TCollections> | TCollections\n}\n\n/**\n * Context passed to `OpenPeriodOptions.carryForward`. Exposes a\n * read-only subset of the live vault (`collection(name).get/list`)\n * plus the prior period's `endDate` so business-date filters can\n * be built by the caller.\n *\n * Writes go via the return value, not via the facade — the\n * `collection()` here is deliberately restricted to reads.\n */\nexport interface CarryForwardContext {\n /** The prior period's `endDate` — the boundary of the closing snapshot. */\n readonly priorEndDate: string\n /** Read-only collection facade over current vault state. */\n collection<T = unknown>(name: string): ReadOnlyCollection<T>\n}\n\n/** Minimum read surface exposed to `carryForward`. */\nexport interface ReadOnlyCollection<T> {\n get(id: string): Promise<T | null>\n list(): Promise<T[]>\n}\n\n/**\n * Load every period record currently stored on the adapter.\n * Decrypting is the caller's responsibility (we return plain records\n * so the vault can use its own `_periods` DEK).\n *\n * @internal — called by Vault methods that need the closed-period\n * cache. Not part of the public API surface.\n */\nexport async function loadPeriods(\n adapter: NoydbStore,\n vault: string,\n decrypt: (envelope: EncryptedEnvelope) => Promise<PeriodRecord>,\n): Promise<PeriodRecord[]> {\n const ids = await adapter.list(vault, PERIODS_COLLECTION)\n const records: PeriodRecord[] = []\n for (const id of ids) {\n const env = await adapter.get(vault, PERIODS_COLLECTION, id)\n if (env) records.push(await decrypt(env))\n }\n // Stable order by closedAt so chain verification is reproducible.\n records.sort((a, b) => a.closedAt.localeCompare(b.closedAt))\n return records\n}\n\n/**\n * Given the current ordered period list, pick the last entry that\n * belongs to the hash chain — used as the `priorPeriodHash` anchor\n * for the next closure/opening.\n *\n * @internal\n */\nexport async function chainAnchor(\n records: readonly PeriodRecord[],\n partition?: PeriodPartition,\n): Promise<{ priorPeriodName?: string; priorPeriodHash: string }> {\n // #1005 — each timeline carries its OWN chain. Anchoring a partitioned close\n // to whatever happened to be written last vault-wide would interleave\n // unrelated subjects into one chain, so verifying client A's June would\n // depend on client B never having closed in between.\n const inTimeline = records.filter((p) => samePartition(p.partition, partition))\n const last = inTimeline[inTimeline.length - 1]\n if (!last) return { priorPeriodHash: '' }\n const hash = await sha256Hex(canonicalJson(last as unknown as Record<string, unknown>))\n return { priorPeriodName: last.name, priorPeriodHash: hash }\n}\n\n/**\n * Throw `PeriodClosedError` if the record being touched falls within\n * any closed period.\n *\n * Three signals, evaluated per period:\n *\n * 1. If the period declares a `dateField`, the guard reads\n * `record[dateField]` on BOTH the existing (prior) record AND the\n * incoming (new) record. Either comparing `<= endDate` triggers\n * the error — callers cannot slide a record into a closed period\n * by editing its date field.\n * 2. If the period has no `dateField`, the guard falls back to the\n * envelope `_ts` of the existing record. Fresh inserts (no\n * existing envelope) pass.\n * 3. For a delete, only the existing side is checked.\n *\n * @internal\n */\nexport function assertTsWritable(\n existing: { ts: string | null; record: Record<string, unknown> | null } | null,\n incomingRecord: Record<string, unknown> | null,\n closedPeriods: readonly PeriodRecord[],\n scope?: { collection: string; resolve?: PartitionResolver },\n): void {\n // #1005 — a period only governs records that resolve to ITS timeline. With no\n // resolver (the default `withPeriods()`), nothing resolves to a partition, so\n // every record sits on the vault-wide timeline exactly as before and a\n // partitioned period governs nothing.\n const partitionOf = (r: Record<string, unknown> | null): PeriodPartition | undefined => {\n if (!r || !scope?.resolve) return undefined\n return scope.resolve(scope.collection, r)\n }\n const existingRecord = existing?.record ?? null\n const existingPartition = partitionOf(existingRecord)\n const incomingPartition = partitionOf(incomingRecord)\n // One instant for the whole check, so a bounded reopen window cannot expire\n // between two periods in the same loop and seal a write half-way.\n const now = new Date().toISOString()\n\n for (const p of closedPeriods) {\n if (p.kind !== 'closed') continue\n // #1022 — a reopened period is writable again. This is the ONLY thing a\n // reopen does: it withdraws the period's veto. It cannot grant a write that\n // some other gate forbids, because the guard bus ANDs every handler and\n // record-level guards are registered ahead of this one.\n if (isEffectivelyReopened(p, now)) continue\n if (p.dateField) {\n const checkRecord = (\n label: string,\n r: Record<string, unknown> | null,\n recordPartition: PeriodPartition | undefined,\n ): void => {\n if (!r) return\n // Both sides are checked under their OWN partition, which is what stops\n // a write from sliding a record either INTO or OUT OF a sealed\n // timeline by rewriting the fields the subject mapping reads.\n if (!samePartition(recordPartition, p.partition)) return\n const v = r[p.dateField!]\n if (typeof v === 'string' && v <= p.endDate) {\n throw new PeriodClosedError(p.name, p.endDate, `${label}[${p.dateField}]=${v}`)\n }\n }\n checkRecord('existing', existingRecord, existingPartition)\n checkRecord('incoming', incomingRecord, incomingPartition)\n continue\n }\n // Fallback: write-time seal via envelope _ts. Scoped by the EXISTING\n // record's partition — `_ts` belongs to the stored envelope, so the\n // incoming side has no write-time of its own to compare.\n if (!samePartition(existingPartition, p.partition)) continue\n const existingTs = existing?.ts ?? null\n if (existingTs !== null && existingTs <= p.endDate) {\n throw new PeriodClosedError(p.name, p.endDate, existingTs)\n }\n }\n}\n\n/**\n * Sanity-check a proposed period name + endDate against existing\n * records. Shared by closePeriod / openPeriod so the two pathways\n * produce identical diagnostics.\n *\n * @internal\n */\nexport function validatePeriodName(\n name: string,\n existing: readonly PeriodRecord[],\n partition?: PeriodPartition,\n): void {\n if (name.length === 0) {\n throw new ValidationError('Period name cannot be empty.')\n }\n // Validates the components as a side effect — an empty or non-finite\n // component must be rejected at the call, not encoded into a storage key.\n resolvePeriodKey(name, partition)\n // #1005 — uniqueness is per TIMELINE. `'2026-06'` in `['A','vat']` does not\n // collide with `'2026-06'` in `['B','vat']`.\n if (existing.some((p) => p.name === name && samePartition(p.partition, partition))) {\n const where = partition && partition.length > 0\n ? ` in partition [${partition.join(', ')}]`\n : ''\n throw new ValidationError(`Period \"${name}\" already exists${where}.`)\n }\n}\n\n/**\n * Wire a reserved-collection ledger append for a period record. The\n * period itself is stored via the adapter as an encrypted envelope;\n * the ledger entry is a normal `put` with the period's payloadHash,\n * so period closures inherit the chain's tamper-evidence.\n *\n * @internal\n */\nexport async function appendPeriodLedgerEntry(\n ledger: LedgerStore | null,\n actor: string,\n envelope: EncryptedEnvelope,\n name: string,\n collection: string = PERIODS_COLLECTION,\n): Promise<void> {\n if (!ledger) return\n const { envelopePayloadHash } = await import('../../with-commit/history/ledger/index.js')\n await ledger.append({\n op: 'put',\n collection,\n id: name,\n version: envelope._v,\n actor,\n payloadHash: await envelopePayloadHash(envelope),\n })\n}\n\n/**\n * @internal #615. Sweep delete markers with `_ts < before` off ANY store\n * (the vault's local adapter, or a push-only sync target). Returns the count\n * removed. Shared by `vault._purgeDeleteMarkers` (local) and\n * `vault._purgePeriodTargets` (push-only targets).\n */\nexport async function purgeMarkersOn(\n store: NoydbStore,\n vault: string,\n before: string,\n collections?: string[],\n): Promise<number> {\n const snapshot = await store.loadAll(vault)\n let removed = 0\n for (const [coll, records] of Object.entries(snapshot)) {\n if (collections && !collections.includes(coll)) continue\n for (const [id, env] of Object.entries(records)) {\n if (isDeleteMarker(env) && env._ts < before) {\n await store.delete(vault, coll, id)\n removed++\n }\n }\n }\n return removed\n}\n","/**\n * Active periods strategy factory. Only reachable through the\n * `@noy-db/hub/periods` subpath.\n */\n\nimport {\n loadPeriods,\n chainAnchor,\n assertTsWritable,\n validatePeriodName,\n appendPeriodLedgerEntry,\n} from './periods.js'\nimport type { PeriodPartition, PartitionResolver } from './periods.js'\nimport type { PeriodsStrategy } from './strategy.js'\n\n/** Options for {@link withPeriods}. */\nexport interface WithPeriodsOptions {\n /**\n * Maps a collection to the timeline each of its records belongs to (#1005) —\n * the answer to \"which close calendar governs THIS record\".\n *\n * ```ts\n * withPeriods({\n * subjects: { receipts: (r) => [r.clientId, layerOf(r)] },\n * })\n * ```\n *\n * Same shape as `withForget({ subjects })`, which answers the same question\n * for erasure. A collection with no entry — and every collection when\n * `subjects` is omitted entirely — stays on the vault-wide timeline, so an\n * existing vault behaves exactly as it did before partitions existed.\n *\n * Return `undefined` from a mapper to put an individual record back on the\n * vault-wide timeline (e.g. a record that predates the field the mapping\n * reads).\n */\n readonly subjects?: Readonly<\n Record<string, (record: Record<string, unknown>) => PeriodPartition | undefined>\n >\n}\n\n/**\n * Build the default periods strategy. Pass into\n * `createNoydb({ periodsStrategy: withPeriods() })` to enable\n * `vault.closePeriod()` / `vault.openPeriod()` / write-guards.\n *\n * Pass `subjects` to run more than one close calendar in a single vault — see\n * {@link WithPeriodsOptions.subjects}.\n */\nexport function withPeriods(options?: WithPeriodsOptions): PeriodsStrategy {\n const subjects = options?.subjects\n const partitionOf: PartitionResolver | undefined = subjects\n ? (collection, record) => subjects[collection]?.(record)\n : undefined\n\n return {\n loadPeriods,\n chainAnchor,\n assertTsWritable,\n validatePeriodName,\n appendPeriodLedgerEntry,\n ...(partitionOf !== undefined && { partitionOf }),\n }\n}\n","/**\n * Strategy seam for the optional accounting-periods service. Core\n * imports `PeriodsStrategy` type-only + `NO_PERIODS` stub; the real\n * `loadPeriods` / `chainAnchor` / `assertTsWritable` /\n * `validatePeriodName` / `appendPeriodLedgerEntry` functions are\n * only reachable via `withPeriods()` in `./active.ts`.\n *\n * Applications that never call `vault.closePeriod()` /\n * `vault.openPeriod()` ship none of the ~363 LOC.\n *\n * @internal\n */\n\nimport type { EncryptedEnvelope, NoydbStore } from '../../kernel/types.js'\nimport type { LedgerStore } from '../../with-commit/history/ledger/store.js'\nimport type { PeriodRecord, PeriodPartition, PartitionResolver } from './periods.js'\n\n/**\n * @internal\n */\nexport interface PeriodsStrategy {\n loadPeriods(\n adapter: NoydbStore,\n vault: string,\n decrypt: (envelope: EncryptedEnvelope) => Promise<PeriodRecord>,\n ): Promise<PeriodRecord[]>\n chainAnchor(records: readonly PeriodRecord[], partition?: PeriodPartition): Promise<{\n priorPeriodName?: string\n priorPeriodHash: string\n }>\n assertTsWritable(\n existing: { ts: string | null; record: Record<string, unknown> | null } | null,\n incoming: Record<string, unknown> | null,\n periods: readonly PeriodRecord[],\n scope?: { collection: string; resolve?: PartitionResolver },\n ): void\n validatePeriodName(\n name: string,\n existing: readonly PeriodRecord[],\n partition?: PeriodPartition,\n ): void\n /**\n * Record → timeline resolver built from `withPeriods({ subjects })` (#1005).\n * `undefined` when the caller configured no subjects, which keeps every\n * record on the vault-wide timeline.\n */\n readonly partitionOf?: PartitionResolver\n appendPeriodLedgerEntry(\n ledger: LedgerStore | null,\n actor: string,\n envelope: EncryptedEnvelope,\n periodName: string,\n collection?: string,\n ): Promise<void>\n}\n\n/**\n * No-periods stub. `loadPeriods` returns `[]`; the write-guards do\n * nothing (vaults without closed periods never reject writes);\n * `validatePeriodName` / `appendPeriodLedgerEntry` throw because\n * those paths are only reached when the user explicitly called\n * `closePeriod()` / `openPeriod()` — if they did that without the\n * strategy, they need to wire it.\n *\n * @internal\n */\nconst NOT_ENABLED = new Error(\n 'Accounting periods require the periods strategy. Import ' +\n '`{ withPeriods }` from \"@noy-db/hub/periods\" and pass it to ' +\n '`createNoydb({ periodsStrategy: withPeriods() })`.',\n)\n\nexport const NO_PERIODS: PeriodsStrategy = {\n async loadPeriods() { return [] },\n async chainAnchor() { return { priorPeriodHash: '' } },\n assertTsWritable() {},\n validatePeriodName() { throw NOT_ENABLED },\n async appendPeriodLedgerEntry() { throw NOT_ENABLED },\n}\n"],"mappings":"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","names":[]}
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-3URQC7EH.js";
7
7
 
8
8
  // src/with-formula/materialized-views/stale.ts
9
- var loadMVExecutor = lazy(() => import("./executor-HYQBNG6J.js"));
9
+ var loadMVExecutor = lazy(() => import("./executor-YIQB2S6N.js"));
10
10
  var _staleByRegistry = /* @__PURE__ */ new WeakMap();
11
11
  function markMVStale(registry, mvName) {
12
12
  let set = _staleByRegistry.get(registry);
@@ -134,4 +134,4 @@ export {
134
134
  clearMVStale,
135
135
  clearMVStaleFully
136
136
  };
137
- //# sourceMappingURL=chunk-XBRNBG4Z.js.map
137
+ //# sourceMappingURL=chunk-BEFVYAR5.js.map
@@ -7,7 +7,7 @@ import {
7
7
  PERIOD_FREEZES_COLLECTION,
8
8
  PERIOD_TARGET_PURGES_COLLECTION,
9
9
  periodExclusiveUpperBound
10
- } from "./chunk-JXIDGFKS.js";
10
+ } from "./chunk-HPLWFNG4.js";
11
11
  import {
12
12
  envelopeBodySize
13
13
  } from "./chunk-3URQC7EH.js";
@@ -1160,4 +1160,4 @@ export {
1160
1160
  SyncEngine,
1161
1161
  SyncTransaction
1162
1162
  };
1163
- //# sourceMappingURL=chunk-J7FM3RAU.js.map
1163
+ //# sourceMappingURL=chunk-BRVW2GS5.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createNoydb
3
- } from "./chunk-YM75S6N6.js";
3
+ } from "./chunk-2GAJGUNM.js";
4
4
  import {
5
5
  deriveSchemaManifest
6
6
  } from "./chunk-2DHSSLOD.js";
@@ -290,4 +290,4 @@ export {
290
290
  followRedirects,
291
291
  open
292
292
  };
293
- //# sourceMappingURL=chunk-4R55OPT2.js.map
293
+ //# sourceMappingURL=chunk-BWVKEOPE.js.map
@@ -2,10 +2,10 @@ import {
2
2
  wrapDbWithPredicates
3
3
  } from "./chunk-7F4DBNSP.js";
4
4
  import {
5
+ canonicalizeMoneyFieldsAsDecimal,
5
6
  decodeMoneyFields,
6
- exactMath,
7
- quantizeMoneyFields
8
- } from "./chunk-NNYENE7N.js";
7
+ exactMath
8
+ } from "./chunk-RXYYFSBB.js";
9
9
  import {
10
10
  groupAndReduce
11
11
  } from "./chunk-CCZJJ4I2.js";
@@ -116,8 +116,8 @@ function applyDerive(spec, rows) {
116
116
  );
117
117
  }
118
118
  }
119
- const stored = spec.moneyFields ? quantizeMoneyFields(patch, spec.moneyFields) : patch;
120
- return { ...row, ...stored };
119
+ const canonical = spec.moneyFields ? canonicalizeMoneyFieldsAsDecimal(patch, spec.moneyFields) : patch;
120
+ return { ...row, ...canonical };
121
121
  });
122
122
  }
123
123
  async function materializeProjectionResult(spec, db) {
@@ -307,4 +307,4 @@ async function listOutputIds(outputColl) {
307
307
  export {
308
308
  MaterializedViewExecutor
309
309
  };
310
- //# sourceMappingURL=chunk-ZMSE2ZZ4.js.map
310
+ //# sourceMappingURL=chunk-GAG7QOVO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-formula/materialized-views/executor.ts"],"sourcesContent":["import type { Collection } from '../../kernel/collection.js'\nimport type { TxContext } from '../../with-commit/tx/transaction.js'\nimport type { EncryptedEnvelope } from '../../kernel/types.js'\nimport { MaterializedViewTooLargeError, MaterializedViewConfigError, LocaleNotSpecifiedError, JoinTooLargeError } from '../../kernel/errors.js'\nimport { DEFAULT_JOIN_MAX_ROWS } from '../../kernel/query/join.js'\nimport type { MaterializedFromMeta, MVQueryContext, MaterializedViewSpec } from './types.js'\nimport type { RegisteredMV } from './registry.js'\nimport { wrapDbWithPredicates } from './registry.js'\nimport { groupAndReduce } from '../../with-lookup/reduce/groupby.js'\nimport { canonicalGroupKey } from '../../with-lookup/reduce/canonical-key.js'\nimport { applyI18nLocale, type I18nTextDescriptor } from '../../via/i18n/core.js'\nimport { canonicalizeMoneyFieldsAsDecimal, decodeMoneyFields } from '../../via/money/normalize.js'\nimport { exactMath } from '../../via/money/exact.js'\nimport { putDerivedOutput, type PutDerivedOutputCtx } from '../../kernel/via/dispatch.js'\n\n/**\n * Accessor shape passed in from the owning Vault. Mirrors v1's\n * `DerivationStaleAccessor` — provides the per-collection resolver\n * and the active TxContext so refresh writes/tombstones register on\n * `_executed` for rollback symmetry.\n */\nexport interface MVExecutorAccessor {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n getCollection(name: string): Collection<any>\n getActiveTxContext(): TxContext | null\n /**\n * Vault-shaped accessor passed to the MV's `query()` callback at\n * each refresh. Same instance the registry used at registration\n * time; threading through the executor lets the refresh path\n * re-evaluate the closure against the live vault state.\n */\n getQueryContext(): MVQueryContext\n /**\n * #638 Task 5 — ctx for `putDerivedOutput`'s frozen-period skip+audit. #641: the lazy\n * resolve-on-read caller (`stale.ts#resolveStaleMVOnRead`) now supplies one too — built at\n * the `Collection.get()`/`list()` entry point with a `'resolve-on-read'` sentinel id (no\n * real \"reacting write\" for a read-triggered materialize, mirroring `refreshView()`'s\n * `'refreshView'` sentinel). Still declared optional here since `MVExecutorAccessor` is a\n * general shape and a future caller could reasonably omit it.\n */\n dispatchCtx?: PutDerivedOutputCtx\n}\n\nexport interface RefreshResult {\n /** Rows newly written / overwritten. */\n written: number\n /** Rows tombstoned via `_internalDelete` (only when `onEmpty: 'delete'`). */\n deleted: number\n /** Failed row writes (non-strict mode). */\n failed: number\n /** #782/#785 — `outputCollection:id` entries from the tombstone pass whose ownership stamp\n * couldn't be decoded under the collection's default DEK (undecodable, mirroring\n * `invalidateMVAtRest`'s #776 posture) — ownership UNCONFIRMED. Only populated when\n * `onEmpty: 'delete'`. */\n residueUndecodable: string[]\n /** #782/#785 — `outputCollection:id` entries that decoded, stamp-matched this MV, but\n * `_internalDelete` declined (the #718 tier-elevation gate) — ownership CONFIRMED, a real\n * silent survival, surfaced rather than dropped. Only populated when `onEmpty: 'delete'`. */\n residueDeclined: string[]\n}\n\n/** Default cost ceiling — overridable per-MV via `spec.maxRows`. */\nconst DEFAULT_MAX_ROWS = 100_000\n\n/**\n * Materialize a query terminal that may be a `Query<T>` (call\n * `.toArray()`), an `Reduction<R>` (call `.run()` returning a\n * single object — wrap as a one-row array), or a `GroupedReduction<R>`\n * (call `.run()` returning an array of grouped rows). Branches on\n * available terminal at runtime — no type-discrimination at registration.\n */\nasync function materializeQueryResult(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n q: any,\n mvName: string,\n i18nLocale?: string,\n i18nFields?: Record<string, I18nTextDescriptor>,\n): Promise<ReadonlyArray<Record<string, unknown>>> {\n if (typeof q?.toArray === 'function') {\n // Query<T> — non-aggregate path. `.toArray()` returns Promise<T[]>.\n return await q.toArray()\n }\n if (typeof q?.run === 'function') {\n // Reduction<R> or GroupedReduction<R>. `.run()` is synchronous\n // and returns either a single object (Reduction) or an array of\n // rows (GroupedReduction). Promise.resolve() normalizes both\n // sync and async (future) variants.\n // Query-form MV grouping: when the MV declares i18nLocale, pass it +\n // i18nFields so a GroupedReduction resolves i18n group keys before\n // bucketing (the Reduction path ignores the extra arg).\n const runOpts = i18nLocale !== undefined ? { locale: i18nLocale, i18nFields } : undefined\n const result: unknown = await Promise.resolve(q.run(runOpts))\n if (Array.isArray(result)) {\n return result as ReadonlyArray<Record<string, unknown>>\n }\n // Single-aggregate result — wrap as one-row array. The consumer's\n // `rowKey()` should return a stable identity (often a literal\n // constant like `'total'`) since there's only one row.\n return [result as Record<string, unknown>]\n }\n throw new Error(\n `MV \"${mvName}\": query() must return a Query<T>, Reduction, or GroupedReduction. ` +\n `Got something without a .toArray() or .run() terminal.`,\n )\n}\n\n/**\n * Materialize a UNION-form MV: read every arm's source\n * collection, apply each arm's `map` to project rows into the unified\n * MV row shape, concatenate the mapped streams, then optionally run\n * `groupBy` + `aggregate` over the result.\n *\n * Modes (driven by `spec.groupBy` / `spec.aggregate`):\n *\n * - No `groupBy` → return the concatenated mapped rows unchanged.\n * - `groupBy` without `aggregate` → dedupe by composite group key,\n * keep the first row seen per key (later arms don't overwrite\n * earlier arms — Map insertion order rules).\n * - `groupBy` + `aggregate` → delegate to the shared `groupAndReduce`\n * pipeline used by `Query.groupBy().aggregate()`.\n *\n * Per-arm `map` is the schema-unification boundary; the strategy's\n * `TRow` type parameter enforces that every arm projects into the\n * same shape at compile time.\n *\n * @internal\n */\nasync function materializeUnionResult<TRow extends Record<string, unknown>>(\n spec: MaterializedViewSpec<TRow>,\n db: MVQueryContext,\n): Promise<ReadonlyArray<Record<string, unknown>>> {\n const unified: TRow[] = []\n for (const arm of spec.unionSources!) {\n const coll = db.collection<Record<string, unknown>>(arm.collection)\n // Optional per-arm FK joins: chain `.join(field, { as, ... })` for\n // each declared leg before terminating. The aliased right-side\n // record lands at `sourceRow[leg.as]`, where the arm's `map` reads\n // it. Cast to `any` because the chained join widens the row type but\n // the executor treats every row as `Record<string, unknown>` — same\n // pattern the query-form join path uses.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let q: any = coll.query()\n if (arm.join?.length) {\n for (const leg of arm.join) {\n q = q.join(leg.field, { as: leg.as, maxRows: leg.maxRows, strategy: leg.strategy })\n }\n }\n const sourceRows = q.toArray() as ReadonlyArray<Record<string, unknown>>\n for (const r of sourceRows) {\n const mapped = arm.map(r)\n // null / undefined means \"omit this source row\" — skip without\n // pushing so groupBy/aggregate never see a null entry.\n if (mapped == null) continue\n unified.push(mapped)\n }\n }\n\n return finalizeMappedRows(spec, unified)\n}\n\n/**\n * Shared post-map tail for the UNION and projection (#810) forms:\n * optional `groupBy` (+ `aggregate`) over the mapped-row stream.\n * Extracted verbatim from `materializeUnionResult` so both forms feed\n * the identical grouping pipeline — i18n group-key resolution, the\n * object-group-key guard, dedup-without-aggregate, and the shared\n * `groupAndReduce` delegate.\n *\n * @internal\n */\nfunction finalizeMappedRows<TRow extends Record<string, unknown>>(\n spec: MaterializedViewSpec<TRow>,\n unified: TRow[],\n): ReadonlyArray<Record<string, unknown>> {\n if (!spec.groupBy) return unified\n\n const groupFields: readonly string[] =\n typeof spec.groupBy === 'string' ? [spec.groupBy] : spec.groupBy\n\n // i18n-aware group keys. An `i18nText` group field carries a raw\n // `{ locale: string }` map, an unstable object key. When `i18nLocale` is\n // declared (with `i18nFields` describing those fields), resolve the declared\n // group-key i18n fields to it at the `mv` layer FIRST — the same unified-rows\n // boundary where money is threaded — so buckets are stable strings and the\n // `mv`-layer `onMissing` policy fires here.\n if (spec.i18nLocale !== undefined && spec.i18nFields !== undefined) {\n const groupI18n: Record<string, I18nTextDescriptor> = {}\n for (const f of groupFields) {\n const d = spec.i18nFields[f]\n if (d !== undefined) groupI18n[f] = d\n }\n if (Object.keys(groupI18n).length > 0) {\n for (let i = 0; i < unified.length; i++) {\n unified[i] = applyI18nLocale(unified[i] as Record<string, unknown>, groupI18n, spec.i18nLocale, undefined, 'mv') as TRow\n }\n }\n }\n // Guard (always): a remaining object-valued group key — an undeclared i18n\n // field or a locale-less MV — would bucket on a map. Refuse, don't bucket wrong.\n for (const f of groupFields) {\n for (const row of unified) {\n const v = (row as Record<string, unknown>)[f]\n if (v !== null && typeof v === 'object') {\n throw new LocaleNotSpecifiedError(\n f,\n `Materialized view \"${spec.name}\" groups by \"${f}\", whose value is a raw i18n locale map — ` +\n `an unstable object group key. Declare { i18nLocale, i18nFields } on the MV to resolve it at ` +\n `the 'mv' layer, or group by a dictKey/staticDict code (the stable key) and resolve the label at read time.`,\n )\n }\n }\n }\n\n // groupBy without aggregate — dedupe by composite key, keep first\n // seen row per key. Useful for cross-arm uniqueness (e.g. unify two\n // sibling collections, keeping one row per natural key).\n if (!spec.aggregate) {\n const seen = new Map<string, TRow>()\n for (const row of unified) {\n const k = canonicalGroupKey(groupFields, row as Record<string, unknown>)\n if (!seen.has(k)) seen.set(k, row)\n }\n return [...seen.values()]\n }\n\n // groupBy + aggregate — delegate to the shared pipeline used by\n // `Query.groupBy().aggregate()`. Result rows carry each grouped\n // field in declaration order followed by the spec's reducer outputs.\n return groupAndReduce<Record<string, unknown>>(unified, groupFields, spec.aggregate, spec.moneyFields)\n}\n\n/**\n * Apply the spec's `derive` to each finished row (#1007).\n *\n * Three rules, all of them about keeping the hook narrow enough that the\n * engine never has to reason about the function:\n *\n * - a `null` / `undefined` return leaves the row untouched;\n * - a returned key that collides with a `groupBy` field is refused — group\n * keys are the row's identity and feed `rowKey`, so letting `derive`\n * rewrite one would silently re-home the row into a different bucket than\n * the one it was aggregated for;\n * - a derived field declared in `moneyFields` is quantised through its\n * descriptor, so the stored value is exact at the declared scale instead of\n * whatever the user's arithmetic produced.\n *\n * @internal\n */\nfunction applyDerive<TRow extends Record<string, unknown>>(\n spec: MaterializedViewSpec<TRow>,\n rows: ReadonlyArray<Record<string, unknown>>,\n): ReadonlyArray<Record<string, unknown>> {\n const groupFields = new Set<string>(\n spec.groupBy === undefined ? [] : typeof spec.groupBy === 'string' ? [spec.groupBy] : spec.groupBy,\n )\n return rows.map((row) => {\n // `derive` sees the row the way a READER would: money decoded to its\n // canonical decimal form, not the scaled integer the reducer left behind.\n // Handing over `1005` where the schema says `10.05` would make every\n // derived money expression quietly wrong by a factor of the scale.\n // `'raw'` keeps it at the exact decimal rather than a locale-formatted\n // string, which is what `exact.*` consumes.\n const view = spec.moneyFields ? decodeMoneyFields(row, spec.moneyFields, 'raw') : row\n const patch = spec.derive!(view as TRow, exactMath)\n if (patch === null || patch === undefined) return row\n for (const key of Object.keys(patch)) {\n if (groupFields.has(key)) {\n throw new MaterializedViewConfigError(\n `Materialized view \"${spec.name}\": derive() returned the group key \"${key}\". ` +\n 'A group key is the row\\'s identity and feeds rowKey — rewriting it would re-home the ' +\n 'row into a bucket it was not aggregated for. Emit a differently-named field instead.',\n )\n }\n }\n // Canonicalize the PATCH only, into the DECIMAL form the money-aware\n // reducers emit — not the scaled-integer storage form a collection uses.\n // An MV row's money fields are decimal strings, so a derived field\n // quantised into storage form reads back as the scaled integer beside\n // correctly-decoded siblings: `\"1000000\"` next to `\"10000.00\"` (#1018).\n // Precision is still enforced; only the output shape differs.\n const canonical = spec.moneyFields\n ? canonicalizeMoneyFieldsAsDecimal(patch, spec.moneyFields)\n : patch\n return { ...row, ...canonical }\n })\n}\n\n/**\n * Materialize a projection-form MV (#810): hydrate the primary source\n * rows, resolve forward FK legs through the same `.join()` machinery\n * the UNION arms use, attach reverse \"collect\" legs via one\n * hash-grouped snapshot pass per leg, then run the projection `map`\n * (null / undefined omits the primary row) and the shared post-map\n * grouping tail.\n *\n * @internal\n */\nasync function materializeProjectionResult<TRow extends Record<string, unknown>>(\n spec: MaterializedViewSpec<TRow>,\n db: MVQueryContext,\n): Promise<ReadonlyArray<Record<string, unknown>>> {\n const projection = spec.projection!\n const coll = db.collection<Record<string, unknown>>(projection.source)\n // Forward legs chain through the query builder exactly like UNION arm\n // joins — ref() resolution, dangling-mode semantics, presentation\n // dressing, and ceilings all ride the existing `.join()` path. Cast to\n // `any` for the same reason `materializeUnionResult` does.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let q: any = coll.query()\n for (const leg of projection.joins) {\n if ('collect' in leg) continue\n q = q.join(leg.field, { as: leg.as, maxRows: leg.maxRows, strategy: leg.strategy })\n }\n let rows = q.toArray() as Array<Record<string, unknown>>\n for (const leg of projection.joins) {\n if (!('collect' in leg)) continue\n rows = applyCollectLeg(rows, leg, spec.name, projection.source, db)\n }\n const mapped: TRow[] = []\n for (const r of rows) {\n const m = projection.map(r)\n // null / undefined means \"omit this primary row\" — same contract\n // as the UNION arm `map`.\n if (m == null) continue\n mapped.push(m)\n }\n return finalizeMappedRows(spec, mapped)\n}\n\n/**\n * Attach one reverse \"collect\" leg (#810): every row of `leg.collect`\n * whose `leg.on` field references a primary record's id lands in a\n * possibly-empty ARRAY under `leg.as` on that primary row. One\n * snapshot pass over the collect collection, hash-grouped by the `on`\n * FK — O(N+M), mirroring the forward hash-join fallback.\n *\n * Semantic check (first materialization, not factory time — parity\n * with join-time ref errors): `leg.on` must carry a `ref()` declared\n * on the collect collection targeting the projection `source`.\n *\n * @internal\n */\nfunction applyCollectLeg(\n primaryRows: ReadonlyArray<Record<string, unknown>>,\n leg: { readonly collect: string; readonly on: string; readonly as: string; readonly maxRows?: number },\n mvName: string,\n source: string,\n db: MVQueryContext,\n): Array<Record<string, unknown>> {\n const childColl = db.collection<Record<string, unknown>>(leg.collect)\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const childQ = childColl.query() as any\n const refDesc = childQ._joinContext?.()?.resolveRef(leg.on) as { target: string } | null | undefined\n if (refDesc == null) {\n throw new MaterializedViewConfigError(\n `\"${mvName}\": projection collect leg \"${leg.as}\" requires a ref() on field \"${leg.on}\" of `\n + `collection \"${leg.collect}\" targeting \"${source}\" — declare `\n + `refs: { ${leg.on}: ref('${source}') } on collection \"${leg.collect}\", then retry`,\n )\n }\n if (refDesc.target !== source) {\n throw new MaterializedViewConfigError(\n `\"${mvName}\": projection collect leg \"${leg.as}\" expects field \"${leg.on}\" of collection `\n + `\"${leg.collect}\" to reference the projection source \"${source}\", but its ref() targets `\n + `\"${refDesc.target}\"`,\n )\n }\n const maxRows = leg.maxRows ?? DEFAULT_JOIN_MAX_ROWS\n const groups = new Map<string, Array<Record<string, unknown>>>()\n for (const child of childQ.toArray() as Array<Record<string, unknown>>) {\n const key = coerceCollectKey(child[leg.on])\n // Nullish / non-scalar FK values mean \"no reference\" — same\n // narrowing as the forward join path's key coercion.\n if (key === null) continue\n const bucket = groups.get(key)\n if (bucket) bucket.push(child)\n else groups.set(key, [child])\n }\n const out: Array<Record<string, unknown>> = []\n for (const row of primaryRows) {\n const key = coerceCollectKey(row.id)\n const children = key === null ? [] : groups.get(key) ?? []\n if (children.length > maxRows) {\n throw new JoinTooLargeError({\n leftRows: primaryRows.length,\n rightRows: children.length,\n maxRows,\n side: 'right',\n message:\n `projection MV \"${mvName}\": collect leg \"${leg.as}\" gathered ${children.length} ` +\n `\"${leg.collect}\" rows for one \"${source}\" record, exceeding the ${maxRows}-row ` +\n `per-primary-row ceiling. Raise the ceiling via { maxRows } on the leg if the ` +\n `fan-out genuinely fits in memory, or restructure the child collection.`,\n })\n }\n out.push({ ...row, [leg.as]: children })\n }\n return out\n}\n\n/**\n * Coerce an unknown FK value into a collect-grouping key. Same\n * narrowing as the join path's `coerceRefKey` (not exported from\n * there): strings and numbers are legitimate ref values; anything\n * else is \"no reference\" and returns `null`.\n *\n * @internal\n */\nfunction coerceCollectKey(value: unknown): string | null {\n if (value === null || value === undefined) return null\n if (typeof value === 'string') return value\n if (typeof value === 'number' || typeof value === 'bigint') return String(value)\n return null\n}\n\n/**\n * Run an MV's `query()` and write the result rows to the output\n * collection. Same-DEK encryption: routes through the standard\n * `Collection.put` pipeline, so the output collection's DEK is what\n * gets used (matches the v2 spec's \"same DEK as the left-most source\"\n * invariant — `Collection.put` looks up the DEK by collection name,\n * and the output collection IS the MV's owned collection).\n *\n * Stamps `_materializedFrom` onto every emitted row.\n *\n * **Tombstoning:** when `spec.onEmpty: 'delete'` (default), rows\n * that existed in a prior refresh but no longer appear in the new\n * materialized result are deleted via `Collection._internalDelete` —\n * the housekeeping bypass primitive prevents user\n * `onDelete` guards on the output collection from firing on these\n * system-internal deletes. `onEmpty: 'keep'` opts out (rows from\n * prior refreshes linger even when the new result lacks them).\n *\n * **Cost ceiling:** if the materialized row count exceeds\n * `spec.maxRows` (default 100k), throws `MaterializedViewTooLargeError`\n * before any writes hit the store — so strict-mode rollback is\n * clean.\n *\n * **Strict mode:** `spec.strict === true` re-throws on any\n * row-write failure; the active TxContext registration means the\n * source-write rolls back atomically via `revertExecuted`.\n *\n * @internal\n */\nexport const MaterializedViewExecutor = {\n async refresh(\n reg: RegisteredMV,\n accessor: MVExecutorAccessor,\n ): Promise<RefreshResult> {\n const spec = reg.spec\n const outputColl = accessor.getCollection(reg.outputCollection)\n const maxRows = spec.maxRows ?? DEFAULT_MAX_ROWS\n const onEmpty = spec.onEmpty ?? 'delete'\n const strict = spec.strict ?? false\n\n // 1. Materialize the query (branches on terminal shape). If the\n // MV declared predicates, wrap the query context the same way\n // the registry did at registration time so `.wherePredicate()`\n // calls resolve to the registered functions.\n const baseCtx = accessor.getQueryContext()\n const ctxForQuery: MVQueryContext = spec.predicates\n ? wrapDbWithPredicates(baseCtx, spec.predicates)\n : baseCtx\n // UNION-form strategies: read every arm, map to the unified\n // row shape, concatenate, then optionally groupBy + aggregate.\n // Projection-form strategies (#810): hydrate the primary source,\n // attach forward + collect legs, map, then the same optional\n // grouping tail. The single-source `query()` path is untouched.\n let rows: ReadonlyArray<Record<string, unknown>>\n if (spec.unionSources) {\n rows = await materializeUnionResult(spec, ctxForQuery)\n } else if (spec.projection) {\n rows = await materializeProjectionResult(spec, ctxForQuery)\n } else {\n const q = spec.query!(ctxForQuery)\n rows = await materializeQueryResult(q, spec.name, spec.i18nLocale, spec.i18nFields)\n }\n\n // #1007 — the post-aggregate projection. Applied here, after every form has\n // produced its finished rows, so union / projection / query all get the\n // same single definition of \"last step before materialisation\".\n if (spec.derive) rows = applyDerive(spec, rows)\n\n // #777 — exclude this MV's OWN previously-stamped output rows from the\n // input scan. A same-collection Query-form MV copies the whole source row\n // verbatim into its output, so a stale output row can itself satisfy the\n // MV's own input filter (when the filter is on a field disjoint from the\n // partition field) and get re-selected as if it were live source — the\n // row self-perpetuates after its true source is gone. Prior output is\n // never this MV's own source.\n rows = rows.filter((row) => {\n const stamp = row._materializedFrom as { mvName?: string } | undefined\n return stamp?.mvName !== spec.name\n })\n\n // 2. Cost ceiling check BEFORE any writes — keeps the rollback\n // clean if the source-write is wrapped in a transaction.\n if (rows.length > maxRows) {\n throw new MaterializedViewTooLargeError(spec.name, rows.length, maxRows)\n }\n\n const txCtx = accessor.getActiveTxContext()\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const adapter = (outputColl as any).adapter as {\n get(v: string, c: string, i: string): Promise<EncryptedEnvelope | null>\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const vaultName = (outputColl as any).vault as string\n\n // 3. Compute the post-refresh id set so we can diff against the\n // prior-emitted id set for tombstoning (when onEmpty === 'delete').\n const newIds = new Set<string>()\n const enrichedRows: Array<{ id: string; record: Record<string, unknown> }> = []\n for (const row of rows) {\n const id = spec.rowKey(row)\n newIds.add(id)\n const meta: MaterializedFromMeta = {\n mvName: spec.name,\n queryHash: reg.queryHash,\n sourceVersions: {},\n materializedAt: new Date().toISOString(),\n }\n enrichedRows.push({ id, record: { ...row, _materializedFrom: meta } })\n }\n\n // 4. Write the new rows.\n let written = 0\n let failed = 0\n for (const { id, record } of enrichedRows) {\n try {\n if (txCtx !== null) {\n const prior = await adapter.get(vaultName, reg.outputCollection, id)\n txCtx._executed.push({\n op: { type: 'put', vaultName, collectionName: reg.outputCollection, id },\n priorEnvelope: prior,\n })\n }\n if (accessor.dispatchCtx) {\n if (await putDerivedOutput(outputColl, id, record, accessor.dispatchCtx) === 'written') written++\n } else {\n await outputColl.put(id, record)\n written++\n }\n } catch (err) {\n failed++\n if (strict) throw err\n \n console.warn(`[mv] \"${spec.name}\" row write failed:`, err)\n }\n }\n\n // 5. Tombstone rows that existed before but don't appear now.\n // `onEmpty: 'keep'` skips this pass entirely. Uses\n // `_internalDelete` so a user-registered `onDelete` on the\n // output collection does NOT fire on housekeeping (composition fix).\n let deleted = 0\n const residueUndecodable: string[] = []\n const residueDeclined: string[] = []\n if (onEmpty === 'delete') {\n const priorIds = await listOutputIds(outputColl)\n for (const priorId of priorIds) {\n if (newIds.has(priorId)) continue\n // #762 — a same-collection partition MV (`output: { collection: <source>, partition }`,\n // the DERIV-PP30-001 shape) writes INTO its own source collection, so `listOutputIds`\n // also returns untouched USER source rows here. Decode each candidate and only\n // tombstone rows THIS MV stamped via `_materializedFrom.mvName` — the exact discipline\n // `invalidateMVAtRest` uses (stale.ts:151-162). An unstamped row, or one stamped by a\n // different MV, is never this MV's to delete.\n const priorEnvelope = await adapter.get(vaultName, reg.outputCollection, priorId)\n if (priorEnvelope === null) continue\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const priorDecoded = await (outputColl as any)._decodeEnvelope(priorEnvelope, priorId)\n if (priorDecoded === null) {\n // #782 part a — ownership unknown (undecodable, e.g. elevated above tier 0 on a\n // tiered output collection): can't rule out this being THIS MV's own stamped row.\n // Surface it rather than silently skip (mirrors invalidateMVAtRest's #776 posture —\n // previously this leg had NO residue channel at all).\n residueUndecodable.push(`${reg.outputCollection}:${priorId}`)\n continue\n }\n const priorStampedBy =\n typeof priorDecoded === 'object'\n ? (priorDecoded as Record<string, unknown>)._materializedFrom as { mvName?: string } | undefined\n : undefined\n if (priorStampedBy?.mvName !== spec.name) continue\n try {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const outAny = outputColl as any\n if (typeof outAny._internalDelete === 'function') {\n // #776 part b — gate on the boolean, matching invalidateMVAtRest's discipline\n // (stale.ts): `_internalDelete` returns `false` for a #718 elevated-skip (no\n // erasure happened), and that must not inflate the tombstone count.\n if (await outAny._internalDelete(priorId, txCtx)) {\n deleted++\n } else {\n // #782 part b — decoded AND stamp-owned, but erasure was declined (#718\n // tier-elevation gate). Ownership IS confirmed here — a real silent survival,\n // not a legit stamp-mismatch skip. Surface it too.\n residueDeclined.push(`${reg.outputCollection}:${priorId}`)\n }\n } else {\n // Defensive fallback — should never hit in real flow since\n // every Collection has `_internalDelete`.\n await outputColl.delete(priorId)\n deleted++\n }\n } catch (err) {\n failed++\n if (strict) throw err\n\n console.warn(`[mv] \"${spec.name}\" tombstone failed for id=\"${priorId}\":`, err)\n }\n }\n }\n\n return { written, deleted, failed, residueUndecodable, residueDeclined }\n },\n}\n\n/**\n * List ids currently present in the MV's output collection via the\n * adapter directly (avoids triggering the lazy resolve-on-read path\n * we're INSIDE). Returns an empty array if the collection doesn't\n * exist or the adapter doesn't surface a list method.\n *\n * @internal\n */\nasync function listOutputIds(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n outputColl: Collection<any>,\n): Promise<string[]> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const cAny = outputColl as any\n const adapter = cAny.adapter as { list?: (v: string, c: string) => Promise<readonly string[]> }\n const vault = cAny.vault as string\n const name = cAny.name as string\n if (typeof adapter?.list !== 'function') return []\n try {\n const ids = await adapter.list(vault, name)\n return [...ids]\n } catch {\n return []\n }\n}\n"],"mappings":"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","names":[]}
@@ -7,6 +7,7 @@ var PERIODS_COLLECTION = "_periods";
7
7
  var PERIOD_FREEZES_COLLECTION = "_period_freezes";
8
8
  var PERIOD_ARCHIVES_COLLECTION = "_period_archives";
9
9
  var PERIOD_TARGET_PURGES_COLLECTION = "_period_target_purges";
10
+ var PERIOD_REOPENS_COLLECTION = "_period_reopens";
10
11
  function periodExclusiveUpperBound(endDate) {
11
12
  const ms = Date.parse(endDate);
12
13
  if (Number.isNaN(ms)) throw new ValidationError(`freezePeriod: unparseable period endDate "${endDate}".`);
@@ -19,6 +20,7 @@ export {
19
20
  PERIOD_FREEZES_COLLECTION,
20
21
  PERIOD_ARCHIVES_COLLECTION,
21
22
  PERIOD_TARGET_PURGES_COLLECTION,
23
+ PERIOD_REOPENS_COLLECTION,
22
24
  periodExclusiveUpperBound
23
25
  };
24
- //# sourceMappingURL=chunk-JXIDGFKS.js.map
26
+ //# sourceMappingURL=chunk-HPLWFNG4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/with-audit/periods/window.ts"],"sourcesContent":["/**\n * Pure period-window primitives — the reserved collection names and the\n * exclusive-upper-bound computation shared by freeze (#604), archive (#613),\n * target-purge (#615) and the period-scoped sync pull (#807).\n *\n * Deliberately dependency-light (kernel errors only): the sync engine\n * (`with-sync/period-scope.ts`) imports THIS module, not\n * `periods.ts`, so a period-scoped pull never drags the ledger hash-chain\n * machinery (`sha256Hex`/`canonicalJson`) into a bundle that opted out of\n * the periods service.\n */\nimport { ValidationError } from '../../kernel/errors.js'\n\n/** The reserved collection name holding closed-period metadata. */\nexport const PERIODS_COLLECTION = '_periods'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding freeze companions (#604). */\nexport const PERIOD_FREEZES_COLLECTION = '_period_freezes'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding archive companions (#613). */\nexport const PERIOD_ARCHIVES_COLLECTION = '_period_archives'\n\n/** Sibling of {@link PERIODS_COLLECTION} holding target-purge companions (#615). */\nexport const PERIOD_TARGET_PURGES_COLLECTION = '_period_target_purges'\n\n/**\n * Sibling of {@link PERIODS_COLLECTION} holding reopen/reclose companions (#1022).\n *\n * Unlike the freeze / archive / target-purge companions — each a single,\n * idempotent, once-only record — this one holds an APPEND-ONLY EVENT LIST,\n * because reopening is a repeating cycle: a month can be closed, reopened,\n * reclosed, and reopened again. The audit value is the sequence, not the\n * latest state.\n */\nexport const PERIOD_REOPENS_COLLECTION = '_period_reopens'\n\n/**\n * Exclusive upper bound for a period's delete-marker purge window (#604).\n * Markers carry no business date (empty body), only write-time `_ts`, so freeze\n * purges markers with `_ts < bound`, `bound` being the instant just after the\n * period's inclusive `endDate`: a date-only `endDate` seals through end-of-day\n * → next midnight; a full-timestamp `endDate` seals through that instant → +1ms.\n */\nexport function periodExclusiveUpperBound(endDate: string): string {\n const ms = Date.parse(endDate)\n if (Number.isNaN(ms)) throw new ValidationError(`freezePeriod: unparseable period endDate \"${endDate}\".`)\n const dateOnly = /^\\d{4}-\\d{2}-\\d{2}$/.test(endDate)\n return new Date(ms + (dateOnly ? 86_400_000 : 1)).toISOString()\n}\n"],"mappings":";;;;;AAcO,IAAM,qBAAqB;AAG3B,IAAM,4BAA4B;AAGlC,IAAM,6BAA6B;AAGnC,IAAM,kCAAkC;AAWxC,IAAM,4BAA4B;AASlC,SAAS,0BAA0B,SAAyB;AACjE,QAAM,KAAK,KAAK,MAAM,OAAO;AAC7B,MAAI,OAAO,MAAM,EAAE,EAAG,OAAM,IAAI,gBAAgB,6CAA6C,OAAO,IAAI;AACxG,QAAM,WAAW,sBAAsB,KAAK,OAAO;AACnD,SAAO,IAAI,KAAK,MAAM,WAAW,QAAa,EAAE,EAAE,YAAY;AAChE;","names":[]}
@@ -4,7 +4,7 @@ import {
4
4
  formatScaledInt,
5
5
  parseToScaledInt,
6
6
  rescaleScaledInt
7
- } from "./chunk-NNYENE7N.js";
7
+ } from "./chunk-RXYYFSBB.js";
8
8
 
9
9
  // src/via/money/arith.ts
10
10
  function parseAmount(label, amount, scale, rounding) {
@@ -120,4 +120,4 @@ export {
120
120
  isMoneyString,
121
121
  moneyNumber
122
122
  };
123
- //# sourceMappingURL=chunk-GOLN6LQ5.js.map
123
+ //# sourceMappingURL=chunk-N35NC4JH.js.map
@@ -399,7 +399,7 @@ async function createOwnerOnAdoptedPartition(store, vaultName, opts) {
399
399
  }
400
400
  }
401
401
  if (isManaged(opts)) {
402
- const { createNoydb } = await import("./noydb-LYIAD4CW.js");
402
+ const { createNoydb } = await import("./noydb-ASEXNJS6.js");
403
403
  const db = await createNoydb({
404
404
  store,
405
405
  user: userId,
@@ -455,4 +455,4 @@ export {
455
455
  withCargo,
456
456
  describeExtraction
457
457
  };
458
- //# sourceMappingURL=chunk-WNFZI3I5.js.map
458
+ //# sourceMappingURL=chunk-RV3ARMPN.js.map
@@ -339,6 +339,40 @@ function quantizeValue(field, raw, desc) {
339
339
  const scale = desc.scaleFor(currency);
340
340
  return { amount: quantizeAmount(field, amount, scale, desc.rounding), currency };
341
341
  }
342
+ function canonicalizeMoneyFieldsAsDecimal(record, moneyFields) {
343
+ const out = { ...record };
344
+ for (const [field, desc] of Object.entries(moneyFields)) {
345
+ if (!isSimpleMoneyPath(field)) continue;
346
+ const raw = out[field];
347
+ if (raw === null || raw === void 0) continue;
348
+ let amount;
349
+ let scale;
350
+ if (desc.mode === "fixed") {
351
+ amount = raw;
352
+ scale = desc.scaleFor(desc.fixedCurrency);
353
+ } else if (isMoneyValueObject(raw)) {
354
+ amount = raw.amount;
355
+ scale = desc.scaleFor(String(raw.currency));
356
+ } else {
357
+ const sole = desc.soleCurrency();
358
+ if (sole === void 0) {
359
+ throw new TypeError(
360
+ `money: field "${field}" is multi-currency \u2014 a derived value must be { amount, currency }, not a bare amount`
361
+ );
362
+ }
363
+ amount = raw;
364
+ scale = desc.scaleFor(sole);
365
+ }
366
+ const r = parseToScaledInt(amount, scale, desc.rounding);
367
+ if (!r.ok) {
368
+ if (r.reason === "precision") throw new MoneyPrecisionError(field, amount, scale);
369
+ throw new TypeError(`money: field "${field}" value ${JSON.stringify(amount)} is not a finite decimal`);
370
+ }
371
+ const decimal = formatScaledInt(r.value, scale);
372
+ out[field] = desc.mode === "fixed" || !isMoneyValueObject(raw) ? decimal : { amount: decimal, currency: String(raw.currency) };
373
+ }
374
+ return out;
375
+ }
342
376
  function quantizeMoneyFields(record, moneyFields) {
343
377
  let out = { ...record };
344
378
  for (const [path, desc] of Object.entries(moneyFields)) {
@@ -1006,8 +1040,8 @@ export {
1006
1040
  MoneyUnsupportedError,
1007
1041
  money,
1008
1042
  isMoneyDescriptor,
1009
- quantizeMoneyFields,
1043
+ canonicalizeMoneyFieldsAsDecimal,
1010
1044
  decodeMoneyFields,
1011
1045
  exactMath
1012
1046
  };
1013
- //# sourceMappingURL=chunk-NNYENE7N.js.map
1047
+ //# sourceMappingURL=chunk-RXYYFSBB.js.map