@docstack/client 0.1.6 → 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/lib/index.umd.js CHANGED
@@ -3540,7 +3540,69 @@ async function execute(_stack, params) {
3540
3540
  }
3541
3541
  ]
3542
3542
  };
3543
- syspatches.push(sys_011, sys_012, sys_013, sys_014, sys_015);
3543
+ /**
3544
+ * Tenancy declared at the datamodel.
3545
+ *
3546
+ * `tenants` on a class model names the tenant spaces the class belongs to. A tenant is a
3547
+ * stack (ADR-0030): the declaration is what a sync channel's entitlement is compiled
3548
+ * against - which stacks the channel is served at all, and which classes travel over a
3549
+ * stack that holds a mix of declarations. A class with no declaration is tenant-neutral
3550
+ * and follows its stack, so a datamodel that never declares one keeps today's behavior
3551
+ * exactly.
3552
+ */
3553
+ const sys_016 = {
3554
+ "_id": "~sys-0.0.16",
3555
+ "~class": "patch",
3556
+ "version": "0.0.16",
3557
+ "target": "system",
3558
+ "changelog": "### Schema Patch: v0.0.16\\n#### New attribute: class.tenants",
3559
+ "docs": [
3560
+ {
3561
+ "_id": "class",
3562
+ "_rev": "auto",
3563
+ "active": true,
3564
+ "name": "class",
3565
+ "description": "A class document representing a data model class",
3566
+ "~class": "~self",
3567
+ // Patch hydration is a shallow merge: this `schema` REPLACES the stored one,
3568
+ // it does not add to it. So a patch to the `class` document must carry the
3569
+ // full attribute set - dropping `ephemeral`/`simple` here silently strips
3570
+ // those flags from every class model on the next validated write.
3571
+ "schema": {
3572
+ "ephemeral": {
3573
+ "name": "ephemeral",
3574
+ "type": "boolean",
3575
+ "description": "Contents are local to one run: emptied when the stack next opens, and never replicated.",
3576
+ "config": {
3577
+ "mandatory": false,
3578
+ "isArray": false,
3579
+ "defaultValue": false
3580
+ }
3581
+ },
3582
+ "simple": {
3583
+ "name": "simple",
3584
+ "type": "boolean",
3585
+ "description": "Documents are stored as given: no schema, no validation, no triggers, no relations.",
3586
+ "config": {
3587
+ "mandatory": false,
3588
+ "isArray": false,
3589
+ "defaultValue": false
3590
+ }
3591
+ },
3592
+ "tenants": {
3593
+ "name": "tenants",
3594
+ "type": "string",
3595
+ "description": "Tenant spaces this class belongs to; a tenant is a stack. Absent means tenant-neutral.",
3596
+ "config": {
3597
+ "mandatory": false,
3598
+ "isArray": true
3599
+ }
3600
+ }
3601
+ }
3602
+ }
3603
+ ]
3604
+ };
3605
+ syspatches.push(sys_011, sys_012, sys_013, sys_014, sys_015, sys_016);
3544
3606
  /**
3545
3607
  * Every document id the system patches seed.
3546
3608
  *
@@ -3637,7 +3699,7 @@ async function execute(_stack, params) {
3637
3699
  return updatedDoc;
3638
3700
  };
3639
3701
 
3640
- const logger$2 = createLogger().child({ module: "pouchdb" });
3702
+ const logger$3 = createLogger().child({ module: "pouchdb" });
3641
3703
  /**
3642
3704
  * Raised when a locked stack is asked to write a class carrying encrypted attributes.
3643
3705
  *
@@ -3696,12 +3758,17 @@ async function execute(_stack, params) {
3696
3758
  const StackPlugin = (pouch, stack, pristine) => {
3697
3759
  const pouchBulkDocs = pristine.bulkDocs;
3698
3760
  const pouchBulkGet = pristine.bulkGet;
3761
+ // Captured from the pristine instance like the two above, and for the same
3762
+ // ADR-0019 reason - `pouch.prototype.get` is not a reliable source under the
3763
+ // plugin-loading shim (it resolves undefined in the UMD build), which is exactly
3764
+ // what got the decrypting `get` override commented out instead of recaptured.
3765
+ const pouchGet = pristine.get;
3699
3766
  return {
3700
3767
  ping: () => {
3701
3768
  return Promise.resolve("pong");
3702
3769
  },
3703
3770
  bulkDocs: async function (docs, options, callback) {
3704
- const fnLogger = logger$2.child({ method: "bulkDocs" });
3771
+ const fnLogger = logger$3.child({ method: "bulkDocs" });
3705
3772
  if (typeof options == 'function') {
3706
3773
  callback = options;
3707
3774
  options = {};
@@ -4121,32 +4188,57 @@ async function execute(_stack, params) {
4121
4188
  }
4122
4189
  return exec();
4123
4190
  },
4124
- /*
4125
- get: async function (docId, options?: PouchDB.Core.GetOptions | null, callback?) {
4191
+ // Single-document reads decrypt, symmetrically with `bulkGet`, `find` and the
4192
+ // query engine: ADR-0020 keeps ciphertext on the *changes feed*, never on
4193
+ // reads. This override spent a while commented out - disabled by a comment
4194
+ // that rode an unrelated refactor commit, with no decision recorded - which
4195
+ // left `stack.getDocument` returning ciphertext while every other read path
4196
+ // decrypted. Re-enabled with a cheaper precheck: the cached class model
4197
+ // answers "does this class encrypt anything?" first, so a class with nothing
4198
+ // encrypted pays a cache lookup here rather than a class snapshot per get.
4199
+ // See ADR-0032.
4200
+ get: async function (docId, options, callback) {
4126
4201
  if (typeof options === "function") {
4127
4202
  callback = options;
4128
4203
  options = undefined;
4129
4204
  }
4130
-
4131
4205
  const exec = async () => {
4132
- const result = await pouchGet.call(this, docId, options ?? {});
4133
- if (result && isDocument(result) && stack.cryptoEngine.isEnabled()
4206
+ var _a, _b;
4207
+ const result = await pouchGet.call(this, docId, options !== null && options !== void 0 ? options : {});
4208
+ // Optional-chained on purpose: this override serves `initialize` itself
4209
+ // (`checkSystem` reads `~system` through it), which runs before the
4210
+ // crypto engine is constructed. And gated on the *key*, not just the
4211
+ // engine: without a document key `decryptDocument` can do nothing, so a
4212
+ // keyless stack - initialization, a locked stack, every stack that never
4213
+ // encrypts - skips the branch and pays nothing per get. Only a keyed
4214
+ // stack reading an encrypted class pays the class lookup and decrypt.
4215
+ if (result && isDocument(result) && ((_a = stack.cryptoEngine) === null || _a === void 0 ? void 0 : _a.isEnabled())
4216
+ && stack.cryptoEngine.getDocumentKey()
4134
4217
  && !stack.isSimpleClass(result["~class"])) {
4135
- const classObj = await stack.getClassSnapshot(result["~class"]).catch(() => null);
4136
- if (classObj && classObj.getEncryptedAttributes().length) {
4137
- await stack.cryptoEngine.decryptDocument(result as Document, classObj);
4218
+ const model = await stack.getClassModel(result["~class"]).catch(() => null);
4219
+ const encrypts = model && Object.values((_b = model.schema) !== null && _b !== void 0 ? _b : {})
4220
+ .some((attribute) => { var _a; return ((_a = attribute === null || attribute === void 0 ? void 0 : attribute.config) === null || _a === void 0 ? void 0 : _a.encrypted) === true; });
4221
+ if (encrypts) {
4222
+ const classObj = await stack.getClassSnapshot(result["~class"]).catch(() => null);
4223
+ if (classObj && classObj.getEncryptedAttributes().length) {
4224
+ await stack.cryptoEngine.decryptDocument(result, classObj);
4225
+ }
4138
4226
  }
4139
4227
  }
4140
4228
  return result;
4141
4229
  };
4142
-
4143
4230
  if (callback) {
4144
4231
  exec().then((res) => callback(null, res)).catch((err) => callback(err, undefined));
4145
4232
  return;
4146
4233
  }
4147
4234
  return exec();
4148
4235
  },
4149
-
4236
+ /*
4237
+ Deliberately retired, not lost: pouchdb-core routes `put` through `bulkDocs`,
4238
+ and the active `bulkDocs` above already owns encryption - a put override that
4239
+ encrypted too would encrypt twice. Kept for the record beside the `get`
4240
+ override's history. See ADR-0032.
4241
+
4150
4242
  put: async function (doc, options?: PouchDB.Core.PutOptions | null, callback?) {
4151
4243
  if (typeof options === "function") {
4152
4244
  callback = options;
@@ -4327,12 +4419,18 @@ async function execute(_stack, params) {
4327
4419
  const createReplicationDb = (db, pristine) => {
4328
4420
  const bulkDocs = pristine.bulkDocs.bind(db);
4329
4421
  const bulkGet = pristine.bulkGet.bind(db);
4422
+ // `get` restored for the same reason as `bulkGet`: since ADR-0032 the plugin
4423
+ // decrypts single-document reads too, and this handle's whole contract is reading
4424
+ // documents exactly as they are stored.
4425
+ const get = pristine.get.bind(db);
4330
4426
  return new Proxy(db, {
4331
4427
  get(target, property) {
4332
4428
  if (property === "bulkDocs")
4333
4429
  return bulkDocs;
4334
4430
  if (property === "bulkGet")
4335
4431
  return bulkGet;
4432
+ if (property === "get")
4433
+ return get;
4336
4434
  return forward(target, property);
4337
4435
  },
4338
4436
  });
@@ -4765,7 +4863,76 @@ async function execute(_stack, params) {
4765
4863
  }));
4766
4864
  };
4767
4865
 
4768
- const logger$1 = createLogger().child({ module: "sync" });
4866
+ /**
4867
+ * Normalizes a class model's tenant declaration.
4868
+ *
4869
+ * @param model - Any object carrying (or omitting) a `tenants` declaration.
4870
+ * @returns The declared tenant names; empty for a tenant-neutral class.
4871
+ */
4872
+ const classTenants = (model) => {
4873
+ const declared = model === null || model === void 0 ? void 0 : model.tenants;
4874
+ if (!Array.isArray(declared))
4875
+ return [];
4876
+ return declared.filter((t) => typeof t === "string" && t.length > 0);
4877
+ };
4878
+ /**
4879
+ * Compiles an entitlement into the stacks it reaches and the class rules it needs.
4880
+ *
4881
+ * A stack is served when its name is an entitled tenant - a tenant *is* a stack - or
4882
+ * when it holds at least one class declared for an entitled tenant. Within a served
4883
+ * stack:
4884
+ *
4885
+ * - classes declared for an entitled tenant travel;
4886
+ * - tenant-neutral classes follow their stack: they travel when the stack itself is the
4887
+ * entitled tenant, which is what keeps a datamodel with no declarations at today's
4888
+ * behavior exactly;
4889
+ * - classes declared only for other tenants are excluded.
4890
+ *
4891
+ * Resolved once per call, the way the sync layer resolves ephemeral classes when a
4892
+ * replication starts (ADR-0028): a declaration that changes later takes effect on the
4893
+ * next `sync()`, never silently mid-stream.
4894
+ *
4895
+ * @param stacks - The candidate stacks, typically every open stack.
4896
+ * @param entitlement - The tenant names this channel may see.
4897
+ * @returns Which stacks to serve, and per-stack class rules where needed.
4898
+ */
4899
+ const deriveTenantScope = async (stacks, entitlement) => {
4900
+ const entitled = new Set(entitlement.filter(t => typeof t === "string" && t.length > 0));
4901
+ const scope = { stacks: [], classes: {} };
4902
+ for (const stack of stacks) {
4903
+ const { list } = await stack.getClassModels();
4904
+ const admitted = [];
4905
+ const foreign = [];
4906
+ for (const model of list) {
4907
+ const declared = classTenants(model);
4908
+ if (!declared.length)
4909
+ continue;
4910
+ if (declared.some(t => entitled.has(t)))
4911
+ admitted.push(model.name);
4912
+ else
4913
+ foreign.push(model.name);
4914
+ }
4915
+ const stackIsTenant = entitled.has(stack.name);
4916
+ if (!stackIsTenant && !admitted.length)
4917
+ continue;
4918
+ scope.stacks.push(stack.name);
4919
+ if (stackIsTenant) {
4920
+ // The stack is itself an entitled tenant: everything follows it except
4921
+ // classes spoken for by tenants outside the entitlement.
4922
+ if (foreign.length)
4923
+ scope.classes[stack.name] = { exclude: foreign.sort() };
4924
+ }
4925
+ else {
4926
+ // Served only because entitled classes live here: nothing else travels.
4927
+ // `include` keeps the data-model classes automatically (class-filter.ts),
4928
+ // so the receiving end stays a readable replica.
4929
+ scope.classes[stack.name] = { include: admitted.sort() };
4930
+ }
4931
+ }
4932
+ return scope;
4933
+ };
4934
+
4935
+ const logger$2 = createLogger().child({ module: "sync" });
4769
4936
  /**
4770
4937
  * Raised when a remote is ahead of this device's data model.
4771
4938
  *
@@ -4930,7 +5097,7 @@ async function execute(_stack, params) {
4930
5097
  * @throws {SyncSchemaMismatchError} When the remote is ahead of this device.
4931
5098
  */
