@docstack/client 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +1 -1
  3. package/lib/core/attribute.d.ts +11 -2
  4. package/lib/core/class.d.ts +40 -6
  5. package/lib/core/content-transfer.d.ts +178 -0
  6. package/lib/core/crypto-engine/index.d.ts +82 -1
  7. package/lib/core/crypto-engine/utils.d.ts +36 -1
  8. package/lib/core/datamodel/index.d.ts +16 -0
  9. package/lib/core/domain.d.ts +1 -1
  10. package/lib/core/guarded-db.d.ts +52 -0
  11. package/lib/core/index.d.ts +106 -1
  12. package/lib/core/policy-engine/index.d.ts +35 -0
  13. package/lib/core/query-engine/classes.d.ts +24 -0
  14. package/lib/core/query-engine/executor.d.ts +11 -0
  15. package/lib/core/query-engine/index.d.ts +2 -1
  16. package/lib/core/query-engine/planner.d.ts +19 -0
  17. package/lib/core/stack.d.ts +676 -7
  18. package/lib/core/sync/class-filter.d.ts +106 -0
  19. package/lib/core/sync/filter-identity.d.ts +53 -0
  20. package/lib/core/sync/index.d.ts +334 -0
  21. package/lib/core/sync/internal-docs.d.ts +159 -0
  22. package/lib/index.d.ts +27 -1
  23. package/lib/index.js +12686 -4
  24. package/lib/index.umd.js +5017 -620
  25. package/lib/plugins/pouchdb.d.ts +43 -3
  26. package/lib/utils/logger/index.d.ts +28 -4
  27. package/lib/utils/logger/transport.d.ts +52 -11
  28. package/package.json +18 -10
  29. package/lib/core/attribute.js +0 -406
  30. package/lib/core/attribute.js.map +0 -1
  31. package/lib/core/class.js +0 -761
  32. package/lib/core/class.js.map +0 -1
  33. package/lib/core/crypto-engine/index.js +0 -229
  34. package/lib/core/crypto-engine/index.js.map +0 -1
  35. package/lib/core/crypto-engine/utils.js +0 -88
  36. package/lib/core/crypto-engine/utils.js.map +0 -1
  37. package/lib/core/datamodel/index.js +0 -1308
  38. package/lib/core/datamodel/index.js.map +0 -1
  39. package/lib/core/domain.js +0 -423
  40. package/lib/core/domain.js.map +0 -1
  41. package/lib/core/index.js +0 -532
  42. package/lib/core/index.js.map +0 -1
  43. package/lib/core/job-engine/index.js +0 -220
  44. package/lib/core/job-engine/index.js.map +0 -1
  45. package/lib/core/policy-engine/index.js +0 -232
  46. package/lib/core/policy-engine/index.js.map +0 -1
  47. package/lib/core/query-engine/accumulators.js +0 -258
  48. package/lib/core/query-engine/accumulators.js.map +0 -1
  49. package/lib/core/query-engine/evaluator.js +0 -179
  50. package/lib/core/query-engine/evaluator.js.map +0 -1
  51. package/lib/core/query-engine/executor.js +0 -405
  52. package/lib/core/query-engine/executor.js.map +0 -1
  53. package/lib/core/query-engine/index.js +0 -4
  54. package/lib/core/query-engine/index.js.map +0 -1
  55. package/lib/core/query-engine/parser.js +0 -515
  56. package/lib/core/query-engine/parser.js.map +0 -1
  57. package/lib/core/query-engine/planner.js +0 -330
  58. package/lib/core/query-engine/planner.js.map +0 -1
  59. package/lib/core/stack.js +0 -1826
  60. package/lib/core/stack.js.map +0 -1
  61. package/lib/core/test-utils/docstack.js +0 -222
  62. package/lib/core/test-utils/docstack.js.map +0 -1
  63. package/lib/core/trigger/index.js +0 -81
  64. package/lib/core/trigger/index.js.map +0 -1
  65. package/lib/index.js.map +0 -1
  66. package/lib/plugins/pouchdb.js +0 -412
  67. package/lib/plugins/pouchdb.js.map +0 -1
  68. package/lib/utils/crypto/index.js +0 -34
  69. package/lib/utils/crypto/index.js.map +0 -1
  70. package/lib/utils/index.js +0 -58
  71. package/lib/utils/index.js.map +0 -1
  72. package/lib/utils/logger/index.js +0 -20
  73. package/lib/utils/logger/index.js.map +0 -1
  74. package/lib/utils/logger/transport.js +0 -28
  75. package/lib/utils/logger/transport.js.map +0 -1
  76. package/lib/workers/dataModel.js +0 -48
  77. package/lib/workers/dataModel.js.map +0 -1
@@ -1,11 +1,14 @@
1
1
  import Class from "./class.js";
2
2
  import Domain from "./domain.js";
3
- import { Stack, StackOptions, AuthSessionProof, ClientCredentials, CachedClass, ClassModelPropagationStart, ClassModelPropagationComplete, CachedDomain, DomainModel } from "@docstack/shared";
3
+ import { Stack, StackOptions, AuthSessionProof, ClientCredentials, CachedClass, ClassModelPropagationStart, ClassModelPropagationComplete, CachedDomain, DomainModel, ChangesSubscription } from "@docstack/shared";
4
4
  import { SystemDoc, Patch, ClassModel, Document, RelationDocument } from "@docstack/shared";
5
+ import { StackSyncHandle } from "./sync/index.js";
6
+ import type { StackSyncOptions, SyncStatus } from "./sync/index.js";
5
7
  import type { SelectAST, UnionAST } from "./query-engine/index.js";
