@noy-db/hub 0.4.0-pre.2 → 0.4.0-pre.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aggregate/index.js +1 -1
- package/dist/blobs/index.js.map +1 -1
- package/dist/broker/index.js +3 -3
- package/dist/broker/index.js.map +1 -1
- package/dist/cargo/index.js +5 -5
- package/dist/{chunk-OGY4ELVE.js → chunk-3XVOLB75.js} +2 -2
- package/dist/{chunk-OTU5ZI3U.js → chunk-4X6DJZHY.js} +3 -3
- package/dist/{chunk-IU22SQGU.js → chunk-5KI3REO3.js} +2 -2
- package/dist/{chunk-IU22SQGU.js.map → chunk-5KI3REO3.js.map} +1 -1
- package/dist/{chunk-PWZKD4HW.js → chunk-BLB7P7LS.js} +3 -3
- package/dist/{chunk-Y5E6CH6S.js → chunk-EW4HFWGJ.js} +2 -2
- package/dist/{chunk-S3OYXPSE.js → chunk-HKR25MGX.js} +2 -2
- package/dist/{chunk-RI3OKC3Y.js → chunk-JIMEB4C6.js} +1 -1
- package/dist/chunk-JIMEB4C6.js.map +1 -0
- package/dist/{chunk-J6U4BOKP.js → chunk-JW3XCZA2.js} +9 -9
- package/dist/{chunk-4JYYIFHW.js → chunk-QUIG6BZT.js} +16 -16
- package/dist/{chunk-4JYYIFHW.js.map → chunk-QUIG6BZT.js.map} +1 -1
- package/dist/{chunk-TYBXP6WE.js → chunk-QYJIOWKN.js} +2 -2
- package/dist/{chunk-A64XXIIB.js → chunk-U73LHVPF.js} +17 -17
- package/dist/{chunk-A64XXIIB.js.map → chunk-U73LHVPF.js.map} +1 -1
- package/dist/{chunk-VZELV7ZJ.js → chunk-W7TCXZWU.js} +455 -554
- package/dist/chunk-W7TCXZWU.js.map +1 -0
- package/dist/{chunk-RGL53NXZ.js → chunk-WEC3JGBA.js} +2 -2
- package/dist/{chunk-HVZT5T22.js → chunk-WJDYZP3U.js} +1 -1
- package/dist/chunk-WJDYZP3U.js.map +1 -0
- package/dist/{collection-facade-ENLTJZBT.js → collection-facade-CMZGQ6IS.js} +2 -2
- package/dist/{executor-UF7LTX6O.js → executor-YORMIBWV.js} +3 -3
- package/dist/index.d.ts +5 -0
- package/dist/index.js +14 -14
- package/dist/kernel/collection-config.d.ts +11 -69
- package/dist/kernel/collection.d.ts +10 -21
- package/dist/kernel/enclave/index.d.ts +1 -1
- package/dist/kernel/noydb.d.ts +24 -13
- package/dist/kernel/vault.d.ts +21 -68
- package/dist/{liberate-W5MDWYOV.js → liberate-FK7JI2QX.js} +3 -3
- package/dist/materialized-views/index.js +3 -3
- package/dist/{noydb-U63S3Z7F.js → noydb-N5IW6OFZ.js} +10 -10
- package/dist/port/with/archive-strategy.d.ts +31 -0
- package/dist/port/with/strategies.d.ts +134 -0
- package/dist/query/index.js +2 -2
- package/dist/{seed-6GTDHL4E.js → seed-EKF4DIBB.js} +2 -2
- package/dist/{stale-NB5BGRVU.js → stale-4UNPHWOU.js} +2 -2
- package/dist/team/index.js +3 -3
- package/dist/tiers/index.js +1 -1
- package/dist/via/blob/active.d.ts +2 -1
- package/dist/with/index.js +2 -2
- package/dist/with-audit/forget/index.d.ts +2 -1
- package/dist/with-audit/tiers/index.d.ts +1 -0
- package/dist/with-cargo/index.d.ts +2 -0
- package/dist/with-formula/derivations/index.d.ts +2 -0
- package/dist/with-formula/materialized-views/index.d.ts +1 -0
- package/dist/with-lookup/aggregate/reducers.d.ts +1 -2
- package/dist/with-party/broker/index.d.ts +1 -0
- package/dist/with-party/team/keyring.d.ts +2 -2
- package/package.json +5 -4
- package/dist/chunk-HVZT5T22.js.map +0 -1
- package/dist/chunk-RI3OKC3Y.js.map +0 -1
- package/dist/chunk-VZELV7ZJ.js.map +0 -1
- /package/dist/{chunk-OGY4ELVE.js.map → chunk-3XVOLB75.js.map} +0 -0
- /package/dist/{chunk-OTU5ZI3U.js.map → chunk-4X6DJZHY.js.map} +0 -0
- /package/dist/{chunk-PWZKD4HW.js.map → chunk-BLB7P7LS.js.map} +0 -0
- /package/dist/{chunk-Y5E6CH6S.js.map → chunk-EW4HFWGJ.js.map} +0 -0
- /package/dist/{chunk-S3OYXPSE.js.map → chunk-HKR25MGX.js.map} +0 -0
- /package/dist/{chunk-J6U4BOKP.js.map → chunk-JW3XCZA2.js.map} +0 -0
- /package/dist/{chunk-TYBXP6WE.js.map → chunk-QYJIOWKN.js.map} +0 -0
- /package/dist/{chunk-RGL53NXZ.js.map → chunk-WEC3JGBA.js.map} +0 -0
- /package/dist/{collection-facade-ENLTJZBT.js.map → collection-facade-CMZGQ6IS.js.map} +0 -0
- /package/dist/{executor-UF7LTX6O.js.map → executor-YORMIBWV.js.map} +0 -0
- /package/dist/{liberate-W5MDWYOV.js.map → liberate-FK7JI2QX.js.map} +0 -0
- /package/dist/{noydb-U63S3Z7F.js.map → noydb-N5IW6OFZ.js.map} +0 -0
- /package/dist/{seed-6GTDHL4E.js.map → seed-EKF4DIBB.js.map} +0 -0
- /package/dist/{stale-NB5BGRVU.js.map → stale-4UNPHWOU.js.map} +0 -0
|
@@ -156,7 +156,7 @@ export declare function avg(field: string, opts?: ReducerOptions<{
|
|
|
156
156
|
sum: number;
|
|
157
157
|
count: number;
|
|
158
158
|
}>;
|
|
159
|
-
interface MinMaxState {
|
|
159
|
+
export interface MinMaxState {
|
|
160
160
|
/**
|
|
161
161
|
* Multiset of contributing field values. Stored as a plain array
|
|
162
162
|
* because we need to support `remove` and a plain array gives us
|
|
@@ -254,4 +254,3 @@ export interface ReducerBuilder<T, S extends keyof T = never, M extends keyof T
|
|
|
254
254
|
* parameter-contravariance, so this single instance works for all `T`/`S`.
|
|
255
255
|
*/
|
|
256
256
|
export declare const reducerBuilder: ReducerBuilder<Record<string, unknown>>;
|
|
257
|
-
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NoydbStore, KeyringFile, KeyringAuthenticator, Role, Permissions, GrantOptions, RevokeOptions, UpdateUserOptions, UserInfo, ExportCapability, ExportFormat, ImportCapability, VaultPolicyOnDisk, UserEnvelope
|
|
1
|
+
import type { NoydbStore, KeyringFile, KeyringAuthenticator, Role, Permissions, GrantOptions, RevokeOptions, UpdateUserOptions, UserInfo, ExportCapability, ExportFormat, ImportCapability, VaultPolicyOnDisk, UserEnvelope } from '../../kernel/types.js';
|
|
2
2
|
import { type EnclaveKey } from '../../kernel/enclave/index.js';
|
|
3
3
|
import { type PassphrasePolicy } from '../../kernel/validation.js';
|
|
4
4
|
/** In-memory representation of an unlocked keyring. */
|
|
@@ -256,7 +256,7 @@ export interface ListUsersOptions {
|
|
|
256
256
|
*/
|
|
257
257
|
export declare function listUsersWithEnvelopes<T = unknown>(adapter: NoydbStore, vault: string, userEnvelopeDek: EnclaveKey, callerRole: Role, options?: ListUsersOptions): Promise<Array<{
|
|
258
258
|
user: UserInfo;
|
|
259
|
-
envelope:
|
|
259
|
+
envelope: UserEnvelope<T> | null;
|
|
260
260
|
}>>;
|
|
261
261
|
/** Ensure a DEK exists for a collection. Generates one if new. */
|
|
262
262
|
export declare function ensureCollectionDEK(adapter: NoydbStore, vault: string, keyring: UnlockedKeyring): Promise<(collectionName: string) => Promise<EnclaveKey>>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/hub",
|
|
3
|
-
"version": "0.4.0-pre.
|
|
3
|
+
"version": "0.4.0-pre.4",
|
|
4
4
|
"description": "Zero-knowledge, offline-first, encrypted document store — core library with AES-256-GCM, PBKDF2, multi-user keyring, and sync engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -192,14 +192,14 @@
|
|
|
192
192
|
"node": ">=22.0.0"
|
|
193
193
|
},
|
|
194
194
|
"dependencies": {
|
|
195
|
-
"@noy-db/attestation": "0.4.0-pre.
|
|
195
|
+
"@noy-db/attestation": "0.4.0-pre.4"
|
|
196
196
|
},
|
|
197
197
|
"devDependencies": {
|
|
198
198
|
"@types/node": "^22.0.0",
|
|
199
199
|
"esbuild": "^0.25.0",
|
|
200
200
|
"zod": "^4.0.0",
|
|
201
201
|
"zod-to-json-schema": "^3.25.2",
|
|
202
|
-
"@noy-db/on-shamir": "0.4.0-pre.
|
|
202
|
+
"@noy-db/on-shamir": "0.4.0-pre.4"
|
|
203
203
|
},
|
|
204
204
|
"peerDependencies": {
|
|
205
205
|
"zod-to-json-schema": "^3.25.0"
|
|
@@ -237,6 +237,7 @@
|
|
|
237
237
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.typetest.json && tsc --noEmit -p tsconfig.tests.json",
|
|
238
238
|
"typecheck:types": "tsc --noEmit -p tsconfig.typetest.json",
|
|
239
239
|
"typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
|
|
240
|
-
"bundle-check": "node scripts/check-bundle.mjs"
|
|
240
|
+
"bundle-check": "node scripts/check-bundle.mjs",
|
|
241
|
+
"check:types": "node scripts/check-type-reachability.mjs"
|
|
241
242
|
}
|
|
242
243
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/kernel/validation.ts","../src/with-party/team/reserved-secret-collections.ts","../src/with-party/team/keyring.ts"],"sourcesContent":["/**\n * Passphrase validation — phrase format (per the three-tier session-tiers\n * design, locked 2026-05-04).\n *\n * Passphrases are **phrases**: multiple simple words, easy to remember,\n * structurally constrained so a weak choice cannot silently collapse the\n * security floor. The format is intentionally narrow: lowercase letters\n * and single spaces only, no punctuation, no symbols, no digits.\n *\n * - Default minimum: 6 words (~77 bits with the 7,776-word EFF list).\n * - Strict minimum: 8 words (~103 bits).\n * - Per-word minimum: 3 characters (excludes \"a\", \"is\", \"of\").\n * - Adjacent repeats rejected (\"the the\").\n *\n * The hub runs validation default-on at every passphrase ingress\n * (`createOwnerKeyring`, `grant`, `rotatePassphrase`); test fixtures and\n * CLI scripts override via `{ allowWeakPassphrase: true }`.\n *\n * @module\n */\nimport { NoydbError, ValidationError } from './errors.js'\n\n/** All reasons a phrase can be rejected. */\nexport type WeakPassphraseReason =\n | 'empty'\n | 'invalid-chars'\n | 'leading-or-trailing-space'\n | 'double-space'\n | 'too-few-words'\n | 'word-too-short'\n | 'repeated-adjacent'\n\n/** Per-vault knobs. Aligns with `VaultPolicy.passphrase`. */\nexport interface PassphrasePolicy {\n /** Minimum number of words. Default 6. Strict policy uses 8. */\n readonly minWords?: number\n /** Minimum characters per word. Default 3. */\n readonly minWordLength?: number\n /** Reject adjacent identical words (\"the the\"). Default true. */\n readonly rejectRepeatedAdjacent?: boolean\n /**\n * Override the default character-class rule (`/^[a-z]+( [a-z]+)*$/`).\n *\n * The hub's strict default is lowercase-letters-and-single-spaces\n * because that's what the EFF wordlist generator emits and what\n * most attacker password lists are keyed on. Use this knob to allow\n * digits, uppercase, hyphens, or non-Latin scripts when the\n * consumer's audience needs them — e.g.:\n *\n * ```ts\n * // Thai + English mix with digits permitted\n * pattern: /^[\\p{L}0-9 ]+( [\\p{L}0-9 ]+)*$/u\n *\n * // Allow uppercase + hyphens (passphrase-with-hyphens style)\n * pattern: /^[A-Za-z]+([- ][A-Za-z]+)*$/\n * ```\n *\n * The OTHER structural rules still apply (min-words split by space,\n * min-word-length, repeated-adjacent, leading/trailing whitespace,\n * double-space). For non-space-delimited word semantics, use\n * {@link customValidator} instead.\n *\n */\n readonly pattern?: RegExp\n /**\n * Replace ALL validation entirely with a custom function. When set,\n * none of the other PassphrasePolicy fields apply — the consumer\n * owns every rule (word splitting, character classes, entropy\n * thresholds, allowlist/denylist). Use sparingly; this is the\n * escape hatch for domain-specific phrase formats:\n *\n * - Localized wordlists with non-space word boundaries\n * - BIP-39 seed phrases (24 words, fixed wordlist, etc.)\n * - Organization-specific HR password policies\n *\n * The returned `PassphraseValidationResult` is what\n * {@link assertStrongPassphrase} dispatches on — `ok: true` accepts;\n * `ok: false` throws `WeakPassphraseError` with the supplied reason.\n *\n */\n readonly customValidator?: (phrase: string) => PassphraseValidationResult\n}\n\n/** Result of a check. Discriminated union — compile-time exhaustive. */\nexport type PassphraseValidationResult =\n | { readonly ok: true; readonly words: number }\n | {\n readonly ok: false\n readonly reason: WeakPassphraseReason\n readonly minimum?: number\n readonly got?: number\n }\n\n/**\n * Thrown by `assertStrongPassphrase()` and by every hub ingress\n * point (`createOwnerKeyring`, `grant`, `rotatePassphrase`) when a\n * supplied phrase fails the structural rules above.\n */\nexport class WeakPassphraseError extends NoydbError {\n readonly reason: WeakPassphraseReason\n readonly suggestion: string\n constructor(reason: WeakPassphraseReason, suggestion: string) {\n super('WEAK_PASSPHRASE', `Weak passphrase (${reason}). ${suggestion}`)\n this.name = 'WeakPassphraseError'\n this.reason = reason\n this.suggestion = suggestion\n }\n}\n\nconst DEFAULT_MIN_WORDS = 6\nconst DEFAULT_MIN_WORD_LENGTH = 3\n\nconst SUGGESTIONS: Record<WeakPassphraseReason, string> = {\n empty: 'Provide a phrase of at least 6 lowercase words separated by single spaces.',\n 'invalid-chars':\n 'Use only lowercase letters [a-z] and single spaces. No punctuation, symbols, digits, or uppercase.',\n 'leading-or-trailing-space': 'Trim leading and trailing spaces.',\n 'double-space': 'Use exactly one space between words.',\n 'too-few-words':\n 'Use at least 6 words by default (8 under strict policy). Example: \"correct horse battery staple printer toaster\".',\n 'word-too-short': 'Each word must be at least 3 characters. Drop short fillers like \"a\", \"is\", \"of\".',\n 'repeated-adjacent': 'Avoid repeating the same word twice in a row.',\n}\n\n/**\n * Inspect a phrase against the format rules and return a structured\n * verdict. Never throws — callers either branch on `ok` or pass the\n * result to {@link assertStrongPassphrase} for the throwing flavour.\n */\nexport function validatePassphrase(\n s: string,\n opts?: PassphrasePolicy,\n): PassphraseValidationResult {\n // Escape hatch: customValidator owns the entire decision. None of\n // the structural rules below run when this is set — the consumer is\n // responsible for the full validation contract.\n if (opts?.customValidator) {\n return opts.customValidator(s)\n }\n\n const minWords = opts?.minWords ?? DEFAULT_MIN_WORDS\n const minWordLength = opts?.minWordLength ?? DEFAULT_MIN_WORD_LENGTH\n const rejectRepeated = opts?.rejectRepeatedAdjacent ?? true\n\n if (s.length === 0) {\n return { ok: false, reason: 'empty' }\n }\n\n if (s !== s.trim()) {\n return { ok: false, reason: 'leading-or-trailing-space' }\n }\n\n if (s.includes(' ')) {\n return { ok: false, reason: 'double-space' }\n }\n\n // The default character class is lowercase-letters-and-spaces;\n // consumers can override via PassphrasePolicy.pattern (e.g. to\n // allow digits, uppercase, or non-Latin scripts). Word splitting\n // below remains space-based — for non-space word semantics the\n // consumer should use customValidator instead.\n const charPattern = opts?.pattern ?? /^[a-z]+( [a-z]+)*$/\n if (!charPattern.test(s)) {\n return { ok: false, reason: 'invalid-chars' }\n }\n\n const words = s.split(' ')\n\n if (words.length < minWords) {\n return { ok: false, reason: 'too-few-words', minimum: minWords, got: words.length }\n }\n\n for (const w of words) {\n if (w.length < minWordLength) {\n return { ok: false, reason: 'word-too-short', minimum: minWordLength, got: w.length }\n }\n }\n\n if (rejectRepeated) {\n for (let i = 1; i < words.length; i++) {\n if (words[i] === words[i - 1]) {\n return { ok: false, reason: 'repeated-adjacent' }\n }\n }\n }\n\n return { ok: true, words: words.length }\n}\n\n/**\n * Throw {@link WeakPassphraseError} when the phrase fails. Used by\n * `createOwnerKeyring`, `grant`, and `rotatePassphrase` at ingress.\n *\n * Pass `{ allowWeakPassphrase: true }` to bypass — intended for test\n * fixtures, CLI scripts, and dev environments. The override never\n * loosens the cryptographic key derivation; it only relaxes the\n * structural-strength gate.\n */\nexport function assertStrongPassphrase(\n s: string,\n opts?: PassphrasePolicy & { allowWeakPassphrase?: boolean },\n): void {\n if (opts?.allowWeakPassphrase) return\n const result = validatePassphrase(s, opts)\n if (result.ok) return\n throw new WeakPassphraseError(result.reason, SUGGESTIONS[result.reason])\n}\n\n/**\n * Estimate the entropy of a phrase, given the EFF 7,776-word list as\n * the assumed wordlist. ~12.9 bits per word.\n *\n * Returns 0 for any input that fails the phrase format — character-class\n * estimates aren't comparable to phrase entropy, and surfacing 0 makes\n * weak inputs visible in any UI that displays an entropy meter.\n */\nexport function estimateEntropy(passphrase: string): number {\n const result = validatePassphrase(passphrase)\n if (!result.ok) return 0\n return Math.round(result.words * Math.log2(7776))\n}\n\n/**\n * Internal compatibility shim. Older code paths used the throwing\n * `validatePassphrase(s)` directly; some still do via re-exports. Routes\n * to the new `assertStrongPassphrase` so the contract holds for both\n * shapes during the transition. New code should call\n * {@link assertStrongPassphrase} directly.\n *\n * @internal\n */\nexport function legacyAssertPassphrase(s: string): void {\n try {\n assertStrongPassphrase(s)\n } catch (err) {\n if (err instanceof WeakPassphraseError) {\n throw new ValidationError(err.message)\n }\n throw err\n }\n}\n","/**\n * Secret-bearing reserved collections — the `_`-prefixed reserved collections\n * whose record CONTENTS are directly-usable secrets (transport OAuth tokens,\n * connection strings, API keys), as opposed to the *operational* reserved\n * collections (`_ledger`, `_history`, `_sync`, …) whose contents are metadata\n * (collection names, record ids, ciphertext hashes).\n *\n * This distinction matters at two trust-boundary seams:\n *\n * 1. `vault.collection()` — a secret-bearing reserved name must never be\n * reachable through the generic public collection handle. It is served\n * ONLY by its dedicated, role-gated API (e.g. `_sync_credentials` →\n * `getCredential`/`putCredential`, which enforce owner/admin access).\n *\n * 2. `grant()` DEK propagation — a sub-admin grantee (operator, viewer,\n * client, custodian) must NOT receive a secret-bearing collection's DEK.\n * Operational reserved DEKs still propagate to every role (grantees must\n * write ledger/history entries on every mutation), but handing a\n * sub-admin the `_sync_credentials` DEK would let them decrypt the firm's\n * transport secrets — a plaintext leak, not a metadata leak.\n *\n * Kept dependency-free so both the kernel (`vault.ts`) and the team layer\n * (`keyring.ts`) can import it without an import cycle.\n */\n\n/** Reserved collection holding per-adapter sync transport secrets. */\nexport const SYNC_CREDENTIALS_COLLECTION = '_sync_credentials'\n\n/**\n * Reserved (pre-allocated) name for the future credential broker (#479).\n * Not yet implemented — reserved now so that neither the public collection\n * handle nor grant propagation can ever expose it before its dedicated,\n * role-gated API lands.\n */\nexport const BROKER_COLLECTION = '_broker'\n\n/**\n * The set of reserved collection names whose record contents are\n * directly-usable secrets. Membership here means: never served via\n * `vault.collection()`, never propagated to sub-admin grantees.\n */\nexport const SECRET_BEARING_RESERVED_COLLECTIONS: ReadonlySet<string> = new Set([\n SYNC_CREDENTIALS_COLLECTION,\n BROKER_COLLECTION,\n])\n\n/** True when `name` is a secret-bearing reserved collection. */\nexport function isSecretBearingReservedCollection(name: string): boolean {\n return SECRET_BEARING_RESERVED_COLLECTIONS.has(name)\n}\n","import type { NoydbStore, KeyringFile, KeyringAuthenticator, Role, Permissions, GrantOptions, RevokeOptions, UpdateUserOptions, UserInfo, EncryptedEnvelope, ExportCapability, ExportFormat, ImportCapability, VaultPolicyOnDisk, UserEnvelope as UserEnvelopeReader } from '../../kernel/types.js'\nimport { NOYDB_KEYRING_VERSION, NOYDB_FORMAT_VERSION } from '../../kernel/types.js'\nimport { USER_ENVELOPE_COLLECTION } from '../../kernel/constants.js'\nimport {\n deriveKey,\n generateDEK,\n generateSalt,\n wrapKey,\n unwrapKey,\n encrypt,\n decrypt,\n bufferToBase64,\n base64ToBuffer,\n type EnclaveKey,\n} from '../../kernel/enclave/index.js'\nimport { NoAccessError, PermissionDeniedError, PrivilegeEscalationError, KeyringExpiredError, KeyringCorruptError, InvalidKeyError, ValidationError, DirectoryDisabledError } from '../../kernel/errors.js'\nimport { readDirectoryConfig } from '../directory/storage.js'\nimport { readUserVisibility, deleteUserVisibility } from '../directory/visibility.js'\nimport { assertStrongPassphrase, type PassphrasePolicy } from '../../kernel/validation.js'\nimport {\n saveUserEnvelope,\n loadUserEnvelope as loadUserEnvelopeFn,\n deleteUserEnvelope,\n} from '../directory/user-envelope/index.js'\nimport { isSecretBearingReservedCollection } from './reserved-secret-collections.js'\n\n// ─── Roles that can grant/revoke ───────────────────────────────────────\n\n/**\n * Roles that an `admin` is allowed to grant and revoke.\n *\n * Includes `'admin'` itself: the model bottlenecked all admin\n * onboarding through the single `owner` principal, which made lateral\n * delegation impossible and left a single-owner bus-factor risk\n * unresolved even when multiple trusted humans existed. opens up\n * admin↔admin lateral delegation, with two guardrails:\n *\n * 1. **No privilege escalation.** Enforced in `grant()`: every DEK\n * wrapped into the new admin's keyring must be present in the\n * grantor's own DEK set. Today this is structurally trivially\n * true (admin grants always inherit the full caller DEK set),\n * but the check is wired in so future per-collection admin scoping\n * cannot accidentally bypass it. See `PrivilegeEscalationError`.\n *\n * 2. **Cascade on revoke.** Enforced in `revoke()`: when an admin is\n * revoked, every admin they (transitively) granted is either\n * revoked too (`cascade: 'strict'`, default) or left in place with\n * a console warning (`cascade: 'warn'`). The walk uses the\n * `granted_by` field on each keyring file as the parent pointer.\n */\n// FR-6: 'custodian' is deliberately ABSENT here. An admin must not be able\n// to mint (or revoke) a custodian — only the (sealed Deed) owner can, because\n// the custodian is the de-facto operational authority and granting one is an\n// ownership-level act. `canGrant(admin, 'custodian')` therefore returns false.\nconst ADMIN_GRANTABLE_TARGETS: readonly Role[] = ['operator', 'viewer', 'client', 'admin']\n\nfunction canGrant(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n // FR-6: a custodian can never grant — it holds the DEKs to OPERATE the\n // vault but no authority to widen the principal set. Explicit + fail-safe\n // (it would also fall through to `return false`, but spell it out).\n if (callerRole === 'custodian') return false\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\nfunction canRevoke(callerRole: Role, targetRole: Role): boolean {\n if (targetRole === 'owner') return false // owner cannot be revoked\n if (callerRole === 'owner') return true\n // FR-6: a custodian can never revoke — same non-owning rationale as canGrant.\n if (callerRole === 'custodian') return false\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n/**\n * Whether `callerRole` can mutate a keyring whose role is (or becomes)\n * `targetRole`. Used by `updateKeyringIdentity`.\n *\n * Mirrors `canGrant`'s hierarchy: admins manage admin/operator/viewer/\n * client laterally; admins cannot create or destroy `owner`-shaped\n * keyrings. Owner can do anything.\n *\n * Both the OLD role and the NEW role must satisfy this check —\n * otherwise admin could elevate themselves (`admin → owner`) or demote\n * an owner (`owner → admin`) under cover of \"update.\"\n */\nfunction canUpdateRole(callerRole: Role, targetRole: Role): boolean {\n if (callerRole === 'owner') return true\n if (callerRole === 'admin') return ADMIN_GRANTABLE_TARGETS.includes(targetRole)\n return false\n}\n\n// ─── Unlocked Keyring ──────────────────────────────────────────────────\n\n/** In-memory representation of an unlocked keyring. */\nexport interface UnlockedKeyring {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly permissions: Permissions\n readonly deks: Map<string, EnclaveKey>\n /**\n * The KEK, when this keyring was unlocked via tier 1 (passphrase) or\n * a wrap-KEK tier-2 method (WebAuthn / OIDC). `null` when the\n * keyring was opened via:\n *\n * - Unencrypted mode (no KEK exists)\n * - Tier-3 PIN quick-resume (`@noy-db/on-pin`)\n * - Wrap-DEKs tier-2 unlock (`@noy-db/on-password`'s\n * `verifyPasswordSlot`)\n * - Session-state restore (`session/session.ts`)\n * - Dev-unlock fixture (`session/dev-unlock.ts`)\n *\n * Consumers performing tier-1 operations that need the KEK\n * (DEK rewrap, keyring persist, delegation issue/unwrap) must\n * null-check and throw a clear error if absent — re-authenticate\n * at tier 1 first to recover the KEK.\n *\n * Tightened from `EnclaveKey` to `EnclaveKey | null`; the runtime\n * contract has always allowed null, the type now matches reality.\n */\n readonly kek: EnclaveKey | null\n readonly salt: Uint8Array\n /**\n * Debug-plaintext layout flag. Set only on the plaintext keyring created\n * in `encrypt: false` + `debugPlaintext: true` mode — it lives here\n * because its lifecycle is identical to the plaintext keyring's (no\n * encrypted vault ever has it, so this never widens the encrypted surface).\n * When true, user-collection records are written with their fields inlined\n * beside the envelope metadata (`_debug: 1`) so native store tooling can\n * read them without unwrapping `_data`.\n */\n readonly debugPlaintext?: boolean\n /**\n * `@noy-db/as-*` export capability. Absent when the\n * keyring was written before this RFC landed — role-based defaults\n * apply via `hasExportCapability`.\n */\n readonly exportCapability?: ExportCapability\n /**\n * `@noy-db/as-*` import capability. Absent when the\n * keyring was written before the import-capability extension\n * landed — default-closed semantics\n * apply via `hasImportCapability` (no plaintext format granted, no\n * bundle import granted, regardless of role).\n */\n readonly importCapability?: ImportCapability\n /**\n * Tier-2 authenticator slots — readonly snapshot loaded from the\n * keyring file. Mutations go through `enrollAuthenticator` /\n * `removeAuthenticator`, which write back via\n * `persistKeyring`. Always defined; loads with an empty array for\n * keyrings written before the multi-slot extension landed.\n */\n readonly authenticators: readonly KeyringAuthenticator[]\n /**\n * Reserved per-keyring policy override (forward-compat for Option C\n * — see {@link VaultPolicyOnDisk}). v1.0 round-trips this field but\n * never enforces it; the gate engine uses `_meta/policy` only.\n */\n readonly policy?: VaultPolicyOnDisk\n}\n\n// ─── Passphrase canary ─────────────────────────────────────────────────\n//\n// The canary is a fixed 256-bit AES-GCM key (32 zero bytes), wrapped\n// under the keyring's KEK with AES-KW. Because AES-KW is deterministic\n// (RFC 3394 fixed IV), wrapping the same constant under the same KEK\n// always yields the same ciphertext — so every write site can mint\n// fresh on each persist without round-tripping a `canary` field\n// through UnlockedKeyring.\n//\n// On load, the canary unwraps cleanly iff the KEK is correct AND the\n// canary bytes on disk are intact. Combined with each-DEK try/catch,\n// this distinguishes wrong-passphrase (canary fails AND every DEK fails)\n// from corruption (canary succeeds OR at least one DEK succeeds) —\n// closing the all-DEKs-corrupt and single-DEK ambiguities that the\n// pre-canary heuristic left open.\n\nconst CANARY_PLAINTEXT_BYTES = new Uint8Array(32)\nlet canaryKeyPromise: Promise<EnclaveKey> | null = null\n\nfunction getCanaryKey(): Promise<EnclaveKey> {\n if (canaryKeyPromise === null) {\n canaryKeyPromise = globalThis.crypto.subtle.importKey(\n 'raw',\n CANARY_PLAINTEXT_BYTES as BufferSource,\n { name: 'AES-GCM', length: 256 },\n true, // extractable so AES-KW can wrap it\n ['encrypt', 'decrypt'],\n )\n }\n return canaryKeyPromise\n}\n\n/** Mint a fresh wrapped-canary string. Deterministic for a given KEK. */\nexport async function mintKeyringCanary(kek: EnclaveKey): Promise<string> {\n const canaryKey = await getCanaryKey()\n return wrapKey(canaryKey, kek)\n}\n\n/** Try to unwrap the canary. Returns true iff KEK + canary bytes are intact. */\nasync function verifyKeyringCanary(wrappedCanary: string, kek: EnclaveKey): Promise<boolean> {\n try {\n await unwrapKey(wrappedCanary, kek)\n return true\n } catch {\n return false\n }\n}\n\n// ─── Load / Create ─────────────────────────────────────────────────────\n\n/** Load and unlock a user's keyring for a vault. */\nexport async function loadKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n): Promise<UnlockedKeyring> {\n const envelope = await adapter.get(vault, '_keyring', userId)\n\n if (!envelope) {\n throw new NoAccessError(`No keyring found for user \"${userId}\" in vault \"${vault}\"`)\n }\n\n const keyringFile = JSON.parse(envelope._data) as KeyringFile\n\n // — refuse to unwrap an expired slot. Check happens before any\n // KEK derivation so an expired slot doesn't leak timing on the\n // passphrase. Comparison uses Date.parse → ms-since-epoch; an\n // unparseable expires_at is treated as \"no expiry\" so a malformed\n // value can't silently lock users out (it'll surface in tests).\n if (keyringFile.expires_at !== undefined) {\n const cutoff = Date.parse(keyringFile.expires_at)\n if (Number.isFinite(cutoff) && Date.now() >= cutoff) {\n throw new KeyringExpiredError({ userId: keyringFile.user_id, expiresAt: keyringFile.expires_at })\n }\n }\n\n const salt = base64ToBuffer(keyringFile.salt)\n const kek = await deriveKey(passphrase, salt)\n\n // Verify the canary first when present. A canary success proves the\n // KEK is correct independent of any DEK byte — so subsequent DEK\n // unwrap failures are unambiguously corruption, not wrong-pass. A\n // canary failure with at least one DEK success indicates the KEK\n // is correct but the canary itself is corrupt.\n // `null` sentinel = legacy keyring without canary; falls back to the\n // multi-DEK heuristic.\n const canaryOk: boolean | null = keyringFile.canary !== undefined\n ? await verifyKeyringCanary(keyringFile.canary, kek)\n : null\n\n // Unwrap each DEK independently — collect successes and failures.\n const deks = new Map<string, EnclaveKey>()\n const failedCollections: string[] = []\n let firstUnwrapError: unknown = null\n for (const [collName, wrappedDek] of Object.entries(keyringFile.deks)) {\n try {\n const dek = await unwrapKey(wrappedDek, kek)\n deks.set(collName, dek)\n } catch (err) {\n failedCollections.push(collName)\n if (firstUnwrapError === null) firstUnwrapError = err\n }\n }\n\n if (canaryOk === true) {\n // KEK proven correct by the canary. Any DEK failure is corruption.\n if (failedCollections.length > 0) {\n throw new KeyringCorruptError({ failedCollections, intactCount: deks.size })\n }\n } else if (canaryOk === false) {\n // Canary failed. If any DEK unwrapped, KEK is correct → canary bytes\n // are corrupted (rare; reported under the '_canary' sentinel).\n if (deks.size > 0) {\n throw new KeyringCorruptError({\n failedCollections: [...failedCollections, '_canary'],\n intactCount: deks.size,\n })\n }\n // Canary failed AND no DEK unwrapped — wrong KEK (or whole-file\n // corruption). Surface the original InvalidKeyError so\n // onInvalidKey: 'reset' can fire its documented recovery path.\n throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError()\n } else {\n // Legacy keyring (no canary). Fall back to the multi-DEK heuristic.\n if (failedCollections.length > 0) {\n if (deks.size > 0) {\n throw new KeyringCorruptError({ failedCollections, intactCount: deks.size })\n }\n throw firstUnwrapError instanceof Error ? firstUnwrapError : new InvalidKeyError()\n }\n }\n\n return {\n userId: keyringFile.user_id,\n displayName: keyringFile.display_name,\n role: keyringFile.role,\n permissions: keyringFile.permissions,\n deks,\n kek,\n salt,\n authenticators: keyringFile.authenticators ?? [],\n ...(keyringFile.export_capability !== undefined && { exportCapability: keyringFile.export_capability }),\n ...(keyringFile.import_capability !== undefined && { importCapability: keyringFile.import_capability }),\n ...(keyringFile.policy !== undefined && { policy: keyringFile.policy }),\n }\n}\n\n/**\n * Open-policy pre-gate: decide create-vs-fail-closed **before** any\n * vault write. `openVault` must not self-provision an owner keyring into a\n * vault held by other principals; create-on-open is allowed only for a\n * genuinely-new vault (no `_keyring/*` at all). Capability-free — one\n * `store.list`. Returns when the open may proceed (the caller is a member, or\n * the vault is genuinely-new and `create` is allowed, in which case the caller\n * falls through to the normal `createOwnerKeyring` path); throws `NoAccessError`\n * otherwise. Placed before managed-passphrase secret resolution (which persists\n * on first open), so a fail-closed open writes nothing.\n */\nexport async function assertKeyringOpenAllowed(\n store: NoydbStore,\n vault: string,\n userId: string,\n create: boolean,\n): Promise<void> {\n const keyringUsers = await store.list(vault, '_keyring')\n if (keyringUsers.includes(userId)) return // caller is a member → load existing\n if (!create) {\n throw new NoAccessError(`Vault \"${vault}\" not opened: create disabled and no keyring for \"${userId}\".`)\n }\n if (keyringUsers.length > 0) {\n throw new NoAccessError(\n `No keyring for user \"${userId}\" in vault \"${vault}\" (held by other principals) — refusing to self-provision.`,\n )\n }\n // empty → genuinely-new vault → caller proceeds to the create path\n}\n\n/**\n * Create the initial owner keyring for a new vault.\n *\n * Pass `{ validate: true }` (or a `PassphrasePolicy`) to gate creation\n * on the phrase-format strength rules — `Noydb` threads this from\n * `NoydbOptions.validatePassphrase`. Direct callers (CLI, scripts,\n * test fixtures) opt in explicitly.\n */\nexport async function createOwnerKeyring(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n passphrase: string,\n passphraseOpts?: PassphrasePolicy & { validate?: boolean; allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (passphraseOpts?.validate && !passphraseOpts.allowWeakPassphrase) {\n assertStrongPassphrase(passphrase, passphraseOpts)\n }\n const salt = generateSalt()\n const kek = await deriveKey(passphrase, salt)\n\n // Eager-provision the _users DEK at owner creation. This guarantees\n // every subsequent grant inherits it via the existing\n // collName.startsWith('_') propagation in grant() — so multi-principal\n // user-envelope reads (alice reading bob's profile) work for new\n // vaults without any per-keyring DEK rotation. Pre-existing vaults\n // get the DEK lazily on first vault.user.* access (which only\n // materializes a single-principal DEK that won't propagate\n // retroactively — that's the documented \"lazy creation for\n // pre-existing keyrings\" rollout note in the spec).\n const userEnvelopeDek = await generateDEK()\n const wrappedUserEnvelopeDek = await wrapKey(userEnvelopeDek, kek)\n const canary = await mintKeyringCanary(kek)\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: userId,\n display_name: userId,\n role: 'owner',\n permissions: {},\n deks: { [USER_ENVELOPE_COLLECTION]: wrappedUserEnvelopeDek },\n salt: bufferToBase64(salt),\n created_at: new Date().toISOString(),\n granted_by: userId,\n canary,\n }\n\n await writeKeyringFile(adapter, vault, userId, keyringFile)\n\n return {\n userId,\n displayName: userId,\n role: 'owner',\n permissions: {},\n deks: new Map([[USER_ENVELOPE_COLLECTION, userEnvelopeDek]]),\n kek,\n salt,\n authenticators: [],\n }\n}\n\n// ─── Grant ─────────────────────────────────────────────────────────────\n\n/** Grant access to a new user. Caller must have grant privilege. */\nexport async function grant(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: GrantOptions,\n): Promise<void> {\n if (!callerKeyring.kek) {\n throw new ValidationError(\n 'grant: caller keyring has no KEK — tier-2 wrap-DEKs and tier-3 PIN-resume ' +\n 'sessions cannot grant access to other users. Re-authenticate at tier 1 ' +\n '(passphrase) before granting.',\n )\n }\n\n if (!canGrant(callerKeyring.role, options.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot grant role \"${options.role}\"`,\n )\n }\n\n // Optional strength validation — opt-in via grant({ validatePassphrase: true })\n // or via the calling Noydb's NoydbOptions.validatePassphrase flag.\n // The override `allowWeakPassphrase: true` skips even when validate is on.\n if (\n (options as { validatePassphrase?: boolean }).validatePassphrase &&\n !options.allowWeakPassphrase\n ) {\n assertStrongPassphrase(options.passphrase)\n }\n\n // Determine which collections the new user gets access to\n const permissions = resolvePermissions(options.role, options.permissions)\n\n // Derive the new user's KEK from their passphrase\n const newSalt = generateSalt()\n const newKek = await deriveKey(options.passphrase, newSalt)\n\n // Only owner and admin may ever hold a secret-bearing reserved DEK\n // (`_sync_credentials`, `_broker`) — they are the roles the dedicated\n // credential API admits. Every other grantee (custodian, viewer, operator,\n // client) is excluded, even from the \"wrap ALL\" branches below: a custodian\n // operates the DATA but must never read the firm's transport secrets (see\n // `requireAdminAccess` in sync-credentials.ts), and handing any sub-admin\n // one of these DEKs is a plaintext leak, not a metadata leak.\n const granteeMayHoldSecrets =\n options.role === 'owner' || options.role === 'admin'\n\n // Wrap the appropriate DEKs with the new user's KEK\n const wrappedDeks: Record<string, string> = {}\n for (const collName of Object.keys(permissions)) {\n // Never hand a secret-bearing reserved DEK to a sub-admin, even if the\n // grantor explicitly names it in `permissions` — that path is served\n // only by the owner/admin-gated credential API, not per-collection grants.\n if (isSecretBearingReservedCollection(collName) && !granteeMayHoldSecrets) continue\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // For owner/admin/custodian/viewer roles, wrap ALL known DEKs.\n // FR-6: a custodian operates EVERY collection, so — like admin — it must\n // receive every collection DEK on grant. Without this branch a custodian\n // could neither read nor write and the role would be inert.\n if (\n options.role === 'owner' ||\n options.role === 'admin' ||\n options.role === 'custodian' ||\n options.role === 'viewer'\n ) {\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName in wrappedDeks) continue\n if (isSecretBearingReservedCollection(collName) && !granteeMayHoldSecrets) continue\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // For ALL roles, propagate system-prefixed collection DEKs\n // (`_ledger`, `_history`, `_sync`, …). These are internal collections\n // that any user with access to the vault must be able to\n // read and write — for example, the hash-chained ledger writes\n // an entry on every put/delete, so operators and clients with write\n // access to a single data collection still need the `_ledger` DEK.\n //\n // Trade-off: a granted user can decrypt every system-collection\n // entry, including ones they would not otherwise have access to\n // (e.g., an operator on `invoices` can read ledger entries for\n // mutations in `salaries`). This is a metadata leak, not a\n // plaintext leak — the ledger entries record collection names,\n // record ids, and ciphertext hashes, but never plaintext records.\n // Per-collection ledger DEKs are tracked as a follow-up.\n //\n // EXCEPTION — secret-bearing reserved collections (`_sync_credentials`,\n // `_broker`) whose record CONTENTS are directly-usable secrets are NOT\n // propagated to sub-admin grantees. Unlike the operational collections\n // above, handing an operator/viewer/client/custodian one of these DEKs\n // IS a plaintext leak (the firm's transport OAuth tokens). Only owner and\n // admin — the roles the dedicated `getCredential`/`putCredential` API\n // admits — receive them, so the legit admin-reads-existing-credential\n // flow (which needs the DEK to decrypt, not regenerate) still works.\n for (const [collName, dek] of callerKeyring.deks) {\n if (!collName.startsWith('_') || collName in wrappedDeks) continue\n if (isSecretBearingReservedCollection(collName) && !granteeMayHoldSecrets) continue\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n\n // Anti-privilege-escalation check. Every DEK we just\n // wrapped into the new keyring must come from the caller's own DEK\n // set — the grantor cannot give the grantee access to a collection\n // they themselves can't read. Today this is structurally trivially\n // satisfied because every wrapped DEK was looked up in\n // `callerKeyring.deks` above, but the explicit check is wired in\n // so a future change (per-collection admin scoping, escrow-based\n // re-wrapping, etc.) cannot accidentally let a widening grant\n // through. See `PrivilegeEscalationError` for the rationale.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: options.userId,\n display_name: options.displayName,\n role: options.role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n canary,\n ...(options.exportCapability !== undefined && { export_capability: options.exportCapability }),\n ...(options.importCapability !== undefined && { import_capability: options.importCapability }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, keyringFile)\n\n // User envelope bootstrap. Seeded with `options.initialProfile` if\n // provided, otherwise an empty `{}`. Encrypted with the caller's\n // _users DEK — which is the same DEK that was wrapped into the new\n // keyring's `wrappedDeks[USER_ENVELOPE_COLLECTION]` above (system-\n // collection propagation), so the new user can decrypt it on first\n // open. Skipped silently if the caller has no _users DEK (pre-feature\n // vault upgrade path — documented \"lazy creation for pre-existing\n // keyrings\" in the spec).\n const userEnvelopeDek = callerKeyring.deks.get(USER_ENVELOPE_COLLECTION)\n if (userEnvelopeDek) {\n const initialPayload = options.initialProfile ?? {}\n await saveUserEnvelope(\n adapter,\n vault,\n options.userId,\n initialPayload,\n userEnvelopeDek,\n )\n }\n}\n\n// ─── Revoke ────────────────────────────────────────────────────────────\n\n/**\n * Walk every keyring in the vault to find admins that the given\n * `rootUserId` (transitively) granted, via the `granted_by` parent\n * pointer recorded on each keyring file.\n *\n * Returns the set of descendant admin user-ids in DFS order, NOT\n * including the root itself. Non-admin descendants are excluded\n * because operators/viewers/clients cannot grant other users — they\n * are leaves in the delegation tree and cleaning them up is the\n * caller's job (or the next rotate, since they'd lose key access\n * anyway when the cascading admin's collections rotate).\n *\n * The walk uses a visited set keyed by user-id so cycles introduced\n * by re-grants (admin-A revoked, then re-granted later by admin-B who\n * was originally granted by A) terminate cleanly.\n */\nasync function findAdminDescendants(\n adapter: NoydbStore,\n vault: string,\n rootUserId: string,\n): Promise<string[]> {\n const allUserIds = await adapter.list(vault, '_keyring')\n\n // Build a map: parentUserId → child KeyringFiles. We only ever\n // descend into admins, so non-admin children are skipped at the\n // edge level rather than after a recursive call.\n const childrenByParent = new Map<string, string[]>()\n for (const userId of allUserIds) {\n const env = await adapter.get(vault, '_keyring', userId)\n if (!env) continue\n const kf = JSON.parse(env._data) as KeyringFile\n // Only admins can grant, so only admins have a delegation subtree to\n // cascade. FR-6: a custodian is intentionally EXCLUDED here — it cannot\n // grant (canGrant(custodian,*) === false), so it is never a cascade root\n // and never a descendant edge. Treating it as an admin-descendant would\n // wrongly sweep it into an admin's revoke cascade.\n if (kf.role !== 'admin') continue // only admins can grant — leaves are uninteresting\n if (kf.user_id === rootUserId) continue // self-edges are noise\n const list = childrenByParent.get(kf.granted_by) ?? []\n list.push(kf.user_id)\n childrenByParent.set(kf.granted_by, list)\n }\n\n const visited = new Set<string>()\n const order: string[] = []\n const stack: string[] = [...(childrenByParent.get(rootUserId) ?? [])]\n while (stack.length > 0) {\n const next = stack.pop()!\n if (visited.has(next)) continue\n visited.add(next)\n order.push(next)\n for (const grandchild of childrenByParent.get(next) ?? []) {\n if (!visited.has(grandchild)) stack.push(grandchild)\n }\n }\n return order\n}\n\n/** Revoke a user's access. Optionally rotate keys for affected collections. */\nexport async function revoke(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: RevokeOptions,\n): Promise<void> {\n // Load the target's keyring to check their role\n const targetEnvelope = await adapter.get(vault, '_keyring', options.userId)\n if (!targetEnvelope) {\n throw new NoAccessError(`User \"${options.userId}\" has no keyring in vault \"${vault}\"`)\n }\n\n const targetKeyring = JSON.parse(targetEnvelope._data) as KeyringFile\n\n if (!canRevoke(callerKeyring.role, targetKeyring.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot revoke role \"${targetKeyring.role}\"`,\n )\n }\n\n // Cascade-on-revoke. Only meaningful when the target is\n // an admin — operators/viewers/clients cannot grant other users so\n // they have no delegation subtree to walk.\n const cascadeMode = options.cascade ?? 'strict'\n const usersToRevoke: string[] = [options.userId]\n const affectedCollections = new Set(Object.keys(targetKeyring.deks))\n\n if (targetKeyring.role === 'admin') {\n const descendants = await findAdminDescendants(adapter, vault, options.userId)\n if (descendants.length > 0) {\n if (cascadeMode === 'warn') {\n // Diagnostic mode: leave the descendants in place but make\n // them visible. The owner / a different admin can clean up\n // manually. The single console.warn is intentionally noisy\n // (a list, not a count) so the operator sees exactly which\n // keyrings will become orphans.\n console.warn(\n `[noy-db] revoke(${options.userId}): cascade='warn' — leaving ` +\n `${descendants.length} descendant admin(s) in place: ` +\n `${descendants.join(', ')}. These admins were granted by the revoked user ` +\n `(transitively) and will become orphans in the delegation tree.`,\n )\n } else {\n // Strict mode (default): pull every descendant into the\n // revoke set. We collect their affected collections too so\n // the single rotation pass at the end covers everything.\n for (const userId of descendants) {\n const descEnv = await adapter.get(vault, '_keyring', userId)\n if (!descEnv) continue\n const descKf = JSON.parse(descEnv._data) as KeyringFile\n usersToRevoke.push(userId)\n for (const c of Object.keys(descKf.deks)) affectedCollections.add(c)\n }\n }\n }\n }\n\n // Delete every keyring in the revoke set. Order doesn't matter\n // because each keyring file is independent on disk; we don't have\n // referential integrity to maintain across deletes.\n for (const userId of usersToRevoke) {\n await adapter.delete(vault, '_keyring', userId)\n // Cascade-delete the principal's user envelope. Idempotent — no\n // error when the envelope was never written (e.g. the user was\n // granted but never authenticated to write their own profile).\n await deleteUserEnvelope(adapter, vault, userId)\n // Also drop the visibility sidecar at `_meta/visibility/<userId>`.\n // If the same `userId` is re-granted later (rare for humans,\n // possible for service accounts and test fixtures), the new\n // principal must start with a fresh visibility state instead of\n // silently inheriting the revoked user's `hidden` flag.\n await deleteUserVisibility(adapter, vault, userId)\n }\n\n // Single rotation pass at the end. The cost is O(records in\n // affected collections), NOT O(records × cascade depth) — every\n // descendant's collections were unioned into `affectedCollections`\n // before we got here, so the rotation re-encrypts each affected\n // record exactly once regardless of how deep the cascade went.\n if (options.rotateKeys !== false && affectedCollections.size > 0) {\n await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections])\n }\n}\n\n// ─── Update User ───────────────────────────────────────────────────────\n\n/**\n * Mutate `role`, `displayName`, and/or `permissions` on an existing\n * keyring. Pure plaintext-header rewrite — no DEK rewrap, no KEK\n * required, no authenticator slots touched. Tier-2 enrollments and\n * recovery codes survive the operation.\n *\n * Role-elevation guard: BOTH the old role AND the new role must\n * satisfy `canUpdateRole(callerRole, _)`. This blocks the two\n * privilege-escalation shapes:\n * - admin elevates someone (or themselves) to owner\n * - admin demotes an owner to a role they then control\n *\n * Owner is always allowed. Admin manages admin / operator / viewer /\n * client laterally.\n *\n * Identity preserved: same userId, same DEK wrappings. Last-write-wins\n * through the standard keyring put (same concurrency story as `grant`\n * and `revoke`).\n *\n * @throws `NoAccessError` when no keyring exists for the target.\n * @throws `PermissionDeniedError` when the role hierarchy rejects.\n * @throws `ValidationError` when the diff is empty (nothing to update).\n *\n */\nexport async function updateKeyringIdentity(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n options: UpdateUserOptions,\n): Promise<void> {\n if (\n options.role === undefined &&\n options.displayName === undefined &&\n options.permissions === undefined\n ) {\n throw new ValidationError(\n `updateUser: at least one of role / displayName / permissions must be provided ` +\n `(userId: \"${options.userId}\").`,\n )\n }\n\n const env = await adapter.get(vault, '_keyring', options.userId)\n if (!env) {\n throw new NoAccessError(\n `updateUser: user \"${options.userId}\" has no keyring in vault \"${vault}\".`,\n )\n }\n const target = JSON.parse(env._data) as KeyringFile\n\n // Role-elevation guard. The OLD role must be one this caller is\n // allowed to manage, AND the NEW role (if changing) must be too.\n // Two-sided check: blocks admin→owner promotion (new side) and\n // demoting an owner (old side).\n if (!canUpdateRole(callerKeyring.role, target.role)) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot update a keyring with role \"${target.role}\"`,\n )\n }\n if (\n options.role !== undefined &&\n options.role !== target.role &&\n !canUpdateRole(callerKeyring.role, options.role)\n ) {\n throw new PermissionDeniedError(\n `Role \"${callerKeyring.role}\" cannot promote target to role \"${options.role}\"`,\n )\n }\n\n const next: KeyringFile = {\n ...target,\n ...(options.role !== undefined && { role: options.role }),\n ...(options.displayName !== undefined && {\n // null clears the field (stored as \"\"); a string sets it.\n display_name: options.displayName ?? '',\n }),\n ...(options.permissions !== undefined && { permissions: options.permissions }),\n }\n\n await writeKeyringFile(adapter, vault, options.userId, next)\n}\n\n// ─── Key Rotation ──────────────────────────────────────────────────────\n\n/**\n * Rotate DEKs for specified collections:\n * 1. Generate new DEKs\n * 2. Re-encrypt all records in affected collections\n * 3. Re-wrap new DEKs for all remaining users\n */\nexport async function rotateKeys(\n adapter: NoydbStore,\n vault: string,\n callerKeyring: UnlockedKeyring,\n collections: string[],\n): Promise<void> {\n // FR-6: re-keying is an owner-only meta-capability. A custodian operates the\n // vault fully but must NOT rotate — rotation would let it mint fresh DEKs and\n // strip the sealed owner's access, breaking the inalienability floor.\n if (callerKeyring.role === 'custodian') {\n throw new PermissionDeniedError(\n 'custodian cannot rotate keys (FR-6: re-key is an owner-only meta-capability; use the Deed owner)',\n )\n }\n // Generate new DEKs for each affected collection\n const newDeks = new Map<string, EnclaveKey>()\n for (const collName of collections) {\n newDeks.set(collName, await generateDEK())\n }\n\n // Re-encrypt all records in affected collections\n //\n // FORWARD REQUIREMENT (not implemented here — DEK rotation predates\n // perRecordKeys/classified fields): a future perRecordKeys-aware DEK\n // rotation MUST DROP `_bidx` here rather than carry it forward. `_bidx` is\n // rooted in the (soon-to-be-dead) old DEK; a stale tag surviving under the\n // new DEK is unreturnable garbage (no key can ever re-derive it to match a\n // query again) that still LEAKS the old equality partition. Unlike\n // `rotateRecordCek` (record-keys/sealing.ts), which carries `_bidx`\n // verbatim because it is CEK-rotation-only and the DEK is unchanged, a DEK\n // rotation changes the root the tag is derived from, so index coverage can\n // only regrow per-record, the next time each record is `put()` under the\n // new DEK.\n //\n // D-5 (pre-existing hazard, flagged not fixed): in a mixed collection, bare\n // (non-`_cek`) records below are re-encrypted and `put()` in this loop\n // *before* any `_cek` record is reached; if a later record in the same\n // collection throws (e.g. an unwrap failure), those already-rewritten bare\n // records are left persisted under the new DEK while `callerKeyring` is\n // never updated with it (the `deks.set` below hasn't run) — an unsaved-DEK\n // state with no rollback.\n for (const collName of collections) {\n const oldDek = callerKeyring.deks.get(collName)\n const newDek = newDeks.get(collName)!\n if (!oldDek) continue\n\n const ids = await adapter.list(vault, collName)\n for (const id of ids) {\n const envelope = await adapter.get(vault, collName, id)\n if (!envelope || !envelope._iv) continue\n\n // Decrypt with old DEK\n const plaintext = await decrypt(envelope._iv, envelope._data, oldDek)\n\n // Re-encrypt with new DEK\n const { iv, data } = await encrypt(plaintext, newDek)\n const newEnvelope: EncryptedEnvelope = {\n _noydb: NOYDB_FORMAT_VERSION,\n _v: envelope._v,\n _ts: new Date().toISOString(),\n _iv: iv,\n _data: data,\n }\n await adapter.put(vault, collName, id, newEnvelope)\n }\n }\n\n // Update caller's keyring with new DEKs\n for (const [collName, newDek] of newDeks) {\n callerKeyring.deks.set(collName, newDek)\n }\n await persistKeyring(adapter, vault, callerKeyring)\n\n // Update all remaining users' keyrings with re-wrapped new DEKs\n const userIds = await adapter.list(vault, '_keyring')\n for (const userId of userIds) {\n if (userId === callerKeyring.userId) continue\n\n const userEnvelope = await adapter.get(vault, '_keyring', userId)\n if (!userEnvelope) continue\n\n const userKeyringFile = JSON.parse(userEnvelope._data) as KeyringFile\n // A user's DEKs are wrapped with that user's KEK, and a KEK derives only\n // from the user's passphrase — the caller can never derive another user's\n // KEK, so re-wrapping the new DEKs for them is impossible. Rotation\n // therefore REMOVES the rotated collections' DEK entries (and permissions)\n // from each remaining user's keyring: secure (revoked keys are gone) but\n // the owner must re-run grant() for those users/collections.\n\n const updatedDeks = { ...userKeyringFile.deks }\n for (const collName of collections) {\n delete updatedDeks[collName]\n }\n\n const updatedPermissions = { ...userKeyringFile.permissions }\n for (const collName of collections) {\n delete updatedPermissions[collName]\n }\n\n const updatedKeyring: KeyringFile = {\n ...userKeyringFile,\n deks: updatedDeks,\n permissions: updatedPermissions,\n }\n\n await writeKeyringFile(adapter, vault, userId, updatedKeyring)\n }\n}\n\n// ─── Change Secret ─────────────────────────────────────────────────────\n\n/**\n * Change the user's passphrase. Re-wraps every DEK under the new KEK.\n *\n * Validates the new passphrase against the strength rules unless\n * `allowWeakPassphrase: true` is passed. Mirrors `rotatePassphrase`'s\n * default-on validation contract.\n *\n * `db.rotatePassphrase()` adds a `checkGate('rotate-passphrase')` step\n * on top of this primitive and additionally requires the OLD passphrase\n * for re-derivation; `changeSecret` reuses the cached unlocked KEK so\n * the OLD passphrase is not retyped.\n */\nexport async function changeSecret(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n newPassphrase: string,\n passphraseOpts?: PassphrasePolicy & { allowWeakPassphrase?: boolean },\n): Promise<UnlockedKeyring> {\n if (!passphraseOpts?.allowWeakPassphrase) {\n assertStrongPassphrase(newPassphrase, passphraseOpts)\n }\n const newSalt = generateSalt()\n const newKek = await deriveKey(newPassphrase, newSalt)\n\n // Re-wrap all DEKs with the new KEK\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n\n const canary = await mintKeyringCanary(newKek)\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n canary,\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n\n return {\n userId: keyring.userId,\n displayName: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: keyring.deks, // Same DEKs, different wrapping\n kek: newKek,\n salt: newSalt,\n // Tier-2 slots are NOT preserved through `changeSecret` —\n // each slot wraps the OLD KEK, so the new keyring has no\n // authenticator slots until the user re-enrolls. The higher-level\n // `db.rotatePassphrase()` preserves slots by rewrapping the\n // KEK reference, not the KEK itself.\n authenticators: [],\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n}\n\n// ─── Bundle recipients ──────────────────────────────────────────\n\n/**\n * Recipient slot in a re-keyed `.noydb` bundle. Each slot becomes its\n * own keyring file inside the bundle, sealed with its own passphrase.\n * Same role/permission semantics as `db.grant()` but no adapter side\n * effect — the slot only exists inside the bundle bytes.\n *\n * @public\n */\nexport interface BundleRecipient {\n /** User id stamped onto the keyring file in the bundle. */\n readonly id: string\n /** Optional display name. Defaults to `id`. */\n readonly displayName?: string\n /** Passphrase the recipient will type to unlock. */\n readonly passphrase: string\n /** Role on the destination vault. Defaults to `'viewer'`. */\n readonly role?: Role\n /**\n * Per-collection permissions. When omitted, role defaults apply.\n * Restricting permissions here ALSO restricts which DEKs are wrapped\n * into the slot — a slot with `{ invoices: 'ro' }` cannot decrypt\n * other collections even though their ciphertext sits in the bundle.\n */\n readonly permissions?: Permissions\n /**\n * Optional `as-*` export grants on the destination vault.\n * Mirrors the `exportCapability` field on a live keyring.\n */\n readonly exportCapability?: ExportCapability\n /**\n * Optional `as-*` import grants on the destination vault.\n * Mirrors the `importCapability` field on a live keyring.\n * Default-closed: no plaintext format granted, no bundle import.\n */\n readonly importCapability?: ImportCapability\n /**\n * Optional bundle-slot expiry. ISO-8601 timestamp; past the\n * cutoff this slot's keyring refuses to load with\n * `KeyringExpiredError`. Time-boxed audit access pattern: \"this\n * slot works for 30 days then becomes opaque to its holder.\"\n */\n readonly expiresAt?: string\n}\n\n/**\n * Build a `KeyringFile` for one bundle recipient, given the source\n * vault's unwrapped DEKs. Mirrors `grant()` minus the adapter write —\n * the produced file is meant to be embedded in the bundle's\n * `keyrings` map, never persisted to the source vault.\n *\n * Privilege-escalation check still runs: every DEK wrapped into the\n * recipient's keyring must come from the source's own DEK set.\n *\n * @internal\n */\nexport async function buildRecipientKeyringFile(\n callerKeyring: UnlockedKeyring,\n recipient: BundleRecipient,\n): Promise<KeyringFile> {\n if (!callerKeyring.kek) {\n throw new ValidationError(\n 'buildRecipientKeyringFile: caller keyring has no KEK — tier-2 wrap-DEKs ' +\n 'and tier-3 PIN-resume sessions cannot create bundle recipients. ' +\n 'Re-authenticate at tier 1 (passphrase) before building a bundle.',\n )\n }\n\n const role: Role = recipient.role ?? 'viewer'\n const permissions = resolvePermissions(role, recipient.permissions)\n\n const newSalt = generateSalt()\n const newKek = await deriveKey(recipient.passphrase, newSalt)\n\n const wrappedDeks: Record<string, string> = {}\n\n // Collections the recipient was explicitly granted permission to.\n for (const collName of Object.keys(permissions)) {\n const dek = callerKeyring.deks.get(collName)\n if (dek) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // owner / admin / custodian / viewer: wrap every known DEK (matches grant).\n // FR-6: a custodian recipient operates every collection, so it receives all\n // DEKs just like admin (kept in lockstep with grant()'s all-DEKs branch).\n if (role === 'owner' || role === 'admin' || role === 'custodian' || role === 'viewer') {\n for (const [collName, dek] of callerKeyring.deks) {\n if (!(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n }\n\n // Always propagate system-prefixed collection DEKs (`_ledger`, etc.) —\n // the recipient needs them to verify the bundle on import.\n for (const [collName, dek] of callerKeyring.deks) {\n if (collName.startsWith('_') && !(collName in wrappedDeks)) {\n wrappedDeks[collName] = await wrapKey(dek, newKek)\n }\n }\n\n // Anti-privilege-escalation: every wrapped DEK must come from the\n // caller's own DEK set. Belt-and-braces with the lookups above.\n for (const collName of Object.keys(wrappedDeks)) {\n if (!callerKeyring.deks.has(collName)) {\n throw new PrivilegeEscalationError(collName)\n }\n }\n\n const canary = await mintKeyringCanary(newKek)\n return {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: recipient.id,\n display_name: recipient.displayName ?? recipient.id,\n role,\n permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(newSalt),\n created_at: new Date().toISOString(),\n granted_by: callerKeyring.userId,\n canary,\n ...(recipient.exportCapability !== undefined\n ? { export_capability: recipient.exportCapability }\n : {}),\n ...(recipient.importCapability !== undefined\n ? { import_capability: recipient.importCapability }\n : {}),\n ...(recipient.expiresAt !== undefined\n ? { expires_at: recipient.expiresAt }\n : {}),\n }\n}\n\n// ─── List Users ────────────────────────────────────────────────────────\n\n/** List all users with access to a vault. */\nexport async function listUsers(\n adapter: NoydbStore,\n vault: string,\n): Promise<UserInfo[]> {\n const userIds = await adapter.list(vault, '_keyring')\n const users: UserInfo[] = []\n\n for (const userId of userIds) {\n const envelope = await adapter.get(vault, '_keyring', userId)\n if (!envelope) continue\n const kf = JSON.parse(envelope._data) as KeyringFile\n users.push({\n userId: kf.user_id,\n displayName: kf.display_name,\n role: kf.role,\n permissions: kf.permissions,\n createdAt: kf.created_at,\n grantedBy: kf.granted_by,\n })\n }\n\n return users\n}\n\n/**\n * Optional filter knobs for {@link listUsersWithEnvelopes}.\n *\n * - `includeHidden` — when true, principals with `_meta/visibility/<id>`\n * set to `{ hidden: true }` are returned alongside everyone else.\n * Requires `owner` or `admin` callerRole; lower roles get\n * {@link import('../../kernel/errors.js').PermissionDeniedError}.\n */\nexport interface ListUsersOptions {\n readonly includeHidden?: boolean\n}\n\n/**\n * Joined enumeration: every keyring + its `_users/<keyringId>`\n * envelope side by side. Convenience for admin UIs that want to\n * render team-member lists with profile data (\"Bob — operator —\n * 'Bob the Auditor' avatar X locale fr-FR\") in a single pass.\n *\n * `userEnvelopeDek` is the vault's `_users` collection DEK\n * (`vault.getDEK('_users')`); used to decrypt every envelope.\n *\n * `callerRole` drives the directory-visibility checks:\n *\n * - When the vault's `_meta/directory` document has `enabled: false`,\n * only `owner` and `admin` callers may enumerate; anyone else gets\n * {@link import('../../kernel/errors.js').DirectoryDisabledError}.\n * - Principals with `_meta/visibility/<id>` set to `{ hidden: true }`\n * are filtered out by default. `owner`/`admin` callers can pass\n * `{ includeHidden: true }` to see them; lower roles passing that\n * option get `PermissionDeniedError`.\n *\n * Honest caveat: these filters are a UX hint, not a security\n * boundary. The keyring file is still listed at `_keyring/*` and the\n * envelope ciphertext at `_users/*`. A caller with direct store access\n * — or a caller that calls this function with `callerRole: 'owner'`\n * unconditionally — sees every principal. The protection is only as\n * strong as the role the calling layer passes in. The hub-level wrapper\n * on `Vault` sources `callerRole` from the unlocked keyring's `role`\n * field, which is signed-by-construction (it lives in the user's own\n * keyring file). See `https://github.com/vLannaAi/noy-db-docs/blob/main/content/docs/services/user-envelope.md` →\n * \"Directory visibility\".\n *\n * Principals without a persisted envelope (legacy keyrings predating\n * the user-envelope feature) come back with `envelope: null`. The\n * caller chooses how to render — usually \"fall back to keyring's\n * `displayName`\".\n *\n * Order matches `listUsers()` (store-defined; sort if you need a\n * stable display order).\n */\nexport async function listUsersWithEnvelopes<T = unknown>(\n adapter: NoydbStore,\n vault: string,\n userEnvelopeDek: EnclaveKey,\n callerRole: Role,\n options: ListUsersOptions = {},\n): Promise<Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }>> {\n // FR-6: custodian is INTENTIONALLY treated as NON-privileged here (SAFER\n // default — review flag). Directory-privilege is a team-MANAGEMENT capability\n // (bypassing the visibility toggle + listing hidden principals); a custodian\n // is non-owning and cannot grant/revoke, so it should not see the hidden\n // team membership. It still gets the normal (non-hidden) directory view.\n const isPrivileged = callerRole === 'owner' || callerRole === 'admin'\n\n // 1. Vault-level directory toggle.\n const dirConfig = await readDirectoryConfig(adapter, vault)\n if (dirConfig?.enabled === false && !isPrivileged) {\n throw new DirectoryDisabledError(vault)\n }\n\n // 2. `includeHidden` requires admin/owner.\n if (options.includeHidden && !isPrivileged) {\n throw new PermissionDeniedError(\n 'Permission denied — listUsersWithEnvelopes({ includeHidden: true }) requires owner or admin role',\n )\n }\n\n const users = await listUsers(adapter, vault)\n const out: Array<{ user: UserInfo; envelope: UserEnvelopeReader<T> | null }> = []\n for (const user of users) {\n if (!options.includeHidden) {\n const visibility = await readUserVisibility(adapter, vault, user.userId)\n if (visibility?.hidden) continue\n }\n const envelope = await loadUserEnvelopeFn<T>(\n adapter,\n vault,\n user.userId,\n userEnvelopeDek,\n )\n out.push({ user, envelope })\n }\n return out\n}\n\n\n// ─── DEK Management ────────────────────────────────────────────────────\n\n/** Ensure a DEK exists for a collection. Generates one if new. */\nexport async function ensureCollectionDEK(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<(collectionName: string) => Promise<EnclaveKey>> {\n // Dedupe concurrent first-time DEK creates per collection. Without\n // this, two concurrent `getDEK('foo')` calls both pass the `existing`\n // check (the Map is empty), both generate fresh DEKs, and the second\n // `set` overwrites the first — making any envelope encrypted with\n // the discarded DEK fail to decrypt later (TamperedError on read).\n // Pre-existing race exposed by the multi-writer ledger work.\n const inFlight = new Map<string, Promise<EnclaveKey>>()\n return async (collectionName: string): Promise<EnclaveKey> => {\n const existing = keyring.deks.get(collectionName)\n if (existing) return existing\n const pending = inFlight.get(collectionName)\n if (pending) return pending\n\n const promise = (async () => {\n const dek = await generateDEK()\n keyring.deks.set(collectionName, dek)\n await persistKeyring(adapter, vault, keyring)\n return dek\n })()\n inFlight.set(collectionName, promise)\n try {\n return await promise\n } finally {\n inFlight.delete(collectionName)\n }\n }\n}\n\n// ─── Permission Checks ─────────────────────────────────────────────────\n\n/** Check if a user has write permission for a collection. */\nexport function hasWritePermission(keyring: UnlockedKeyring, collectionName: string): boolean {\n // FR-6: custodian writes every collection (admin-level operational rw).\n if (keyring.role === 'owner' || keyring.role === 'admin' || keyring.role === 'custodian') return true\n if (keyring.role === 'viewer' || keyring.role === 'client') return false\n return keyring.permissions[collectionName] === 'rw'\n}\n\n/** Check if a user has any access to a collection. */\nexport function hasAccess(keyring: UnlockedKeyring, collectionName: string): boolean {\n // FR-6: custodian reads every collection (admin-level operational access).\n if (\n keyring.role === 'owner' ||\n keyring.role === 'admin' ||\n keyring.role === 'custodian' ||\n keyring.role === 'viewer'\n )\n return true\n return collectionName in keyring.permissions\n}\n\n// ─── Helpers ───────────────────────────────────────────────────────────\n\n/** Persist a keyring file to the adapter. */\nexport async function persistKeyring(\n adapter: NoydbStore,\n vault: string,\n keyring: UnlockedKeyring,\n): Promise<void> {\n if (!keyring.kek) {\n throw new ValidationError(\n 'persistKeyring: keyring.kek is null — cannot wrap DEKs without the KEK. ' +\n 'This typically means the keyring was opened via tier-3 PIN resume, ' +\n 'session restore, or a wrap-DEKs tier-2 unlock. Re-authenticate at ' +\n 'tier 1 (passphrase) before persisting.',\n )\n }\n const wrappedDeks: Record<string, string> = {}\n for (const [collName, dek] of keyring.deks) {\n wrappedDeks[collName] = await wrapKey(dek, keyring.kek)\n }\n const canary = await mintKeyringCanary(keyring.kek)\n\n const keyringFile: KeyringFile = {\n _noydb_keyring: NOYDB_KEYRING_VERSION,\n user_id: keyring.userId,\n display_name: keyring.displayName,\n role: keyring.role,\n permissions: keyring.permissions,\n deks: wrappedDeks,\n salt: bufferToBase64(keyring.salt),\n created_at: new Date().toISOString(),\n granted_by: keyring.userId,\n canary,\n ...(keyring.exportCapability !== undefined && { export_capability: keyring.exportCapability }),\n ...(keyring.importCapability !== undefined && { import_capability: keyring.importCapability }),\n ...(keyring.authenticators.length > 0 && { authenticators: keyring.authenticators }),\n ...(keyring.policy !== undefined && { policy: keyring.policy }),\n }\n\n await writeKeyringFile(adapter, vault, keyring.userId, keyringFile)\n}\n\n// ─── Export capability ──────────────────────────────────────\n\n/**\n * Role-based default policy for the encrypted-bundle capability.\n *\n * Applied when `keyring.exportCapability` is absent or\n * `exportCapability.bundle` is undefined:\n *\n * - `owner` / `admin` → `true` (happy-path backup without friction)\n * - `operator` / `viewer` / `client` → `false` (explicit grant required)\n *\n * Rationale: a bundle is inert without the KEK, so an owner backing up\n * their own vault doesn't need friction; a non-admin role producing a\n * bundle for an external party does, because the bundle outlives\n * keyring revocation.\n */\nfunction defaultBundleCapability(role: Role): boolean {\n // FR-6: custodian is INTENTIONALLY not in the default-true set (SAFER\n // default — review flag). A bundle is an external artifact that outlives\n // keyring revocation; the owner can still grant a custodian\n // `exportCapability.bundle = true` explicitly when an offline backup is\n // part of the custody contract. Defaulting it off keeps a custodian from\n // silently minting a revocation-surviving copy of the whole vault.\n return role === 'owner' || role === 'admin'\n}\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * export tier.\n *\n * - `tier: 'plaintext'` — returns true iff `exportCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard. Default for\n * every role is empty — no grant, no plaintext export.\n * - `tier: 'bundle'` — returns `exportCapability.bundle` if present, or\n * the role-based default otherwise (owner/admin → true, else false).\n *\n * `@noy-db/as-*` packages MUST call this before invoking the underlying\n * export primitive. Rogue forks that skip the check are caught by code\n * review — the single-entry-point contract is a convention, not a\n * runtime invariant. Vault-level gated wrappers\n * (`vault.exportRecords` / `exportBlobs` / `writeBundle`) will land in a\n * follow-up PR to enforce at the primitive level.\n */\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasExportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.exportCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle'\n return cap?.bundle ?? defaultBundleCapability(keyring.role)\n}\n\n/**\n * Same-shape inspector for an `ExportCapability` value that isn't yet\n * attached to a keyring (e.g. for previewing a grant before applying).\n * Role must be supplied separately so bundle defaults can be computed.\n */\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateExportCapability(\n capability: ExportCapability | undefined,\n role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle ?? defaultBundleCapability(role)\n}\n\n// ─── Import capability (issue ) ────────────────────────────────────\n\n/**\n * Check whether a keyring is authorised for a given `@noy-db/as-*`\n * import tier (issue ).\n *\n * - `tier: 'plaintext'` — true iff `importCapability.plaintext`\n * contains the requested `format` or the `'*'` wildcard.\n * - `tier: 'bundle'` — true iff `importCapability.bundle === true`.\n *\n * **Default-closed for every role on every dimension** — including\n * owner. Import is more dangerous than export (corrupts vs leaks), so\n * the policy refuses to assume intent. Owners must positively grant\n * the capability via `vault.grant({ importCapability: ... })`.\n */\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'bundle',\n): boolean\nexport function hasImportCapability(\n keyring: UnlockedKeyring,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n const cap = keyring.importCapability\n if (tier === 'plaintext') {\n const allowed = cap?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n // tier === 'bundle' — closed default for every role\n return cap?.bundle === true\n}\n\n/**\n * Same-shape inspector for an `ImportCapability` value that isn't yet\n * attached to a keyring (e.g. previewing a grant before applying).\n * `role` is accepted for symmetry with `evaluateExportCapability` even\n * though the import policy ignores it — bundle defaults are\n * role-agnostic and closed.\n */\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'plaintext',\n format: ExportFormat,\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n role: Role,\n tier: 'bundle',\n): boolean\nexport function evaluateImportCapability(\n capability: ImportCapability | undefined,\n _role: Role,\n tier: 'plaintext' | 'bundle',\n format?: ExportFormat,\n): boolean {\n if (tier === 'plaintext') {\n const allowed = capability?.plaintext ?? []\n return allowed.includes('*') || (format !== undefined && allowed.includes(format))\n }\n return capability?.bundle === true\n}\n\nfunction resolvePermissions(role: Role, explicit?: Permissions): Permissions {\n // FR-6: custodian is full-access-by-role (hasAccess/hasWritePermission\n // short-circuit on the role), so — like admin — its permissions map is\n // empty. Returning {} also prevents a caller-supplied `permissions` from\n // accidentally NARROWING a custodian below its role guarantee.\n if (role === 'owner' || role === 'admin' || role === 'custodian' || role === 'viewer') return {}\n return explicit ?? {}\n}\n\nasync function writeKeyringFile(\n adapter: NoydbStore,\n vault: string,\n userId: string,\n keyringFile: KeyringFile,\n): Promise<void> {\n const envelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(keyringFile),\n }\n await adapter.put(vault, '_keyring', userId, envelope)\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/with-lookup/aggregate/reducers.ts","../src/with-lookup/aggregate/aggregation.ts","../src/with-lookup/aggregate/canonical-key.ts","../src/with-lookup/aggregate/groupby.ts"],"sourcesContent":["/**\n * Aggregation reducers for the query DSL.\n *\n * the reducer protocol plus five built-in factories\n * (`count`, `sum`, `avg`, `min`, `max`) consumed by `Query.aggregate()`\n * and, in the future, `Scan.aggregate()`. Every factory accepts\n * an optional `{ seed }` parameter that is plumbed through the\n * protocol but unused by the executor — that's the load-bearing\n * half of constraint #2. When partition-aware aggregation\n * lands, the seed carries the previous partition's running total into\n * the next partition without requiring a protocol change.\n *\n * Reducers are intentionally generic over their internal state type\n * `S` so compound reducers (avg keeps `{sum, count}`, min/max keep a\n * value bag) can model internal bookkeeping without leaking the\n * implementation through the accumulator's public shape. `finalize`\n * collapses `S` back into the user-visible `R`.\n *\n * Reducers are pure data — `init` / `step` / `finalize` / optional\n * `remove` are stateless functions that receive and return `S`. This\n * is the shape that admits O(1) incremental maintenance in a future\n * optimization (delta-aware `LiveAggregation` applies `step` or\n * `remove` per delta), without blocking the simpler \"full re-run on\n * source change\" that ships.\n */\n\nimport { readPath } from '../../kernel/query/predicate.js'\nimport type { MoneyString } from '../../via/money/branded.js'\nimport type { QueryField } from '../../kernel/types.js'\n\n/**\n * A single reducer: factory-produced, ready to plug into an\n * `.aggregate()` spec.\n *\n * Type parameters:\n * - `R` — user-visible result type (what the aggregation returns\n * for this slot, e.g. `number` for `sum()`)\n * - `S` — internal state type, defaults to `R` for simple reducers\n * that don't need compound bookkeeping\n *\n * A reducer is stateless: every method is pure over `S`. `init()` is\n * called once per aggregation run to build the initial state; `step()`\n * folds a record into the state; `remove()` (optional) un-folds a\n * record, enabling incremental live maintenance; `finalize()` reads\n * the final answer out of the state at the end of the run.\n */\nexport interface Reducer<R, S = R> {\n /** Build the initial state for a fresh aggregation run. */\n init(): S\n /** Fold a record into the state. Returns the new state. */\n step(state: S, record: unknown): S\n /**\n * Un-fold a record from the state. Returns the new state.\n *\n * Optional — reducers without `remove` cannot be maintained\n * incrementally and must be re-run from scratch when the underlying\n * record set changes. `sum`, `count`, `avg` implement `remove` in\n * O(1); `min` and `max` implement it in O(N) worst case (when the\n * extremum itself is removed and the next extremum must be\n * recomputed from the remaining contributing values).\n */\n remove?(state: S, record: unknown): S\n /** Collapse the internal state into the user-visible result. */\n finalize(state: S): R\n /**\n * Combine two independent partial states into one (then `finalize` once).\n * Optional. MUST be associative + commutative with `init()` as identity.\n * Never merge finalized results — only states. Enables parallel /\n * hierarchical aggregation (e.g. cross-shard or advisor→firm rollup).\n */\n merge?(a: S, b: S): S\n /**\n * Identifying operation tag stamped by each built-in factory.\n * Used by `summariseAggregateOp` in the introspection walker to\n * render human-readable aggregate descriptors in `dumpSchema()`.\n * Optional so third-party custom reducers are unaffected.\n */\n readonly op?: 'count' | 'sum' | 'avg' | 'min' | 'max'\n /**\n * Field name for field-based reducers (`sum`, `avg`, `min`, `max`).\n * Absent on `count` which aggregates over record count, not a field.\n */\n readonly field?: string\n /**\n * Money-only: target currency for `sum` over a multi-currency money\n * field. Consumed by `wrapMoneyReducers` to convert per-currency\n * subtotals to one figure. Ignored for non-money fields.\n */\n readonly convertTo?: string\n /**\n * Money-only: FX rate map (`'USD->EUR' → rate`) used with `convertTo`.\n */\n readonly fx?: Record<string, number | string>\n}\n\n/**\n * Common options accepted by every reducer factory.\n *\n * `seed` — optional initial value for the internal state. **Unused by\n * the executor**, plumbed through the protocol for constraint\n * #2 (partition-aware aggregation seam). In, partitioned\n * aggregations will pass the previous partition's carry as `seed` so\n * a long time series can be rolled forward one partition at a time\n * without re-aggregating closed partitions.\n *\n * always uses `init()` with the factory's zero value, regardless\n * of whether `seed` was passed. Do not remove the parameter — that's\n * the whole point of having it exist now.\n */\nexport interface ReducerOptions<TSeed = unknown> {\n /** constraint #2 — seed is plumbed through but unused in. */\n readonly seed?: TSeed\n /**\n * Money-only (honored by `sum` over a multi-currency money field):\n * convert per-currency subtotals to this currency for a single figure.\n */\n readonly convertTo?: string\n /** Money-only: FX rate map (`'USD->EUR' → rate`) used with `convertTo`. */\n readonly fx?: Record<string, number | string>\n}\n\n// ---------------------------------------------------------------------------\n// Factories\n// ---------------------------------------------------------------------------\n\n/**\n * Count the number of records that match the query. Ignores field\n * values entirely — the count is over the number of records, not over\n * the number of non-null field values in any column.\n */\nexport function count(opts?: ReducerOptions<number>): Reducer<number> {\n // Seed captured on the closure but unused at execution time in\n //. The reference in _seed keeps lint happy.\n const _seed = opts?.seed\n void _seed\n return {\n op: 'count',\n init: () => 0,\n step: (state) => state + 1,\n remove: (state) => state - 1,\n finalize: (state) => state,\n merge: (a, b) => a + b,\n }\n}\n\n/**\n * Sum a numeric field across all matching records. Non-number values\n * at the field path are coerced to 0 — consumers who want a different\n * behavior (throw, skip, treat as NaN) should filter upstream via\n * `.where()` or write a custom reducer.\n *\n * KNOWN LIMITATION (type imprecision): the declared result type is `number`,\n * but when `field` is a **money** field the runtime returns a decimal *string*\n * (e.g. `'0.30'`, or `{ EUR: '0.30' }` in multi-currency mode) — money sums are\n * BigInt-exact in scaled space and never collapse to a float. The `number` type\n * is therefore a lie for money fields; narrow the result yourself at the call\n * site. A precise fix requires threading money-field declarations into the type\n * system (tracked separately).\n */\nexport function sum(\n field: string,\n opts?: ReducerOptions<number>,\n): Reducer<number> {\n const _seed = opts?.seed\n void _seed\n return {\n op: 'sum',\n field,\n // Money-only metadata, read by `wrapMoneyReducers`. No effect on a\n // generic numeric sum.\n ...(opts?.convertTo !== undefined ? { convertTo: opts.convertTo } : {}),\n ...(opts?.fx !== undefined ? { fx: opts.fx } : {}),\n init: () => 0,\n step: (state, record) => state + readNumber(record, field),\n remove: (state, record) => state - readNumber(record, field),\n finalize: (state) => state,\n merge: (a, b) => a + b,\n }\n}\n\n/**\n * Arithmetic mean of a numeric field across all matching records.\n *\n * Returns `null` for an empty result set (zero records is not a\n * well-defined denominator — returning NaN would poison downstream\n * arithmetic, and throwing would force every consumer to wrap in\n * try/catch just to handle \"no matches\"). Consumers who want an\n * explicit zero should coalesce with `?? 0`.\n *\n * Internal state is `{sum, count}` so the running average can be\n * maintained incrementally — on each delta, both fields update in\n * O(1) and `finalize` divides. Directly storing `avg` as state would\n * not admit incremental removal without also tracking count.\n */\nexport function avg(\n field: string,\n opts?: ReducerOptions<{ sum: number; count: number }>,\n): Reducer<number | null, { sum: number; count: number }> {\n const _seed = opts?.seed\n void _seed\n return {\n op: 'avg',\n field,\n init: () => ({ sum: 0, count: 0 }),\n step: (state, record) => ({\n sum: state.sum + readNumber(record, field),\n count: state.count + 1,\n }),\n remove: (state, record) => ({\n sum: state.sum - readNumber(record, field),\n count: state.count - 1,\n }),\n finalize: (state) => (state.count === 0 ? null : state.sum / state.count),\n merge: (a, b) => ({ sum: a.sum + b.sum, count: a.count + b.count }),\n }\n}\n\ninterface MinMaxState {\n /**\n * Multiset of contributing field values. Stored as a plain array\n * because we need to support `remove` and a plain array gives us\n * O(1) push + O(N) worst-case removal — which matches the\n * documented min/max removal complexity. A sorted structure would\n * let us drop the O(N) rescan but adds complexity that doesn't\n * need; consumers hitting the O(N) ceiling should file an issue.\n */\n readonly values: number[]\n}\n\nfunction pushValue(state: MinMaxState, value: number): MinMaxState {\n return { values: [...state.values, value] }\n}\n\nfunction removeValue(state: MinMaxState, value: number): MinMaxState {\n // Remove the first matching value — duplicates are fine, we only\n // need to drop one instance per `remove()` call so the multiset\n // count stays consistent with the record count.\n const idx = state.values.indexOf(value)\n if (idx < 0) return state\n const next = state.values.slice()\n next.splice(idx, 1)\n return { values: next }\n}\n\n/**\n * Smallest numeric value of a field across all matching records.\n * Returns `null` for an empty result set. See `avg()` for the\n * reasoning on `null` vs NaN vs throwing.\n *\n * Incremental complexity: O(1) for `step`, O(N) worst case for\n * `remove` when the current minimum is removed (the state holds the\n * full multiset of contributing values and `finalize` scans for the\n * new minimum). Consumers with very large result sets and frequent\n * removals of the current extremum should either accept the cost or\n * wait for a future optimization.\n */\nexport function min(\n field: string,\n opts?: ReducerOptions<number>,\n): Reducer<number | null, MinMaxState> {\n const _seed = opts?.seed\n void _seed\n return {\n op: 'min',\n field,\n init: () => ({ values: [] }),\n step: (state, record) => pushValue(state, readNumber(record, field)),\n remove: (state, record) => removeValue(state, readNumber(record, field)),\n finalize: (state) => {\n if (state.values.length === 0) return null\n let out = state.values[0]!\n for (let i = 1; i < state.values.length; i++) {\n const v = state.values[i]!\n if (v < out) out = v\n }\n return out\n },\n merge: (a, b) => ({ values: [...a.values, ...b.values] }),\n }\n}\n\n/**\n * Largest numeric value of a field across all matching records.\n * Mirror of `min()` — see that doc for semantics, null-on-empty\n * behavior, and the O(N) removal caveat.\n */\nexport function max(\n field: string,\n opts?: ReducerOptions<number>,\n): Reducer<number | null, MinMaxState> {\n const _seed = opts?.seed\n void _seed\n return {\n op: 'max',\n field,\n init: () => ({ values: [] }),\n step: (state, record) => pushValue(state, readNumber(record, field)),\n remove: (state, record) => removeValue(state, readNumber(record, field)),\n finalize: (state) => {\n if (state.values.length === 0) return null\n let out = state.values[0]!\n for (let i = 1; i < state.values.length; i++) {\n const v = state.values[i]!\n if (v > out) out = v\n }\n return out\n },\n merge: (a, b) => ({ values: [...a.values, ...b.values] }),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Money-typed reducer constructors\n// ---------------------------------------------------------------------------\n\n/**\n * `sum()` for a **declared money field**, typed to match the runtime.\n *\n * `sum()` returns `Reducer<number>`, but `wrapMoneyReducers` (applied at\n * `query.aggregate()` time, once `moneyFields` is known) rewrites any\n * `sum`/`min`/`max` over a money field to a money reducer that finalizes to a\n * `MoneyString` decimal — so the `number` type is a lie for money fields and\n * consumers need a cast at every read site. `moneySum` is the same reducer with\n * the correct `Reducer<MoneyString>` return type, so no read-site cast is\n * needed. It is the caller's assertion that `field` is a money field; use plain\n * `sum()` for non-money fields. (For a multi-currency money field WITHOUT\n * `convertTo`, the runtime returns a per-currency `Record<string, MoneyString>`\n * rather than a single `MoneyString` — pass `convertTo` to collapse to one\n * currency, or read the map at the boundary.)\n */\nexport function moneySum(field: string, opts?: ReducerOptions<number>): Reducer<MoneyString> {\n // The constructed reducer is the plain numeric `sum`; `wrapMoneyReducers`\n // swaps in the MoneyString-producing money reducer at aggregate() time.\n return sum(field, opts) as unknown as Reducer<MoneyString>\n}\n\n/**\n * `min()` for a declared money field, typed `Reducer<MoneyString | null>`\n * (null on an empty result set in fixed-currency mode, mirroring `min()`). See\n * {@link moneySum} for the late-binding rewrite. Note: `convertTo`/`fx` on\n * `opts` have no effect on min/max (cross-currency min/max is unsupported — use\n * {@link moneySum} for currency conversion). In multi-currency mode the runtime\n * returns a per-currency map and an empty result is `{}` rather than `null`.\n */\nexport function moneyMin(field: string, opts?: ReducerOptions<number>): Reducer<MoneyString | null> {\n return min(field, opts) as unknown as Reducer<MoneyString | null>\n}\n\n/**\n * `max()` for a declared money field, typed `Reducer<MoneyString | null>`\n * (null on an empty result set in fixed-currency mode, mirroring `max()`). See\n * {@link moneyMin} for the `convertTo`/`fx` and multi-currency caveats.\n */\nexport function moneyMax(field: string, opts?: ReducerOptions<number>): Reducer<MoneyString | null> {\n return max(field, opts) as unknown as Reducer<MoneyString | null>\n}\n\n// ---------------------------------------------------------------------------\n// Builder (typed spec-builder for aggregate())\n// ---------------------------------------------------------------------------\n\n/**\n * Typed builder passed to the `aggregate(b => spec)` overload.\n *\n * Each field-taking method narrows `field` to `QueryField<T, S>`, which\n * excludes any field listed in the collection's `sensitive` option at\n * compile time. `count()` carries no field argument and is always allowed.\n *\n * The type parameters match the `Query<T, S>` they come from:\n * - `T` — the record type of the collection\n * - `S` — the union of sensitive field keys (defaults to `never`)\n *\n * ONE shared runtime instance (`reducerBuilder`) serves all `T`/`S`\n * combinations — the field narrowing is type-only; the methods delegate\n * directly to the standalone factories.\n */\nexport interface ReducerBuilder<T, S extends keyof T = never, M extends keyof T & string = never> {\n count(opts?: ReducerOptions<number>): Reducer<number>\n sum<F extends QueryField<T, S>>(field: F, opts?: ReducerOptions<number>): [F] extends [M] ? Reducer<MoneyString> : Reducer<number>\n avg(field: QueryField<T, S>, opts?: ReducerOptions<{ sum: number; count: number }>): ReturnType<typeof avg>\n min<F extends QueryField<T, S>>(field: F, opts?: ReducerOptions<number>): [F] extends [M] ? Reducer<MoneyString | null> : ReturnType<typeof min>\n max<F extends QueryField<T, S>>(field: F, opts?: ReducerOptions<number>): [F] extends [M] ? Reducer<MoneyString | null> : ReturnType<typeof max>\n moneySum(field: QueryField<T, S>, opts?: ReducerOptions<number>): Reducer<MoneyString>\n moneyMin(field: QueryField<T, S>, opts?: ReducerOptions<number>): Reducer<MoneyString | null>\n moneyMax(field: QueryField<T, S>, opts?: ReducerOptions<number>): Reducer<MoneyString | null>\n}\n\n/**\n * Shared runtime instance for the `aggregate(b => spec)` builder form.\n *\n * The field-narrowing to `QueryField<T, S>` is type-only — each method\n * delegates directly to its standalone factory. A `(field: string) => R`\n * factory is assignable to a `(field: QueryField<T,S>) => R` method by\n * parameter-contravariance, so this single instance works for all `T`/`S`.\n */\nexport const reducerBuilder: ReducerBuilder<Record<string, unknown>> = {\n count,\n // The generic F parameter on sum/min/max in the interface is type-only; the\n // standalone factories are plain `(field: string)` functions. With M=never the\n // conditional return collapses to the numeric branch, matching the factories'\n // return type, but TypeScript cannot verify that collapse for a generic method\n // signature — so each factory is cast directly to the method's type.\n sum: sum as ReducerBuilder<Record<string, unknown>>['sum'],\n avg,\n min: min as ReducerBuilder<Record<string, unknown>>['min'],\n max: max as ReducerBuilder<Record<string, unknown>>['max'],\n moneySum, moneyMin, moneyMax,\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Read a numeric field from a record. Non-number values (null,\n * undefined, strings, objects) coerce to 0 so sum/avg/min/max don't\n * produce NaN on one bad row. Consumers who want strict typing should\n * validate upstream with Standard Schema, which NOYDB already runs on\n * every `put()`.\n */\nfunction readNumber(record: unknown, field: string): number {\n const value = readPath(record, field)\n // Defensive: a `{ amount, currency }` value means a multi-currency\n // money field reached a generic numeric reducer instead of being\n // rewritten by `wrapMoneyReducers` — that would silently produce a\n // wrong total. Surface it loudly rather than coercing to 0.\n if (\n typeof value === 'object' &&\n value !== null &&\n 'amount' in value &&\n 'currency' in value\n ) {\n throw new Error(\n `aggregate: field \"${field}\" holds a money value but was not money-aware — ` +\n `declare it in the collection's moneyFields so sum/min/max stay exact`,\n )\n }\n return typeof value === 'number' && Number.isFinite(value) ? value : 0\n}\n","/**\n * Aggregate execution — the runtime behind `Query.aggregate()`.\n *\n * takes an `AggregateSpec` (a record of named reducers\n * built from `reducers.ts`) and runs every reducer over the records\n * produced by the underlying query. Two terminal surfaces:\n *\n * - `.run(): R` — synchronous one-shot reduction. Matches the\n * existing `Query.toArray()` / `.first()` / `.count()` style.\n * - `.live(): LiveAggregation<R>` — reactive primitive that\n * re-runs the reduction whenever the query's source notifies of\n * a change. uses naive full re-run; incremental delta\n * maintenance is admitted by the reducer protocol (`remove()`)\n * but not wired to the executor yet — a follow-up optimization\n * can switch from full re-run to delta-based without breaking\n * the public API. Consumers get correct, reactive values today.\n *\n * The `Aggregation<R>` wrapper is deliberately tiny — it exists so\n * `.aggregate(spec)` can be chained with either `.run()` or `.live()`\n * without the builder needing two separate terminal methods. It\n * holds the closure over the query execution (produces the current\n * matching record set) and the spec, and stitches them together in\n * either mode.\n *\n * This file depends ONLY on `reducers.ts` — it has no knowledge of\n * the `Query` class. Tests can therefore exercise the reduction\n * surface with plain record arrays, without spinning up a Collection.\n */\n\nimport type { Reducer } from './reducers.js'\n\n/**\n * A named set of reducers, keyed by output field name. Each key\n * becomes a field on the aggregated result.\n *\n * ```ts\n * const spec = {\n * total: sum('amount'),\n * n: count(),\n * avgAmount: avg('amount'),\n * }\n * ```\n */\nexport type AggregateSpec = Readonly<Record<string, Reducer<unknown, unknown>>>\n\n/**\n * Map an `AggregateSpec` to its reduced result shape — each key\n * carries the finalized result type from its reducer. A spec built\n * from `{ total: sum('amount'), n: count() }` yields a result of\n * `{ total: number, n: number }`.\n *\n * This uses a mapped type with a conditional to extract `R` from\n * each `Reducer<R, _>`. The `infer` captures the user-visible result\n * type, discarding the internal state type `S`.\n */\nexport type AggregateResult<Spec extends AggregateSpec> = {\n [K in keyof Spec]: Spec[K] extends Reducer<infer R, unknown> ? R : never\n}\n\n/**\n * Pure reduction over a record array. Runs every reducer's\n * `init → step* → finalize` pipeline exactly once over the records.\n *\n * Called by `Aggregation.run()` and by the live-mode refresh path.\n * Exported for tests and for future `scan().aggregate()` reuse\n * — the streaming path will call the same reducer protocol with a\n * per-page loop instead of a single array.\n */\nexport function reduceRecords<Spec extends AggregateSpec>(\n records: readonly unknown[],\n spec: Spec,\n): AggregateResult<Spec> {\n // Per-slot state, keyed by the spec's output field name.\n const state: Record<string, unknown> = {}\n for (const key of Object.keys(spec)) {\n state[key] = spec[key]!.init()\n }\n for (const record of records) {\n for (const key of Object.keys(spec)) {\n state[key] = spec[key]!.step(state[key], record)\n }\n }\n const result: Record<string, unknown> = {}\n for (const key of Object.keys(spec)) {\n result[key] = spec[key]!.finalize(state[key])\n }\n return result as AggregateResult<Spec>\n}\n\n/**\n * A minimal reactive primitive for aggregation results.\n *\n * Same spirit as the `LiveQuery` in : frame-agnostic, a plain\n * object with `value` / `error` fields and a `subscribe(cb)`\n * notification channel that Vue / React / Solid adapters wrap in\n * their own primitive. Intentionally NOT a Promise — aggregations\n * have a well-defined \"current value\" at every instant, and the\n * reactive consumer wants to read that value synchronously.\n *\n * Error semantics mirror `LiveQuery`: if a re-run throws, the\n * previous successful `value` is preserved and the error is stored\n * in `error` so consumers can render an error state without losing\n * the last-known-good result. The throw does NOT propagate out of\n * the source's change handler (which would tear down the upstream\n * emitter).\n *\n * `stop()` tears down the upstream subscription. It is idempotent —\n * calling it multiple times is safe — and subscribe calls after\n * stop are no-ops (they immediately return a no-op unsubscribe).\n * Always call `stop()` when done; Vue's `onUnmounted` is the\n * canonical place. Raw consumers must do it themselves.\n */\nexport interface LiveAggregation<R> {\n /** Current reduced value. Undefined only if the first compute threw. */\n readonly value: R | undefined\n /** Last execution error, if any. Cleared on the next successful run. */\n readonly error: unknown\n /** Notify on every recomputation (success or error). Returns unsubscribe. */\n subscribe(cb: () => void): () => void\n /** Tear down the upstream subscription. Idempotent. */\n stop(): void\n}\n\n/**\n * Upstream change-notification hook for live aggregation.\n *\n * Matches the shape that `QuerySource.subscribe` already uses — a\n * single method that accepts a callback and returns an unsubscribe\n * function. The `Aggregation` wrapper collects upstreams from the\n * query's source and wires them into a single re-run trigger.\n */\nexport interface AggregationUpstream {\n subscribe(cb: () => void): () => void\n}\n\n/**\n * Internal implementation of `LiveAggregation`. Not exported —\n * consumers get the interface only. The class wraps a `recompute`\n * closure (which runs the full reduction and returns the new value)\n * and a list of upstreams (sources whose changes should trigger a\n * re-run).\n *\n * Error isolation: if an individual listener callback throws, the\n * other listeners still fire and the error is logged to the warn\n * channel. This matches `LiveQuery` from and keeps one misbehaving\n * consumer from tearing down the whole live aggregation.\n */\nclass LiveAggregationImpl<R> implements LiveAggregation<R> {\n public value: R | undefined\n public error: unknown\n private readonly listeners = new Set<() => void>()\n private readonly unsubscribes: Array<() => void> = []\n private stopped = false\n\n constructor(\n private readonly recompute: () => R,\n upstreams: readonly AggregationUpstream[],\n ) {\n // Initial computation — surface any error through the `error`\n // field rather than letting the constructor throw, so consumers\n // can always construct a LiveAggregation and check its state\n // afterwards. Throwing from a constructor would force every\n // caller to wrap in try/catch, which is the opposite of the\n // \"reactive value with error state\" ergonomics we want.\n try {\n this.value = recompute()\n this.error = undefined\n } catch (err) {\n this.value = undefined\n this.error = err\n }\n\n // Wire up upstream subscriptions. Each one triggers a full\n // recomputation; we don't attempt incremental updates in.\n for (const upstream of upstreams) {\n const unsub = upstream.subscribe(() => this.refresh())\n this.unsubscribes.push(unsub)\n }\n }\n\n private refresh(): void {\n if (this.stopped) return\n try {\n this.value = this.recompute()\n this.error = undefined\n } catch (err) {\n // Preserve the previous successful value — consumers render an\n // error state using `error` without losing the last-known-good\n // number. This matches LiveQuery's error-preservation contract.\n this.error = err\n }\n for (const listener of this.listeners) {\n try {\n listener()\n } catch (err) {\n // Isolate listener errors so one bad consumer can't tear\n // down every other subscriber on the same aggregation.\n console.warn('[noy-db] LiveAggregation listener threw:', err)\n }\n }\n }\n\n subscribe(cb: () => void): () => void {\n if (this.stopped) {\n // No-op after stop. Returning a harmless unsubscribe lets\n // consumers use the same teardown pattern unconditionally.\n return () => {}\n }\n this.listeners.add(cb)\n return () => {\n this.listeners.delete(cb)\n }\n }\n\n stop(): void {\n if (this.stopped) return\n this.stopped = true\n for (const unsub of this.unsubscribes) {\n try {\n unsub()\n } catch (err) {\n console.warn('[noy-db] LiveAggregation upstream unsubscribe threw:', err)\n }\n }\n this.unsubscribes.length = 0\n this.listeners.clear()\n }\n}\n\n/**\n * Chainable wrapper returned by `Query.aggregate(spec)`. Holds the\n * execute-records closure and the spec; terminal methods (`run`,\n * `live`) stitch them together in either mode.\n *\n * Why a wrapper instead of two terminal methods on `Query` directly?\n *\n * The `.aggregate(spec)` call is where the spec is bound — both\n * `.run()` and `.live()` need the same spec, and the consumer's\n * fluent style is `query.where(...).aggregate(spec).run()` or\n * `.aggregate(spec).live()`. Wrapping lets the spec be named once\n * and reused for either terminal, and keeps the `Query` class\n * from growing a pair of near-duplicate method overloads\n * (`aggregateRun` / `aggregateLive`) that would be harder to\n * discover.\n */\nexport class Aggregation<R> {\n constructor(\n private readonly executeRecords: () => readonly unknown[],\n private readonly spec: AggregateSpec,\n private readonly upstreams: readonly AggregationUpstream[],\n ) {}\n\n /**\n * Execute the query and reduce the results synchronously.\n * Returns the reduced shape matching the spec — e.g. a spec of\n * `{ total: sum('amount'), n: count() }` returns\n * `{ total: number, n: number }`.\n */\n run(): R {\n return reduceRecords(this.executeRecords(), this.spec) as unknown as R\n }\n\n /**\n * Build a reactive `LiveAggregation<R>` that re-runs the reduction\n * whenever any upstream source notifies of a change. The initial\n * value is computed eagerly in the constructor, so consumers can\n * read `live.value` immediately after calling `.live()`.\n *\n * Always call `live.stop()` when finished — it tears down the\n * upstream subscriptions. Vue's `onUnmounted` is the canonical\n * place.\n *\n * **Implementation note:** every upstream change triggers a full\n * re-reduction. Incremental maintenance (O(1) per delta for\n * sum/count/avg via the reducer protocol's `remove()` method) is a\n * planned follow-up optimization — the protocol already supports\n * it, but the executor doesn't drive it yet. Consumers get\n * correct, reactive values today; future PRs can switch to\n * delta-based maintenance without changing this API.\n */\n live(): LiveAggregation<R> {\n const recompute = (): R =>\n reduceRecords(this.executeRecords(), this.spec) as unknown as R\n return new LiveAggregationImpl<R>(recompute, this.upstreams)\n }\n}\n\n/**\n * Build a `LiveAggregation<V>` from a recompute closure and a list\n * of upstreams. Exposed so sibling files in the query DSL\n * (currently `groupby.ts`) can reuse the reactive primitive\n * without reaching into `LiveAggregationImpl` directly. This keeps\n * the implementation class private while still allowing planned\n * composition with `.groupBy().aggregate().live()`.\n */\nexport function buildLiveAggregation<V>(\n recompute: () => V,\n upstreams: readonly AggregationUpstream[],\n): LiveAggregation<V> {\n return new LiveAggregationImpl<V>(recompute, upstreams)\n}\n","/**\n * Canonicalise a group-key tuple to a stable string for dedup hashing.\n *\n * Sorts field names lexicographically before serialising, so that\n * `.groupBy('a', 'b')` and `.groupBy('b', 'a')` produce identical\n * keys for the same logical group. Values are JSON-stringified;\n * `undefined` and `null` are distinguished (matching the Map-key\n * semantics in `groupAndReduce`).\n *\n * NOT part of the public API. Used by:\n * - `groupAndReduce` for the dedup Map's key\n * - `materialized-views/query-hash` for UNION MV cross-arm row-key dedup (PR 2)\n *\n * Pure: same input → same output, no side effects.\n */\nexport function canonicalGroupKey(\n fields: readonly string[],\n row: Record<string, unknown>,\n): string {\n const sorted = [...fields].sort()\n const parts: string[] = []\n for (const name of sorted) {\n const v = row[name]\n const serialised =\n v === undefined ? 'undefined' : JSON.stringify(v)\n parts.push(`${name}=${serialised}`)\n }\n return parts.join('|')\n}\n","/**\n * Query DSL `.groupBy()` —.\n *\n * Chains after `.where()` / `.filter()` / `.or()` / `.and()` on a\n * Query and before a reducer spec, so consumers can compute\n * per-bucket aggregates without folding in userland:\n *\n * ```ts\n * const byClient = invoices.query()\n * .where('status', '==', 'open')\n * .groupBy('clientId')\n * .aggregate({ total: sum('amount'), n: count() })\n * .run()\n * // → [ { clientId: 'c1', total: 5250, n: 3 }, … ]\n * ```\n *\n * Execution pipeline:\n *\n * 1. Run the query's where/filter clauses (same candidate /\n * filter pipeline as `.aggregate()` directly on Query).\n * 2. Partition the matching records into buckets keyed by\n * `readPath(record, field)`. JS `Map` preserves insertion\n * order, so the first-seen key for a bucket determines its\n * position in the result array — consumers who want a\n * specific ordering should `.sort()` downstream.\n * 3. Enforce cardinality: warn once per field at 10% of the cap\n * (10_000 buckets), throw `GroupCardinalityError` at 100% of\n * the cap (100_000 buckets).\n * 4. For each bucket, build a per-group reducer state and\n * step every record in the bucket through it.\n * 5. Emit one result row per bucket, shaped as\n * `{ [field]: key, ...reduced }`.\n *\n * **Null / undefined keys:** `Map` distinguishes `null` from\n * `undefined`, so records with a missing group field get their own\n * bucket, and records with an explicit `null` value get a separate\n * bucket from that. Consumers who want them merged can coalesce\n * upstream with `.filter()`.\n *\n * **Live mode:** `.groupBy().aggregate().live()` re-runs the full\n * grouping pipeline on every source change. Per-bucket incremental\n * delta maintenance is a future optimization — the reducer\n * protocol's `remove()` hook admits it, but ships naive\n * re-grouping for simplicity.\n *\n * **Type-level stable-key narrowing:** when\n * `dictKey` lands, `groupBy<DictField>()` will narrow the group key\n * type to the stable dictionary key rather than the resolved locale\n * label. That prevents grouping by the locale-resolved label,\n * which would produce different buckets per reader. types the\n * key as `unknown` at the result shape; the dictKey narrowing\n * layers on top without an API break.\n *\n * Partition-awareness seam: when partitioned collections land,\n * per-partition grouping will need to merge sub-results across\n * partitions. The reducer protocol's `{ seed }` parameter\n * (already plumbed through in `reducers.ts`) is the mechanism —\n * groupBy doesn't need its own seam for the moment, because it\n * delegates to the reducer protocol for all per-bucket state.\n */\n\nimport { readPath } from '../../kernel/query/predicate.js'\nimport type {\n AggregateSpec,\n AggregateResult,\n AggregationUpstream,\n LiveAggregation,\n} from './aggregation.js'\nimport { buildLiveAggregation } from './aggregation.js'\nimport type { ReducerBuilder } from './reducers.js'\nimport { reducerBuilder } from './reducers.js'\nimport { canonicalGroupKey } from './canonical-key.js'\nimport { GroupCardinalityError } from '../../kernel/errors.js'\nimport type { MoneyDescriptor } from '../../via/money/descriptor.js'\nimport type { ViaPipeline } from '../../kernel/via/pipeline.js'\nimport { viaBinder } from '../../kernel/via/index.js'\nimport { applyI18nLocale, type I18nTextDescriptor } from '../../via/i18n/core.js'\n\n/**\n * Cardinality thresholds for `.groupBy()`. The warn threshold gives\n * consumers a heads-up before the hard error; the cap is a fixed\n * constant in (not overridable). A `{ maxGroups }` override\n * can be added later without a break if a real consumer asks.\n */\nexport const GROUPBY_WARN_CARDINALITY = 10_000\nexport const GROUPBY_MAX_CARDINALITY = 100_000\n\n/**\n * One-shot warning dedup per-field-set — reactive dashboards\n * re-executing the same grouped query should produce the warning\n * once, not once per re-fire. Keyed on the sorted JSON of grouping\n * field names so `.groupBy('a', 'b')` and `.groupBy('b', 'a')`\n * share the same dedup slot (their result tuples are isomorphic).\n */\nconst warnedCardinalityFields = new Set<string>()\nfunction warnCardinalityApproaching(\n fields: readonly string[],\n observed: number,\n): void {\n const key = JSON.stringify([...fields].sort())\n if (warnedCardinalityFields.has(key)) return\n warnedCardinalityFields.add(key)\n const label = `[${fields.join(', ')}]`\n console.warn(\n `[noy-db] .groupBy(${label}) produced ${observed} distinct groups, ` +\n `${Math.round((observed / GROUPBY_MAX_CARDINALITY) * 100)}% of the ` +\n `${GROUPBY_MAX_CARDINALITY}-group ceiling. Narrow the query with ` +\n `.where() before grouping, or switch to a lower-cardinality field.`,\n )\n}\n\n/**\n * Test-only: clear the per-field cardinality warning dedup between\n * tests. Production code never calls this — matching the\n * `resetJoinWarnings` pattern in `join.ts`.\n */\nexport function resetGroupByWarnings(): void {\n warnedCardinalityFields.clear()\n}\n\n/**\n * Result row shape for a grouped aggregation. Each row carries the\n * group key value under the grouping field name plus every reducer\n * output from the spec.\n *\n * types the group key as `unknown` at the result shape — the\n * runtime read via `readPath` can return any value, and narrowing\n * to a specific type would require the caller to assert at the\n * call site. `dictKey` narrowing layers on top of this by\n * adding an overload that constrains `F` when the grouping field\n * is a `dictKey`.\n */\nexport type GroupedRow<F extends string, R> = { [K in F]: unknown } & R\n\n/**\n * Multi-key variant — result-row shape for variadic\n * `.groupBy(...fields)`. Every grouped field name appears on the row\n * (typed as `unknown` for the same reason as `GroupedRow`), plus the\n * reducer outputs from the spec.\n */\nexport type GroupedRowN<F extends readonly string[], R> =\n { [K in F[number]]: unknown } & R\n\n/**\n * Shared base class for the chainable grouped-query wrappers. Holds\n * the constructor + protected fields that both single-key\n * `GroupedQuery<T, F>` and variadic `GroupedQueryN<T, F>` need; each\n * subclass only overrides `aggregate()` with its own result-row\n * generic.\n *\n * Not exported — implementation detail. Adding `.having()` /\n * `.live()` / `.orderByGroup()` etc. in the future lands here once\n * and both subclasses pick it up automatically.\n *\n * @internal\n */\nabstract class GroupedQueryBase {\n /**\n * Field set this grouped query buckets on. Stored in declaration\n * order — the same order is preserved on every result row by\n * `groupAndReduce`. For the single-field constructor, this is\n * `[field]`.\n */\n protected readonly fields: readonly string[]\n\n constructor(\n protected readonly executeRecords: () => readonly unknown[],\n fieldOrFields: string | readonly string[],\n protected readonly upstreams: readonly AggregationUpstream[],\n /**\n * Optional dict label resolver attached by the query builder when\n * the grouping field is a dictKey. Variadic groupings always pass\n * `undefined` — `<field>Label` projection has no meaningful shape\n * for composite keys.\n */\n protected readonly dictLabelResolver?: (\n key: string,\n locale: string,\n fallback?: string | readonly string[],\n ) => Promise<string | undefined>,\n /**\n * The backing collection's compiled Via pipeline — used to rewrite\n * `sum`/`min`/`max` over Via-covered fields (e.g. money) into exact\n * BigInt reducers when `.aggregate(spec)` is terminated.\n */\n protected readonly via?: ViaPipeline,\n ) {\n this.fields =\n typeof fieldOrFields === 'string' ? [fieldOrFields] : [...fieldOrFields]\n }\n\n /** Apply Via-aware reducer rewriting (e.g. money) when the source declares one. */\n protected wrapSpec<Spec extends AggregateSpec>(spec: Spec): Spec {\n return this.via ? this.via.wrapReducers(spec) : spec\n }\n}\n\n/**\n * Chainable wrapper returned by `Query.groupBy(field)`. Terminates\n * with `.aggregate(spec)` which returns a `GroupedAggregation`.\n *\n * Kept minimal — the only operation on a grouped query is\n * aggregation. Ordering, limiting, and further filtering belong on\n * the underlying `Query` before `.groupBy()` is called; applying\n * them post-group would be a different operation (`having` /\n * `groupOrderBy`), out of scope for.\n */\nexport class GroupedQuery<T, F extends string, S extends keyof T = never, M extends keyof T & string = never> extends GroupedQueryBase {\n /**\n * Build a grouped aggregation. Returns a `GroupedAggregation`\n * with `.run()`, `.runAsync()`, and `.live()` terminals — same shape\n * as the non-grouped `.aggregate()` wrapper, just with an array\n * result (one row per bucket) instead of a single reduced object.\n *\n * The builder overload `aggregate(b => spec)` types `b` as\n * `ReducerBuilder<T, S, M>`, so field-taking reducers (`sum`, `avg`,\n * `min`, `max`) refuse any field listed in the collection's\n * `sensitive` option at compile time, and `sum`/`min`/`max` over a\n * declared `moneyFields` (`M`) member return a `MoneyString`. The\n * bare-spec overload is preserved for backward compatibility.\n */\n aggregate<Spec extends AggregateSpec>(spec: Spec): GroupedAggregation<GroupedRow<F, AggregateResult<Spec>>>\n aggregate<Spec extends AggregateSpec>(build: (b: ReducerBuilder<T, S, M>) => Spec): GroupedAggregation<GroupedRow<F, AggregateResult<Spec>>>\n aggregate<Spec extends AggregateSpec>(\n specOrBuild: Spec | ((b: ReducerBuilder<T, S, M>) => Spec),\n ): GroupedAggregation<GroupedRow<F, AggregateResult<Spec>>> {\n const spec: Spec = typeof specOrBuild === 'function'\n ? (specOrBuild as (b: ReducerBuilder<T, S, M>) => Spec)(reducerBuilder as unknown as ReducerBuilder<T, S, M>)\n : specOrBuild\n // T is phantom on the wrapper so consumers can still see the\n // source row type on hover. Reference it to keep lint quiet.\n void undefined as T | undefined\n return new GroupedAggregation<GroupedRow<F, AggregateResult<Spec>>>(\n this.executeRecords,\n this.fields,\n this.wrapSpec(spec),\n this.upstreams,\n this.dictLabelResolver,\n )\n }\n}\n\n/**\n * Variadic-keyed sibling of `GroupedQuery<T, F>`. Constructed by the\n * multi-arg `Query.groupBy(...fields)` overload. The runtime shape is\n * identical — only the type-level result-row narrowing differs.\n */\nexport class GroupedQueryN<T, F extends readonly string[], S extends keyof T = never, M extends keyof T & string = never> extends GroupedQueryBase {\n aggregate<Spec extends AggregateSpec>(spec: Spec): GroupedAggregation<GroupedRowN<F, AggregateResult<Spec>>>\n aggregate<Spec extends AggregateSpec>(build: (b: ReducerBuilder<T, S, M>) => Spec): GroupedAggregation<GroupedRowN<F, AggregateResult<Spec>>>\n aggregate<Spec extends AggregateSpec>(\n specOrBuild: Spec | ((b: ReducerBuilder<T, S, M>) => Spec),\n ): GroupedAggregation<GroupedRowN<F, AggregateResult<Spec>>> {\n const spec: Spec = typeof specOrBuild === 'function'\n ? (specOrBuild as (b: ReducerBuilder<T, S, M>) => Spec)(reducerBuilder as unknown as ReducerBuilder<T, S, M>)\n : specOrBuild\n void undefined as T | undefined\n return new GroupedAggregation<GroupedRowN<F, AggregateResult<Spec>>>(\n this.executeRecords,\n this.fields,\n this.wrapSpec(spec),\n this.upstreams,\n this.dictLabelResolver,\n )\n }\n}\n\n/**\n * Execute the group-and-reduce pipeline. Pure function over a\n * record array and a spec — shared by `GroupedAggregation.run()`\n * and the live-mode refresh path. Exported for tests and for any\n * future `scan().groupBy().aggregate()` reuse.\n *\n * Enforces the cardinality cap incrementally during the partition\n * loop, so a runaway grouping throws at the moment the 100_001st\n * bucket would be created — the consumer doesn't have to wait for\n * the full partition to materialize before the error fires.\n */\nexport function groupAndReduce<R>(\n records: readonly unknown[],\n fieldOrFields: string | readonly string[],\n spec: AggregateSpec,\n moneyFields?: Record<string, MoneyDescriptor>,\n): R[] {\n const fields: readonly string[] =\n typeof fieldOrFields === 'string' ? [fieldOrFields] : fieldOrFields\n if (fields.length === 0) {\n throw new Error('.groupBy() requires at least one field')\n }\n\n // Money-aware aggregation: when the caller declares money descriptors\n // for output/intermediate fields, rewrite any `sum`/`min`/`max` over\n // them into exact BigInt reducers before bucketing. Omitted → spec\n // passes through unchanged (backward compatible). The chainable\n // `GroupedQuery` path already wraps upstream via `wrapSpec`; this\n // covers direct `groupAndReduce` callers (UNION-form MVs) that have\n // no Query wrapper to do it. Builds a transient money binding via the\n // kernel's Via port rather than a Query-attached pipeline — `moneyFields`\n // here is the MV spec's OWN descriptor map, not a collection's.\n if (moneyFields) {\n spec = viaBinder('money')({ moneyFields }).wrapReducers!(spec) as AggregateSpec\n }\n\n // Bucket value is { keyValues, records } so the output row can stamp\n // every grouped field in DECLARATION ORDER. Map preserves insertion\n // order natively (ES2015), so first-seen keys determine ordering.\n interface Bucket {\n keyValues: Record<string, unknown>\n records: unknown[]\n }\n const buckets = new Map<string, Bucket>()\n // Field-label string for error messages — matches the variadic\n // surface (`[a, b]` for multi-key, `\"k\"` for single-key back-compat).\n const fieldLabel = fields.length === 1 ? fields[0]! : `[${fields.join(', ')}]`\n\n for (const record of records) {\n // Read each field's value into a row object, then canonicalise.\n const keyValues: Record<string, unknown> = {}\n for (const f of fields) {\n keyValues[f] = readPath(record, f)\n }\n const dedupKey = canonicalGroupKey(fields, keyValues)\n let bucket = buckets.get(dedupKey)\n if (bucket === undefined) {\n if (buckets.size >= GROUPBY_MAX_CARDINALITY) {\n throw new GroupCardinalityError(\n fieldLabel,\n buckets.size + 1,\n GROUPBY_MAX_CARDINALITY,\n )\n }\n bucket = { keyValues, records: [] }\n buckets.set(dedupKey, bucket)\n }\n bucket.records.push(record)\n }\n\n if (buckets.size >= GROUPBY_WARN_CARDINALITY) {\n warnCardinalityApproaching(fields, buckets.size)\n }\n\n // Reduce each bucket through the spec. Same init/step/finalize\n // pipeline as `reduceRecords` in aggregate.ts, but one state per\n // bucket. Inlining the loop here keeps the per-bucket path tight\n // — calling `reduceRecords` per bucket would recompute\n // `Object.keys(spec)` once per bucket unnecessarily.\n const reducerKeys = Object.keys(spec)\n const out: R[] = []\n for (const bucket of buckets.values()) {\n const state: Record<string, unknown> = {}\n for (const rk of reducerKeys) {\n state[rk] = spec[rk]!.init()\n }\n for (const record of bucket.records) {\n for (const rk of reducerKeys) {\n state[rk] = spec[rk]!.step(state[rk], record)\n }\n }\n // Stamp grouped fields FIRST, in declaration order — this is\n // tested via `Object.keys(row).slice(0, fields.length)`.\n const row: Record<string, unknown> = {}\n for (const f of fields) {\n row[f] = bucket.keyValues[f]\n }\n for (const rk of reducerKeys) {\n row[rk] = spec[rk]!.finalize(state[rk])\n }\n out.push(row as unknown as R)\n }\n return out\n}\n\n/**\n * Grouped aggregation wrapper — the `.groupBy(field).aggregate(spec)`\n * terminal. Shape mirrors `Aggregation<R>` from aggregate.ts: two\n * terminals (`.run()` and `.live()`), spec bound at construction\n * time, upstreams collected for live mode.\n *\n * The generic `R` is the per-row result shape (i.e. a single\n * grouped row), and the terminals return `R[]` — one row per\n * bucket.\n */\nexport class GroupedAggregation<R> {\n private readonly fields: readonly string[]\n\n constructor(\n private readonly executeRecords: () => readonly unknown[],\n fields: string | readonly string[],\n private readonly spec: AggregateSpec,\n private readonly upstreams: readonly AggregationUpstream[],\n /**\n * Optional dict label resolver for `<field>Label` projection\n *. Present when the grouping field is a dictKey.\n */\n private readonly dictLabelResolver?: (\n key: string,\n locale: string,\n fallback?: string | readonly string[],\n ) => Promise<string | undefined>,\n ) {\n this.fields = typeof fields === 'string' ? [fields] : [...fields]\n }\n\n /**\n * Execute the query, group, reduce, and return an array of rows.\n *\n * `opts` (query-form MV grouping): when a `locale` + `i18nFields` are\n * given, the declared group-key `i18nText` fields are resolved to that locale\n * at the `mv` layer BEFORE bucketing — so an i18n group key is a stable string\n * instead of a raw `{locale}` map. The MV executor passes the MV's\n * `i18nLocale`/`i18nFields`; ordinary `.run()` callers pass nothing and are\n * unaffected.\n */\n run(opts?: { locale?: string; i18nFields?: Record<string, I18nTextDescriptor> }): R[] {\n let records = this.executeRecords()\n if (opts?.locale !== undefined && opts.i18nFields !== undefined) {\n const groupI18n: Record<string, I18nTextDescriptor> = {}\n for (const f of this.fields) {\n const d = opts.i18nFields[f]\n if (d !== undefined) groupI18n[f] = d\n }\n if (Object.keys(groupI18n).length > 0) {\n records = records.map((r) => applyI18nLocale(r as Record<string, unknown>, groupI18n, opts.locale!, undefined, 'mv'))\n }\n }\n return groupAndReduce<R>(records, this.fields, this.spec)\n }\n\n /**\n * Execute the query, group, reduce, and resolve `<field>Label` for\n * each result row when the grouping field is a `dictKey` and a\n * `locale` is provided. Returns `R[]` synchronously when\n * no locale is specified (identical to `.run()`).\n *\n * The `<field>Label` field is appended to each row. Rows whose group\n * key has no dictionary entry get `<field>Label: undefined`.\n *\n * Dict-label resolution is single-field only — multi-key groupings\n * do not produce a `<field>Label`. The resolver is only attached\n * by the builder when `fields.length === 1`.\n */\n async runAsync(opts?: {\n locale?: string\n fallback?: string | readonly string[]\n }): Promise<R[]> {\n const rows = groupAndReduce<R>(this.executeRecords(), this.fields, this.spec)\n if (!opts?.locale || !this.dictLabelResolver || this.fields.length !== 1) return rows\n\n const resolve = this.dictLabelResolver\n const locale = opts.locale\n const fallback = opts.fallback\n const field = this.fields[0]!\n const labelKey = `${field}Label`\n\n return Promise.all(\n rows.map(async (row) => {\n const key = (row as Record<string, unknown>)[field]\n if (typeof key !== 'string') return row\n const label = await resolve(key, locale, fallback)\n return { ...(row as Record<string, unknown>), [labelKey]: label } as unknown as R\n }),\n )\n }\n\n /**\n * Build a reactive `LiveAggregation<R[]>` that re-runs the full\n * group-and-reduce pipeline whenever any upstream source notifies\n * of a change. Same error-isolation and idempotent-stop contract\n * as `Aggregation.live()` — the implementation delegates to the\n * same `LiveAggregationImpl` class by threading a fresh\n * recompute closure through the existing constructor.\n *\n * uses naive full re-run on every change. Incremental\n * per-bucket maintenance (apply `step` on inserted records,\n * `remove` on deleted records, route by bucket key) is a future\n * optimization — the reducer protocol admits it, but wiring\n * delta-aware source subscriptions is a separate PR.\n *\n * Always call `live.stop()` when finished.\n */\n live(): LiveAggregation<R[]> {\n const recompute = (): R[] =>\n groupAndReduce<R>(this.executeRecords(), this.fields, this.spec)\n return buildLiveAggregation<R[]>(recompute, this.upstreams)\n }\n}\n"],"mappings":"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","names":[]}
|