slotlock 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/engine.js
CHANGED
|
@@ -47,13 +47,27 @@ export function findNextAvailable(params) {
|
|
|
47
47
|
const { busy, windows, durationMs } = params;
|
|
48
48
|
if (!Number.isFinite(durationMs) || durationMs <= 0)
|
|
49
49
|
return null;
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
const slotAt = (start) => ({ start, end: new Date(start.getTime() + durationMs) });
|
|
51
|
+
// One walk of the merged busy list alongside the merged windows, both by start: a busy interval
|
|
52
|
+
// ending by a window's start ends before every later one too.
|
|
53
|
+
const merged = mergeIntervals(busy);
|
|
54
|
+
let next = 0;
|
|
55
|
+
for (const window of mergeIntervals(windows)) {
|
|
56
|
+
while (next < merged.length && merged[next].end.getTime() <= window.start.getTime()) {
|
|
57
|
+
next += 1;
|
|
56
58
|
}
|
|
59
|
+
let cursor = window.start;
|
|
60
|
+
for (let index = next; index < merged.length; index++) {
|
|
61
|
+
const taken = merged[index];
|
|
62
|
+
if (taken.start.getTime() >= window.end.getTime())
|
|
63
|
+
break;
|
|
64
|
+
if (taken.start.getTime() - cursor.getTime() >= durationMs)
|
|
65
|
+
return slotAt(cursor);
|
|
66
|
+
if (taken.end.getTime() > cursor.getTime())
|
|
67
|
+
cursor = taken.end;
|
|
68
|
+
}
|
|
69
|
+
if (window.end.getTime() - cursor.getTime() >= durationMs)
|
|
70
|
+
return slotAt(cursor);
|
|
57
71
|
}
|
|
58
72
|
return null;
|
|
59
73
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const SLOTLOCK_EVENT_OUTPUT: z.ZodObject<{
|
|
3
|
+
id: z.ZodString;
|
|
4
|
+
resource_id: z.ZodString;
|
|
5
|
+
starts_at: z.ZodString;
|
|
6
|
+
ends_at: z.ZodString;
|
|
7
|
+
timezone: z.ZodString;
|
|
8
|
+
title: z.ZodNullable<z.ZodString>;
|
|
9
|
+
description: z.ZodNullable<z.ZodString>;
|
|
10
|
+
location: z.ZodNullable<z.ZodString>;
|
|
11
|
+
organizer: z.ZodNullable<z.ZodObject<{
|
|
12
|
+
address: z.ZodString;
|
|
13
|
+
name: z.ZodNullable<z.ZodString>;
|
|
14
|
+
}, z.core.$strict>>;
|
|
15
|
+
attendees: z.ZodArray<z.ZodObject<{
|
|
16
|
+
address: z.ZodString;
|
|
17
|
+
name: z.ZodNullable<z.ZodString>;
|
|
18
|
+
role: z.ZodEnum<{
|
|
19
|
+
chair: "chair";
|
|
20
|
+
non_participant: "non_participant";
|
|
21
|
+
optional: "optional";
|
|
22
|
+
required: "required";
|
|
23
|
+
}>;
|
|
24
|
+
participation_status: z.ZodEnum<{
|
|
25
|
+
accepted: "accepted";
|
|
26
|
+
declined: "declined";
|
|
27
|
+
delegated: "delegated";
|
|
28
|
+
needs_action: "needs_action";
|
|
29
|
+
tentative: "tentative";
|
|
30
|
+
}>;
|
|
31
|
+
rsvp: z.ZodBoolean;
|
|
32
|
+
}, z.core.$strict>>;
|
|
33
|
+
reminders: z.ZodArray<z.ZodObject<{
|
|
34
|
+
minutes_before: z.ZodNumber;
|
|
35
|
+
channel: z.ZodEnum<{
|
|
36
|
+
display: "display";
|
|
37
|
+
email: "email";
|
|
38
|
+
}>;
|
|
39
|
+
}, z.core.$strict>>;
|
|
40
|
+
status: z.ZodEnum<{
|
|
41
|
+
cancelled: "cancelled";
|
|
42
|
+
confirmed: "confirmed";
|
|
43
|
+
tentative: "tentative";
|
|
44
|
+
}>;
|
|
45
|
+
transparency: z.ZodEnum<{
|
|
46
|
+
opaque: "opaque";
|
|
47
|
+
transparent: "transparent";
|
|
48
|
+
}>;
|
|
49
|
+
sequence: z.ZodNumber;
|
|
50
|
+
revision: z.ZodNumber;
|
|
51
|
+
recurrence_rule: z.ZodNullable<z.ZodString>;
|
|
52
|
+
recurrence_exceptions: z.ZodArray<z.ZodObject<{
|
|
53
|
+
recurrence_id: z.ZodString;
|
|
54
|
+
cancelled: z.ZodBoolean;
|
|
55
|
+
starts_at: z.ZodNullable<z.ZodString>;
|
|
56
|
+
ends_at: z.ZodNullable<z.ZodString>;
|
|
57
|
+
}, z.core.$strict>>;
|
|
58
|
+
recurrence_id: z.ZodNullable<z.ZodString>;
|
|
59
|
+
}, z.core.$strict>;
|
package/event-schema.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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).
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
const DATE_TIME = z.string().datetime({ offset: true });
|
|
7
|
+
const RESOURCE_ID = z.string().trim().min(1).max(200);
|
|
8
|
+
const EVENT_ID = z.string().trim().min(1).max(500);
|
|
9
|
+
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.
|
|
13
|
+
const TRUSTED_SUMMARY_OUTPUT_MAX = 4_096;
|
|
14
|
+
const TRUSTED_TEXT_OUTPUT_MAX = 16_384;
|
|
15
|
+
const TRUSTED_PERSON_NAME_OUTPUT_MAX = 1_024;
|
|
16
|
+
const TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX = 1_000;
|
|
17
|
+
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).
|
|
20
|
+
const STORED_ADDRESS = z
|
|
21
|
+
.string()
|
|
22
|
+
.min(3)
|
|
23
|
+
.max(320)
|
|
24
|
+
.refine((value) => /^[^\s@]+@[^\s@]+$/.test(value), {
|
|
25
|
+
message: 'must be one @ between characters other than spaces',
|
|
26
|
+
});
|
|
27
|
+
const ORGANIZER = z
|
|
28
|
+
.object({
|
|
29
|
+
address: STORED_ADDRESS,
|
|
30
|
+
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
const ATTENDEE = z
|
|
34
|
+
.object({
|
|
35
|
+
address: STORED_ADDRESS,
|
|
36
|
+
name: z.string().trim().max(TRUSTED_PERSON_NAME_OUTPUT_MAX).nullable(),
|
|
37
|
+
role: z.enum(['chair', 'required', 'optional', 'non_participant']),
|
|
38
|
+
participation_status: z.enum([
|
|
39
|
+
'needs_action',
|
|
40
|
+
'accepted',
|
|
41
|
+
'declined',
|
|
42
|
+
'tentative',
|
|
43
|
+
'delegated',
|
|
44
|
+
]),
|
|
45
|
+
rsvp: z.boolean(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
const REMINDER = z
|
|
49
|
+
.object({
|
|
50
|
+
minutes_before: z.number().int().min(0).max(TRUSTED_REMINDER_MINUTES_OUTPUT_MAX),
|
|
51
|
+
channel: z.enum(['display', 'email']),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
const RECURRENCE_EXCEPTION = z
|
|
55
|
+
.object({
|
|
56
|
+
recurrence_id: DATE_TIME,
|
|
57
|
+
cancelled: z.boolean(),
|
|
58
|
+
starts_at: DATE_TIME.nullable(),
|
|
59
|
+
ends_at: DATE_TIME.nullable(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
|
62
|
+
export const SLOTLOCK_EVENT_OUTPUT = z
|
|
63
|
+
.object({
|
|
64
|
+
id: EVENT_ID,
|
|
65
|
+
resource_id: RESOURCE_ID,
|
|
66
|
+
starts_at: DATE_TIME,
|
|
67
|
+
ends_at: DATE_TIME,
|
|
68
|
+
timezone: z.string().min(1).max(100),
|
|
69
|
+
title: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
70
|
+
description: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
71
|
+
location: z.string().max(TRUSTED_TEXT_OUTPUT_MAX).nullable(),
|
|
72
|
+
organizer: ORGANIZER.nullable(),
|
|
73
|
+
attendees: z.array(ATTENDEE).max(100),
|
|
74
|
+
reminders: z.array(REMINDER).max(20),
|
|
75
|
+
status: z.enum(['tentative', 'confirmed', 'cancelled']),
|
|
76
|
+
transparency: z.enum(['opaque', 'transparent']),
|
|
77
|
+
sequence: z.number().int().nonnegative(),
|
|
78
|
+
revision: REVISION,
|
|
79
|
+
recurrence_rule: z.string().max(TRUSTED_SUMMARY_OUTPUT_MAX).nullable(),
|
|
80
|
+
recurrence_exceptions: z
|
|
81
|
+
.array(RECURRENCE_EXCEPTION)
|
|
82
|
+
.max(TRUSTED_RECURRENCE_EXCEPTION_OUTPUT_MAX),
|
|
83
|
+
recurrence_id: DATE_TIME.nullable(),
|
|
84
|
+
})
|
|
85
|
+
.strict();
|
package/hooks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ignoreRejection(result: unknown): void;
|
package/hooks.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
export function ignoreRejection(result) {
|
|
8
|
+
if ((typeof result === 'object' || typeof result === 'function') &&
|
|
9
|
+
result !== null &&
|
|
10
|
+
typeof result.then === 'function') {
|
|
11
|
+
Promise.resolve(result).catch(() => undefined);
|
|
12
|
+
}
|
|
13
|
+
}
|
package/ical-worker-thread.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// One worker of createICalendarWorkerPool: reads the feeds it is sent with the ordinary parsers and
|
|
2
2
|
// posts back what they return, or the code of the CalendarContractError they throw.
|
|
3
3
|
import { parentPort } from 'node:worker_threads';
|
|
4
|
-
import { CalendarContractError, CalendarExpansionLimitError, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
|
|
4
|
+
import { CalendarContractError, CalendarExpansionLimitError, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, } from './sync.js';
|
|
5
5
|
const port = parentPort;
|
|
6
6
|
if (!port)
|
|
7
7
|
throw new Error('slotlock: ical-worker-thread runs only as a worker thread');
|
|
@@ -10,7 +10,9 @@ port.on('message', (request) => {
|
|
|
10
10
|
try {
|
|
11
11
|
const value = request.op === 'changes'
|
|
12
12
|
? parseICalendarChanges(request.input, request.calendarTimezone, request.window)
|
|
13
|
-
:
|
|
13
|
+
: request.op === 'feed'
|
|
14
|
+
? parseICalendarFeed(request.input, request.calendarTimezone, request.window)
|
|
15
|
+
: parseTrustedICalendarEvents(request.input, request.calendarTimezone);
|
|
14
16
|
reply = { id: request.id, ok: true, value };
|
|
15
17
|
}
|
|
16
18
|
catch (error) {
|
package/ical-worker.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { CalendarContractError, type ExternalCalendarChange, type ICalendarExpansionWindow } from './sync.js';
|
|
1
|
+
import { CalendarContractError, type ExternalCalendarChange, type ICalendarExpansionWindow, type ICalendarFeedRead } from './sync.js';
|
|
2
2
|
import type { TrustedICalendarEvent } from './types.js';
|
|
3
3
|
/** What the pool posts to a worker. */
|
|
4
4
|
export type ICalendarWorkerRequest = {
|
|
5
5
|
id: number;
|
|
6
|
-
op: 'changes';
|
|
6
|
+
op: 'changes' | 'feed';
|
|
7
7
|
input: string;
|
|
8
8
|
calendarTimezone: string | undefined;
|
|
9
9
|
window: ICalendarExpansionWindow | undefined;
|
|
@@ -29,8 +29,8 @@ export interface ICalendarWorkerPoolOptions {
|
|
|
29
29
|
size?: number;
|
|
30
30
|
/**
|
|
31
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:
|
|
33
|
-
*
|
|
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.
|
|
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. */
|
|
@@ -46,6 +46,8 @@ export interface ICalendarWorkerPoolOptions {
|
|
|
46
46
|
export interface ICalendarWorkerPool {
|
|
47
47
|
/** `parseICalendarChanges`, run on a worker. */
|
|
48
48
|
parseICalendarChanges(input: string, calendarTimezone?: string, expansionWindow?: ICalendarExpansionWindow): Promise<ExternalCalendarChange[]>;
|
|
49
|
+
/** `parseICalendarFeed`, run on a worker. */
|
|
50
|
+
parseICalendarFeed(input: string, calendarTimezone?: string, expansionWindow?: ICalendarExpansionWindow): Promise<ICalendarFeedRead>;
|
|
49
51
|
/** `parseTrustedICalendarEvents`, run on a worker. */
|
|
50
52
|
parseTrustedICalendarEvents(input: string, calendarTimezone?: string): Promise<TrustedICalendarEvent[]>;
|
|
51
53
|
/** Refuse waiting parses and stop every worker. */
|
|
@@ -63,7 +65,8 @@ export declare class ICalendarWorkerPoolClosedError extends Error {
|
|
|
63
65
|
}
|
|
64
66
|
/**
|
|
65
67
|
* A pool of worker threads that read untrusted iCalendar for the calling thread. Results and
|
|
66
|
-
* errors are those of `parseICalendarChanges
|
|
68
|
+
* errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
|
|
69
|
+
* `parseTrustedICalendarEvents` (the same
|
|
67
70
|
* `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
|
|
68
71
|
* deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
|
|
69
72
|
* ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
|
package/ical-worker.js
CHANGED
|
@@ -31,7 +31,8 @@ function workerFailure(error) {
|
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* A pool of worker threads that read untrusted iCalendar for the calling thread. Results and
|
|
34
|
-
* errors are those of `parseICalendarChanges
|
|
34
|
+
* errors are those of `parseICalendarChanges`, `parseICalendarFeed` and
|
|
35
|
+
* `parseTrustedICalendarEvents` (the same
|
|
35
36
|
* `CalendarContractError` codes), plus CalendarExpansionLimitError when a worker passes its
|
|
36
37
|
* deadline or its heap, ICalendarWorkerBusyError when the queue is full, and
|
|
37
38
|
* ICalendarWorkerPoolClosedError after `close()`. Idle workers do not keep the process alive.
|
|
@@ -134,7 +135,7 @@ export function createICalendarWorkerPool(options = {}) {
|
|
|
134
135
|
if (request.calendarTimezone !== undefined && typeof request.calendarTimezone !== 'string') {
|
|
135
136
|
throw new CalendarContractError('invalid_icalendar');
|
|
136
137
|
}
|
|
137
|
-
if (request.op
|
|
138
|
+
if (request.op !== 'trusted' &&
|
|
138
139
|
request.window !== undefined &&
|
|
139
140
|
!(request.window?.start instanceof Date && request.window.end instanceof Date)) {
|
|
140
141
|
throw new CalendarContractError('invalid_icalendar');
|
|
@@ -172,6 +173,12 @@ export function createICalendarWorkerPool(options = {}) {
|
|
|
172
173
|
window,
|
|
173
174
|
}));
|
|
174
175
|
},
|
|
176
|
+
async parseICalendarFeed(input, calendarTimezone, expansionWindow) {
|
|
177
|
+
const window = expansionWindow
|
|
178
|
+
? { start: expansionWindow.start, end: expansionWindow.end }
|
|
179
|
+
: undefined;
|
|
180
|
+
return (await run({ op: 'feed', input, calendarTimezone, window }));
|
|
181
|
+
},
|
|
175
182
|
async parseTrustedICalendarEvents(input, calendarTimezone) {
|
|
176
183
|
return (await run({ op: 'trusted', input, calendarTimezone }));
|
|
177
184
|
},
|
package/index.d.ts
CHANGED
|
@@ -5,20 +5,24 @@ export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SL
|
|
|
5
5
|
export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
|
|
6
6
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
7
7
|
export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
|
|
8
|
-
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
8
|
+
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
9
9
|
export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
|
|
10
10
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
11
11
|
export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
|
|
12
|
-
export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
|
|
13
|
-
export type { SlotlockStoreAgentBackendOptions } from './agent-store-backend.js';
|
|
12
|
+
export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
|
|
13
|
+
export type { SlotlockStoreAgentBackendOptions, SlotlockStoreApplicationBackendOptions, } from './agent-store-backend.js';
|
|
14
|
+
export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
|
|
15
|
+
export type { SlotlockApplicationBackend, SlotlockApplicationInvocationContext, SlotlockApplicationOperation, SlotlockApplicationOperationDefinition, } from './application-api.js';
|
|
16
|
+
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
17
|
+
export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
|
|
14
18
|
export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
|
|
15
19
|
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
16
20
|
export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
|
|
17
21
|
export type { CreateSlotlockApiKeyInput, SlotlockApiKey, SlotlockApiKeyAuthenticatorOptions, SlotlockApiKeyIdentity, SlotlockApiKeyScope, SlotlockApiKeyStore, } from './api-keys.js';
|
|
18
22
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
19
23
|
export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-worker.js';
|
|
20
|
-
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
|
|
21
|
-
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ITipMethod, } from './sync.js';
|
|
22
|
-
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
23
|
-
export { calendarEventRollingHorizon, createSlotlockStore, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MAX_EVENT_DURATION_DAYS, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantStore, } from './store.js';
|
|
24
|
-
export type { AcquireHoldResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockHold, SlotlockCalendarEvent, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
|
|
24
|
+
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
25
|
+
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ICalendarFeedRead, ICalendarSeriesRefusal, ITipMethod, } from './sync.js';
|
|
26
|
+
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
27
|
+
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
|
|
28
|
+
export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockHold, SlotlockCalendarEvent, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
|
package/index.js
CHANGED
|
@@ -7,12 +7,14 @@ export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
|
|
|
7
7
|
export { expandRules } from './rules.js';
|
|
8
8
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
9
9
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
10
|
-
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
10
|
+
export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
|
|
11
11
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
12
|
-
export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
|
|
12
|
+
export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
|
|
13
|
+
export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
|
|
14
|
+
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
13
15
|
export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
|
|
14
16
|
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
15
17
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
16
|
-
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
|
|
17
|
-
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
18
|
-
export { calendarEventRollingHorizon, createSlotlockStore, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MAX_EVENT_DURATION_DAYS, } from './store.js';
|
|
18
|
+
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
19
|
+
export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
20
|
+
export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, } from './store.js';
|
package/maintenance.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Sql } from 'postgres';
|
|
2
|
+
import { type SlotlockStore } from './store.js';
|
|
3
|
+
export interface SlotlockMaintenanceResult {
|
|
4
|
+
/** Recurring events whose stored occurrences now reach the horizon. */
|
|
5
|
+
extended: number;
|
|
6
|
+
/** Series that could not extend because an occurrence would overlap a booking. */
|
|
7
|
+
conflicts: number;
|
|
8
|
+
/** Series over the expansion budget, or whose roll failed otherwise; they keep their old coverage. */
|
|
9
|
+
refused: number;
|
|
10
|
+
/**
|
|
11
|
+
* Expired holds and the answers kept for keyed ones, and past the retention window event
|
|
12
|
+
* idempotency answers, agents' event cancellations and booking references retired with no
|
|
13
|
+
* booking, deleted. A cancelled booking's tombstone stays.
|
|
14
|
+
*/
|
|
15
|
+
pruned: number;
|
|
16
|
+
/** More was left than one pass does: run another. */
|
|
17
|
+
capped: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* The roll stopped before the series it had put back: series it has not tried since may still
|
|
20
|
+
* extend, so another pass should follow at once.
|
|
21
|
+
*/
|
|
22
|
+
rollPending: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then purge
|
|
26
|
+
* its expired holds with their answers and prune its events' and bookings' bookkeeping past the
|
|
27
|
+
* retention window, each in at most `batches` batches (1-100, default 10), every batch its own
|
|
28
|
+
* transaction. Aborting `signal` stops it between batches with the signal's reason.
|
|
29
|
+
*/
|
|
30
|
+
export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
|
|
31
|
+
batches?: number;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}): Promise<SlotlockMaintenanceResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
|
36
|
+
* `undefined` without running it when another server holds the lock.
|
|
37
|
+
*/
|
|
38
|
+
export declare function withSlotlockMaintenanceLock<T>(sql: Sql, tenantRef: string, work: () => Promise<T>): Promise<T | undefined>;
|
|
39
|
+
export interface SlotlockMaintenanceScheduler {
|
|
40
|
+
/**
|
|
41
|
+
* A tenant the server just served. Unless it was maintained within the interval, it is queued
|
|
42
|
+
* now, and from then on maintained every interval with the others.
|
|
43
|
+
*/
|
|
44
|
+
touch(tenantRef: string): void;
|
|
45
|
+
/** Stop: no new pass starts, and the one running finishes first. */
|
|
46
|
+
close(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export interface SlotlockMaintenanceSchedulerOptions {
|
|
49
|
+
store: SlotlockStore;
|
|
50
|
+
sql: Sql;
|
|
51
|
+
/** Tenants maintained from the start, whether or not a key of theirs calls. */
|
|
52
|
+
tenants: readonly string[];
|
|
53
|
+
/** How often each tenant is maintained, at most a day. */
|
|
54
|
+
intervalMs: number;
|
|
55
|
+
/** Tenants remembered; the one served longest ago is forgotten first. Default 10,000. */
|
|
56
|
+
maxTenants?: number;
|
|
57
|
+
/** Batches of each task per pass (maintainSlotlockTenant). */
|
|
58
|
+
batches?: number;
|
|
59
|
+
/** A pass that ran: counts only, never the tenant. Not awaited; what it throws is ignored. */
|
|
60
|
+
onPass?: (result: SlotlockMaintenanceResult) => void;
|
|
61
|
+
/**
|
|
62
|
+
* A pass that failed. The scheduler carries on with the next tenant. Not awaited; what it throws,
|
|
63
|
+
* or its promise rejects with, is ignored.
|
|
64
|
+
*/
|
|
65
|
+
onError?: (error: unknown) => void;
|
|
66
|
+
}
|
|
67
|
+
export declare function startSlotlockMaintenance(options: SlotlockMaintenanceSchedulerOptions): SlotlockMaintenanceScheduler;
|
package/maintenance.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { ignoreRejection } from './hooks.js';
|
|
2
|
+
import { calendarEventRollingHorizon } from './store.js';
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
10
|
+
const batches = options.batches ?? 10;
|
|
11
|
+
if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
|
|
12
|
+
throw new RangeError('batches must be a whole number from 1 to 100');
|
|
13
|
+
}
|
|
14
|
+
const window = calendarEventRollingHorizon();
|
|
15
|
+
let extended = 0;
|
|
16
|
+
let conflicts = 0;
|
|
17
|
+
let refused = 0;
|
|
18
|
+
let rollCapped = true;
|
|
19
|
+
let after = null;
|
|
20
|
+
// A roll that fails does not stop the pruning: expired holds are purged all the same, and the
|
|
21
|
+
// roll's error is thrown after.
|
|
22
|
+
let rollFailure = null;
|
|
23
|
+
try {
|
|
24
|
+
for (let batch = 0; batch < batches; batch++) {
|
|
25
|
+
options.signal?.throwIfAborted();
|
|
26
|
+
const from = after;
|
|
27
|
+
const roll = await store.withTenant(tenantRef, (tenant) => tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }));
|
|
28
|
+
extended += roll.extended;
|
|
29
|
+
conflicts += roll.conflicts;
|
|
30
|
+
refused += roll.refused;
|
|
31
|
+
after = roll.next;
|
|
32
|
+
if (!after) {
|
|
33
|
+
rollCapped = false;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (options.signal?.aborted)
|
|
40
|
+
throw error;
|
|
41
|
+
rollFailure = { error };
|
|
42
|
+
}
|
|
43
|
+
let pruned = 0;
|
|
44
|
+
let eventsLeft = true;
|
|
45
|
+
let bookingsLeft = true;
|
|
46
|
+
for (let batch = 0; batch < batches && (eventsLeft || bookingsLeft); batch++) {
|
|
47
|
+
options.signal?.throwIfAborted();
|
|
48
|
+
if (eventsLeft) {
|
|
49
|
+
const prune = await store.withTenant(tenantRef, (tenant) => tenant.pruneCalendarEventRetention({ tenantRef }));
|
|
50
|
+
pruned += prune.commandsDeleted + prune.tombstonesDeleted;
|
|
51
|
+
eventsLeft = prune.hasMore;
|
|
52
|
+
}
|
|
53
|
+
if (bookingsLeft) {
|
|
54
|
+
const prune = await store.withTenant(tenantRef, (tenant) => tenant.pruneBookingRetention({ tenantRef }));
|
|
55
|
+
pruned += prune.expiredHoldsDeleted + prune.holdCommandsDeleted + prune.tombstonesDeleted;
|
|
56
|
+
bookingsLeft = prune.hasMore;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (rollFailure)
|
|
60
|
+
throw rollFailure.error;
|
|
61
|
+
return {
|
|
62
|
+
extended,
|
|
63
|
+
conflicts,
|
|
64
|
+
refused,
|
|
65
|
+
pruned,
|
|
66
|
+
capped: rollCapped || eventsLeft || bookingsLeft,
|
|
67
|
+
rollPending: rollCapped && after !== null && (after.rollRetryAt ?? null) === null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
|
|
71
|
+
const LOCK_NAMESPACE = 'slotlock.maintenance:';
|
|
72
|
+
/**
|
|
73
|
+
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
|
74
|
+
* `undefined` without running it when another server holds the lock.
|
|
75
|
+
*/
|
|
76
|
+
export async function withSlotlockMaintenanceLock(sql, tenantRef, work) {
|
|
77
|
+
const reserved = await sql.reserve();
|
|
78
|
+
try {
|
|
79
|
+
const key = `${LOCK_NAMESPACE}${tenantRef}`;
|
|
80
|
+
const [row] = await reserved `
|
|
81
|
+
SELECT pg_catalog.pg_try_advisory_lock(pg_catalog.hashtextextended(${key}, 0)) AS locked`;
|
|
82
|
+
if (!row?.locked)
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
return await work();
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await reserved `SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended(${key}, 0))`;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
reserved.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const DAY_MS = 86_400_000;
|
|
96
|
+
export function startSlotlockMaintenance(options) {
|
|
97
|
+
const { intervalMs } = options;
|
|
98
|
+
if (!(Number.isSafeInteger(intervalMs) && intervalMs >= 1_000 && intervalMs <= DAY_MS)) {
|
|
99
|
+
throw new RangeError('intervalMs must be from one second to one day');
|
|
100
|
+
}
|
|
101
|
+
const maxTenants = options.maxTenants ?? 10_000;
|
|
102
|
+
/** Configured tenants, never forgotten: tenant reference to when its last pass finished (0: never). */
|
|
103
|
+
const pinned = new Map(options.tenants.map((tenantRef) => [tenantRef, 0]));
|
|
104
|
+
/** Served tenants, in the order last served; the oldest is forgotten past `maxTenants`. */
|
|
105
|
+
const served = new Map();
|
|
106
|
+
const queue = [];
|
|
107
|
+
/** Tenants waiting or in a pass: a tenant served during its own pass is not queued again. */
|
|
108
|
+
const queued = new Set();
|
|
109
|
+
const controller = new AbortController();
|
|
110
|
+
let working = null;
|
|
111
|
+
const lastPass = (tenantRef) => pinned.get(tenantRef) ?? served.get(tenantRef);
|
|
112
|
+
const recordPass = (tenantRef, finishedAt) => {
|
|
113
|
+
if (pinned.has(tenantRef))
|
|
114
|
+
pinned.set(tenantRef, finishedAt);
|
|
115
|
+
else if (served.has(tenantRef))
|
|
116
|
+
served.set(tenantRef, finishedAt);
|
|
117
|
+
};
|
|
118
|
+
const due = (tenantRef) => Date.now() - (lastPass(tenantRef) ?? 0) >= intervalMs;
|
|
119
|
+
const enqueue = (tenantRef) => {
|
|
120
|
+
if (controller.signal.aborted || queued.has(tenantRef))
|
|
121
|
+
return;
|
|
122
|
+
queued.add(tenantRef);
|
|
123
|
+
queue.push(tenantRef);
|
|
124
|
+
if (!working)
|
|
125
|
+
working = drain();
|
|
126
|
+
};
|
|
127
|
+
const drain = async () => {
|
|
128
|
+
try {
|
|
129
|
+
for (;;) {
|
|
130
|
+
const tenantRef = queue.shift();
|
|
131
|
+
if (tenantRef === undefined || controller.signal.aborted)
|
|
132
|
+
return;
|
|
133
|
+
let again = false;
|
|
134
|
+
try {
|
|
135
|
+
const result = await withSlotlockMaintenanceLock(options.sql, tenantRef, () => maintainSlotlockTenant(options.store, tenantRef, {
|
|
136
|
+
...(options.batches !== undefined ? { batches: options.batches } : {}),
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
}));
|
|
139
|
+
recordPass(tenantRef, Date.now());
|
|
140
|
+
if (result) {
|
|
141
|
+
try {
|
|
142
|
+
ignoreRejection(options.onPass?.(result));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Reporting must never stop maintenance, or make a pass that ran look failed.
|
|
146
|
+
}
|
|
147
|
+
// A capped pass that got somewhere goes round again at once, as does one whose roll
|
|
148
|
+
// stopped before the series it had put back: each series it could not extend is now
|
|
149
|
+
// behind every one it has not tried (the store puts it there), so the next pass meets
|
|
150
|
+
// new ones, and however many are stuck, each is met once before a healthy one is
|
|
151
|
+
// reached. A pass that reached the series put back waits for the next interval: they
|
|
152
|
+
// take turns there, and the next pass reaches anything new before them.
|
|
153
|
+
const progressed = result.extended + result.pruned > 0;
|
|
154
|
+
again = result.capped && (progressed || result.rollPending);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (controller.signal.aborted)
|
|
159
|
+
return;
|
|
160
|
+
recordPass(tenantRef, Date.now());
|
|
161
|
+
try {
|
|
162
|
+
ignoreRejection(options.onError?.(error));
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Reporting must never stop maintenance.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
queued.delete(tenantRef);
|
|
170
|
+
}
|
|
171
|
+
if (again)
|
|
172
|
+
enqueue(tenantRef);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
working = null;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const tick = () => {
|
|
180
|
+
for (const tenantRef of [...pinned.keys(), ...served.keys()]) {
|
|
181
|
+
if (due(tenantRef))
|
|
182
|
+
enqueue(tenantRef);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const timer = setInterval(tick, Math.min(intervalMs, 60_000));
|
|
186
|
+
timer.unref();
|
|
187
|
+
tick();
|
|
188
|
+
return {
|
|
189
|
+
touch(tenantRef) {
|
|
190
|
+
if (controller.signal.aborted || pinned.has(tenantRef)) {
|
|
191
|
+
if (pinned.has(tenantRef) && due(tenantRef))
|
|
192
|
+
enqueue(tenantRef);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const previous = served.get(tenantRef);
|
|
196
|
+
served.delete(tenantRef);
|
|
197
|
+
served.set(tenantRef, previous ?? 0);
|
|
198
|
+
while (served.size > maxTenants) {
|
|
199
|
+
const forgotten = served.keys().next().value;
|
|
200
|
+
served.delete(forgotten);
|
|
201
|
+
}
|
|
202
|
+
if (due(tenantRef))
|
|
203
|
+
enqueue(tenantRef);
|
|
204
|
+
},
|
|
205
|
+
async close() {
|
|
206
|
+
clearInterval(timer);
|
|
207
|
+
controller.abort(new Error('Slotlock maintenance stopped'));
|
|
208
|
+
queue.length = 0;
|
|
209
|
+
queued.clear();
|
|
210
|
+
await working;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|