6
8
  import { JobEngine } from "./job-engine/index.js";
7
9
  import { PolicyEngine } from "./policy-engine/index.js";
8
10
  import { CryptoEngine } from "./crypto-engine/index.js";
11
+ import type { ContentExport, ContentExportOptions, ContentImportOptions, ContentImportReport } from "./content-transfer.js";
9
12
  export declare const BASE_SCHEMA: ClassModel["schema"];
10
13
  export declare const CLASS_SCHEMA: ClassModel["schema"];
11
14
  /**
@@ -45,6 +48,22 @@ declare class ClientStack extends Stack {
45
48
  * ```
46
49
  */
47
50
  db: PouchDB.Database<{}>;
51
+ /**
52
+ * The unguarded PouchDB instance. Only the sync layer reaches for it, through
53
+ * {@link getReplicationHandle}.
54
+ * @internal
55
+ */
56
+ private rawDb;
57
+ /**
58
+ * `bulkDocs`/`bulkGet` as PouchDB defines them, captured before {@link StackPlugin}
59
+ * replaces them.
60
+ * @internal
61
+ */
62
+ private pristineDbMethods;
63
+ /** Memoised {@link getReplicationHandle} result. @internal */
64
+ private replicationDb?;
65
+ /** The stack's replication, once {@link sync} has been called. @internal */
66
+ private syncHandle?;
48
67
  /** The unique name identifier for this stack instance, derived from the connection string. */
49
68
  name: string;
50
69
  lastDocId: number;
@@ -62,7 +81,57 @@ declare class ClientStack extends Stack {
62
81
  [className: string]: CachedClass | CachedDomain;
63
82
  };
64
83
  patchCount: number;
65
- listeners: PouchDB.Core.Changes<{}>[];
84
+ /**
85
+ * Stored class models, keyed by whatever the caller looked them up with (name or id).
86
+ *
87
+ * `getClassModel` runs at least once per document on every read and write path (the
88
+ * policy engine resolves each document's class through it), and each miss is an
89
+ * unindexed find. Entries include `null` for names that resolved to nothing - the
90
+ * caches are cleared whenever a class document is written, so a class created later
91
+ * evicts its own negative entry.
92
+ *
93
+ * Invalidated as a pair with {@link classSnapshotCache}: synchronously by the write
94
+ * path (StackPlugin calls {@link invalidateWriteCaches} after every batch that
95
+ * touches a class model) and again by the shared changes feed for writes this stack
96
+ * did not make itself. Cleared wholesale rather than per-entry because a rename
97
+ * leaves the old name keyed to a model that no longer answers to it.
98
+ */
99
+ private classModelCache;
100
+ /**
101
+ * Built, non-subscribing Class instances, keyed by class name and pinned to the
102
+ * model revision they were built from.
103
+ *
104
+ * Building a Class rebuilds every Attribute and re-derives the Zod schema, per
105
+ * attribute - work `getClassSnapshot` used to repeat on every call. The `_rev` pin
106
+ * means a stale entry can never be served even if invalidation lags: the model is
107
+ * looked up first (through {@link classModelCache}) and the snapshot is reused only
108
+ * when its revision still matches.
109
+ */
110
+ private classSnapshotCache;
111
+ /**
112
+ * Every live changes subscription this stack has handed out; released on close.
113
+ */
114
+ listeners: ChangesSubscription[];
115
+ /**
116
+ * The one live feed behind every class-document subscription.
117
+ *
118
+ * PouchDB attaches a `destroyed` listener to the database for each
119
+ * `db.changes({ live: true })` and holds it until that feed is cancelled, so a feed
120
+ * per watched class crosses Node's ten-listener limit - and prints
121
+ * `MaxListenersExceededWarning` - on an app with a handful of classes on screen. It
122
+ * is also wasted work: the local adapter runs every filter over the same change
123
+ * stream, so N filtered feeds each see every change anyway.
124
+ */
125
+ private classDocFeed?;
126
+ /**
127
+ * Change handlers, keyed `"<metaKey>:<name>"`. Empty means {@link classDocFeed} can be
128
+ * cancelled.
129
+ *
130
+ * The meta key is part of the key because classes and domains are separate namespaces:
131
+ * a relation document is named by `~domain` and carries no `~class`, so routing both
132
+ * through one keyspace would let a class and a same-named domain hear each other.
133
+ */
134
+ private classDocSubscribers;
66
135
  modelWorker: Worker | null;
67
136
  /**
68
137
  * Engine for executing background jobs and scheduled tasks.
@@ -92,13 +161,91 @@ declare class ClientStack extends Stack {
92
161
  */
93
162
  authSession?: AuthSessionProof;
94
163
  private cryptoEngineDisabled;
164
+ /**
165
+ * Application patches held back because they write encrypted attributes and the stack
166
+ * has no key yet. Replayed by {@link unlock}. Kept in memory on purpose - reopening
167
+ * the stack runs the same options through the same check.
168
+ */
169
+ private deferredPatches;
95
170
  private constructor();
96
171
  private initialize;
97
172
  /**
98
- * Returns the underlying PouchDB database instance.
99
- * @returns The PouchDB database
173
+ * Returns the stack's PouchDB database handle.
174
+ *
175
+ * The handle is guarded: reads and ordinary writes behave exactly as PouchDB
176
+ * documents them, and `put`/`post`/`remove`/`bulkDocs` all run the stack's
177
+ * authoring path. The two routes that would skip it - `bulkDocs` with
178
+ * `new_edits: false`, and the `_`-prefixed adapter methods - throw
179
+ * {@link StackWriteGuardError}; replicating into a stack goes through
180
+ * {@link sync} instead.
181
+ *
182
+ * @returns The guarded PouchDB database
100
183
  */
