slotlock 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -6
  3. package/agent-server.js +1 -5
  4. package/agent-store-backend.d.ts +5 -6
  5. package/agent-store-backend.js +41 -67
  6. package/api-keys.js +1 -1
  7. package/application-api.d.ts +3 -3
  8. package/application-api.js +16 -24
  9. package/assistant.d.ts +18 -27
  10. package/assistant.js +36 -53
  11. package/booking-rules.d.ts +30 -41
  12. package/booking-rules.js +17 -25
  13. package/change-watch.d.ts +1 -0
  14. package/change-watch.js +1 -1
  15. package/cli.js +3 -4
  16. package/config.d.ts +17 -2
  17. package/config.js +44 -6
  18. package/dashboard.js +13 -0
  19. package/ddl.d.ts +20 -27
  20. package/ddl.js +166 -108
  21. package/engine.d.ts +6 -9
  22. package/engine.js +5 -9
  23. package/event-feed.d.ts +23 -31
  24. package/event-feed.js +30 -45
  25. package/event-schema.js +6 -9
  26. package/hooks.d.ts +2 -4
  27. package/hooks.js +2 -4
  28. package/ical-worker-thread.js +3 -4
  29. package/ical-worker.d.ts +8 -10
  30. package/ical-worker.js +7 -9
  31. package/interop.js +1 -4
  32. package/maintenance.d.ts +14 -10
  33. package/maintenance.js +43 -26
  34. package/mcp-app.d.ts +7 -9
  35. package/mcp-app.js +7 -9
  36. package/mcp-modern.d.ts +14 -16
  37. package/mcp-modern.js +23 -27
  38. package/metrics.js +0 -1
  39. package/node-server.d.ts +9 -9
  40. package/node-server.js +25 -34
  41. package/package-version.js +2 -4
  42. package/package.json +1 -1
  43. package/rate-limit.d.ts +6 -1
  44. package/rate-limit.js +24 -8
  45. package/rest-api.d.ts +13 -14
  46. package/rest-api.js +11 -12
  47. package/rules.d.ts +6 -7
  48. package/rules.js +30 -42
  49. package/self-host.d.ts +16 -2
  50. package/self-host.js +42 -21
  51. package/slotlock.manifest.json +1 -1
  52. package/store.d.ts +109 -161
  53. package/store.js +253 -371
  54. package/sync.d.ts +44 -59
  55. package/sync.js +325 -469
  56. package/timezone.d.ts +25 -33
  57. package/timezone.js +37 -52
  58. package/types.d.ts +35 -56
  59. package/webhooks.d.ts +22 -25
  60. package/webhooks.js +35 -40
  61. package/weekly-hours.d.ts +3 -0
  62. package/weekly-hours.js +1 -1
package/event-feed.js CHANGED
@@ -1,7 +1,6 @@
1
- // The event feed (RFC 0002 P1-5): what changed for a tenant, read by position, and the webhook
2
- // endpoints it is delivered to. Rows are appended by triggers in the same transaction as the change
3
- // (ddl.ts, SLOTLOCK_OUTBOX_DDL). These are the store's methods for reading them and for keeping each
4
- // endpoint's delivery state; webhooks.ts signs and sends.
1
+ // The event feed: what changed for a tenant, read by position, and the webhook endpoints it is
2
+ // delivered to. Rows are appended by triggers in the changing transaction (ddl.ts,
3
+ // SLOTLOCK_OUTBOX_DDL); webhooks.ts signs and sends.
5
4
  import { randomBytes } from 'node:crypto';
6
5
  /** A transaction on a client, or a savepoint inside the caller's (a tenant's, say). */