4932
5099
  async start() {
4933
- const fnLogger = logger$1.child({ method: "start", stack: this.stack.name });
5100
+ const fnLogger = logger$2.child({ method: "start", stack: this.stack.name });
4934
5101
  this.cancelled = false;
4935
5102
  this.setState("starting");
4936
5103
  try {
@@ -4969,7 +5136,7 @@ async function execute(_stack, params) {
4969
5136
  this.replication.cancel();
4970
5137
  }
4971
5138
  catch (error) {
4972
- logger$1.warn("Error while cancelling replication", { error, stack: this.stack.name });
5139
+ logger$2.warn("Error while cancelling replication", { error, stack: this.stack.name });
4973
5140
  }
4974
5141
  if (typeof this.replication.removeAllListeners === "function") {
4975
5142
  this.replication.removeAllListeners();
@@ -5242,6 +5409,22 @@ async function execute(_stack, params) {
5242
5409
  this.dispatchEvent(new CustomEvent("status", { detail: this.getStatus() }));
5243
5410
  });
5244
5411
  }
5412
+ /** The stacks this handle covers. What is missing from this list is not
5413
+ * replicating - compare against `DocStack.getStacks()`, or use
5414
+ * `DocStack.getSyncCoverage()` which does exactly that. */
5415
+ get names() {
5416
+ return [...this.handles.keys()];
5417
+ }
5418
+ /** @internal - use {@link DocStack.removeStack}. Cancels and drops one stack's
5419
+ * replication; the rest are untouched. */
5420
+ remove(name) {
5421
+ const handle = this.handles.get(name);
5422
+ if (!handle)
5423
+ return false;
5424
+ handle.cancel();
5425
+ this.handles.delete(name);
5426
+ return true;
5427
+ }
5245
5428
  /** Every stack's status, keyed by stack name. */
5246
5429
  getStatus() {
5247
5430
  const status = {};
@@ -7781,6 +7964,549 @@ async function execute(_stack, params) {
7781
7964
  }
7782
7965
  }
7783
7966
 
7967
+ /**
7968
+ * The schedule grammar a client can actually honour.
7969
+ *
7970
+ * `JobModel.schedule` is a string, and the obvious thing to put in it is cron. Cron is
7971
+ * not offered here, and the reason is not implementation cost: cron's entire vocabulary
7972
+ * is about *naming occurrences* — "02:15 on the 3rd of every month" — and a client
7973
+ * cannot promise to be running at an occurrence. It is a closed tab, a suspended app, a
7974
+ * sleeping laptop. Accepting the syntax would promise a precision the runtime has no way
7975
+ * to keep, and the failure would be silent: the job simply never runs on the 3rd.
7976
+ *
7977
+ * So the grammar says only what a client can honour, which is a floor rather than a
7978
+ * moment: *not more often than this*.
7979
+ *
7980
+ * | Form | Meaning |
7981
+ * | --- | --- |
7982
+ * | `@every 30m`, `@every 6h`, `@every 7d` | Fixed interval since the last run. |
7983
+ * | `@hourly` | Top of each local hour. |
7984
+ * | `@daily` | Local midnight. |
7985
+ * | `@daily@09:00` | A local wall-clock time. |
7986
+ * | `@weekly` | Monday, local midnight. |
7987
+ * | `@weekly@09:00` | Monday, at a local wall-clock time. |
7988
+ *
7989
+ * Anchored forms are computed against *local* time through `Date`, so they follow the
7990
+ * device across daylight-saving changes: `@daily@09:00` stays 09:00 to the person
7991
+ * reading the screen, which is the only definition of "nine" that a campaign cares
7992
+ * about.
7993
+ *
7994
+ * @module
7995
+ */
7996
+ /** Milliseconds per unit accepted by `@every`. */
7997
+ const UNIT_MS = {
7998
+ s: 1000,
7999
+ m: 60000,
8000
+ h: 3600000,
8001
+ d: 86400000,
8002
+ w: 604800000,
8003
+ };
8004
+ /** Shortest interval `@every` will accept. Below this a client is polling, not scheduling. */
8005
+ const MIN_PERIOD_MS = 30000;
8006
+ /** `HH:MM`, 24-hour, as minutes past local midnight. `null` when the text is not a time. */
8007
+ const parseTimeOfDay = (text) => {
8008
+ const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(text);
8009
+ if (!match)
8010
+ return null;
8011
+ return Number(match[1]) * 60 + Number(match[2]);
8012
+ };
8013
+ /**
8014
+ * Reads a schedule string, or returns `null` when it is not one.
8015
+ *
8016
+ * `null` is a value the scheduler acts on rather than an error to throw: a job document
8017
+ * carrying a schedule nobody can parse should be skipped and reported, not allowed to
8018
+ * take down the tick that would have run the other jobs.
8019
+ *
8020
+ * @example
8021
+ * ```typescript
8022
+ * parseSchedule("@every 6h"); // { kind: "interval", periodMs: 21600000, ... }
8023
+ * parseSchedule("@daily@09:00"); // { kind: "daily", minutes: 540, ... }
8024
+ * parseSchedule("0 9 * * *"); // null — cron is not accepted, see the module docblock
8025
+ * ```
8026
+ */
8027
+ const parseSchedule = (schedule) => {
8028
+ if (typeof schedule !== "string")
8029
+ return null;
8030
+ const source = schedule.trim();
8031
+ const text = source.toLowerCase();
8032
+ if (!text.startsWith("@"))
8033
+ return null;
8034
+ const every = /^@every\s+(\d+)\s*([smhdw])$/.exec(text);
8035
+ if (every) {
8036
+ const periodMs = Number(every[1]) * UNIT_MS[every[2]];
8037
+ if (!Number.isFinite(periodMs) || periodMs < MIN_PERIOD_MS)
8038
+ return null;
8039
+ return { kind: "interval", source, periodMs };
8040
+ }
8041
+ if (text === "@hourly")
8042
+ return { kind: "hourly", source };
8043
+ const parts = text.split("@").filter(Boolean);
8044
+ const [keyword, at] = parts;
8045
+ if (keyword === "daily") {
8046
+ if (at === undefined)
8047
+ return { kind: "daily", source, minutes: 0 };
8048
+ const minutes = parseTimeOfDay(at);
8049
+ return minutes === null ? null : { kind: "daily", source, minutes };
8050
+ }
8051
+ if (keyword === "weekly") {
8052
+ // Monday, because a week that starts on Sunday surprises most of the people who
8053
+ // write "@weekly" and none of the ones who wanted Monday.
8054
+ if (at === undefined)
8055
+ return { kind: "weekly", source, weekday: 1, minutes: 0 };
8056
+ const minutes = parseTimeOfDay(at);
8057
+ return minutes === null ? null : { kind: "weekly", source, weekday: 1, minutes };
8058
+ }
8059
+ return null;
8060
+ };
8061
+ /** Local midnight of the day containing `at`. */
8062
+ const startOfLocalDay = (at) => {
8063
+ const date = new Date(at);
8064
+ date.setHours(0, 0, 0, 0);
8065
+ return date;
8066
+ };
8067
+ /** `n` days later in *local* time — `setDate` rather than arithmetic, so DST is handled. */
8068
+ const addDays = (date, n) => {
8069
+ const next = new Date(date.getTime());
8070
+ next.setDate(next.getDate() + n);
8071
+ return next;
8072
+ };
8073
+ /**
8074
+ * The first moment this schedule comes due strictly after `from`.
8075
+ *
8076
+ * **The occurrences between the last run and `from` are not returned, and there is no
8077
+ * way to ask for them.** That absence is the design: a device that was closed for a
8078
+ * fortnight has missed fourteen occurrences of a daily job, and replaying them would run
8079
+ * the campaign fourteen times over data that only justifies running it once. A sweep
8080
+ * that reads current state does the right thing in a single pass; fourteen sweeps do the
8081
+ * same thing plus a stampede.
8082
+ *
8083
+ * @param schedule - A parsed schedule.
8084
+ * @param from - The instant to measure from, normally "now".
8085
+ * @returns The next due timestamp, strictly greater than `from`.
8086
+ */
8087
+ const nextOccurrence = (schedule, from) => {
8088
+ switch (schedule.kind) {
8089
+ case "interval":
8090
+ return from + schedule.periodMs;
8091
+ case "hourly": {
8092
+ const hour = new Date(from);
8093
+ hour.setMinutes(0, 0, 0);
8094
+ return hour.getTime() + UNIT_MS.h;
8095
+ }
8096
+ case "daily": {
8097
+ const candidate = startOfLocalDay(from).getTime() + schedule.minutes * UNIT_MS.m;
8098
+ if (candidate > from)
8099
+ return candidate;
8100
+ return addDays(startOfLocalDay(from), 1).getTime() + schedule.minutes * UNIT_MS.m;
8101
+ }
8102
+ case "weekly": {
8103
+ const today = startOfLocalDay(from);
8104
+ const shift = (schedule.weekday - today.getDay() + 7) % 7;
8105
+ const candidate = addDays(today, shift).getTime() + schedule.minutes * UNIT_MS.m;
8106
+ if (candidate > from)
8107
+ return candidate;
8108
+ return addDays(today, shift + 7).getTime() + schedule.minutes * UNIT_MS.m;
8109
+ }
8110
+ }
8111
+ };
8112
+ /** The longest a schedule may legitimately wait — the ceiling used to detect a bad clock. */
8113
+ const periodCeilingMs = (schedule) => {
8114
+ switch (schedule.kind) {
8115
+ case "interval":
8116
+ return schedule.periodMs;
8117
+ case "hourly":
8118
+ return UNIT_MS.h;
8119
+ case "daily":
8120
+ return UNIT_MS.d;
8121
+ case "weekly":
8122
+ return UNIT_MS.w;
8123
+ }
8124
+ };
8125
+ /**
8126
+ * Whether a stored `nextRunAt` is too far in the future to have been computed honestly.
8127
+ *
8128
+ * The device clock belongs to the user: it can be wrong, and it can be set back. A run
8129
+ * recorded while the clock read 2031 leaves a `nextRunAt` that would suppress the job for
8130
+ * years once the clock is corrected. Anything further out than two periods did not come
8131
+ * from this schedule, so the scheduler recomputes it from now rather than honouring it.
8132
+ */
8133
+ const isImplausible = (nextRunAt, schedule, now) => nextRunAt - now > periodCeilingMs(schedule) * 2;
8134
+
8135
+ /**
8136
+ * Running jobs unattended, on a client.
8137
+ *
8138
+ * {@link JobEngine} executes a job when something asks it to. This decides *when* to
8139
+ * ask, on a device that a server-side scheduler's assumptions do not describe:
8140
+ *
8141
+ * 1. **The app is closed most of the time.** "Daily at 09:00" is missed on most days.
8142
+ * 2. **Timers are throttled or frozen.** A background tab gets about one tick a minute;
8143
+ * a suspended app gets none, and `setInterval` does not catch up on wake.
8144
+ * 3. **There are several instances.** Two devices, or two tabs, run this against
8145
+ * replicas of the same `~Job` documents.
8146
+ * 4. **A run can vanish mid-flight.** A closed tab kills a `RUNNING` job with no
8147
+ * `catch`, no `finally`, and no process left to notice.
8148
+ * 5. **The clock is the user's.** It can be wrong, and it can move backwards.
8149
+ *
8150
+ * Four rules answer those, and each is load-bearing:
8151
+ *
8152
+ * - **Missed occurrences collapse into one run** ({@link nextOccurrence}). Never a
8153
+ * backlog.
8154
+ * - **Schedule state is device-local**, in a `_local/` document. `JobModel.nextRunTimestamp`
8155
+ * looks like the place for it and is not: an application's `~Job` documents replicate —
8156
+ * `DATA_MODEL_CLASSES` keeps them even under an `include` allow-list — so every device
8157
+ * would write that field on every run and collide on a document whose `content` field is
8158
+ * executable code. A losing revision there does not lose a timestamp, it forks what the
8159
+ * job does.
8160
+ * - **Duplicate work is answered by the jobs, not by a lock.** Leader election needs a
8161
+ * consensus point that two offline replicas do not have. Jobs that run here must write
8162
+ * documents whose `_id` is derived from what they are about (`ReviewRequest-<orderId>`),
8163
+ * so a second device's sweep collides into one document instead of sending a second
8164
+ * email. The scheduler cannot enforce that; it is the price of running campaign logic
8165
+ * on clients.
8166
+ * - **Only named jobs run unattended** ({@link SchedulerOptions.jobs}). `~Job.content` is
8167
+ * JavaScript, it replicates, and {@link Job} hydrates it with `new Function` — which
8168
+ * runs with full ambient authority, whatever the docs call it. Until now a human was
8169
+ * always behind an execution. An allow-list keeps that true: a job document arriving
8170
+ * over sync cannot become code that runs itself.
8171
+ *
8172
+ * @module
8173
+ */
8174
+ const logger$1 = createLogger().child({ module: "job-scheduler" });
8175
+ /** The `_local/` document holding this device's schedule state. Never replicates. */
8176
+ const JOB_SCHEDULE_DOC_ID = "_local/docstack-job-schedule";
8177
+ const DEFAULTS = {
8178
+ intervalMs: 60000,
8179
+ minIntervalMs: 5000,
8180
+ staleRunMs: 15 * 60000,
8181
+ backoffBaseMs: 5 * 60000,
8182
+ maxBackoffMs: 6 * 60 * 60000,
8183
+ /** Most abandoned runs reaped per tick — a sweep is not a migration. */
8184
+ staleRunBatch: 50,
8185
+ };
8186
+ /**
8187
+ * Decides when the jobs an application has approved should run, and dispatches them.
8188
+ *
8189
+ * Mounted at `stack.jobScheduler`, but never started by the stack: what may run
8190
+ * unattended is the application's decision.
8191
+ *
8192
+ * @example
8193
+ * ```typescript
8194
+ * stack.jobScheduler.start({
8195
+ * jobs: ["Job-review-campaign", "Job-cross-sell"],
8196
+ * pinnedHashes: { "Job-review-campaign": "9f2c…" },
8197
+ * });
8198
+ *
8199
+ * // Wake sources are the application's, because `core/` imports no DOM:
8200
+ * document.addEventListener("visibilitychange", () => {
8201
+ * if (document.visibilityState === "visible") void stack.jobScheduler.tick();
8202
+ * });
8203
+ * ```
8204
+ */
8205
+ class JobScheduler {
8206
+ constructor(host) {
8207
+ this.options = null;
8208
+ this.timer = null;
8209
+ /** Jobs this device has dispatched and not yet seen finish. */
8210
+ this.inFlight = new Set();
8211
+ /** Dispatches still running, so {@link drain} can wait for them. */
8212
+ this.pending = new Set();
8213
+ /** Deduplicates concurrent ticks — a wake signal and the interval can land together. */
8214
+ this.ticking = null;
8215
+ /** Serialises read-modify-write of the `_local/` document. */
8216
+ this.stateWrites = Promise.resolve();
8217
+ /** Last state read, for {@link status} — reporting must not require a database round-trip. */
8218
+ this.snapshot = {};
8219
+ this.host = host;
8220
+ }
8221
+ /**
8222
+ * Begins scheduling, and evaluates once immediately.
8223
+ *
8224
+ * The immediate evaluation is the point: a client's most reliable clock signal is
8225
+ * "the app just opened", not a timer that was frozen while it was closed.
8226
+ */
8227
+ start(options) {
8228
+ var _a, _b, _c, _d, _e, _f, _g, _h;
8229
+ if (this.timer)
8230
+ this.stop();
8231
+ this.options = {
8232
+ jobs: [...options.jobs],
8233
+ pinnedHashes: (_a = options.pinnedHashes) !== null && _a !== void 0 ? _a : {},
8234
+ intervalMs: Math.max(DEFAULTS.minIntervalMs, (_b = options.intervalMs) !== null && _b !== void 0 ? _b : DEFAULTS.intervalMs),
8235
+ staleRunMs: (_c = options.staleRunMs) !== null && _c !== void 0 ? _c : DEFAULTS.staleRunMs,
8236
+ backoffBaseMs: (_d = options.backoffBaseMs) !== null && _d !== void 0 ? _d : DEFAULTS.backoffBaseMs,
8237
+ maxBackoffMs: (_e = options.maxBackoffMs) !== null && _e !== void 0 ? _e : DEFAULTS.maxBackoffMs,
8238
+ now: (_f = options.now) !== null && _f !== void 0 ? _f : (() => Date.now()),
8239
+ onRun: options.onRun,
8240
+ };
8241
+ this.timer = setInterval(() => void this.tick(), this.options.intervalMs);
8242
+ // Node keeps the process alive for a pending interval; a scheduler should not be
8243
+ // the reason a CLI or a test runner refuses to exit.
8244
+ (_h = (_g = this.timer) === null || _g === void 0 ? void 0 : _g.unref) === null || _h === void 0 ? void 0 : _h.call(_g);
8245
+ void this.tick();
8246
+ }
8247
+ /**
8248
+ * Stops scheduling. Jobs already dispatched keep running — a hydrated `new Function`
8249
+ * has no cancellation, and pretending otherwise would leave a `RUNNING` run behind.
8250
+ */
8251
+ stop() {
8252
+ if (this.timer)
8253
+ clearInterval(this.timer);
8254
+ this.timer = null;
8255
+ // Dropping the options, not just the timer: an application's wake handlers
8256
+ // outlive teardown more often than not, and `tick()` from one of them after
8257
+ // `stop()` has to be nothing rather than one last dispatch.
8258
+ this.options = null;
8259
+ }
8260
+ /** Whether {@link start} is in effect. */
8261
+ get isRunning() {
8262
+ return this.timer !== null;
8263
+ }
8264
+ /**
8265
+ * Evaluates every allowed job and dispatches those that are due.
8266
+ *
8267
+ * Idempotent and safe to call from any wake signal. Concurrent calls share one
8268
+ * evaluation. It returns once dispatch has *started*: a long job does not hold the
8269
+ * tick open, because a tick that waits is a tick that stops the others.
8270
+ */
8271
+ tick() {
8272
+ if (this.ticking)
8273
+ return this.ticking;
8274
+ const run = this.runTick().finally(() => {
8275
+ this.ticking = null;
8276
+ });
8277
+ this.ticking = run;
8278
+ return run;
8279
+ }
8280
+ /** Resolves when every job this scheduler started has finished. For teardown and tests. */
8281
+ async drain() {
8282
+ while (this.pending.size) {
8283
+ await Promise.all([...this.pending]);
8284
+ }
8285
+ }
8286
+ /** What the scheduler believes, without touching the database. */
8287
+ status() {
8288
+ return {
8289
+ running: this.isRunning,
8290
+ inFlight: [...this.inFlight],
8291
+ jobs: Object.assign({}, this.snapshot),
8292
+ };
8293
+ }
8294
+ // ---------------------------------------------------------------- evaluation
8295
+ async runTick() {
8296
+ const options = this.options;
8297
+ const at = options ? options.now() : Date.now();
8298
+ const report = { at, dispatched: [], skipped: [], sweptRuns: 0 };
8299
+ // A wake signal can arrive after stop(); that is not an error, it is nothing.
8300
+ if (!options)
8301
+ return report;
8302
+ report.sweptRuns = await this.sweepAbandonedRuns(at, options.staleRunMs);
8303
+ const due = [];
8304
+ // The whole read-evaluate-write shares {@link recordOutcome}'s serialisation. A
8305
+ // job finishing mid-evaluation would otherwise bump the `_local` rev between
8306
+ // this read and this write; the write would 409 and be dropped - and with it
8307
+ // the claims it carried, so a job this very tick dispatched would still look
8308
+ // due on the next one and run twice inside its own period.
8309
+ await this.withState(async (doc) => {
8310
+ for (const jobId of options.jobs) {
8311
+ const job = await this.host.db.get(jobId).catch(() => null);
8312
+ if (!job) {
8313
+ report.skipped.push({ jobId, reason: "missing" });
8314
+ continue;
8315
+ }
8316
+ if (!job.isEnabled) {
8317
+ report.skipped.push({ jobId, reason: "disabled" });
8318
+ continue;
8319
+ }
8320
+ const pinned = options.pinnedHashes[jobId];
8321
+ if (pinned && pinned !== job.hash) {
8322
+ // Fail closed. The content behind an unexpected hash is exactly the case
8323
+ // the allow-list exists for.
8324
+ logger$1.warn("tick - job hash does not match the pinned value; not running", { jobId });
8325
+ report.skipped.push({ jobId, reason: "hash-mismatch" });
8326
+ continue;
8327
+ }
8328
+ if (!job.schedule) {
8329
+ report.skipped.push({ jobId, reason: "no-schedule" });
8330
+ continue;
8331
+ }
8332
+ const schedule = parseSchedule(job.schedule);
8333
+ if (!schedule) {
8334
+ logger$1.warn("tick - job carries a schedule this client cannot read", {
8335
+ jobId,
8336
+ schedule: job.schedule,
8337
+ });
8338
+ report.skipped.push({ jobId, reason: "unparseable-schedule" });
8339
+ continue;
8340
+ }
8341
+ const state = doc.jobs[jobId];
8342
+ const changedSchedule = state && state.schedule !== schedule.source;
8343
+ // First sight of a job, or a schedule that has been rewritten: give it a
8344
+ // starting point rather than running it on the spot. A newly installed daily
8345
+ // job that fires the instant it is saved would fire again on every device
8346
+ // that receives it, which is a stampede dressed as a first run.
8347
+ if (!state || changedSchedule) {
8348
+ doc.jobs[jobId] = {
8349
+ nextRunAt: nextOccurrence(schedule, at),
8350
+ consecutiveFailures: 0,
8351
+ schedule: schedule.source,
8352
+ };
8353
+ report.skipped.push({ jobId, reason: "not-due" });
8354
+ continue;
8355
+ }
8356
+ if (isImplausible(state.nextRunAt, schedule, at)) {
8357
+ logger$1.warn("tick - stored nextRunAt is further out than the schedule allows; recomputing", {
8358
+ jobId,
8359
+ nextRunAt: state.nextRunAt,
8360
+ });
8361
+ state.nextRunAt = nextOccurrence(schedule, at);
8362
+ report.skipped.push({ jobId, reason: "not-due" });
8363
+ continue;
8364
+ }
8365
+ if (state.nextRunAt > at) {
8366
+ report.skipped.push({ jobId, reason: "not-due" });
8367
+ continue;
8368
+ }
8369
+ // Due, but the previous dispatch has not come back. Leaving `nextRunAt`
8370
+ // where it is means the job runs as soon as it is free, rather than losing
8371
+ // the slot to a run that is still doing it.
8372
+ if (this.inFlight.has(jobId)) {
8373
+ report.skipped.push({ jobId, reason: "in-flight" });
8374
+ continue;
8375
+ }
8376
+ // Claim before dispatching, not after. A tab closed mid-run then costs the
8377
+ // job one period, rather than re-running it on every boot from then on —
8378
+ // and jobs here are required to be idempotent anyway, so a lost run is the
8379
+ // cheaper of the two failures.
8380
+ state.nextRunAt = nextOccurrence(schedule, at);
8381
+ state.lastRunAt = at;
8382
+ due.push({ jobId, job, nextRunAt: state.nextRunAt });
8383
+ }
8384
+ });
8385
+ for (const entry of due) {
8386
+ this.inFlight.add(entry.jobId);
8387
+ report.dispatched.push(entry.jobId);
8388
+ this.track(this.dispatch(entry.jobId, options));
8389
+ }
8390
+ return report;
8391
+ }
8392
+ /** Runs one job and records what happened. Never throws: a tick outlives its jobs. */
8393
+ async dispatch(jobId, options) {
8394
+ var _a;
8395
+ try {
8396
+ const run = await this.host.jobEngine.executeJob(jobId, undefined, "scheduled");
8397
+ await this.recordOutcome(jobId, run.status, options);
8398
+ (_a = options.onRun) === null || _a === void 0 ? void 0 : _a.call(options, run);
8399
+ }
8400
+ catch (error) {
8401
+ // `executeJob` throws for a disabled job and for a blocked singleton, and
8402
+ // rejects if the content hash no longer matches. All three are outcomes, not
8403
+ // crashes; the backoff is what keeps a permanently broken job from becoming
8404
+ // a write per minute.
8405
+ logger$1.warn("dispatch - scheduled job did not run", { jobId, error: (error === null || error === void 0 ? void 0 : error.message) || String(error) });
8406
+ await this.recordOutcome(jobId, "FAILURE", options);
8407
+ }
8408
+ finally {
8409
+ this.inFlight.delete(jobId);
8410
+ }
8411
+ }
8412
+ async recordOutcome(jobId, status, options) {
8413
+ await this.withState(doc => {
8414
+ const state = doc.jobs[jobId];
8415
+ if (!state)
8416
+ return;
8417
+ state.lastStatus = status;
8418
+ if (status === "SUCCESS") {
8419
+ state.consecutiveFailures = 0;
8420
+ return;
8421
+ }
8422
+ state.consecutiveFailures += 1;
8423
+ const backoff = Math.min(options.backoffBaseMs * 2 ** (state.consecutiveFailures - 1), options.maxBackoffMs);
8424
+ // Backoff replaces the claimed slot only when it pushes the job further out;
8425
+ // a daily job that fails should not come back in five minutes *sooner* than
8426
+ // its own schedule would have.
8427
+ state.nextRunAt = Math.max(state.nextRunAt, options.now() + backoff);
8428
+ });
8429
+ }
8430
+ // ------------------------------------------------------------------- sweeping
8431
+ /**
8432
+ * Moves this device's abandoned runs to `CANCELED`.
8433
+ *
8434
+ * A `~JobRun` only changes status inside `Job.execute`'s `try`/`catch`, so a tab
8435
+ * closed mid-run leaves one `RUNNING` for ever — and `hasRunningInstance` then skips
8436
+ * that singleton job on this device permanently. Unattended execution turns that from
8437
+ * a latent oddity into a job that silently stops working, so the sweep runs before
8438
+ * every dispatch. `~JobRun` never replicates, so these are unambiguously *this*
8439
+ * device's abandoned runs.
8440
+ */
8441
+ async sweepAbandonedRuns(at, staleRunMs) {
8442
+ var _a;
8443
+ try {
8444
+ const found = await this.host.db.find({
8445
+ selector: { "~class": "~JobRun", status: "RUNNING" },
8446
+ limit: DEFAULTS.staleRunBatch,
8447
+ });
8448
+ const stale = ((_a = found === null || found === void 0 ? void 0 : found.docs) !== null && _a !== void 0 ? _a : []).filter((run) => typeof run.startTime === "number" && at - run.startTime > staleRunMs);
8449
+ if (!stale.length)
8450
+ return 0;
8451
+ await this.host.db.bulkDocs(stale.map((run) => (Object.assign(Object.assign({}, run), { status: "CANCELED", endTime: at, durationMs: at - run.startTime, errorMessage: "Run abandoned — the client stopped before it finished." }))));
8452
+ logger$1.info("sweepAbandonedRuns - reaped abandoned runs", { count: stale.length });
8453
+ return stale.length;
8454
+ }
8455
+ catch (error) {
8456
+ logger$1.warn("sweepAbandonedRuns - could not sweep", { error: (error === null || error === void 0 ? void 0 : error.message) || String(error) });
8457
+ return 0;
8458
+ }
8459
+ }
8460
+ // ---------------------------------------------------------------------- state
8461
+ async readState() {
8462
+ var _a;
8463
+ try {
8464
+ const doc = await this.host.db.get(JOB_SCHEDULE_DOC_ID);
8465
+ const jobs = ((_a = doc === null || doc === void 0 ? void 0 : doc.jobs) !== null && _a !== void 0 ? _a : {});
8466
+ this.snapshot = jobs;
8467
+ return { _id: JOB_SCHEDULE_DOC_ID, _rev: doc === null || doc === void 0 ? void 0 : doc._rev, jobs };
8468
+ }
8469
+ catch (_b) {
8470
+ return { _id: JOB_SCHEDULE_DOC_ID, jobs: {} };
8471
+ }
8472
+ }
8473
+ async writeState(doc) {
8474
+ this.snapshot = doc.jobs;
8475
+ try {
8476
+ await this.host.db.put(doc);
8477
+ }
8478
+ catch (error) {
8479
+ // A 409 means another tick wrote first; its state is as good as this one's,
8480
+ // and the next tick reads the winner.
8481
+ logger$1.warn("writeState - could not persist schedule state", {
8482
+ error: (error === null || error === void 0 ? void 0 : error.message) || String(error),
8483
+ });
8484
+ }
8485
+ }
8486
+ /**
8487
+ * Read-modify-write against the `_local/` document, serialised.
8488
+ *
8489
+ * Everything that touches the state document goes through here - tick evaluation
8490
+ * and outcome recording alike - so within this instance no write can land between
8491
+ * another's read and write. Two *instances* (two tabs) still contend; there the 409
8492
+ * in {@link writeState} stands, logged, and the jobs' own idempotency is the answer,
8493
+ * as the module docblock requires of them.
8494
+ */
8495
+ withState(change) {
8496
+ const next = this.stateWrites.then(async () => {
8497
+ const doc = await this.readState();
8498
+ await change(doc);
8499
+ await this.writeState(doc);
8500
+ });
8501
+ this.stateWrites = next.catch(() => undefined);
8502
+ return next;
8503
+ }
8504
+ track(work) {
8505
+ this.pending.add(work);
8506
+ void work.finally(() => this.pending.delete(work));
8507
+ }
8508
+ }
8509
+
7784
8510
  /**
7785
8511
  * Set of system classes that bypass policy evaluation.
7786
8512
  * These classes are internal to DocStack and always accessible.
@@ -7877,9 +8603,12 @@ async function execute(_stack, params) {
7877
8603
  // Read raw rather than through `findDocuments`: policies are a system class
7878
8604
  // that bypasses policy evaluation anyway, and going through the read path
7879
8605
  // here recursed into a policy check per policy document. The selector is the
7880
- // same one `findDocuments` produced (it injects `active: true`); the explicit
7881
- // limit is because pouchdb-find otherwise silently caps results at 25, which
7882
- // for policies means silently not enforcing the 26th.
8606
+ // same one `findDocuments` produced (it injects `active: true`), and that is
8607
+ // the contract, not an accident: a policy enforces only while `active: true`,
8608
+ // exactly as a document is visible only while active - an unflagged or
8609
+ // explicitly inactive policy does not apply (ADR-0032). The explicit limit is
8610
+ // because pouchdb-find otherwise silently caps results at 25, which for
8611
+ // policies means silently not enforcing the 26th.
7883
8612
  const result = await this.stack.db.find({
7884
8613
  selector: { "~class": "~Policy", active: true },
7885
8614
  limit: 2 ** 31 - 1,
@@ -9740,7 +10469,11 @@ async function execute(_stack, params) {
9740
10469
  * Removes event listeners and terminates background workers.
9741
10470
  */
9742
10471
  this.close = () => {
10472
+ var _a;
9743
10473
  this.cancelSync();
10474
+ // Before the listeners go: a surviving interval would tick against a database
10475
+ // this stack no longer serves.
10476
+ (_a = this.jobScheduler) === null || _a === void 0 ? void 0 : _a.stop();
9744
10477
  this.removeAllListeners();
9745
10478
  if (this.modelWorker)
9746
10479
  this.modelWorker.terminate();
@@ -10044,9 +10777,10 @@ async function execute(_stack, params) {
10044
10777
  { description: { $regex: RegExp(search, "i") } }
10045
10778
  ];
10046
10779
  }
10047
- // `ephemeral` is projected too: without it the models come back with the flag
10048
- // stripped, and every class looks durable. See ADR-0028.
10049
- const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev', 'ephemeral', 'simple'];
10780
+ // `ephemeral`, `simple` and `tenants` are projected too: a fixed field list
10781
+ // silently strips any model flag it does not name, so every class would read
10782
+ // as durable, schema-full and tenant-neutral. See ADR-0028, ADR-0030.
10783
+ const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev', 'ephemeral', 'simple', 'tenants'];
10050
10784
  const response = await this.findDocuments(selector, fields);
10051
10785
  const result = response.docs;
10052
10786
  if (!conf.listen) {
@@ -10132,9 +10866,10 @@ async function execute(_stack, params) {
10132
10866
  { description: { $regex: RegExp(search, "i") } }
10133
10867
  ];
10134
10868
  }
10135
- // `ephemeral` is projected too: without it the models come back with the flag
10136
- // stripped, and every class looks durable. See ADR-0028.
10137
- const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev', 'ephemeral', 'simple'];
10869
+ // `ephemeral`, `simple` and `tenants` are projected too: a fixed field list
10870
+ // silently strips any model flag it does not name, so every class would read
10871
+ // as durable, schema-full and tenant-neutral. See ADR-0028, ADR-0030.
10872
+ const fields = ['_id', 'name', 'description', 'schema', '~class', '_rev', 'ephemeral', 'simple', 'tenants'];
10138
10873
  const response = await this.findDocuments(selector, fields);
10139
10874
  const result = response.docs;
10140
10875
  if (!conf.listen) {
@@ -10843,7 +11578,7 @@ async function execute(_stack, params) {
10843
11578
  // `undefined` and capturing from there silently yields nothing. Replication needs
10844
11579
  // them too - it writes documents verbatim and reads them exactly as stored.
10845
11580
  // Unbound on purpose: StackPlugin forwards with `.call(this, ...)`.
10846
- this.pristineDbMethods = { bulkDocs: rawDb.bulkDocs, bulkGet: rawDb.bulkGet };
11581
+ this.pristineDbMethods = { bulkDocs: rawDb.bulkDocs, bulkGet: rawDb.bulkGet, get: rawDb.get };
10847
11582
  // Built from the pristine methods rather than looking them up: the plugin can no
10848
11583
  // longer be constructed at a moment when its capture would be wrong, because the
10849
11584
  // capture is an argument. See ADR-0019.
@@ -10851,6 +11586,7 @@ async function execute(_stack, params) {
10851
11586
  rawDb.ping = stackPlugin.ping;
10852
11587
  rawDb.bulkDocs = stackPlugin.bulkDocs;
10853
11588
  rawDb.bulkGet = stackPlugin.bulkGet;
11589
+ rawDb.get = stackPlugin.get;
10854
11590
  this.rawDb = rawDb;
10855
11591
  this.replicationDb = undefined;
10856
11592
  // What consumers get is guarded: the two ways around the authoring path -
@@ -10866,6 +11602,7 @@ async function execute(_stack, params) {
10866
11602
  // empty at init
10867
11603
  };
10868
11604
  this.jobEngine = new JobEngine(this);
11605
+ this.jobScheduler = new JobScheduler(this);
10869
11606
  this.policyEngine = new PolicyEngine(this);
10870
11607
  this.cryptoEngine = new CryptoEngine(this);
10871
11608
  if (options === null || options === void 0 ? void 0 : options.documentKey) {
@@ -12208,6 +12945,13 @@ async function execute(_stack, params) {
12208
12945
  * on the same database. See ADR-0022.
12209
12946
  */
12210
12947
  this.pendingStacks = new Map();
12948
+ /**
12949
+ * What the last un-scoped {@link sync} call asked for, kept so a stack added
12950
+ * later can be bound to the same replication. `null` when sync was never
12951
+ * called, was called with an explicit `stacks` list (a caller who named three
12952
+ * databases asked for three), or was cancelled. See ADR-0033.
12953
+ */
12954
+ this.syncOptions = null;
12211
12955
  this.logger = createLogger().child({ module: "client" });
12212
12956
  /**
12213
12957
  * Opens a stack and adds it to this instance.
@@ -12251,10 +12995,19 @@ async function execute(_stack, params) {
12251
12995
  }
12252
12996
  // Registered before the first `await`, which is what makes the guard atomic:
12253
12997
  // nothing else runs between the miss above and this line.
12254
- const creation = ClientStack.create(connection, options).then(stack => {
12998
+ const creation = ClientStack.create(connection, options).then(async (stack) => {
12255
12999
  this.stacks.push(stack);
12256
13000
  if (!this.store)
12257
13001
  this.store = stack;
13002
+ // Bound to any live un-scoped sync BEFORE the stack is announced, so that
13003
+ // when addStack resolves - or a 'stack-added' listener runs - the stack is
13004
+ // already replicating. Without this, a database mounted after sync() sat
13005
+ // outside replication for the lifetime of the handle, with every status
13006
+ // surface reporting healthy: the handle simply had no entry for it, and a
13007
+ // missing key reads as "nothing to say" rather than "not covered". A
13008
+ // workspace registry that lives in one database and names the others makes
13009
+ // that ordering a boot-time coin toss, not an exotic case. See ADR-0033.
13010
+ await this.bindStackToSync(stack);
12258
13011
  this.dispatchEvent(new CustomEvent("stack-added", { detail: { stack } }));
12259
13012
  return stack;
12260
13013
  });
@@ -12281,6 +13034,7 @@ async function execute(_stack, params) {
12281
13034
  * @returns `true` if a stack was removed, `false` if there was none by that name.
12282
13035
  */
12283
13036
  this.removeStack = async (name, options) => {
13037
+ var _a;
12284
13038
  const stack = this.getStack(name);
12285
13039
  if (!stack)
12286
13040
  return false;
@@ -12288,6 +13042,9 @@ async function execute(_stack, params) {
12288
13042
  this.stacks = this.stacks.filter(s => s !== stack);
12289
13043
  if (this.store === stack)
12290
13044
  this.store = this.stacks[0];
13045
+ // A removed stack must also leave the sync handle, or getStatus() keeps
13046
+ // reporting a database this instance no longer holds.
13047
+ (_a = this.syncHandle) === null || _a === void 0 ? void 0 : _a.remove(stack.name);
12291
13048
  if (options === null || options === void 0 ? void 0 : options.destroy) {
12292
13049
  await stack.destroyDb();
12293
13050
  }
@@ -12313,8 +13070,8 @@ async function execute(_stack, params) {
12313
13070
  * ```
12314
13071
  */
12315
13072
  this.sync = async (options) => {
12316
- const { stacks: names } = options, stackOptions = __rest(options, ["stacks"]);
12317
- const targets = names
13073
+ const { stacks: names, tenants } = options, stackOptions = __rest(options, ["stacks", "tenants"]);
13074
+ let targets = names
12318
13075
  ? names.map(name => {
12319
13076
  const stack = this.getStack(name);
12320
13077
  if (!stack)
@@ -12322,13 +13079,85 @@ async function execute(_stack, params) {
12322
13079
  return stack;
12323
13080
  })
12324
13081
  : this.stacks;
13082
+ // A tenant entitlement compiles into per-stack configuration: stacks outside
13083
+ // the scope are not synced at all - withheld structurally, which is a stronger
13084
+ // grant than any filter - and stacks holding a mix of declarations get class
13085
+ // rules. See ADR-0030 and sync/tenants.ts.
13086
+ let scopedClasses = {};
13087
+ if (tenants) {
13088
+ if (stackOptions.classes) {
13089
+ throw new Error("`tenants` and `classes` cannot be combined: the tenant scope compiles its own " +
13090
+ "class rules per stack. Narrow further with `filter`, or scope by hand without `tenants`.");
13091
+ }
13092
+ const scope = await deriveTenantScope(targets, tenants);
13093
+ const served = new Set(scope.stacks);
13094
+ targets = targets.filter(stack => served.has(stack.name));
13095
+ scopedClasses = scope.classes;
13096
+ }
12325
13097
  const handle = new DocStackSyncHandle();
13098
+ // Registered before the loop, and the options with it: a stack added while
13099
+ // this loop awaits is bound by addStack through bindStackToSync, and the
13100
+ // has() guards on both paths keep the two from double-binding it.
13101
+ this.syncHandle = handle;
13102
+ this.syncOptions = names ? null : options;
12326
13103
  for (const stack of targets) {
12327
- handle.add(stack.name, await stack.sync(stackOptions));
13104
+ if (handle.handles.has(stack.name))
13105
+ continue;
13106
+ const classes = scopedClasses[stack.name];
13107
+ handle.add(stack.name, await stack.sync(classes ? Object.assign(Object.assign({}, stackOptions), { classes }) : stackOptions));
12328
13108
  }
12329
- this.syncHandle = handle;
12330
13109
  return handle;
12331
13110
  };
13111
+ /**
13112
+ * Binds one stack to the live sync, if there is one and it was un-scoped.
13113
+ *
13114
+ * Failure here must not fail {@link addStack} - the stack itself opened fine -
13115
+ * but it must not be silent either, silence being this defect's whole shape:
13116
+ * it is logged and dispatched as an `error` event on the sync handle.
13117
+ */
13118
+ this.bindStackToSync = async (stack) => {
13119
+ const handle = this.syncHandle;
13120
+ const options = this.syncOptions;
13121
+ if (!handle || !options)
13122
+ return;
13123
+ if (handle.handles.has(stack.name))
13124
+ return;
13125
+ const { stacks: _names, tenants } = options, stackOptions = __rest(options, ["stacks", "tenants"]);
13126
+ try {
13127
+ let classes;
13128
+ if (tenants) {
13129
+ // The same entitlement the original call compiled, derived for this
13130
+ // stack alone: outside the scope means not synced at all - withheld
13131
+ // structurally, exactly as it would have been at sync() time.
13132
+ const scope = await deriveTenantScope([stack], tenants);
13133
+ if (!scope.stacks.includes(stack.name))
13134
+ return;
13135
+ classes = scope.classes[stack.name];
13136
+ }
13137
+ if (handle.handles.has(stack.name))
13138
+ return; // bound while deriving
13139
+ handle.add(stack.name, await stack.sync(classes ? Object.assign(Object.assign({}, stackOptions), { classes }) : stackOptions));
13140
+ }
13141
+ catch (error) {
13142
+ this.logger.child({ method: "bindStackToSync" }).error("Stack opened but could not join replication", { stack: stack.name, error });
13143
+ handle.dispatchEvent(new CustomEvent("error", { detail: { stack: stack.name, error } }));
13144
+ }
13145
+ };
13146
+ /**
13147
+ * Which open stacks the current sync covers, and which it does not.
13148
+ *
13149
+ * An idle stack and an unbound one are opposite problems, and `getStatus()`
13150
+ * cannot tell them apart - the unbound one has no key at all. With no sync
13151
+ * running, every open stack is unbound.
13152
+ */
13153
+ this.getSyncCoverage = () => {
13154
+ const bound = this.syncHandle ? [...this.syncHandle.handles.keys()] : [];
13155
+ const boundSet = new Set(bound);
13156
+ return {
13157
+ bound,
13158
+ unbound: this.stacks.map(s => s.name).filter(name => !boundSet.has(name)),
13159
+ };
13160
+ };
12332
13161
  /**
12333
13162
  * Returns the handle from the last {@link sync} call, or `null`.
12334
13163
  */
@@ -12341,6 +13170,9 @@ async function execute(_stack, params) {
12341
13170
  this.cancelSync = () => {
12342
13171
  if (this.syncHandle)
12343
13172
  this.syncHandle.cancel();
13173
+ // A stack added after this point must not start replicating into a handle
13174
+ // whose other members were just stopped.
13175
+ this.syncOptions = null;
12344
13176
  };
12345
13177
  this.initStacks = async (configs) => {
12346
13178
  // TODO: Consider changing to Promise.all for concurrency
@@ -12694,6 +13526,9 @@ async function execute(_stack, params) {
12694
13526
  exports.INTERNAL_DOC_CLASSES = INTERNAL_DOC_CLASSES;
12695
13527
  exports.INTERNAL_DOC_IDS = INTERNAL_DOC_IDS;
12696
13528
  exports.INTERNAL_DOC_ID_PREFIXES = INTERNAL_DOC_ID_PREFIXES;
13529
+ exports.JOB_SCHEDULE_DOC_ID = JOB_SCHEDULE_DOC_ID;
13530
+ exports.JobEngine = JobEngine;
13531
+ exports.JobScheduler = JobScheduler;
12697
13532
  exports.META_CLASSES = META_CLASSES;
12698
13533
  exports.OPTIONAL_INTERNAL_DOC_CLASSES = OPTIONAL_INTERNAL_DOC_CLASSES;
12699
13534
  exports.SYNC_META_DOC_ID = SYNC_META_DOC_ID;
@@ -12703,11 +13538,13 @@ async function execute(_stack, params) {
12703
13538
  exports.StackWriteGuardError = StackWriteGuardError;
12704
13539
  exports.SyncSchemaMismatchError = SyncSchemaMismatchError;
12705
13540
  exports.Trigger = Trigger;
13541
+ exports.classTenants = classTenants;
12706
13542
  exports.collectQueryClasses = collectQueryClasses;
12707
13543
  exports.createClassFilter = createClassFilter;
12708
13544
  exports.createReplicationFilter = createReplicationFilter;
12709
13545
  exports.default = DocStack;
12710
13546
  exports.deriveKeyId = deriveKeyId;
13547
+ exports.deriveTenantScope = deriveTenantScope;
12711
13548
  exports.describeFilter = describeFilter;
12712
13549
  exports.hasClassRules = hasClassRules;
12713
13550
  exports.isContentClassName = isContentClassName;
@@ -12715,6 +13552,8 @@ async function execute(_stack, params) {
12715
13552
  exports.isContentRelation = isContentRelation;
12716
13553
  exports.isEncryptedPayload = isEncryptedPayload;
12717
13554
  exports.isInternalDoc = isInternalDoc;
13555
+ exports.nextOccurrence = nextOccurrence;
13556
+ exports.parseSchedule = parseSchedule;
12718
13557
  exports.publishSchemaVersion = publishSchemaVersion;
12719
13558
  exports.readRemoteSchemaVersion = readRemoteSchemaVersion;
12720
13559
  exports.resolveInternalClasses = resolveInternalClasses;