/*! * Copyright (c) 2026 Interop Alliance. All rights reserved. */ /** * LocalStore: the always-on local replica. A GENERIC per-entity envelope store * over an app's registered collections. Owns one RxDB (Dexie/IndexedDB) * database holding one collection per entity on the shared `syncedDocSchema()`; * every at-rest row is `{ id, updatedAt, version, data }`. On a PRIVATE * (default) collection `data` is the EDV envelope `{ id, sequence, jwe }` -- * the server never sees plaintext. On a PUBLIC collection `data` is the * plaintext payload as-is, behind the same {@link DocCipher} seam (a * pass-through codec), so everything below this paragraph applies to both. * * Two id planes: the logical entity `uuid` lives INSIDE the encrypted payload; * the RxDB primary key is the opaque random EDV envelope id. An in-memory * `uuid -> envelopeId` index (built during hydration) routes updates/deletes. * (On a public collection the planes coincide -- the row id IS the payload * uuid, giving a public document a stable, shareable resource URL -- and the * index degenerates to identity.) Two timestamp planes: the row-level * `updatedAt` is only the sync checkpoint; the payload's own `createdAt` / * `updatedAt` (inside the ciphertext) drive domain sorting and LWW. * * Writes: create mints a fresh random envelope; update re-encrypts under the * SAME envelope id with `sequence`+1 (the mutable-head model); delete is an RxDB * soft-delete tombstone. */ import { type RxCollection, type RxStorage } from 'rxdb/plugins/core'; import { type SharedCollectionConfig, type WasCollectionConfig } from '../config.js'; import type { CollectionEncryption } from '@interop/was-client'; import { type EncryptionDescriptorSource } from '@interop/wallet-core/descriptors'; import { type Json, type SyncedDoc } from '../sync/index.js'; import type { IKeyAgreementKey, IKeyResolver } from '@interop/data-integrity-core'; type EntityPayload = { id: string; }; /** * A stable, RxDB-safe database name per controller DID (FNV-1a hex), so two * wallet users on one browser never collide on the same local database. * * @param options {object} * @param options.dbName {string} the app's base database name * @param options.controllerDid {string} the session controller DID * @returns {string} */ export declare function dbNameForController({ dbName, controllerDid }: { dbName: string; controllerDid: string; }): string; /** * The local store. Construct via {@link LocalStore.init}, which builds each * collection's cipher -- an EDV cipher on the app's identity KAK (private * collections) or the pass-through plaintext codec (public collections) -- and * opens RxDB. */ export declare class LocalStore { #private; private constructor(); /** * Opens (or creates) the store: builds one cipher per PRIVATE collection on * the app's identity KAK (a PUBLIC collection gets the pass-through plaintext * codec instead) and opens one RxDB collection per entity. The key material * is derived ONCE, by the caller, and shared by every private collection: an * epoch-roster recipient is always the X25519 twin of a controller did:key. * * When `descriptors` carries an epoch-bearing encryption descriptor for a * private collection (from the offline descriptor cache), that collection's * cipher is built before any live description read; a collection with no * cached roster opens FAIL-CLOSED behind a placeholder cipher until a live * descriptor read supplies one (epoch-from-birth: there is no single-key * cipher to fall back to). * * @param options {object} * @param options.keyAgreementKey {IKeyAgreementKey} the app's identity KAK * (`IdentityAgents.keyAgreementKey`) * @param options.keyResolver {IKeyResolver} its one-key resolver * (`IdentityAgents.keyResolver`) * @param options.collections {WasCollectionConfig[]} the collection registry * (logical key to WAS collection id) * @param [options.sharedCollections] {SharedCollectionConfig[]} the shared * (read-only, wallet-owned) registry. Nothing is opened for these -- they * have no local replica -- but they are validated against the app-owned * registry here, before any replica exists * @param [options.descriptors] {Record} cached * encryption descriptors keyed by WAS collection id (from the offline cache) * @param [options.storage] {RxStorage} defaults to * Dexie/IndexedDB; injectable for tests * @param [options.dbName] {string} defaults to {@link DEFAULT_DB_NAME} * @returns {Promise} */ static init({ keyAgreementKey, keyResolver, collections, sharedCollections, descriptors, storage, dbName }: { keyAgreementKey: IKeyAgreementKey; keyResolver: IKeyResolver; collections: WasCollectionConfig[]; sharedCollections?: SharedCollectionConfig[]; descriptors?: Record; storage?: RxStorage; dbName?: string; }): Promise; /** * The registered {@link WasCollectionConfig} for one collection key (the * WAS collection id, visibility, and declared indexes the storage layer * routes on). * * @param key {string} the collection logical key * @returns {WasCollectionConfig} */ collectionConfig(key: string): WasCollectionConfig; /** * Installs the encryption-descriptor source: one live Collection Description * read per collection id. Called once a remote store exists; a decrypt that * meets an unknown key epoch uses it to re-read the descriptor and rebuild * that collection's cipher, at most once per collection per session. * * @param source {EncryptionDescriptorSource} * @returns {void} */ setDescriptorSource(source: EncryptionDescriptorSource): void; /** * Rebuilds one private collection's cipher from a new (epoch-bearing) * encryption descriptor, on the same identity KAK the store was opened with. A public (plaintext) * collection has no EDV cipher and is a no-op. The new cipher replaces the * held one in place, so the conflict handler and every read path pick it up. * * The collection metadata last installed by {@link applyCollectionMeta} is * re-applied at build time, so the blinded-index schema survives an * epoch-rotation rebuild and the unknown-epoch refresh (both funnel through * here). The schema refresh deliberately does NOT ride the descriptor-equality * gate in {@link applyRemoteDescriptor}: a schema-only change rotates no * epochs, so it is installed on the live cipher rather than waiting for a * descriptor to differ. * * @param options {object} * @param options.key {string} the collection logical key * @param options.encryption {CollectionEncryption} the new descriptor * @returns {Promise} */ rebuildCipher({ key, encryption }: { key: string; encryption: CollectionEncryption; }): Promise; /** * Installs one collection's stored metadata (by WAS collection id) on its * cipher: the persisted blinded-index schema lives inside that metadata, and * installing it is what makes subsequent LOCAL writes emit blinded `indexed` * entries, so the envelopes this replica pushes are findable by an equality * query. It is the sync-path analogue of the direct path re-reading the * schema whenever a handle's codec is re-resolved. * * The metadata is also remembered, so a later {@link rebuildCipher} (an epoch * rotation, or the first real cipher swapped in behind the fail-closed * placeholder) re-installs the schema rather than dropping it. Unknown ids and * public collections are ignored. A metadata value the cipher cannot decode * throws: an undecodable envelope is the caller's warn-and-continue. * * @param options {object} * @param options.collectionId {string} the WAS collection id * @param [options.custom] {unknown} the stored `custom` value from the * collection's `/meta` (an opaque envelope on an encrypted collection) * @returns {Promise} whether the metadata was remembered/applied */ applyCollectionMeta({ collectionId, custom }: { collectionId: string; custom?: unknown; }): Promise; /** * Applies a freshly fetched remote encryption descriptor (by WAS collection id): * rebuilds that collection's cipher when the descriptor's current epoch differs * from the one the current cipher was built from (a wallet-side rotation, or * first-ever epochs), so subsequent writes stamp the current epoch. Returns * whether a rebuild happened. Unknown / public collections are ignored, and * so is a descriptor with no key-epoch roster (a bare `edv` declaration): no * cipher can be built from it, so the collection keeps its current cipher -- * the fail-closed placeholder, when it opened without a cached roster. * * Installing a fresh descriptor here also RE-ARMS the collection's * unknown-epoch refresh (the policy's documented `reset` contract): this path * is the sync bootstrap's own install, so the next unknown epoch after it is * evidence of a NEW rotation elsewhere and deserves its own re-read. * * @param options {object} * @param options.collectionId {string} the WAS collection id * @param options.encryption {CollectionEncryption} the fetched descriptor * @returns {Promise} */ applyRemoteDescriptor({ collectionId, encryption }: { collectionId: string; encryption: CollectionEncryption; }): Promise; /** * Encrypts `payload` into a fresh EDV envelope and inserts it as a new row. * * @param key {string} the collection logical key * @param payload {EntityPayload} the plaintext entity (carries its own uuid) * @returns {Promise} */ insertEntity(key: string, payload: T): Promise; /** * Re-encrypts `payload` in place under its existing envelope id, advancing the * envelope `sequence` (the mutable-head update). The row keeps its primary key; * only `data` and the checkpoint `updatedAt` change. * * @param key {string} * @param payload {EntityPayload} * @returns {Promise} */ updateEntity(key: string, payload: T): Promise; /** * Inserts the entity if the collection has no row for its uuid yet, otherwise * re-encrypts it in place. The hydration index is the source of truth for * existence, so callers (e.g. an app's singleton collection) need not track * an insert-vs-update flag of their own. * * @param key {string} * @param payload {EntityPayload} * @returns {Promise} */ upsertEntity(key: string, payload: T): Promise; /** * Tombstones the entity's row (RxDB soft delete) so the deletion replicates. * * @param key {string} * @param uuid {string} the logical entity uuid * @returns {Promise} */ deleteEntity(key: string, uuid: string): Promise; /** * The number of live (non-tombstoned) rows in a collection, without * decrypting any of them (e.g. the "is there anything to adopt?" check * behind a pre-login adoption prompt). * * @param key {string} * @returns {Promise} */ countEntities(key: string): Promise; /** * Decrypts every live row of a collection into its plaintext payload, and * (re)builds the `uuid -> envelopeId` index as a side effect of hydration. * * @param key {string} * @returns {Promise} */ listEntities(key: string): Promise; /** * Hydrates a singleton collection (at most one logical entity, e.g. an app's * current-selection doc) and reconciles any duplicates. Two devices that each * created the singleton before syncing produce distinct envelope rows that all * decrypt to the same logical id; because LWW conflict resolution is * per-envelope-id, those duplicates never reconcile on their own. This keeps * the last-writer-wins winner (payload `updatedAt`, `writerId` tiebreak) and * tombstones the losers so the deletion replicates and the space converges on * one row. Returns the winning payload, or `null` when the collection is empty. * * @param key {string} * @returns {Promise} */ hydrateSingleton(key: string): Promise; /** * Decrypts a single EDV envelope into its plaintext payload, for per-doc * reactive patching of a pulled remote change (without a whole-collection * re-hydrate). * * @param key {string} * @param envelope {Json} the `data` field of the at-rest row * @returns {Promise} */ decryptEnvelope(key: string, envelope: Json): Promise; /** * Records a `uuid -> envelopeId` mapping for a remotely-pulled row so a * subsequent LOCAL edit of that entity can find its envelope. A no-op until * the collection has been hydrated once (hydration builds the full index). * * @param key {string} * @param uuid {string} * @param envelopeId {string} * @returns {void} */ rememberEnvelope(key: string, uuid: string, envelopeId: string): void; /** * Forgets a `uuid -> envelopeId` mapping (a remotely-pulled tombstone). * * @param key {string} * @param uuid {string} * @returns {void} */ forgetEnvelope(key: string, uuid: string): void; /** * The envelope id the hydration index currently maps a logical uuid to, or * `undefined` when unknown (not yet hydrated, or no such entity). Lets the * sync patch path tell a tombstone for the LIVE envelope apart from one for a * stale duplicate (a reconciled singleton loser or a pre-resurrection row). * * @param key {string} * @param uuid {string} * @returns {string | undefined} */ envelopeIdFor(key: string, uuid: string): string | undefined; /** * The live RxDB collection handle, for reactive subscriptions and the sync * controller. * * @param key {string} * @returns {RxCollection} */ rxCollection(key: string): RxCollection; /** * Closes the database (without removing data). * * @returns {Promise} */ close(): Promise; /** * Removes the database and all its data (the clear-data / logout-wipe path). * Unlike {@link close}, this deletes the underlying Dexie/IndexedDB store. * * @returns {Promise} */ remove(): Promise; /** * Deletes a database and all its data by name, without opening it (the * post-adoption cleanup of a replica that is already closed). * * @param options {object} * @param options.dbName {string} the full per-controller database name * @param [options.storage] {RxStorage} defaults to * Dexie/IndexedDB; must match the storage the database was created with * @returns {Promise} */ static removeDatabase({ dbName, storage }: { dbName: string; storage?: RxStorage; }): Promise; } export {}; //# sourceMappingURL=localStore.d.ts.map