7
6
  async function inTransaction(sql, callback) {
@@ -28,8 +27,7 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
28
27
  'waitlist.offered',
29
28
  'waitlist.expired',
30
29
  'webhook.disabled',
31
- // Configuration: what a resource is and how it books, the sources it is read from, where the
32
- // feed is delivered and the keys that reach the tenant. Ids and states, never a secret or a URL.
30
+ // Configuration changes: ids and states, never a secret or a URL.
33
31
  'resource.created',
34
32
  'resource.updated',
35
33
  'resource.archived',
@@ -45,10 +43,9 @@ export const SLOTLOCK_CHANGE_TYPES = Object.freeze([
45
43
  'key.revoked',
46
44
  ]);
47
45
  /**
48
- * Where a change came from, as the history and the feed record it (withTenant's `origin`): an agent
49
- * protocol (`mcp`, `a2a`), the REST API's agent routes (`rest`) or its application operations
50
- * (`application`), the dashboard, a member's dashboard assistant, maintenance, or code calling the
51
- * store itself (`library`). Never an address or anything else about a person.
46
+ * Where a change came from (withTenant's `origin`): an agent protocol (`mcp`, `a2a`), the REST
47
+ * API's agent routes (`rest`) or application operations (`application`), the dashboard, its
48
+ * assistant, maintenance, or direct store calls (`library`). Never anything about a person.
52
49
  */
53
50
  export const SLOTLOCK_CHANGE_ORIGINS = Object.freeze([
54
51
  'mcp',
@@ -65,20 +62,16 @@ export function isSlotlockChangeOrigin(value) {
65
62
  return typeof value === 'string' && SLOTLOCK_CHANGE_ORIGINS.includes(value);
66
63
  }
67
64
  /**
68
- * The digest 0.5.3 named a coverage source by on the feed: `src_` and the first 24 hex digits of
69
- * sha256(tenant, newline, source). Unkeyed, so anyone who knows the tenant could confirm a guessed
70
- * source against it; 0.6 names a source by a random id instead (`calendar_coverage.source_id`), and
71
- * the upgrade uses this only to find the changes 0.5.3 wrote and rename them. `tenant` and `source`
72
- * are SQL expressions.
65
+ * The unkeyed digest 0.5.3 named a coverage source by (`src_` + 24 hex of sha256(tenant, newline,
66
+ * source)); anyone knowing the tenant could confirm a guess. Used only by the upgrade to find and
67
+ * rename those changes. `tenant` and `source` are SQL expressions.
73
68
  */
74
69
  export function legacyCoverageSourceDigestSql(tenant, source) {
75
70
  return `'src_' || pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(${tenant} || E'\\n' || ${source}, 'UTF8')), 'hex'), 24)`;
76
71
  }
77
72
  /**
78
- * 0.5.3 worked out a source's id here: an unkeyed digest of the tenant and the source, which let
79
- * anyone who saw a change confirm a guessed source. Since 0.6 a source's `source_id` is random and
80
- * only the database knows it, so this throws (`coverage_source_id_unavailable`) rather than give a
81
- * digest nothing carries any more.
73
+ * Throws `coverage_source_id_unavailable`: since 0.6 a source's `source_id` is random and only the
74
+ * database knows it (0.5.3's unkeyed digest let anyone confirm a guessed source).
82
75
  *
83
76
  * @deprecated Read `sourceId` from `listCoverage` (`source_id` of `slotlock_get_coverage`). It may
84
77
  * be removed in 0.7.0.
@@ -89,17 +82,15 @@ export function slotlockCoverageSourceId(_tenantRef, _source) {
89
82
  /** What a coverage source's `source_id` is: `src_` and 32 random hex digits. */
90
83
  const SLOTLOCK_COVERAGE_SOURCE_ID = /^src_[0-9a-f]{32}$/;
91
84
  /**
92
- * The `source_id` of a `coverage.*` change whose source could not be named any more: one an
93
- * earlier release recorded by the source itself (or by its 0.5.3 digest) after the source was
94
- * deleted, or one not yet renamed when it is read.
85
+ * The `source_id` of a `coverage.*` change whose source can no longer be named: an earlier
86
+ * release's change whose source was deleted, or one not yet renamed.
95
87
  */
96
88
  const SLOTLOCK_REDACTED_SOURCE_ID = 'src_redacted';
97
89
  /**
98
- * A change's subject and data as the feed serves them. A `coverage.*` change never carries its
99
- * source (`source`) or the error read from it (`last_error`, which can quote it), and is named by a
100
- * `source_id` of this release or `src_redacted`: an earlier release wrote both, and the upgrade
101
- * renames those rows in batches after it commits, so a row it has not reached yet, or one an
102
- * earlier release's server writes during a rolling upgrade, is served redacted.
90
+ * A change's subject and data as the feed serves them. A `coverage.*` change never carries `source`
91
+ * or `last_error` (which can quote it), and is named by a current `source_id` or `src_redacted`:
92
+ * rows an earlier release wrote are renamed in batches after upgrade, so one not reached yet is
93
+ * served redacted.
103
94
  */
104
95
  export function slotlockServedChange(type, subject, data) {
105
96
  if (!type.startsWith('coverage.'))
@@ -136,7 +127,7 @@ function toChange(row) {
136
127
  ...slotlockServedChange(row.type, row.subject, row.data),
137
128
  occurredAt: row.occurred_at,
138
129
  actor: row.actor,
139
- // One a later release added, read by this one: unknown here, so unrecorded, not a failed page.
130
+ // An origin a later release added: unrecorded here, not a failed page.
140
131
  origin: isSlotlockChangeOrigin(row.origin) ? row.origin : null,
141
132
  keyId: row.key_id,
142
133
  position: encodePosition({ txid: row.txid, id: row.id, at: row.occurred_at.getTime() }),
@@ -202,8 +193,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
202
193
  const after = params.after === undefined ? null : decodePosition(params.after);
203
194
  if (params.after !== undefined && !after)
204
195
  return { ok: false, code: 'invalid_cursor' };
205
- // One statement, so the pruned position and the page are read from one snapshot: a prune
206
- // that commits meanwhile is either wholly seen or not at all.
196
+ // One statement, so the pruned position and the page share a snapshot.
207
197
  const rows = await sql `
208
198
  SELECT mark.txid::text AS pruned_txid, mark.id::text AS pruned_id,
209
199
  pg_snapshot_xmin(pg_current_snapshot())::text AS horizon,
@@ -227,9 +217,8 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
227
217
  const head = rows[0];
228
218
  if (!head)
229
219
  throw new Error('slotlock: change page returned no row');
230
- // Whichever server pruned it, and however long it keeps changes: reading on could skip one.
231
- // A 0.4 cursor also by its date, as 0.4 did: 0.4 pruned by age, not in feed order, so the
232
- // mark its changes started at does not cover every one it took.
220
+ // Pruned by any server: reading on could skip a change. A 0.4 cursor also expires by date,
221
+ // since 0.4 pruned by age, not feed order.
233
222
  if (after &&
234
223
  ((head.pruned_txid !== null &&
235
224
  (BigInt(head.pruned_txid) > BigInt(after.txid) ||
@@ -241,8 +230,8 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
241
230
  }
242
231
  const changes = rows.flatMap((row) => (row.id === null ? [] : [toChange(row)]));
243
232
  const last = changes.at(-1);
244
- // With nothing new the position stays, dated now: a reader that is up to date stays current.
245
- // Before any change, every one still to come is after the oldest transaction still running.
233
+ // Nothing new: the position stays, dated now. Before any change, every one to come is after
234
+ // the oldest running transaction.
246
235
  const here = after ?? { txid: head.horizon, id: '0', at: 0 };
247
236
  return {
248
237
  ok: true,
@@ -281,7 +270,6 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
281
270
  }
282
271
  const secret = `whsec_${randomBytes(32).toString('base64')}`;
283
272
  return inTransaction(sql, async (tx) => {
284
- // One at a time per tenant, so racing creates stop at the limit.
285
273
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:webhooks:${params.tenantRef}`}, 0))`;
286
274
  const [count] = await tx `
287
275
  SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
@@ -289,7 +277,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
289
277
  if ((count?.count ?? 0) >= SLOTLOCK_MAX_WEBHOOK_ENDPOINTS) {
290
278
  return { ok: false, code: 'webhook_limit_reached', limit: SLOTLOCK_MAX_WEBHOOK_ENDPOINTS };
291
279
  }
292
- // From now on: every transaction that has not ended yet is at or after this position.
280
+ // From now on: every transaction not yet ended is at or after this position.
293
281
  const [row] = await tx `
294
282
  INSERT INTO slotlock.webhook_endpoints (tenant_ref, url, secret, event_types, cursor_txid)
295
283
  VALUES (${params.tenantRef}, ${params.url}, ${secret}, ${types}::text[],
@@ -323,7 +311,6 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
323
311
  const [left] = await tx `
324
312
  SELECT count(*)::int AS count FROM slotlock.webhook_endpoints
325
313
  WHERE tenant_ref = ${params.tenantRef}`;
326
- // The tenant's last endpoint takes it off the list servers deliver for.
327
314
  if ((left?.count ?? 0) === 0) {
328
315
  await tx `SELECT slotlock.unregister_webhook_tenant(${params.tenantRef}::text)`;
329
316
  }
@@ -443,13 +430,12 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
443
430
  AND lease_token = ${params.leaseToken}::uuid
444
431
  RETURNING disabled_at IS NOT NULL AS disabled`;
445
432
  if (settled?.disabled) {
446
- // The tenant hears of it on the feed, which every reader and other endpoint sees.
447
433
  await tx `
448
434
  INSERT INTO slotlock.outbox (tenant_ref, type, subject, data)
449
435
  VALUES (${params.tenantRef}, 'webhook.disabled', ${params.endpointId},
450
436
  ${tx.json({
451
- // The id alone: the feed goes to the tenant's other endpoints, and a URL can
452
- // carry a token.
437
+ // The id alone: the feed reaches other endpoints, and a URL can carry a
438
+ // token.
453
439
  id: params.endpointId,
454
440
  reason: failure?.gone === true ? 'gone' : 'failing',
455
441
  last_status: failure?.status ?? null,
@@ -493,10 +479,9 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
493
479
  async pruneChanges(params) {
494
480
  if (!isIdentity(params.tenantRef))
495
481
  throw identityError();
496
- // Oldest first in feed order, and only up to the first change kept: a change still within the
497
- // retention, one an enabled webhook has still to receive, or one of a transaction later than
498
- // the oldest still running. So every change kept is after every change pruned (the trigger
499
- // on the feed records how far), and a reader starting over reads on past the gap.
482
+ // Oldest first in feed order, and only up to the first change kept (within retention, still
483
+ // owed to an enabled webhook, or later than the oldest running transaction), so every kept
484
+ // change is after every pruned one and a reader starting over reads past the gap.
500
485
  const rows = await sql `
501
486
  WITH head AS MATERIALIZED (
502
487
  SELECT outbox.id, outbox.txid,
package/event-schema.js CHANGED
@@ -1,22 +1,19 @@
1
- // A calendar event as the event tools return it, and as the application API returns any of a
2
- // tenant's events. Its own module because both the agent server and the application API describe
3
- // events with it, and the application API imports nothing from the agent server at runtime (the
4
- // agent server imports it, so the reverse would be a cycle).
1
+ // A calendar event as the event tools and the application API return it. Its own module so the
2
+ // application API need not import the agent server at runtime (that would be a cycle).
5
3
  import { z } from 'zod';
6
4
  const DATE_TIME = z.string().datetime({ offset: true });
7
5
  const RESOURCE_ID = z.string().trim().min(1).max(200);
8
6
  const EVENT_ID = z.string().trim().min(1).max(500);
9
7
  const REVISION = z.number().int().positive();
10
- // Output bounds mirror the trusted store's byte ceilings. A value admitted by a UTF-8 byte
11
- // ceiling can never exceed that many UTF-16 code units, so every valid stored/provider event is
12
- // serializable without truncating recurrence semantics or calendar identities.
8
+ // Output bounds mirror the store's UTF-8 byte ceilings, which never exceed as many UTF-16 code
9
+ // units, so every stored event serializes untruncated.
13
10
  const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
14
11
  const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
15
12
  const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
16
13
  const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
17
14
  const TRUSTED_REMINDER_MINUTES_OUTPUT_MAX = 366 * 24 * 60;
18
- // The grammar the store keeps an address in (normalizeEmail in sync.ts), so every stored one can be
19
- // returned: an address from an imported feed need not be one z.email() takes (ops@localhost).
15
+ // The grammar the store keeps an address in (sync.ts normalizeEmail): an imported address need not
16
+ // pass z.email() (ops@localhost).
20
17
  const STORED_ADDRESS = z
21
18
  .string()
22
19
  .min(3)
package/hooks.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  /**
2
- * Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
3
- * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
4
- * the process on one by default. A hook that throws is caught where it is called; this is the same
5
- * for one that rejects.
2
+ * Let a hook's promise fail unseen: an async `onEvent` or `onError` returns a promise whose
3
+ * rejection would be unhandled, which ends a Node process by default.
6
4
  */
7
5
  export declare function ignoreRejection(result: unknown): void;
package/hooks.js CHANGED
@@ -1,8 +1,6 @@
1
1
  /**
2
- * Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
3
- * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
4
- * the process on one by default. A hook that throws is caught where it is called; this is the same
5
- * for one that rejects.
2
+ * Let a hook's promise fail unseen: an async `onEvent` or `onError` returns a promise whose
3
+ * rejection would be unhandled, which ends a Node process by default.
6
4
  */
7
5
  export function ignoreRejection(result) {
8
6
  if ((typeof result === 'object' || typeof result === 'function') &&
@@ -1,5 +1,5 @@
1
- // One worker of createICalendarWorkerPool: reads the feeds it is sent with the ordinary parsers and
2
- // posts back what they return, or the code of the CalendarContractError they throw.
1
+ // One worker of createICalendarWorkerPool: runs the ordinary parsers and posts back their result or
2
+ // CalendarContractError code.
3
3
  import { parentPort } from 'node:worker_threads';
4
4
  import { CalendarContractError, CalendarExpansionLimitError, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, } from './sync.js';
5
5
  const port = parentPort;
@@ -16,8 +16,7 @@ port.on('message', (request) => {
16
16
  reply = { id: request.id, ok: true, value };
17
17
  }
18
18
  catch (error) {
19
- // The parsers turn every failure into a CalendarContractError; anything else is a fault, and
20
- // ends this worker so the pool reports it.
19
+ // Anything but a CalendarContractError is a fault: end this worker so the pool reports it.
21
20
  if (!(error instanceof CalendarContractError))
22
21
  throw error;
23
22
  reply = {
package/ical-worker.d.ts CHANGED
@@ -28,9 +28,9 @@ export interface ICalendarWorkerPoolOptions {
28
28
  /** Workers that may run at once, 1-64. Defaults to 2. */
29
29
  size?: number;
30
30
  /**
31
- * How long one parse may run on its worker, in ms, before the worker is stopped and the feed
32
- * refused (CalendarExpansionLimitError), 100-60,000. Defaults to 3,000: several times what the
33
- * parser's step budget takes, with room for a new worker to start.
31
+ * How long one parse may run, in ms (100-60,000), before its worker is stopped and the feed
32
+ * refused (CalendarExpansionLimitError). Defaults to 3,000: several times the parser's step
33
+ * budget, with room for a worker to start.
34
34
  */
35
35
  deadlineMs?: number;
36
36
  /** Parses that may wait for a worker, 0-10,000; more are refused at once. Defaults to 64. */
@@ -38,8 +38,8 @@ export interface ICalendarWorkerPoolOptions {
38
38
  /** The heap one worker may use, in MB, 16-4,096. Defaults to 128. */
39
39
  maxHeapMb?: number;
40
40
  /**
41
- * The module each worker runs. Defaults to `ical-worker-thread.js` beside this module, which the
42
- * built package ships; Slotlock's own TypeScript sources cannot start it (tests pass a stand-in).
41
+ * The module each worker runs. Defaults to the built `ical-worker-thread.js` beside this module;
42
+ * Slotlock's TypeScript sources cannot start it (tests pass a stand-in).
43
43
  */
44
44
  entry?: URL;
45
45
  }
@@ -64,11 +64,9 @@ export declare class ICalendarWorkerPoolClosedError extends Error {
64
64
  constructor();
65
65
  }
66
66
  /**
67
- * A pool of worker threads that read untrusted iCalendar for the calling thread. Results and
68
- * errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
69
- * `parseTrustedICalendarEvents` (the same
70
- * `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
71
- * deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
67
+ * A pool of worker threads reading untrusted iCalendar for the calling thread. Results and errors
68
+ * are the parsers' (same `CalendarContractError` codes), plus CalendarExpansionLimitError past a
69
+ * worker's deadline or heap, ICalendarWorkerBusyError when the queue is full, and
72
70
  * ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
73
71
  */
74
72
  export declare function createICalendarWorkerPool(options?: ICalendarWorkerPoolOptions): ICalendarWorkerPool;
package/ical-worker.js CHANGED
@@ -30,11 +30,9 @@ function workerFailure(error) {
30
30
  return new Error('slotlock: an iCalendar worker failed', { cause: error });
31
31
  }
32
32
  /**
33
- * A pool of worker threads that read untrusted iCalendar for the calling thread. Results and
34
- * errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
35
- * `parseTrustedICalendarEvents` (the same
36
- * `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
37
- * deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
33
+ * A pool of worker threads reading untrusted iCalendar for the calling thread. Results and errors
34
+ * are the parsers' (same `CalendarContractError` codes), plus CalendarExpansionLimitError past a
35
+ * worker's deadline or heap, ICalendarWorkerBusyError when the queue is full, and
38
36
  * ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
39
37
  */
40
38
  export function createICalendarWorkerPool(options = {}) {
@@ -100,8 +98,8 @@ export function createICalendarWorkerPool(options = {}) {
100
98
  nextId += 1;
101
99
  slot.id = nextId;
102
100
  slot.worker.ref();
103
- // Past its deadline the feed is refused and its worker stopped: whatever ical.js is doing,
104
- // terminate() ends it, and the next parse gets a new worker.
101
+ // Past its deadline the feed is refused and terminate() ends the worker, whatever ical.js is
102
+ // doing.
105
103
  slot.timer = setTimeout(() => retire(slot, new CalendarExpansionLimitError()), deadlineMs);
106
104
  slot.worker.postMessage({ ...job.request, id: slot.id });
107
105
  }
@@ -144,8 +142,8 @@ export function createICalendarWorkerPool(options = {}) {
144
142
  WorkerClass ??= (await threads).Worker;
145
143
  if (closed)
146
144
  throw new ICalendarWorkerPoolClosedError();
147
- // After the await: parses that arrived together resume in order, each seeing the queue as the
148
- // ones before it left it.
145
+ // After the await, parses that arrived together resume in order, each seeing the queue as the
146
+ // ones before left it.
149
147
  if (queue.length >= maxQueue && !hasIdleCapacity())
150
148
  throw new ICalendarWorkerBusyError();
151
149
  return new Promise((resolve, reject) => {
package/interop.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // directly.
5
5
  import ICAL from 'ical.js';
6
6
  import { zonedDateTimeFields } from './timezone.js';
7
- import { parseAvailabilityRules } from './weekly-hours.js';
7
+ import { invalidAvailability, parseAvailabilityRules } from './weekly-hours.js';
8
8
  /** The longest VAVAILABILITY text `parseVAvailability` reads. */
9
9
  export const SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS = 65_536;
10
10
  const PRODID = '-//Pylota//Slotlock//EN';
@@ -13,9 +13,6 @@ const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
13
13
  /** 2024-01-07 is a Sunday: the reference week runs Sunday 7 to Saturday 13 January. */
14
14
  const REFERENCE_SUNDAY = Date.UTC(2024, 0, 7);
15
15
  const VAVAILABILITY_DTSTAMP = '20240101T000000Z';
16
- function invalidAvailability(message) {
17
- return Object.assign(new Error(message), { code: 'invalid_availability' });
18
- }
19
16
  const pad = (value, width = 2) => String(value).padStart(width, '0');
20
17
  /** RFC 5545 §3.1: lines of at most 75 octets, continued with a leading space. */
21
18
  function fold(line) {
package/maintenance.d.ts CHANGED
@@ -8,10 +8,9 @@ export interface SlotlockMaintenanceResult {
8
8
  /** Series over the expansion budget, or whose roll failed otherwise; they keep their old coverage. */
9
9
  refused: number;
10
10
  /**
11
- * Expired holds and the answers kept for keyed ones, and past the retention window event
12
- * idempotency answers, agents' event cancellations, booking references retired with no
13
- * booking and feed changes, deleted, and dashboard assistant conversations past their expiry.
14
- * A cancelled booking's tombstone stays.
11
+ * Rows deleted: expired holds and their keyed answers; past retention, event idempotency answers,
12
+ * agent event cancellations, stray booking retirements and feed changes; and expired assistant
13
+ * conversations. A cancelled booking's tombstone stays.
15
14
  */
16
15
  pruned: number;
17
16
  /**
@@ -28,16 +27,15 @@ export interface SlotlockMaintenanceResult {
28
27
  /** More was left than one pass does: run another. */
29
28
  capped: boolean;
30
29
  /**
31
- * The roll stopped before the series it had put back: series it has not tried since may still
32
- * extend, so another pass should follow at once.
30
+ * The roll stopped before the series it had deferred: others may still extend, so run another
31
+ * pass at once.
33
32
  */
34
33
  rollPending: boolean;
35
34
  }
36
35
  /**
37
- * One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then purge
38
- * its expired holds with their answers and prune its events' and bookings' bookkeeping past the
39
- * retention window, each in at most `batches` batches (1-100, default 10), every batch its own
40
- * transaction. Aborting `signal` stops it between batches with the signal's reason.
36
+ * One maintenance pass for a tenant: roll its recurring events to the horizon, then purge expired
37
+ * holds and prune bookkeeping past retention, each in at most `batches` batches (1-100, default
38
+ * 10), each its own transaction. Aborting `signal` stops it between batches.
41
39
  */
42
40
  export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
43
41
  batches?: number;
@@ -63,6 +61,12 @@ export interface SlotlockMaintenanceSchedulerOptions {
63
61
  sql: Sql;
64
62
  /** Tenants maintained from the start, whether or not a key of theirs calls. */
65
63
  tenants: readonly string[];
64
+ /**
65
+ * Every tenant with data (slotlock.maintenance_tenants), read at the start and once an interval;
66
+ * each not maintained within the interval is queued, so a tenant whose keys never call is
67
+ * maintained too. What it throws goes to onError.
68
+ */
69
+ discover?: () => Promise<readonly string[]>;
66
70
  /** How often each tenant is maintained, at most a day. */
67
71
  intervalMs: number;
68
72
  /** Tenants remembered; the one served longest ago is forgotten first. Default 10,000. */
package/maintenance.js CHANGED
@@ -1,17 +1,15 @@
1
1
  import { ignoreRejection } from './hooks.js';
2
2
  import { SLOTLOCK_MAX_WAITLIST_PER_TENANT, calendarEventRollingHorizon, } from './store.js';
3
3
  /**
4
- * One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then purge
5
- * its expired holds with their answers and prune its events' and bookings' bookkeeping past the
6
- * retention window, each in at most `batches` batches (1-100, default 10), every batch its own
7
- * transaction. Aborting `signal` stops it between batches with the signal's reason.
4
+ * One maintenance pass for a tenant: roll its recurring events to the horizon, then purge expired
5
+ * holds and prune bookkeeping past retention, each in at most `batches` batches (1-100, default
6
+ * 10), each its own transaction. Aborting `signal` stops it between batches.
8
7
  */
9
8
  export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
10
9
  const batches = options.batches ?? 10;
11
10
  if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
12
11
  throw new RangeError('batches must be a whole number from 1 to 100');
13
12
  }
14
- // What a pass changes (a no-show released, a waitlist offer) is recorded as maintenance's.
15
13
  const withTenant = (callback) => store.withTenant(tenantRef, callback, { origin: 'maintenance' });
16
14
  const window = calendarEventRollingHorizon();
17
15
  let extended = 0;
@@ -19,8 +17,7 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
19
17
  let refused = 0;
20
18
  let rollCapped = true;
21
19
  let after = null;
22
- // A roll that fails does not stop the pruning: expired holds are purged all the same, and the
23
- // roll's error is thrown after.
20
+ // A failed roll does not stop the pruning; its error is thrown after.
24
21
  let rollFailure = null;
25
22
  try {
26
23
  for (let batch = 0; batch < batches; batch++) {
@@ -65,15 +62,14 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
65
62
  }
66
63
  }
67
64
  options.signal?.throwIfAborted();
68
- // Conversations last 2 hours after their last message (dashboard.ts): an expired one is
69
- // unreadable already, and deleted here.
65
+ // Conversations expire 2 hours after their last message (dashboard.ts).
70
66
  pruned += await withTenant((tenant) => tenant.pruneAssistantConversations({ tenantRef }));
71
67
  const zones = await withTenant((tenant) => tenant.followZoneRules({ tenantRef, move: options.followZoneRules === true }));
72
68
  const staleSources = await withTenant((tenant) => tenant.reportStaleCoverage({ tenantRef }));
73
69
  // No-shows first: the time they free is offered to the waitlist in the same pass.
74
70
  const noShows = await withTenant((tenant) => tenant.releaseNoShows({ tenantRef }));
75
- // The whole waitlist, in join order, a page at a time and each page its own transaction, so a
76
- // later entry whose window is free is offered however many wait before it.
71
+ // The whole waitlist in join order, a page per transaction, so a later free entry is offered
72
+ // however many wait before it.
77
73
  let waitlistOffers = 0;
78
74
  let page = null;
79
75
  for (let pages = 0; pages < WAITLIST_PAGES; pages++) {
@@ -183,12 +179,10 @@ export function startSlotlockMaintenance(options) {
183
179
  catch {
184
180
  // Reporting must never stop maintenance, or make a pass that ran look failed.
185
181
  }
186
- // A capped pass that got somewhere goes round again at once, as does one whose roll
187
- // stopped before the series it had put back: each series it could not extend is now
188
- // behind every one it has not tried (the store puts it there), so the next pass meets
189
- // new ones, and however many are stuck, each is met once before a healthy one is
190
- // reached. A pass that reached the series put back waits for the next interval: they
191
- // take turns there, and the next pass reaches anything new before them.
182
+ // A capped pass that progressed, or whose roll stopped before its deferred series, goes
183
+ // again at once: deferred series sit behind untried ones, so each stuck one is met once
184
+ // before a healthy one is reached. A pass that reached the deferred series waits for
185
+ // the next interval.
192
186
  const progressed = result.extended + result.pruned > 0;
193
187
  again = result.capped && (progressed || result.rollPending);
194
188
  }
@@ -215,20 +209,42 @@ export function startSlotlockMaintenance(options) {
215
209
  working = null;
216
210
  }
217
211
  };
218
- // Expired assistant conversations of every tenant, the ones whose keys never call included (a
219
- // tenant only the dashboard serves): one bounded sweep at most every quarter hour.
212
+ const report = (error) => {
213
+ try {
214
+ ignoreRejection(options.onError?.(error));
215
+ }
216
+ catch {
217
+ // Reporting must never stop maintenance.
218
+ }
219
+ };
220
+ // Expired conversations of every tenant, including those no key calls (dashboard-only): one
221
+ // bounded sweep at most every quarter hour.
220
222
  let lastSweep = 0;
221
223
  const sweep = () => {
222
224
  if (controller.signal.aborted || Date.now() - lastSweep < Math.min(intervalMs, SWEEP_INTERVAL_MS))
223
225
  return;
224
226
  lastSweep = Date.now();
225
- options.store.sweepAssistantConversations().catch((error) => {
226
- try {
227
- ignoreRejection(options.onError?.(error));
228
- }
229
- catch {
230
- // Reporting must never stop maintenance.
231
- }
227
+ options.store.sweepAssistantConversations().catch(report);
228
+ };
229
+ // Every tenant with data, once an interval: those no key has called since the server started too.
230
+ let lastDiscovery = Number.NEGATIVE_INFINITY;
231
+ let discovering = false;
232
+ const discover = () => {
233
+ const list = options.discover;
234
+ if (!list || discovering || controller.signal.aborted)
235
+ return;
236
+ if (Date.now() - lastDiscovery < intervalMs)
237
+ return;
238
+ lastDiscovery = Date.now();
239
+ discovering = true;
240
+ (async () => list())()
241
+ .then((tenants) => {
242
+ for (const tenantRef of tenants)
243
+ if (due(tenantRef))
244
+ enqueue(tenantRef);
245
+ }, report)
246
+ .finally(() => {
247
+ discovering = false;
232
248
  });
233
249
  };
234
250
  const tick = () => {
@@ -236,6 +252,7 @@ export function startSlotlockMaintenance(options) {
236
252
  if (due(tenantRef))
237
253
  enqueue(tenantRef);
238
254
  }
255
+ discover();
239
256
  sweep();
240
257
  };
241
258
  const timer = setInterval(tick, Math.min(intervalMs, 60_000));