101
184
  getDb(): PouchDB.Database<{}>;
185
+ /**
186
+ * Returns the database with PouchDB's own `bulkDocs`/`bulkGet` restored, for the
187
+ * sync layer's exclusive use.
188
+ *
189
+ * Replication needs both halves of the plugin out of the way: it writes documents
190
+ * with revisions it already owns (`new_edits: false`), and it must read documents
191
+ * exactly as they are stored - the plugin's `bulkGet` decrypts on read, which would
192
+ * push plaintext to a remote that is meant to hold ciphertext.
193
+ *
194
+ * @returns A handle suitable for `PouchDB.replicate`/`PouchDB.sync`
195
+ * @internal
196
+ */
197
+ getReplicationHandle(): PouchDB.Database<{}>;
198
+ /**
199
+ * Starts replicating this stack against a remote.
200
+ *
201
+ * DocStack owns the lifecycle - the filter that keeps `~system`, the crypto marker,
202
+ * design documents, locks, sessions and the patch ledger on this device; the schema
203
+ * gate that refuses a remote written by a newer build; the convergence state a UI
204
+ * renders; and cancellation when the stack closes. It owns nothing about the
205
+ * transport: the remote is whatever PouchDB database the caller hands over, so
206
+ * credentials and adapter configuration stay in the application.
207
+ *
208
+ * Calling it again replaces the previous replication.
209
+ *
210
+ * @param options - See {@link StackSyncOptions}.
211
+ * @returns The handle, once replication is running.
212
+ * @throws {SyncSchemaMismatchError} When the remote was last written by a newer schema.
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * const sync = await stack.sync({
217
+ * remote: () => new PouchDB("workspace", { adapter: "googledrive", accessToken }),
218
+ * direction: "both",
219
+ * live: true,
220
+ * retry: true,
221
+ * });
222
+ *
223
+ * sync.addEventListener("status", (event) => {
224
+ * console.log((event as CustomEvent).detail.state);
225
+ * });
226
+ * ```
227
+ */
228
+ sync(options: StackSyncOptions): Promise<StackSyncHandle>;
229
+ /**
230
+ * Returns this stack's replication handle, or `null` if {@link sync} was never called.
231
+ */
232
+ getSyncHandle(): StackSyncHandle | null;
233
+ /**
234
+ * Returns where this stack's replication stands, or `null` if it has none.
235
+ *
236
+ * @example
237
+ * ```typescript
238
+ * const status = stack.getSyncStatus();
239
+ * if (status?.lastConvergedAt) {
240
+ * ui.setLabel(`Synced ${formatAgo(status.lastConvergedAt)}`);
241
+ * }
242
+ * ```
243
+ */
244
+ getSyncStatus(): SyncStatus | null;
245
+ /**
246
+ * Stops this stack's replication. Idempotent; called automatically by {@link close}.
247
+ */
248
+ cancelSync(): void;
102
249
  /**
103
250
  * Retrieves information about the database including document count and update sequence.
104
251
  * @returns Database information object
@@ -119,18 +266,207 @@ declare class ClientStack extends Stack {
119
266
  * Called automatically by {@link authenticate}, but can be set manually for custom auth flows.
120
267
  * @param proof - The authentication session proof containing session and encryption keys
121
268
  */
122
- setAuthSession(proof: AuthSessionProof): void;
269
+ setAuthSession(proof: AuthSessionProof): Promise<void>;
123
270
  /**
124
271
  * Clears the current authentication session and removes the document encryption key.
125
272
  * Call this when a user logs out.
126
273
  */
127
274
  clearAuthSession(): void;
275
+ /**
276
+ * Whether the stack is operating without its document encryption key.
277
+ *
278
+ * A locked stack reads everything that needs no key and refuses writes to classes
279
+ * carrying encrypted attributes, rather than storing them in the clear. Patches that
280
+ * would write encrypted data are deferred until {@link unlock}. Stacks with the crypto
281
+ * engine disabled are never locked - there is no key to be missing.
282
+ *
283
+ * @returns `true` when encryption is enabled but no key is held.
284
+ */
285
+ isLocked(): boolean;
286
+ /**
287
+ * Supplies the document encryption key to a locked stack.
288
+ *
289
+ * The key is checked against the stack's canary before it is accepted, so passing the
290
+ * wrong one throws instead of quietly producing unreadable writes. On the first unlock
291
+ * of a stack that has none, the canary is minted from the key given - which is what
292
+ * makes every later open verifiable.
293
+ *
294
+ * Unlocking resumes any bootstrap deferred while locked, then emits `unlocked`.
295
+ *
296
+ * @param documentKey - The hex-encoded document key, from wherever the application
297
+ * provisions it.
298
+ * @throws If the stack has encryption disabled, or the key does not match the canary.
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * const stack = await ClientStack.create('db-app'); // opens locked
303
+ * await stack.unlock(await myServer.fetchDocumentKey());
304
+ * stack.isLocked(); // false
305
+ * ```
306
+ */
307
+ unlock(documentKey: string): Promise<this>;
308
+ /**
309
+ * Encrypts bootstrap documents that were seeded before this stack had a key.
310
+ *
311
+ * The seed system user is the one document DocStack must write before a key can
312
+ * exist: the first open of a database has no wrapped key to recover one from, and
313
+ * refusing to seed it would leave nothing to authenticate against. It is therefore
314
+ * written in the clear - its password is the published constant `"system"`, so
315
+ * nothing secret is exposed - and repaired here.
316
+ *
317
+ * Rewriting it through the authoring path encrypts its attributes and lets
318
+ * `auto-wrap-document-key` run for the first time, producing the
319
+ * `wrappedDocumentKey` that lets another device recover this same document key.
320
+ * Without this step a stack bootstrapped locked could never authenticate, because
321
+ * the trigger no-ops when no key is held. See ADR-0018.
322
+ */
323
+ private rekeyBootstrapDocuments;
128
324
  /**
129
325
  * Exports all documents from the database.
130
326
  * Useful for debugging or creating backups.
131
327
  * @returns All documents including their content
132
328
  */
