@docstack/client 0.1.5 → 0.1.8

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