@docstack/client 0.1.6 → 0.2.0

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.
@@ -3,6 +3,7 @@ import Class from "./class.js";
3
3
  import Domain from './domain.js';
4
4
  import { Trigger } from "./trigger/index.js";
5
5
  import { JobEngine } from "./job-engine/index.js";
6
+ import { JobScheduler } from "./job-engine/scheduler.js";
6
7
  import Attribute from './attribute.js';
7
8
  import { AttributeType, ClientCredentials, StackConfig } from "@docstack/shared";
8
9
  import { DocStackSyncHandle } from './sync/index.js';
@@ -56,6 +57,13 @@ declare class DocStack extends EventTarget {
56
57
  private pendingStacks;
57
58
  /** The handle from the last {@link sync} call. */
58
59
  private syncHandle?;
60
+ /**
61
+ * What the last un-scoped {@link sync} call asked for, kept so a stack added
62
+ * later can be bound to the same replication. `null` when sync was never
63
+ * called, was called with an explicit `stacks` list (a caller who named three
64
+ * databases asked for three), or was cancelled. See ADR-0033.
65
+ */
66
+ private syncOptions;
59
67
  private logger;
60
68
  /**
61
69
  * Splits a {@link StackConfig} into the connection string and the options a stack
@@ -125,6 +133,25 @@ declare class DocStack extends EventTarget {
125
133
  * ```
126
134
  */
127
135
  sync: (options: DocStackSyncOptions) => Promise<DocStackSyncHandle>;
136
+ /**
137
+ * Binds one stack to the live sync, if there is one and it was un-scoped.
138
+ *
139
+ * Failure here must not fail {@link addStack} - the stack itself opened fine -
140
+ * but it must not be silent either, silence being this defect's whole shape:
141
+ * it is logged and dispatched as an `error` event on the sync handle.
142
+ */
143
+ private bindStackToSync;
144
+ /**
145
+ * Which open stacks the current sync covers, and which it does not.
146
+ *
147
+ * An idle stack and an unbound one are opposite problems, and `getStatus()`
148
+ * cannot tell them apart - the unbound one has no key at all. With no sync
149
+ * running, every open stack is unbound.
150
+ */
151
+ getSyncCoverage: () => {
152
+ bound: string[];
153
+ unbound: string[];
154
+ };
128
155
  /**
129
156
  * Returns the handle from the last {@link sync} call, or `null`.
130
157
  */
@@ -272,9 +299,13 @@ declare class DocStack extends EventTarget {
272
299
  *
273
300
  * @module @docstack/client
274
301
  */
275
- export { ClientStack, Trigger, Class, Attribute, Domain, JobEngine };
276
- export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, } from "./sync/index.js";
277
- export type { SyncDirection, SyncState, SyncStatus, StackSyncOptions, DocStackSyncOptions, RemoteResolver, SyncMetaDoc, InternalDocFilterOptions, ClassFilterOptions, } from "./sync/index.js";
302
+ export { ClientStack, Trigger, Class, Attribute, Domain, JobEngine, JobScheduler };
303
+ export { JOB_SCHEDULE_DOC_ID } from "./job-engine/scheduler.js";
304
+ export type { SchedulerOptions, SchedulerHost, JobScheduleState, TickReport, SkipReason, } from "./job-engine/scheduler.js";
305
+ export { parseSchedule, nextOccurrence, MIN_PERIOD_MS } from "./job-engine/schedule.js";
306
+ export type { ParsedSchedule } from "./job-engine/schedule.js";
307
+ export { StackSyncHandle, DocStackSyncHandle, SyncSchemaMismatchError, SYNC_META_DOC_ID, readRemoteSchemaVersion, readRemoteConsumerSchemaVersion, publishSchemaVersion, createReplicationFilter, isInternalDoc, resolveInternalClasses, createClassFilter, hasClassRules, DATA_MODEL_CLASSES, withFilterIdentity, describeFilter, INTERNAL_DOC_IDS, INTERNAL_DOC_ID_PREFIXES, INTERNAL_DOC_CLASSES, OPTIONAL_INTERNAL_DOC_CLASSES, deriveTenantScope, classTenants, } from "./sync/index.js";
308
+ export type { SyncDirection, SyncState, SyncStatus, StackSyncOptions, DocStackSyncOptions, RemoteResolver, SyncMetaDoc, InternalDocFilterOptions, ClassFilterOptions, TenantScope, } from "./sync/index.js";
278
309
  export type { ClassBuildOptions } from "./class.js";
279
310
  export { CONTENT_EXPORT_FORMAT, META_CLASSES, isContentClassName, isContentDocument, isContentRelation, stripTransientFields, } from "./content-transfer.js";
280
311
  export type { ContentExport, ContentExportOptions, ContentImportOptions, ContentImportReport, ContentImportIssue, } from "./content-transfer.js";
@@ -282,6 +313,8 @@ export { SYSTEM_SEEDED_DOC_IDS } from "./datamodel/index.js";
282
313
  export { collectQueryClasses } from "./query-engine/index.js";
283
314
  export { StackWriteGuardError } from "./guarded-db.js";
284
315
  export { StackLockedError } from "../plugins/pouchdb.js";
316
+ export { TransactionEngine, TransactionHandle, TransactionDb, TransactionsDisabledError, TransactionStateError, TransactionValidationError, TransactionConflictError, TransactionUnsupportedDocError, } from "./transaction-engine/index.js";
317
+ export type { TransactionCommitReport, TransactionStatus } from "./transaction-engine/index.js";
285
318
  /**
286
319
  * Key-identity helpers, for applications that re-key a database.
287
320
  *
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The schedule grammar a client can actually honour.
3
+ *
4
+ * `JobModel.schedule` is a string, and the obvious thing to put in it is cron. Cron is
5
+ * not offered here, and the reason is not implementation cost: cron's entire vocabulary
6
+ * is about *naming occurrences* — "02:15 on the 3rd of every month" — and a client
7
+ * cannot promise to be running at an occurrence. It is a closed tab, a suspended app, a
8
+ * sleeping laptop. Accepting the syntax would promise a precision the runtime has no way
9
+ * to keep, and the failure would be silent: the job simply never runs on the 3rd.
10
+ *
11
+ * So the grammar says only what a client can honour, which is a floor rather than a
12
+ * moment: *not more often than this*.
13
+ *
14
+ * | Form | Meaning |
15
+ * | --- | --- |
16
+ * | `@every 30m`, `@every 6h`, `@every 7d` | Fixed interval since the last run. |
17
+ * | `@hourly` | Top of each local hour. |
18
+ * | `@daily` | Local midnight. |
19
+ * | `@daily@09:00` | A local wall-clock time. |
20
+ * | `@weekly` | Monday, local midnight. |
21
+ * | `@weekly@09:00` | Monday, at a local wall-clock time. |
22
+ *
23
+ * Anchored forms are computed against *local* time through `Date`, so they follow the
24
+ * device across daylight-saving changes: `@daily@09:00` stays 09:00 to the person
25
+ * reading the screen, which is the only definition of "nine" that a campaign cares
26
+ * about.
27
+ *
28
+ * @module
29
+ */
30
+ /** Shortest interval `@every` will accept. Below this a client is polling, not scheduling. */
31
+ export declare const MIN_PERIOD_MS = 30000;
32
+ /** A schedule string after parsing. `source` is kept so callers can report what they read. */
33
+ export type ParsedSchedule = {
34
+ kind: "interval";
35
+ source: string;
36
+ periodMs: number;
37
+ } | {
38
+ kind: "hourly";
39
+ source: string;
40
+ } | {
41
+ kind: "daily";
42
+ source: string;
43
+ minutes: number;
44
+ } | {
45
+ kind: "weekly";
46
+ source: string;
47
+ weekday: number;
48
+ minutes: number;
49
+ };
50
+ /**
51
+ * Reads a schedule string, or returns `null` when it is not one.
52
+ *
53
+ * `null` is a value the scheduler acts on rather than an error to throw: a job document
54
+ * carrying a schedule nobody can parse should be skipped and reported, not allowed to
55
+ * take down the tick that would have run the other jobs.
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * parseSchedule("@every 6h"); // { kind: "interval", periodMs: 21600000, ... }
60
+ * parseSchedule("@daily@09:00"); // { kind: "daily", minutes: 540, ... }
61
+ * parseSchedule("0 9 * * *"); // null — cron is not accepted, see the module docblock
62
+ * ```
63
+ */
64
+ export declare const parseSchedule: (schedule: string | null | undefined) => ParsedSchedule | null;
65
+ /**
66
+ * The first moment this schedule comes due strictly after `from`.
67
+ *
68
+ * **The occurrences between the last run and `from` are not returned, and there is no
69
+ * way to ask for them.** That absence is the design: a device that was closed for a
70
+ * fortnight has missed fourteen occurrences of a daily job, and replaying them would run
71
+ * the campaign fourteen times over data that only justifies running it once. A sweep
72
+ * that reads current state does the right thing in a single pass; fourteen sweeps do the
73
+ * same thing plus a stampede.
74
+ *
75
+ * @param schedule - A parsed schedule.
76
+ * @param from - The instant to measure from, normally "now".
77
+ * @returns The next due timestamp, strictly greater than `from`.
78
+ */
79
+ export declare const nextOccurrence: (schedule: ParsedSchedule, from: number) => number;
80
+ /** The longest a schedule may legitimately wait — the ceiling used to detect a bad clock. */
81
+ export declare const periodCeilingMs: (schedule: ParsedSchedule) => number;
82
+ /**
83
+ * Whether a stored `nextRunAt` is too far in the future to have been computed honestly.
84
+ *
85
+ * The device clock belongs to the user: it can be wrong, and it can be set back. A run
86
+ * recorded while the clock read 2031 leaves a `nextRunAt` that would suppress the job for
87
+ * years once the clock is corrected. Anything further out than two periods did not come
88
+ * from this schedule, so the scheduler recomputes it from now rather than honouring it.
89
+ */
90
+ export declare const isImplausible: (nextRunAt: number, schedule: ParsedSchedule, now: number) => boolean;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Running jobs unattended, on a client.
3
+ *
4
+ * {@link JobEngine} executes a job when something asks it to. This decides *when* to
5
+ * ask, on a device that a server-side scheduler's assumptions do not describe:
6
+ *
7
+ * 1. **The app is closed most of the time.** "Daily at 09:00" is missed on most days.
8
+ * 2. **Timers are throttled or frozen.** A background tab gets about one tick a minute;
9
+ * a suspended app gets none, and `setInterval` does not catch up on wake.
10
+ * 3. **There are several instances.** Two devices, or two tabs, run this against
11
+ * replicas of the same `~Job` documents.
12
+ * 4. **A run can vanish mid-flight.** A closed tab kills a `RUNNING` job with no
13
+ * `catch`, no `finally`, and no process left to notice.
14
+ * 5. **The clock is the user's.** It can be wrong, and it can move backwards.
15
+ *
16
+ * Four rules answer those, and each is load-bearing:
17
+ *
18
+ * - **Missed occurrences collapse into one run** ({@link nextOccurrence}). Never a
19
+ * backlog.
20
+ * - **Schedule state is device-local**, in a `_local/` document. `JobModel.nextRunTimestamp`
21
+ * looks like the place for it and is not: an application's `~Job` documents replicate —
22
+ * `DATA_MODEL_CLASSES` keeps them even under an `include` allow-list — so every device
23
+ * would write that field on every run and collide on a document whose `content` field is
24
+ * executable code. A losing revision there does not lose a timestamp, it forks what the
25
+ * job does.
26
+ * - **Duplicate work is answered by the jobs, not by a lock.** Leader election needs a
27
+ * consensus point that two offline replicas do not have. Jobs that run here must write
28
+ * documents whose `_id` is derived from what they are about (`ReviewRequest-<orderId>`),
29
+ * so a second device's sweep collides into one document instead of sending a second
30
+ * email. The scheduler cannot enforce that; it is the price of running campaign logic
31
+ * on clients.
32
+ * - **Only named jobs run unattended** ({@link SchedulerOptions.jobs}). `~Job.content` is
33
+ * JavaScript, it replicates, and {@link Job} hydrates it with `new Function` — which
34
+ * runs with full ambient authority, whatever the docs call it. Until now a human was
35
+ * always behind an execution. An allow-list keeps that true: a job document arriving
36
+ * over sync cannot become code that runs itself.
37
+ *
38
+ * @module
39
+ */
40
+ import type { JobRunModel } from "@docstack/shared";
41
+ /** The `_local/` document holding this device's schedule state. Never replicates. */
42
+ export declare const JOB_SCHEDULE_DOC_ID = "_local/docstack-job-schedule";
43
+ /** What the scheduler needs from a stack. Narrow on purpose, so it can be tested without one. */
44
+ export interface SchedulerHost {
45
+ db: {
46
+ get: (id: string) => Promise<any>;
47
+ put: (doc: any) => Promise<any>;
48
+ bulkDocs: (docs: any[]) => Promise<any>;
49
+ find: (request: any) => Promise<{
50
+ docs: any[];
51
+ }>;
52
+ };
53
+ jobEngine: {
54
+ executeJob: (jobId: string, runtimeArgs?: Record<string, any>, triggerType?: string) => Promise<JobRunModel>;
55
+ };
56
+ }
57
+ /** Per-job bookkeeping, as stored in {@link JOB_SCHEDULE_DOC_ID}. */
58
+ export interface JobScheduleState {
59
+ /** When this job next comes due on this device. */
60
+ nextRunAt: number;
61
+ /** When this device last dispatched it. */
62
+ lastRunAt?: number;
63
+ /** Outcome of that dispatch. */
64
+ lastStatus?: string;
65
+ /** Drives the backoff. Reset by any success. */
66
+ consecutiveFailures: number;
67
+ /** The schedule string this state was computed from; a change to it resets the state. */
68
+ schedule?: string;
69
+ }
70
+ /** Why a due-looking job was not dispatched. Reported rather than thrown. */
71
+ export type SkipReason = "missing" | "disabled" | "no-schedule" | "unparseable-schedule" | "hash-mismatch" | "in-flight" | "not-due";
72
+ export interface TickReport {
73
+ /** The instant the tick was evaluated at. */
74
+ at: number;
75
+ /** Jobs dispatched by this tick. They may still be running when the report is returned. */
76
+ dispatched: string[];
77
+ skipped: {
78
+ jobId: string;
79
+ reason: SkipReason;
80
+ }[];
81
+ /** Abandoned `RUNNING` runs moved to `CANCELED`. */
82
+ sweptRuns: number;
83
+ }
84
+ export interface SchedulerOptions {
85
+ /**
86
+ * Job ids allowed to run unattended.
87
+ *
88
+ * Required, and there is deliberately no "all": see the module docblock. Manual
89
+ * {@link JobEngine.executeJob} is unaffected — it already has a human behind it.
90
+ */
91
+ jobs: string[];
92
+ /**
93
+ * Expected `hash` per job, for jobs whose code must not change under the application.
94
+ *
95
+ * `JobModel.hash` is stored beside the content it certifies, so a peer that writes
96
+ * one writes the other: on its own it detects corruption, not authorship. Pinning the
97
+ * value *in application code* is what makes it mean something.
98
+ */
99
+ pinnedHashes?: Record<string, string>;
100
+ /** Floor between automatic ticks. Default 60s, minimum 5s. */
101
+ intervalMs?: number;
102
+ /** A `RUNNING` run older than this is swept to `CANCELED`. Default 15 minutes. */
103
+ staleRunMs?: number;
104
+ /** First retry delay after a failure; doubles per consecutive failure. Default 5 minutes. */
105
+ backoffBaseMs?: number;
106
+ /** Ceiling for that doubling. Default 6 hours. */
107
+ maxBackoffMs?: number;
108
+ /** Injectable clock, for tests. */
109
+ now?: () => number;
110
+ /** Called with each completed run, successful or not. */
111
+ onRun?: (run: JobRunModel) => void;
112
+ }
113
+ /**
114
+ * Decides when the jobs an application has approved should run, and dispatches them.
115
+ *
116
+ * Mounted at `stack.jobScheduler`, but never started by the stack: what may run
117
+ * unattended is the application's decision.
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * stack.jobScheduler.start({
122
+ * jobs: ["Job-review-campaign", "Job-cross-sell"],
123
+ * pinnedHashes: { "Job-review-campaign": "9f2c…" },
124
+ * });
125
+ *
126
+ * // Wake sources are the application's, because `core/` imports no DOM:
127
+ * document.addEventListener("visibilitychange", () => {
128
+ * if (document.visibilityState === "visible") void stack.jobScheduler.tick();
129
+ * });
130
+ * ```
131
+ */
132
+ export declare class JobScheduler {
133
+ private readonly host;
134
+ private options;
135
+ private timer;
136
+ /** Jobs this device has dispatched and not yet seen finish. */
137
+ private readonly inFlight;
138
+ /** Dispatches still running, so {@link drain} can wait for them. */
139
+ private readonly pending;
140
+ /** Deduplicates concurrent ticks — a wake signal and the interval can land together. */
141
+ private ticking;
142
+ /** Serialises read-modify-write of the `_local/` document. */
143
+ private stateWrites;
144
+ /** Last state read, for {@link status} — reporting must not require a database round-trip. */
145
+ private snapshot;
146
+ constructor(host: SchedulerHost);
147
+ /**
148
+ * Begins scheduling, and evaluates once immediately.
149
+ *
150
+ * The immediate evaluation is the point: a client's most reliable clock signal is
151
+ * "the app just opened", not a timer that was frozen while it was closed.
152
+ */
153
+ start(options: SchedulerOptions): void;
154
+ /**
155
+ * Stops scheduling. Jobs already dispatched keep running — a hydrated `new Function`
156
+ * has no cancellation, and pretending otherwise would leave a `RUNNING` run behind.
157
+ */
158
+ stop(): void;
159
+ /** Whether {@link start} is in effect. */
160
+ get isRunning(): boolean;
161
+ /**
162
+ * Evaluates every allowed job and dispatches those that are due.
163
+ *
164
+ * Idempotent and safe to call from any wake signal. Concurrent calls share one
165
+ * evaluation. It returns once dispatch has *started*: a long job does not hold the
166
+ * tick open, because a tick that waits is a tick that stops the others.
167
+ */
168
+ tick(): Promise<TickReport>;
169
+ /** Resolves when every job this scheduler started has finished. For teardown and tests. */
170
+ drain(): Promise<void>;
171
+ /** What the scheduler believes, without touching the database. */
172
+ status(): {
173
+ running: boolean;
174
+ inFlight: string[];
175
+ jobs: Record<string, JobScheduleState>;
176
+ };
177
+ private runTick;
178
+ /** Runs one job and records what happened. Never throws: a tick outlives its jobs. */
179
+ private dispatch;
180
+ private recordOutcome;
181
+ /**
182
+ * Moves this device's abandoned runs to `CANCELED`.
183
+ *
184
+ * A `~JobRun` only changes status inside `Job.execute`'s `try`/`catch`, so a tab
185
+ * closed mid-run leaves one `RUNNING` for ever — and `hasRunningInstance` then skips
186
+ * that singleton job on this device permanently. Unattended execution turns that from
187
+ * a latent oddity into a job that silently stops working, so the sweep runs before
188
+ * every dispatch. `~JobRun` never replicates, so these are unambiguously *this*
189
+ * device's abandoned runs.
190
+ */
191
+ private sweepAbandonedRuns;
192
+ private readState;
193
+ private writeState;
194
+ /**
195
+ * Read-modify-write against the `_local/` document, serialised.
196
+ *
197
+ * Everything that touches the state document goes through here - tick evaluation
198
+ * and outcome recording alike - so within this instance no write can land between
199
+ * another's read and write. Two *instances* (two tabs) still contend; there the 409
200
+ * in {@link writeState} stands, logged, and the jobs' own idempotency is the answer,
201
+ * as the module docblock requires of them.
202
+ */
203
+ private withState;
204
+ private track;
205
+ }
206
+ export default JobScheduler;
@@ -6,8 +6,10 @@ import { StackSyncHandle } from "./sync/index.js";
6
6
  import type { StackSyncOptions, SyncStatus } from "./sync/index.js";
7
7
  import type { SelectAST, UnionAST } from "./query-engine/index.js";
8
8
  import { JobEngine } from "./job-engine/index.js";
9
+ import { JobScheduler } from "./job-engine/scheduler.js";
9
10
  import { PolicyEngine } from "./policy-engine/index.js";
10
11
  import { CryptoEngine } from "./crypto-engine/index.js";
12
+ import { TransactionEngine, TransactionHandle, TransactionStage, TransactionCommitReport } from "./transaction-engine/index.js";
11
13
  import type { ContentExport, ContentExportOptions, ContentImportOptions, ContentImportReport } from "./content-transfer.js";
12
14
  export declare const BASE_SCHEMA: ClassModel["schema"];
13
15
  export declare const CLASS_SCHEMA: ClassModel["schema"];
@@ -144,6 +146,19 @@ declare class ClientStack extends Stack {
144
146
  * ```
145
147
  */
146
148
  jobEngine: JobEngine;
149
+ /**
150
+ * Decides when approved jobs run, and dispatches them.
151
+ *
152
+ * Constructed with the stack but deliberately **not started by it**: `~Job.content`
153
+ * is JavaScript that replicates, so which jobs may run with nobody watching is the
154
+ * application's decision, named at {@link JobScheduler.start}.
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * stack.jobScheduler.start({ jobs: ['Job-review-campaign'] });
159
+ * ```
160
+ */
161
+ jobScheduler: JobScheduler;
147
162
  /**
148
163
  * Engine for enforcing read/write access control policies.
149
164
  * Policies are evaluated based on user session and document content.
@@ -154,6 +169,11 @@ declare class ClientStack extends Stack {
154
169
  * Handles key derivation (PBKDF2) and AES-GCM encryption.
155
170
  */
156
171
  cryptoEngine: CryptoEngine;
172
+ /**
173
+ * Named write transactions (ADR-0039). Dormant unless the stack was opened with
174
+ * `transactions: true`; see {@link beginTransaction}.
175
+ */
176
+ transactionEngine: TransactionEngine;
157
177
  schemaVersion: string | undefined;
158
178
  /**
159
179
  * The current authenticated user session, if any.
@@ -507,7 +527,64 @@ declare class ClientStack extends Stack {
507
527
  *
508
528
  * @param patches - Patches not yet present in this stack.
509
529
  */
530
+ /**
531
+ * The highest consumer patch version this device has applied, from the patch
532
+ * ledger - `null` when no consumer patch has ever applied (or they are all
533
+ * deferred, which for the schema gate is the same thing: the schema those
534
+ * patches install is not here yet). The sync layer folds this into what it
535
+ * publishes and compares, so consumer-schema skew between devices refuses at
536
+ * the gate instead of pulling documents this device's schema cannot describe
537
+ * (ADR-0040).
538
+ */
539
+ getConsumerSchemaVersion(): Promise<string | null>;
510
540
  private applyConsumerPatches;
541
+ /**
542
+ * The stack a patch job executes against (ADR-0044): reads see the chain
543
+ * transaction's overlay, writes stage into it - so a migration's data
544
+ * transformation lands in the same commit as the model it prepares, or not at
545
+ * all. While the stack is locked, class-aware reads of an encrypting class
546
+ * THROW instead of serving the null convention: a migration wants the refusal
547
+ * (a `requiresKey: false` job that was declared wrongly must fail loudly, and
548
+ * the chain converts that failure to a deferral). Raw reads (`db.find`) bypass
549
+ * the class-aware path by design and stay the author's responsibility.
550
+ */
551
+ private createPatchJobStack;
552
+ /**
553
+ * Runs one of a patch's one-shot jobs against the transaction facade. The run
554
+ * receipt is a `~JobRun` with NO `jobId` - patch jobs are deliberately never
555
+ * persisted as `~Job` documents, so there is no row to point at (`~sys-0.0.17`
556
+ * made the foreign key optional for exactly this) - carrying the patch
557
+ * identity in `runtimeArgs`. It writes DIRECTLY, win or lose: a failed
558
+ * migration's receipt is the troubleshooting trail and must survive the
559
+ * discard that protects everything else (ADR-0044).
560
+ */
561
+ private runPatchJob;
562
+ /**
563
+ * The ADR-0042 protocol: the whole pending chain stages through one internal
564
+ * transaction - patch N+1 hydrates against the classes patch N staged, so the
565
+ * ADR-0038 merge composes in memory before anything is real - propagation is
566
+ * validated dry with nothing kept, and one commit lands every staged doc (class
567
+ * models and data documents alike, since the mixed extension) as one batch
568
+ * through the unchanged pipeline, where real propagation runs. The ledger
569
+ * (ADR-0041) arms only after that commit; any refusal beforehand persists
570
+ * nothing and names the patch at fault.
571
+ */
572
+ private applyConsumerPatchChain;
573
+ /**
574
+ * Stages one patch's documents into the chain transaction. Hydration reads
575
+ * through the transaction's overlay, so an `_rev: "auto"` document merges onto
576
+ * what an earlier patch staged - or onto committed state when the chain has not
577
+ * touched it.
578
+ */
579
+ private stagePatch;
580
+ /**
581
+ * ADR-0042 §3 - propagation, validated dry: for every class the chain staged
582
+ * over a committed predecessor, run the schema delta across the class's
583
+ * committed documents and keep nothing. The point is the refusal - a document
584
+ * that cannot satisfy the new model fails here, before the first write, naming
585
+ * the patch, the document and the attribute.
586
+ */
587
+ private validateChainPropagation;
511
588
  /**
512
589
  * Decides whether applying a patch would write an encrypted attribute.
513
590
  *
@@ -518,6 +595,9 @@ declare class ClientStack extends Stack {
518
595
  * simulate schema evolution ahead of time.
519
596
  *
520
597
  * @param patch - The patch about to be applied.
598
+ * @param stagedSchema - Chain staging only (ADR-0042): resolves a class the
599
+ * current transaction already staged, so patch N+1 defers exactly as it would
600
+ * have when patch N had committed.
521
601
  * @returns `true` if any document in it belongs to a class with encrypted attributes.
522
602
  */
523
603
  private patchNeedsDocumentKey;
@@ -584,6 +664,34 @@ declare class ClientStack extends Stack {
584
664
  getLastDocId(): Promise<number>;
585
665
  getSystem(): Promise<SystemDoc>;
586
666
  private loadPatches;
667
+ /**
668
+ * Finds a patch's ledger entry, raw - ledger documents are read outside the
669
+ * `active: true` visibility convention because `active` carries the ledger's own
670
+ * meaning here: `true` is applied, `false` is deferred, absent is a legacy entry
671
+ * from before the flag (treated as applied). See ADR-0041.
672
+ */
673
+ private findPatchLedgerEntry;
674
+ /**
675
+ * Records a successful application: the ledger entry arms with `active: true` -
676
+ * flipping the deferral entry in place when one exists, so a replayed patch does
677
+ * not duplicate its record.
678
+ */
679
+ private recordPatchApplication;
680
+ /**
681
+ * Records a deferral: the patch is known but dormant (`active: false`), waiting
682
+ * on the document key. The entry is what makes a deferred device honest at the
683
+ * sync gate - {@link getConsumerSchemaVersion} does not count it.
684
+ */
685
+ private recordPatchDeferral;
686
+ /**
687
+ * The ADR-0038 half of patch hydration: `schema` does not ride the shallow
688
+ * merge, which would replace it wholesale - it merges attribute by attribute.
689
+ * A patch states only the attributes it changes, an absent attribute stays as
690
+ * stored, and an explicit `null` entry drops the attribute - from the model
691
+ * here, and from the documents when the write propagates. Shared by
692
+ * {@link applyPatch} and the chain staging of ADR-0042 so the two cannot drift.
693
+ */
694
+ private static mergePatchSchema;
587
695
  applyPatch: (patch: Patch) => Promise<string>;
588
696
  private applyPatches;
589
697
  checkSystem(): Promise<void>;
@@ -787,6 +895,27 @@ declare class ClientStack extends Stack {
787
895
  * Removes event listeners and terminates background workers.
788
896
  */
789
897
  close: () => void;
898
+ /**
899
+ * Opens a named write transaction (ADR-0039). Requires the stack to have been
900
+ * opened with `transactions: true`.
901
+ *
902
+ * Writes through the handle validate at the call site and stage in memory;
903
+ * reads through it see the staged state overlaid on committed state. Nothing
904
+ * reaches the database - or replication, or any other reader - until
905
+ * {@link commit}. `stack.db` stays live and unchanged next to open transactions:
906
+ * direct writes land immediately, and only touch a transaction by making its
907
+ * commit refuse when they advance a staged document's revision.
908
+ */
909
+ beginTransaction: () => TransactionHandle;
910
+ /**
911
+ * Flushes a transaction's staged writes as one batch through the authoring
912
+ * pipeline. On refusal - validation, or a document changed underneath - nothing
913
+ * is persisted and the transaction stays open. The report says what landed and
914
+ * on what storage guarantee (`adapter.atomicBatch`).
915
+ */
916
+ commit: (t: TransactionHandle | string) => Promise<TransactionCommitReport>;
917
+ /** Drops a transaction's staged writes. Idempotent. */
918
+ discardTransaction: (t: TransactionHandle | string) => void;
790
919
  /**
791
920
  * Retrieves a Class instance by name.
792
921
  * Results are cached for 15 minutes to improve performance.
@@ -886,6 +1015,26 @@ declare class ClientStack extends Stack {
886
1015
  [key: string]: any;
887
1016
  docs: T[];
888
1017
  }>;
1018
+ /**
1019
+ * {@link findDocuments} with an optional transaction stage overlaid - the shared
1020
+ * implementation, so a transaction's reads and ordinary reads run the identical
1021
+ * pipeline (policy, decryption, field visibility) and cannot drift (ADR-0039).
1022
+ *
1023
+ * With a stage: the database's index cannot see staged documents, so the
1024
+ * committed query runs unwindowed, staged ids mask their committed rows, staged
1025
+ * matches join the set, and sort/skip/limit apply after the merge. A selector
1026
+ * over a class the stage never touched skips all of that.
1027
+ *
1028
+ * @internal
1029
+ */
1030
+ findDocumentsForView: <T extends Document | RelationDocument = Document>(stage: TransactionStage | undefined, selector: {
1031
+ [key: string]: any;
1032
+ }, fields?: string[], skip?: number, limit?: number, sort?: {
1033
+ [field: string]: "asc" | "desc";
1034
+ }[]) => Promise<{
1035
+ [key: string]: any;
1036
+ docs: T[];
1037
+ }>;
889
1038
  /**
890
1039
  * Runs raw fetched documents through the read pipeline: per-document policy
891
1040
  * check, decryption, and field visibility. Shared by {@link findDocuments} and
@@ -1139,6 +1288,18 @@ declare class ClientStack extends Stack {
1139
1288
  rows: any;
1140
1289
  ast: (SelectAST | UnionAST)[];
1141
1290
  }>;
1291
+ /**
1292
+ * {@link query}'s implementation, with the executor's data source as a
1293
+ * parameter: the executor reaches documents only through stack APIs, so a
1294
+ * transaction hands in a facade that routes them at its overlay while everything
1295
+ * else - parsing, binding, planning - stays exactly this code (ADR-0039).
1296
+ *
1297
+ * @internal
1298
+ */
1299
+ runQuery: (sql: string, params: any[], execStack: ClientStack) => Promise<{
1300
+ rows: any;
1301
+ ast: (SelectAST | UnionAST)[];
1302
+ }>;
1142
1303
  /**
1143
1304
  * Executes a SQL query as an async stream of rows.
1144
1305
  *