133
329
  dump: () => Promise<PouchDB.Core.AllDocsResponse<{}>>;
330
+ /**
331
+ * Lists the content classes and domains this stack holds.
332
+ *
333
+ * "Content" means created by an application: DocStack's own classes are `~`-prefixed
334
+ * and its datamodel documents use the reserved names in `META_CLASSES`.
335
+ *
336
+ * @returns The class names and domain names an export would cover.
337
+ */
338
+ /**
339
+ * Every class name in this stack, DocStack's own included.
340
+ *
341
+ * The fail-open path for a live query: when the classes a query reads cannot be
342
+ * determined from its AST, watching all of them is wasteful but correct, and watching
343
+ * none is silently wrong. Cheap to act on - subscriptions share one database
344
+ * listener, so the cost is a set entry per class rather than a feed. See ADR-0025.
345
+ *
346
+ * For the application's classes alone, use {@link getContentClassNames}.
347
+ *
348
+ * @returns The class names, sorted.
349
+ */
350
+ /**
351
+ * The names of classes whose documents are local to one run.
352
+ *
353
+ * See {@link ClassModel.ephemeral}. Resolved from the stored models rather than a
354
+ * hardcoded list, so an application that declares its own ephemeral class - a cache,
355
+ * a derived view, anything a peer neither needs nor should receive - is covered
356
+ * without DocStack knowing about it.
357
+ *
358
+ * @returns The ephemeral class names, sorted.
359
+ */
360
+ /**
361
+ * Classes declared `simple`, held for synchronous lookup.
362
+ *
363
+ * {@link StackPlugin} needs the answer inside `bulkDocs`, before it decides whether to
364
+ * load the class at all - and loading the class to find out is the very cost the flag
365
+ * exists to avoid. Refreshed when the datamodel is in place and whenever a class model
366
+ * changes. See ADR-0028.
367
+ */
368
+ private simpleClassNames;
369
+ /**
370
+ * Whether a class stores its documents as given.
371
+ *
372
+ * See {@link ClassModel.simple}. Answers `false` for a class it has not heard of,
373
+ * which is the safe direction: an unknown class gets the full authoring path.
374
+ *
375
+ * @param className - The `~class` of a document.
376
+ */
377
+ isSimpleClass: (className: unknown) => boolean;
378
+ /**
379
+ * Re-reads which classes are `simple`.
380
+ *
381
+ * @returns The names, for callers that want them.
382
+ */
383
+ refreshSimpleClasses: () => Promise<string[]>;
384
+ getEphemeralClassNames: () => Promise<string[]>;
385
+ /**
386
+ * Empties the ephemeral classes.
387
+ *
388
+ * Called when the stack opens, which is what "one run" means: contents cover the
389
+ * session that is starting, a logout leaves them alone, and a crash is cleaned up by
390
+ * the next open rather than leaving documents on disk forever.
391
+ *
392
+ * Failures are logged and swallowed. A stack that cannot clear its scratch data should
393
+ * still open.
394
+ *
395
+ * @returns How many documents were removed.
396
+ */
397
+ private purgeEphemeralDocuments;
398
+ getClassNames: () => Promise<string[]>;
399
+ getContentClassNames: () => Promise<{
400
+ classes: string[];
401
+ domains: string[];
402
+ }>;
403
+ /**
404
+ * Exports this stack's application content, and nothing else.
405
+ *
406
+ * Deliberately narrower than {@link dump}, which returns the database verbatim -
407
+ * class models, patches, users, sessions, policies, design documents, and encrypted
408
+ * attributes as unreadable payloads. That is a backup of *this* database. This is the
409
+ * portable one: the documents an application put in, ready for
410
+ * {@link importContent} to place into a stack whose schema its own patches built and
411
+ * whose document key is its own.
412
+ *
413
+ * What it does **not** do:
414
+ *
415
+ * - **It does not bypass encryption.** Documents are read through the decrypting path,
416
+ * so encrypted attributes come out as plaintext. That is what makes the export
417
+ * portable across keys - and what makes the result as sensitive as the data itself.
418
+ * A locked stack cannot decrypt, so the export is refused rather than silently
419
+ * emitting `null` where values should be (see `allowLossyWhenLocked`).
420
+ * - **It does not bypass read policies.** Documents the current session may not read
421
+ * are absent, exactly as they are absent from `findDocuments`.
422
+ * - **It carries no schema, no patches and no system documents.**
423
+ *
424
+ * @param options - Which classes and domains to cover; see {@link ContentExportOptions}.
425
+ * @returns The portable envelope.
426
+ * @throws Error when the stack is locked and an exported class has encrypted
427
+ * attributes, unless `allowLossyWhenLocked` is set.
428
+ *
429
+ * @example
430
+ * ```typescript
431
+ * const payload = await stack.exportContent({ classes: ["Task", "Project"] });
432
+ * download(new Blob([JSON.stringify(payload)], { type: "application/json" }));
433
+ * ```
434
+ */
435
+ exportContent: (options?: ContentExportOptions) => Promise<ContentExport>;
436
+ /**
437
+ * Imports content produced by {@link exportContent} into this stack.
438
+ *
439
+ * The counterpart, and it is not symmetric: an export is a read, an import is a
440
+ * reconciliation. The payload carries data and no schema, so this stack's datamodel
441
+ * decides what is allowed in.
442
+ *
443
+ * - **Reconciled against the datamodel.** Every document's class must already exist
444
+ * here; a missing one is reported rather than invented, because the export carries
445
+ * no schema to create it from. Attributes the target class does not define are
446
+ * dropped by default.
447
+ * - **Written through the authoring path**, so schema validation, relation checks and
448
+ * triggers all run - and so encrypted attributes are **encrypted under this stack's
449
+ * document key**, not the one they were exported from.
450
+ * - **Documents before relations**, because a relation is rejected unless both ends
451
+ * already exist.
452
+ *
453
+ * Not a transaction: a failure part way through leaves what was already written. The
454
+ * report says what landed.
455
+ *
456
+ * @param payload - An envelope from {@link exportContent}.
457
+ * @param options - How to reconcile; see {@link ContentImportOptions}.
458
+ * @returns What was written, skipped, and why.
459
+ * @throws Error when the payload is not a recognised export, or when a `"fail"` option
460
+ * is set and the condition it names occurs.
461
+ *
462
+ * @example
463
+ * ```typescript
464
+ * const report = await stack.importContent(JSON.parse(await file.text()));
465
+ * report.documents.written; // 128
466
+ * report.issues; // [{ docId: "Task-9", kind: "missing-class", ... }]
467
+ * ```
468
+ */
469
+ importContent: (payload: ContentExport, options?: ContentImportOptions) => Promise<ContentImportReport>;
134
470
  private ensureDefaultPolicyForClass;
