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.
- package/CHANGELOG.md +25 -0
- package/README.md +13 -6
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +13 -0
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/event-feed.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// The event feed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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:
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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.
|
|
79
|
-
*
|
|
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
|
|
93
|
-
*
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
231
|
-
//
|
|
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
|
-
//
|
|
245
|
-
//
|
|
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
|
|
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
|
|
452
|
-
//
|
|
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
|
|
497
|
-
//
|
|
498
|
-
//
|
|
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
|
|
2
|
-
//
|
|
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
|
|
11
|
-
//
|
|
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 (
|
|
19
|
-
//
|
|
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
|
|
3
|
-
*
|
|
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
|
|
3
|
-
*
|
|
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') &&
|
package/ical-worker-thread.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// One worker of createICalendarWorkerPool:
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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
|
|
32
|
-
* refused (CalendarExpansionLimitError)
|
|
33
|
-
*
|
|
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
|
|
42
|
-
*
|
|
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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
104
|
-
//
|
|
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
|
|
148
|
-
// ones before
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
|
32
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
|
76
|
-
//
|
|
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
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
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
|
-
|
|
219
|
-
|
|
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(
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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));
|