@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.
- package/LICENSE +10 -0
- package/README.md +1 -1
- package/lib/core/attribute.d.ts +11 -2
- package/lib/core/class.d.ts +29 -6
- package/lib/core/content-transfer.d.ts +178 -0
- package/lib/core/crypto-engine/index.d.ts +82 -1
- package/lib/core/crypto-engine/utils.d.ts +36 -1
- package/lib/core/datamodel/index.d.ts +16 -0
- package/lib/core/domain.d.ts +1 -1
- package/lib/core/guarded-db.d.ts +53 -0
- package/lib/core/index.d.ts +138 -2
- package/lib/core/job-engine/schedule.d.ts +90 -0
- package/lib/core/job-engine/scheduler.d.ts +206 -0
- package/lib/core/policy-engine/index.d.ts +35 -0
- package/lib/core/query-engine/classes.d.ts +24 -0
- package/lib/core/query-engine/executor.d.ts +11 -0
- package/lib/core/query-engine/index.d.ts +2 -1
- package/lib/core/query-engine/planner.d.ts +19 -0
- package/lib/core/stack.d.ts +690 -7
- package/lib/core/sync/class-filter.d.ts +106 -0
- package/lib/core/sync/filter-identity.d.ts +53 -0
- package/lib/core/sync/index.d.ts +352 -0
- package/lib/core/sync/internal-docs.d.ts +159 -0
- package/lib/core/sync/tenants.d.ts +67 -0
- package/lib/index.d.ts +38 -1
- package/lib/index.js +5865 -664
- package/lib/index.umd.js +5903 -681
- package/lib/plugins/pouchdb.d.ts +43 -3
- package/lib/utils/logger/index.d.ts +28 -4
- package/lib/utils/logger/transport.d.ts +52 -11
- package/package.json +18 -10
- package/lib/core/attribute.js +0 -406
- package/lib/core/attribute.js.map +0 -1
- package/lib/core/class.js +0 -774
- package/lib/core/class.js.map +0 -1
- package/lib/core/crypto-engine/index.js +0 -229
- package/lib/core/crypto-engine/index.js.map +0 -1
- package/lib/core/crypto-engine/utils.js +0 -88
- package/lib/core/crypto-engine/utils.js.map +0 -1
- package/lib/core/datamodel/index.js +0 -1308
- package/lib/core/datamodel/index.js.map +0 -1
- package/lib/core/domain.js +0 -423
- package/lib/core/domain.js.map +0 -1
- package/lib/core/index.js +0 -532
- package/lib/core/index.js.map +0 -1
- package/lib/core/job-engine/index.js +0 -220
- package/lib/core/job-engine/index.js.map +0 -1
- package/lib/core/policy-engine/index.js +0 -232
- package/lib/core/policy-engine/index.js.map +0 -1
- package/lib/core/query-engine/accumulators.js +0 -258
- package/lib/core/query-engine/accumulators.js.map +0 -1
- package/lib/core/query-engine/evaluator.js +0 -179
- package/lib/core/query-engine/evaluator.js.map +0 -1
- package/lib/core/query-engine/executor.js +0 -405
- package/lib/core/query-engine/executor.js.map +0 -1
- package/lib/core/query-engine/index.js +0 -4
- package/lib/core/query-engine/index.js.map +0 -1
- package/lib/core/query-engine/parser.js +0 -515
- package/lib/core/query-engine/parser.js.map +0 -1
- package/lib/core/query-engine/planner.js +0 -330
- package/lib/core/query-engine/planner.js.map +0 -1
- package/lib/core/stack.js +0 -1827
- package/lib/core/stack.js.map +0 -1
- package/lib/core/test-utils/docstack.js +0 -222
- package/lib/core/test-utils/docstack.js.map +0 -1
- package/lib/core/trigger/index.js +0 -81
- package/lib/core/trigger/index.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/plugins/pouchdb.js +0 -412
- package/lib/plugins/pouchdb.js.map +0 -1
- package/lib/utils/crypto/index.js +0 -34
- package/lib/utils/crypto/index.js.map +0 -1
- package/lib/utils/index.js +0 -58
- package/lib/utils/index.js.map +0 -1
- package/lib/utils/logger/index.js +0 -20
- package/lib/utils/logger/index.js.map +0 -1
- package/lib/utils/logger/transport.js +0 -28
- package/lib/utils/logger/transport.js.map +0 -1
- package/lib/workers/dataModel.js +0 -48
- package/lib/workers/dataModel.js.map +0 -1
package/lib/core/index.d.ts
CHANGED
|
@@ -3,8 +3,11 @@ 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";
|
|
9
|
+
import { DocStackSyncHandle } from './sync/index.js';
|
|
10
|
+
import type { DocStackSyncOptions } from './sync/index.js';
|
|
8
11
|
/**
|
|
9
12
|
* The main entry point for the DocStack client library.
|
|
10
13
|
*
|
|
@@ -43,8 +46,120 @@ declare class DocStack extends EventTarget {
|
|
|
43
46
|
private store;
|
|
44
47
|
/** Array of all initialized ClientStack instances. */
|
|
45
48
|
stacks: ClientStack[];
|
|
49
|
+
/**
|
|
50
|
+
* Stacks currently being opened, by name.
|
|
51
|
+
*
|
|
52
|
+
* `stacks` only gains an entry once `ClientStack.create` resolves, so it cannot answer
|
|
53
|
+
* "is this one already on its way?". Without that, {@link addStack}'s guard is a check
|
|
54
|
+
* followed by an `await` - not atomic - and two concurrent callers each open a handle
|
|
55
|
+
* on the same database. See ADR-0022.
|
|
56
|
+
*/
|
|
57
|
+
private pendingStacks;
|
|
58
|
+
/** The handle from the last {@link sync} call. */
|
|
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;
|
|
46
67
|
private logger;
|
|
47
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Splits a {@link StackConfig} into the connection string and the options a stack
|
|
70
|
+
* is created with.
|
|
71
|
+
*
|
|
72
|
+
* Everything the caller passed is forwarded - including `adapter` and any
|
|
73
|
+
* adapter-specific keys - so a stack can be opened on a transport other than the
|
|
74
|
+
* default one.
|
|
75
|
+
*
|
|
76
|
+
* @param config - A stack name, or a full configuration object.
|
|
77
|
+
* @returns The connection string and the resolved {@link StackOptions}.
|
|
78
|
+
*/
|
|
79
|
+
private static resolveStackConfig;
|
|
80
|
+
/**
|
|
81
|
+
* Opens a stack and adds it to this instance.
|
|
82
|
+
*
|
|
83
|
+
* Public because an application's set of databases is not always known at startup:
|
|
84
|
+
* a workspace joined at runtime needs its own database, and its own replication
|
|
85
|
+
* pair, without tearing down the stacks already open. Adding a stack that is
|
|
86
|
+
* already open returns the existing instance rather than opening a second handle
|
|
87
|
+
* on the same database.
|
|
88
|
+
*
|
|
89
|
+
* Dispatches `stack-added` with the new stack in `detail`.
|
|
90
|
+
*
|
|
91
|
+
* @param config - A stack name, or a full configuration object.
|
|
92
|
+
* @returns The stack, initialized and ready.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* const stack = await docstack.addStack({ name: `ws-${workspace.slug}`, patches });
|
|
97
|
+
* await stack.sync({ remote: () => driveFor(workspace) });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
addStack: (config: StackConfig) => Promise<ClientStack>;
|
|
101
|
+
/**
|
|
102
|
+
* Closes a stack and drops it from this instance.
|
|
103
|
+
*
|
|
104
|
+
* Closing cancels the stack's replication and releases its listeners; the data on
|
|
105
|
+
* disk is left alone unless `destroy` is set, which is what leaving a workspace for
|
|
106
|
+
* good looks like.
|
|
107
|
+
*
|
|
108
|
+
* Dispatches `stack-removed` with the stack's name in `detail`.
|
|
109
|
+
*
|
|
110
|
+
* @param name - The stack name or connection string.
|
|
111
|
+
* @param options - `destroy: true` also deletes the underlying database.
|
|
112
|
+
* @returns `true` if a stack was removed, `false` if there was none by that name.
|
|
113
|
+
*/
|
|
114
|
+
removeStack: (name: string, options?: {
|
|
115
|
+
destroy?: boolean;
|
|
116
|
+
}) => Promise<boolean>;
|
|
117
|
+
/**
|
|
118
|
+
* Starts replication for every open stack against one transport.
|
|
119
|
+
*
|
|
120
|
+
* The `remote` resolver is called once per stack, which is what makes a
|
|
121
|
+
* database-per-workspace application a single call rather than a loop the
|
|
122
|
+
* application has to keep in step with its own stack list.
|
|
123
|
+
*
|
|
124
|
+
* @param options - See {@link DocStackSyncOptions}. `stacks` narrows it to a subset.
|
|
125
|
+
* @returns A handle holding every stack's replication.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* ```typescript
|
|
129
|
+
* const sync = await docstack.sync({
|
|
130
|
+
* remote: (stack) => new PouchDB(stack.name, { adapter: 'googledrive', accessToken }),
|
|
131
|
+
* });
|
|
132
|
+
* sync.addEventListener('status', () => render(sync.getStatus()));
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
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
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* Returns the handle from the last {@link sync} call, or `null`.
|
|
157
|
+
*/
|
|
158
|
+
getSyncHandle: () => DocStackSyncHandle | null;
|
|
159
|
+
/**
|
|
160
|
+
* Stops replication on every stack.
|
|
161
|
+
*/
|
|
162
|
+
cancelSync: () => void;
|
|
48
163
|
private initStacks;
|
|
49
164
|
/**
|
|
50
165
|
* Resets all initialized stacks.
|
|
@@ -184,5 +299,26 @@ declare class DocStack extends EventTarget {
|
|
|
184
299
|
*
|
|
185
300
|
* @module @docstack/client
|
|
186
301
|
*/
|
|
187
|
-
export { ClientStack, Trigger, Class, Attribute, Domain, JobEngine };
|
|
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, 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";
|
|
309
|
+
export type { ClassBuildOptions } from "./class.js";
|
|
310
|
+
export { CONTENT_EXPORT_FORMAT, META_CLASSES, isContentClassName, isContentDocument, isContentRelation, stripTransientFields, } from "./content-transfer.js";
|
|
311
|
+
export type { ContentExport, ContentExportOptions, ContentImportOptions, ContentImportReport, ContentImportIssue, } from "./content-transfer.js";
|
|
312
|
+
export { SYSTEM_SEEDED_DOC_IDS } from "./datamodel/index.js";
|
|
313
|
+
export { collectQueryClasses } from "./query-engine/index.js";
|
|
314
|
+
export { StackWriteGuardError } from "./guarded-db.js";
|
|
315
|
+
export { StackLockedError } from "../plugins/pouchdb.js";
|
|
316
|
+
/**
|
|
317
|
+
* Key-identity helpers, for applications that re-key a database.
|
|
318
|
+
*
|
|
319
|
+
* `deriveKeyId` names a key the same way stored payloads do, so an application can tell
|
|
320
|
+
* which of its keys a field belongs to without holding any of them.
|
|
321
|
+
*/
|
|
322
|
+
export { deriveKeyId, isEncryptedPayload } from "./crypto-engine/index.js";
|
|
323
|
+
export type { EncryptedPayload } from "./crypto-engine/index.js";
|
|
188
324
|
export { DocStack };
|
|
@@ -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;
|
|
@@ -28,11 +28,35 @@ export type PolicyOperation = "read" | "write";
|
|
|
28
28
|
export declare class PolicyEngine {
|
|
29
29
|
/** Reference to the parent stack for database and session access. */
|
|
30
30
|
private readonly stack;
|
|
31
|
+
/**
|
|
32
|
+
* Every `~Policy` document, loaded once and reused across evaluations.
|
|
33
|
+
*
|
|
34
|
+
* `isReadableDocument` runs once per document a read returns, and it used to re-fetch
|
|
35
|
+
* the policy list from the database each time - the dominant cost of every read path.
|
|
36
|
+
* The list is invalidated on any write that touches a `~Policy` document: the write
|
|
37
|
+
* path calls {@link invalidatePolicyCache} synchronously (see StackPlugin), and the
|
|
38
|
+
* stack's shared changes feed calls it again for out-of-band writes such as another
|
|
39
|
+
* tab's. `null` means not loaded.
|
|
40
|
+
*/
|
|
41
|
+
private allPoliciesCache;
|
|
42
|
+
/**
|
|
43
|
+
* Compiled policy rules, keyed by their source text.
|
|
44
|
+
*
|
|
45
|
+
* A rule is evaluated once per policy per document, and `new Function` is a full
|
|
46
|
+
* compile each time. The source text is the key - not the policy id - so two policies
|
|
47
|
+
* sharing a rule share the compilation, and an edited rule is simply a new key.
|
|
48
|
+
*/
|
|
49
|
+
private compiledRules;
|
|
31
50
|
/**
|
|
32
51
|
* Creates a new PolicyEngine instance.
|
|
33
52
|
* @param stack - The parent ClientStack instance
|
|
34
53
|
*/
|
|
35
54
|
constructor(stack: ClientStack);
|
|
55
|
+
/**
|
|
56
|
+
* Drops the cached policy list so the next evaluation re-reads it.
|
|
57
|
+
* Called by the write path and the changes feed whenever a `~Policy` document lands.
|
|
58
|
+
*/
|
|
59
|
+
invalidatePolicyCache(): void;
|
|
36
60
|
/**
|
|
37
61
|
* Gets the current authentication session proof.
|
|
38
62
|
* @returns The session proof, or undefined if not authenticated
|
|
@@ -61,6 +85,17 @@ export declare class PolicyEngine {
|
|
|
61
85
|
private evaluateRule;
|
|
62
86
|
private filterPoliciesForSession;
|
|
63
87
|
private authorize;
|
|
88
|
+
/**
|
|
89
|
+
* Whether any policy applies to a class - i.e. whether reads of it are filtered.
|
|
90
|
+
*
|
|
91
|
+
* Lets the query engine know when a database-level LIMIT is safe: with no
|
|
92
|
+
* applicable policies, no row fetched within the limit can be dropped afterwards.
|
|
93
|
+
* Cheap once the policy list is cached.
|
|
94
|
+
*
|
|
95
|
+
* @param targetClass - The class name or id.
|
|
96
|
+
* @returns `true` if at least one policy targets the class.
|
|
97
|
+
*/
|
|
98
|
+
hasPoliciesFor(targetClass: string): Promise<boolean>;
|
|
64
99
|
/**
|
|
65
100
|
* Ensures write access is allowed for a document of the given class.
|
|
66
101
|
* Throws an error if no policy permits the write operation.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SelectAST, UnionAST } from "@docstack/shared";
|
|
2
|
+
/**
|
|
3
|
+
* Collects the class names a parsed query reads.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately generic: rather than enumerating the node types that can hold a nested
|
|
6
|
+
* query - `scalar_subquery`, `exists_expr`, the right-hand side of `IN` - it walks the
|
|
7
|
+
* whole graph and harvests every `select` node it finds. A new subquery form is then
|
|
8
|
+
* covered on the day it is added, rather than on the day someone notices a stale list.
|
|
9
|
+
*
|
|
10
|
+
* @param ast - The `ast` from {@link ClientStack.query}.
|
|
11
|
+
* @returns The class names read, or `null` when the query reads a source that is not a
|
|
12
|
+
* named class. That distinction is the contract: `[]` means *this query reads nothing* -
|
|
13
|
+
* `SELECT 1` - and it is safe to subscribe to nothing; `null` means *I cannot account for
|
|
14
|
+
* this*, and the caller should fail open rather than watch an incomplete set.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const { rows, ast } = await stack.query("SELECT * FROM Task JOIN Project ON …");
|
|
19
|
+
* collectQueryClasses(ast); // ["Task", "Project"]
|
|
20
|
+
* collectQueryClasses([]); // [] - reads nothing
|
|
21
|
+
* collectQueryClasses(null); // null - unknown, fail open
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export declare const collectQueryClasses: (ast: (SelectAST | UnionAST)[] | null | undefined) => string[] | null;
|
|
@@ -12,3 +12,14 @@ import ClientStack from '../stack.js';
|
|
|
12
12
|
* @throws {Error} If a table specified in the query is not found.
|
|
13
13
|
*/
|
|
14
14
|
export declare function executePlan(stack: ClientStack, plan: any, params: any, outerRow?: any, outerAliases?: any): Promise<any>;
|
|
15
|
+
/**
|
|
16
|
+
* Executes a plan as an async stream of rows.
|
|
17
|
+
*
|
|
18
|
+
* A single-table plan with no aggregation, DISTINCT, ORDER BY, or subqueries streams
|
|
19
|
+
* for real: rows ride `findDocumentsIterator`'s keyset pages, get the WHERE re-check
|
|
20
|
+
* and projection per row, and honor OFFSET/LIMIT by counting - peak memory is one
|
|
21
|
+
* batch regardless of result size, and a LIMIT stops the underlying scan early. Any
|
|
22
|
+
* other plan falls back to `executePlan` and yields from the materialized result, so
|
|
23
|
+
* the API is uniform even where streaming isn't.
|
|
24
|
+
*/
|
|
25
|
+
export declare function executePlanStream(stack: ClientStack, plan: any, params: any): AsyncGenerator<any, void, unknown>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parse } from "./parser.js";
|
|
2
2
|
export type { SelectAST, UnionAST } from "@docstack/shared";
|
|
3
3
|
export { createPlan } from "./planner.js";
|
|
4
|
-
export { executePlan } from "./executor.js";
|
|
4
|
+
export { executePlan, executePlanStream } from "./executor.js";
|
|
5
|
+
export { collectQueryClasses } from "./classes.js";
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a column name is safe to hand to Mango - as a selector key, a sort field,
|
|
3
|
+
* or a projection field. See {@link PUSHABLE_COLUMN_RE} and {@link UNPUSHABLE_COLUMNS}.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isPushableColumnName(name: any): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Checks if a predicate is a simple binary expression that can be converted
|
|
8
|
+
* directly into a Mango selector by `buildSelector`.
|
|
9
|
+
*
|
|
10
|
+
* This is the single authority on pushability - `buildSelector` imports it and refuses
|
|
11
|
+
* predicates that fail it, so the planner and the executor cannot drift apart and a
|
|
12
|
+
* predicate classified as pushed can never be silently dropped.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} predicate The predicate AST node.
|
|
15
|
+
* @returns {boolean} True if the predicate is simple enough for pushdown.
|
|
16
|
+
*/
|
|
17
|
+
export declare function isPushablePredicate(predicate: any): boolean;
|
|
1
18
|
/**
|
|
2
19
|
* Creates a logical query plan from a list of Abstract Syntax Trees (ASTs) for a UNION query.
|
|
3
20
|
* @param {Array<object>} astList The list of ASTs generated by the parser.
|
|
@@ -18,10 +35,12 @@ export declare function createPlan(astList: any): {
|
|
|
18
35
|
aggregation: any;
|
|
19
36
|
orderBy: any;
|
|
20
37
|
limit: any;
|
|
38
|
+
offset: any;
|
|
21
39
|
} | {
|
|
22
40
|
type: string;
|
|
23
41
|
selectPlans: any[];
|
|
24
42
|
unionOps: any[];
|
|
25
43
|
orderBy: any;
|
|
26
44
|
limit: any;
|
|
45
|
+
offset: any;
|
|
27
46
|
};
|