135
471
  /**
136
472
  * Creates and initializes a new ClientStack instance.
@@ -157,6 +493,34 @@ declare class ClientStack extends Stack {
157
493
  * ```
158
494
  */
159
495
  static create(conn: string, options?: StackOptions): Promise<ClientStack>;
496
+ /**
497
+ * Applies application-supplied patches, holding back any that need a document key.
498
+ *
499
+ * A locked stack must not write encrypted attributes in the clear, so a patch that
500
+ * would do so is kept for {@link unlock} instead. This is a barrier rather than a
501
+ * filter: patches apply in order and a later one may depend on the schema an earlier
502
+ * one installs, so the first deferral stops the run.
503
+ *
504
+ * The held-back patches live on the instance, not in the database - reopening the
505
+ * stack replays the same options through the same check, so there is no persisted
506
+ * state to drift.
507
+ *
508
+ * @param patches - Patches not yet present in this stack.
509
+ */
510
+ private applyConsumerPatches;
511
+ /**
512
+ * Decides whether applying a patch would write an encrypted attribute.
513
+ *
514
+ * The judgement is made immediately before the patch would be applied, against the
515
+ * schema as it stands then, plus any class model the patch carries itself - a patch
516
+ * can introduce an encrypted attribute and write a document using it in one go, as
517
+ * `~sys-0.0.8` does. Everything earlier has already landed, so nothing needs to
518
+ * simulate schema evolution ahead of time.
519
+ *
520
+ * @param patch - The patch about to be applied.
521
+ * @returns `true` if any document in it belongs to a class with encrypted attributes.
522
+ */
523
+ private patchNeedsDocumentKey;
160
524
  /**
161
525
  * Authenticates a user and establishes a session.
162
526
  *
@@ -180,6 +544,43 @@ declare class ClientStack extends Stack {
180
544
  * ```
181
545
  */
182
546
  authenticate(credentials: ClientCredentials): Promise<AuthSessionProof>;
547
+ /**
548
+ * Finishes the work that could not be done while the stack had no document key.
549
+ *
550
+ * Reached from both ways a key arrives - {@link unlock} and {@link authenticate} -
551
+ * because the consequences are the same either way: the canary has to exist for later
552
+ * opens to be verifiable, bootstrap documents seeded in the clear have to be
553
+ * encrypted, and patches held back have to be applied.
554
+ */
555
+ private onDocumentKeyAvailable;
556
+ /**
557
+ * Mints an identifier for a new document.
558
+ *
559
+ * Random, not sequential, and that is the whole point. Ids used to be
560
+ * `${type}-${lastDocId + 1}`, from a counter that only *local* writes advance: a
561
+ * document arriving by replication goes through {@link getReplicationHandle}, which
562
+ * bypasses that path by design, so the counter stood still while ids were consumed.
563
+ * The next local write then minted an id the database already held, PouchDB resolved
564
+ * the two as revisions of one document, and the new one was gone - with no error,
565
+ * because the conflict was swallowed. Two devices did it to each other from their
566
+ * very first document, both starting at `1`.
567
+ *
568
+ * No counter repair fixes that. Feeding replicated documents back into the counter
569
+ * still leaves two offline devices minting the same id, because a sequence derived
570
+ * from local state cannot be unique across devices that have not met. The identifier
571
+ * has to stop being derived from local state at all. See ADR-0023.
572
+ *
573
+ * The class prefix stays, so an id still says what it is.
574
+ *
575
+ * @param type - The class or domain name, used as the prefix.
576
+ * @returns An id of the form `Task-9f2c...`, 96 random bits wide.
577
+ *
578
+ * @example
579
+ * ```typescript
580
+ * stack.generateDocId("Task"); // "Task-3f9a2b7c1d4e5f60a1b2c3d4"
581
+ * ```
582
+ */
583
+ generateDocId(type: string): string;
183
584
  getLastDocId(): Promise<number>;
184
585
  getSystem(): Promise<SystemDoc>;
185
586
  private loadPatches;
@@ -210,10 +611,176 @@ declare class ClientStack extends Stack {
210
611
  onClassLock: (className: string) => PouchDB.Core.Changes<{}>;
211
612
  addClassLock: (className: string) => Promise<boolean>;
212
613
  clearClassLock: (className: string) => Promise<boolean>;
213
- onClassDoc: (className: string) => PouchDB.Core.Changes<{}>;
614
+ /**
615
+ * Opens the shared class-document feed if it is not already running.
616
+ *
617
+ * Dispatch is keyed on the document's `~class`, which is what the per-class filters
618
+ * used to test. A change with no document therefore cannot be routed and is dropped,
619
+ * exactly as the filters dropped it: PouchDB hands a filter only `{_id, _rev,
620
+ * _deleted}` for a hard deletion, so `~class` was already absent. DocStack deletes
621
+ * are soft - the document arrives with `active: false` - so this is not the delete
622
+ * path.
623
+ */
624
+ /** The subscriber-map key for a name in one of the two namespaces. */
625
+ private static subscriberKey;
626
+ /**
627
+ * Resolves which subscribers a change belongs to.
628
+ *
629
+ * A document names its owner in exactly one of two fields - `~class` for a class's
630
+ * documents, `~domain` for a domain's relation documents - so the routing key comes
631
+ * from whichever is present. A change with neither cannot be routed and is dropped,
632
+ * exactly as the per-class filters dropped it: PouchDB hands a filter only
633
+ * `{_id, _rev, _deleted}` for a hard deletion, so no meta field was there either.
634
+ * DocStack deletes are soft - the document arrives with `active: false` - so this is
635
+ * not the delete path.
636
+ */
637
+ private static routingKeyFor;
638
+ private ensureClassDocFeed;
639
+ private addClassDocSubscriber;
640
+ private removeClassDocSubscriber;
641
+ /**
642
+ * Subscribes to changes on the documents of a class, or of a domain.
643
+ *
644
+ * Returns a handle onto {@link classDocFeed} rather than a feed of its own, so the
645
+ * database carries one `destroyed` listener no matter how many are watched.
646
+ * Cancelling releases only this subscriber; the feed stops once the last one goes.
647
+ *
648
+ * Prefer {@link subscribeClassDocs} / {@link subscribeDomainDocs}, which route changes
649
+ * through the decrypting preparation step (ADR-0020). Whichever is used, the handle
650
+ * must be handed to {@link releaseListener} when the watcher is done.
651
+ *
652
+ * @param className - The class or domain whose documents to watch.
653
+ * @param metaKey - Which field names the owner: `~class` (default) for a class's
654
+ * documents, `~domain` for a domain's relation documents. Separate namespaces.
655
+ * @returns A cancellable subscription handle.
656
+ */
657
+ onClassDoc: (className: string, metaKey?: "~class" | "~domain") => ChangesSubscription;
658
+ /**
659
+ * Prepares a document delivered by the changes feed for a listener.
660
+ *
661
+ * The changes feed is the one read path that does not pass through
662
+ * {@link StackPlugin}: decryption lives in the `bulkGet` wrapper, which is what makes
663
+ * `getCards` and `findDocuments` transparent, while `include_docs` hands back exactly
664
+ * what is stored. Every read decrypted except the one that pushed, so a live view
665
+ * received an `EncryptedPayload` object where it had just rendered a string.
666
+ *
667
+ * @param doc - The document from `change.doc`.
668
+ * @param classObj - The class, when known; without it encrypted values are still
669
+ * recognised by shape.
670
+ * @returns A copy safe to hand to a consumer. Never contains an `EncryptedPayload`.
671
+ *
672
+ * @example
673
+ * ```typescript
674
+ * const doc = await stack.prepareChangeDocument(change.doc, classObj);
675
+ * doc.ssn; // plaintext, or null when it cannot be opened
676
+ * ```
677
+ */
678
+ prepareChangeDocument: (doc: Document, classObj?: Class) => Promise<Document>;
679
+ /**
680
+ * Evicts derived caches after documents were written.
681
+ *
682
+ * Called synchronously by StackPlugin after every successful `bulkDocs` batch (which
683
+ * every local write funnels through, `put`/`post`/`remove` and replication included),
684
+ * and again by the shared changes feed for writes made outside this instance -
685
+ * another tab on the same database, most commonly. The write-path call is what makes
686
+ * a policy or schema write visible to the very next read: the changes feed delivers
687
+ * asynchronously, and a cache invalidated only by the feed would serve stale answers
688
+ * in that window.
689
+ *
690
+ * Class-model writes clear the model and snapshot caches wholesale rather than by
691
+ * key - a rename leaves the old name keyed to a model that no longer answers to it,
692
+ * and class writes are rare enough that precision buys nothing.
693
+ *
694
+ * @param docs - The documents just written; omit to invalidate everything.
695
+ */
696
+ invalidateWriteCaches: (docs?: unknown[]) => void;
697
+ /**
698
+ * Whether a database-level `limit` returns the same rows as limiting in memory.
699
+ *
700
+ * `findDocuments` filters per document *after* the query - policy checks drop
701
+ * unreadable documents, and a locked crypto engine drops documents whose visible
702
+ * fields are all encrypted. A limit applied before either would under-fill. The
703
+ * query engine asks this before pushing a SQL LIMIT into the fetch.
704
+ *
705
+ * @param className - The class being queried.
706
+ */
707
+ canApplyQueryLimitEarly: (className: string) => Promise<boolean>;
708
+ /**
709
+ * Creates the stack's standing Mango indexes.
710
+ *
711
+ * Every `findDocuments` selector carries `~class` and `active`, and without an index
712
+ * pouchdb-find answers each one with a full `allDocs` scan - linear in database
713
+ * size, per call. One fixed index serves them all. This replaces the old commented
714
+ * per-query `createIndex` inside `findDocuments`, which built a fresh index for
715
+ * every distinct selector shape and buried the database in design documents - the
716
+ * "breaks find and even db" the comment there warned about.
717
+ *
718
+ * Failure is deliberately non-fatal: an index is an optimization, and pouchdb-find
719
+ * falls back to scanning exactly as before.
720
+ */
721
+ private ensureMangoIndexes;
722
+ /** Registry of on-demand sort indexes; a `_local` doc, so per-device and unreplicated. */
723
+ private static readonly SORT_INDEX_REGISTRY_ID;
724
+ /** Prefix shared by every sort-index design document this stack creates. */
725
+ private static readonly SORT_INDEX_DDOC_PREFIX;
726
+ /** Most sort indexes a stack will maintain; past this, queries sort in memory. */
727
+ private static readonly MAX_SORT_INDEXES;
728
+ /** How stale a sort index may go before {@link cleanupSortIndexes} removes it. */
729
+ private static readonly SORT_INDEX_MAX_IDLE_MS;
730
+ /** Fields whose sort index exists this session; avoids re-running createIndex. */
731
+ private sortIndexSession;
732
+ private readSortIndexRegistry;
733
+ /**
734
+ * Creates (or confirms) a Mango index for sorting by `field`, and records the use.
735
+ *
736
+ * Indexes are made on demand by the query engine when an ORDER BY can ride the
737
+ * database, and every index is a standing cost: a view updated on every write from
738
+ * then on. Three things keep that bounded: a cap ({@link MAX_SORT_INDEXES}) past
739
+ * which this returns `false` and the caller sorts in memory; a usage registry (a
740
+ * `_local` document, per device) stamped on each use; and
741
+ * {@link cleanupSortIndexes}, run at init, dropping indexes idle past
742
+ * {@link SORT_INDEX_MAX_IDLE_MS}. A dropped index is not an error - the next sorted
743
+ * query recreates it.
744
+ *
745
+ * @param field - The document field to index for sorting (under `~class`).
746
+ * @returns `true` when the index exists and may be used for a sorted query.
747
+ */
748
+ ensureSortIndex: (field: string) => Promise<boolean>;
749
+ /**
750
+ * Drops sort indexes that have gone unused.
751
+ *
752
+ * Covers indexes this device created *and* ones that replicated in as design
753
+ * documents from a peer: every `_design/docstack-sort-*` doc is considered, and one
754
+ * with no registry entry is adopted with the current time as first-seen, so it gets
755
+ * a full idle period before removal. Runs automatically at init; callable directly
756
+ * for an immediate sweep.
757
+ *
758
+ * @param options.olderThanMs - Idle threshold; defaults to {@link SORT_INDEX_MAX_IDLE_MS}.
759
+ * @returns Which fields were removed and which kept.
760
+ */
761
+ cleanupSortIndexes: (options?: {
762
+ olderThanMs?: number;
763
+ }) => Promise<{
764
+ removed: string[];
765
+ kept: string[];
766
+ }>;
214
767
  initdb(): Promise<this>;
215
768
  private ensureCryptoConfigDocument;
216
769
  private ensureCryptoMarkerEncryption;
770
+ /**
771
+ * Checks a stack's stored crypto configuration against how it is being opened.
772
+ *
773
+ * The canary is the admission test for keys: `encryptedMarker` holds a value only the
774
+ * stack's own document key can decrypt, so a wrong key is caught here rather than
775
+ * becoming unreadable data later. Note what is *not* checked - whether the stack
776
+ * already holds encrypted documents. A key is admitted on proof, so a second device
777
+ * can open a stack the first one wrote, which is the whole point of the wrapped-key
778
+ * path. See ADR-0018.
779
+ *
780
+ * @param existing - The stored `~crypto-engine-config` document.
781
+ * @throws If the engine flag disagrees with the stored one, or a key is present but
782
+ * fails to decrypt the canary.
783
+ */
217
784
  private validateCryptoConfig;
218
785
  /**
219
786
  * Closes the stack and cleans up all resources.
@@ -245,6 +812,33 @@ declare class ClientStack extends Stack {
245
812
  * @param fresh - If `true`, bypasses the cache and fetches from database
246
813
  * @returns The Domain instance, or `null` if not found
247
814
  */
815
+ /**
816
+ * Reads a class's current stored model without subscribing or caching it.
817
+ *
818
+ * The counterpart to {@link getClass} for code that wants a schema rather than a live
819
+ * view: validation, encryption, and anything else that runs per write or per row.
820
+ * Two properties matter and pull in opposite directions in {@link getClass}:
821
+ *
822
+ * - It is always current. The cache is invalidated by a changes feed, which is
823
+ * asynchronous, so during a burst of schema writes - patch application, most
824
+ * obviously - the cached instance can still be the previous schema. Validating a
825
+ * document against that fails.
826
+ * - It does not subscribe. A Class built by {@link Class.get} watches its documents
827
+ * until closed, so building one per written document leaves live feeds behind and
828
+ * PouchDB eventually warns about the `destroyed` listeners they hold.
829
+ *
830
+ * The returned instance emits no `doc` events and needs no `close()`.
831
+ *
832
+ * @param className - The name or ID of the class.
833
+ * @returns The class, or `null` if there is no model by that name.
834
+ *
835
+ * @example
836
+ * ```typescript
837
+ * const classObj = await stack.getClassSnapshot(doc["~class"]);
838
+ * classObj?.getEncryptedAttributes();
839
+ * ```
840
+ */
841
+ getClassSnapshot: (className: string) => Promise<Class | null>;
248
842
  getDomain: (domainName: string, fresh?: boolean) => Promise<Domain | null>;
249
843
  initIndex(): Promise<void>;
250
844
  /**
@@ -286,10 +880,46 @@ declare class ClientStack extends Stack {
286
880
  */
287
881
  findDocuments: <T extends Document | RelationDocument = Document>(selector: {
288
882
  [key: string]: any;
289
- }, fields?: string[], skip?: number, limit?: number) => Promise<{
883
+ }, fields?: string[], skip?: number, limit?: number, sort?: {
884
+ [field: string]: "asc" | "desc";
885
+ }[]) => Promise<{
290
886
  [key: string]: any;
291
887
  docs: T[];
292
888
  }>;
889
+ /**
890
+ * Runs raw fetched documents through the read pipeline: per-document policy
891
+ * check, decryption, and field visibility. Shared by {@link findDocuments} and
892
+ * {@link findDocumentsIterator} so the two cannot drift.
893
+ */
894
+ private processFoundDocuments;
895
+ /**
896
+ * Reads documents matching a selector as an async stream, in `_id` order.
897
+ *
898
+ * Pages through the database with a keyset cursor on `_id` (which the primary
899
+ * index serves) instead of materializing the full result: peak memory is one
900
+ * batch, and total work across all pages is the same one scan a single big read
901
+ * would do. Each batch goes through the same policy/decryption pipeline as
902
+ * {@link findDocuments}. The cursor advances by the last *fetched* document, not
903
+ * the last *readable* one, so pages thinned out by policy filtering cannot stall
904
+ * the iteration.
905
+ *
906
+ * @param selector - A Mango selector; `active: true` is injected unless present.
907
+ * @param options.fields - Projection; `_id` is always included (the cursor needs it).
908
+ * @param options.batchSize - Documents fetched per page (default 100).
909
+ *
910
+ * @example
911
+ * ```typescript
912
+ * for await (const doc of stack.findDocumentsIterator({ "~class": "Task" })) {
913
+ * render(doc);
914
+ * }
915
+ * ```
916
+ */
917
+ findDocumentsIterator: <T extends Document | RelationDocument = Document>(selector: {
918
+ [key: string]: any;
919
+ }, options?: {
920
+ fields?: string[];
921
+ batchSize?: number;
922
+ }) => AsyncGenerator<T, void, void>;
293
923
  private processReadableDocument;
294
924
  /**
295
925
  * Finds a single document matching a selector.
@@ -336,6 +966,13 @@ declare class ClientStack extends Stack {
336
966
  search?: string;
337
967
  }) => Promise<Domain[]>;
338
968
  incrementLastDocId(): Promise<number>;
969
+ /**
970
+ * Advances the document-id counter by `count` in one database write.
971
+ *
972
+ * Batch creation hands out `count` ids from the in-memory counter and commits them
973
+ * here once, instead of a get+put round-trip per document.
974
+ */
975
+ advanceLastDocId(count: number): Promise<number>;
339
976
  reset(): Promise<this>;
340
977
  destroyDb(): Promise<boolean>;
341
978
  static clear(conn: string): Promise<unknown>;
@@ -489,9 +1126,41 @@ declare class ClientStack extends Stack {
489
1126
  * const { rows } = await stack.query('SELECT * FROM Task WHERE priority = ?', 'high');
490
1127
  * ```
491
1128
  */
1129
+ /**
1130
+ * Replaces `?` placeholder nodes in a parsed AST with the caller's parameter values.
1131
+ *
1132
+ * Values are restricted to plain scalars: an object here could carry Mango
1133
+ * operators of its own and reach the database as part of a pushed-down selector,
1134
+ * changing what the query matches. Structured values belong in the document
1135
+ * model, not in a comparison.
1136
+ */
1137
+ private static bindQueryParams;
492
1138
  query: (sql: string, ...params: any[]) => Promise<{
493
1139
  rows: any;
494
1140
  ast: (SelectAST | UnionAST)[];
495
1141
  }>;
1142
+ /**
1143
+ * Executes a SQL query as an async stream of rows.
1144
+ *
1145
+ * The streaming counterpart to {@link query}: single-table plans without
1146
+ * aggregation, DISTINCT, ORDER BY, or subqueries stream row by row on top of
1147
+ * {@link findDocumentsIterator} - peak memory is one page regardless of result
1148
+ * size, and a LIMIT stops the underlying scan early. More complex plans execute
1149
+ * normally and yield from the materialized result, so the API is uniform.
1150
+ * Row order on the streaming path is `_id` order.
1151
+ *
1152
+ * @param sql - The SQL SELECT statement.
1153
+ * @param params - Values for `?` placeholders.
1154
+ *
1155
+ * @example
1156
+ * ```typescript
1157
+ * for await (const row of stack.queryStream("SELECT t.title FROM Task AS t WHERE t.done = FALSE;")) {
1158
+ * render(row);
1159
+ * }
1160
+ * ```
1161
+ */
1162
+ queryStream: (sql: string, ...params: any[]) => AsyncGenerator<{
1163
+ [column: string]: any;
1164
+ }, void, void>;
496
1165
  }
497
1166
  export default ClientStack;