slotlock 0.2.0 → 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 +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -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 +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- 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.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +213 -9
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/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
|
@@ -21,8 +21,8 @@ export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardReso
|
|
|
21
21
|
export type { CreateSlotlockApiKeyInput, SlotlockApiKey, SlotlockApiKeyAuthenticatorOptions, SlotlockApiKeyIdentity, SlotlockApiKeyScope, SlotlockApiKeyStore, } from './api-keys.js';
|
|
22
22
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
23
23
|
export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-worker.js';
|
|
24
|
-
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
|
|
25
|
-
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, 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_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
27
|
-
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 SlotlockHoldTenantOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantStore, } from './store.js';
|
|
28
|
-
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
|
@@ -15,6 +15,6 @@ export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenan
|
|
|
15
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';
|
|
16
16
|
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
17
17
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
18
|
-
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } 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_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
|
|
20
|
-
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
CHANGED
|
@@ -5,17 +5,27 @@ export interface SlotlockMaintenanceResult {
|
|
|
5
5
|
extended: number;
|
|
6
6
|
/** Series that could not extend because an occurrence would overlap a booking. */
|
|
7
7
|
conflicts: number;
|
|
8
|
-
/** Series over the expansion budget; they keep their old coverage. */
|
|
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 and booking references retired with no
|
|
13
|
+
* booking, deleted. A cancelled booking's tombstone stays.
|
|
14
|
+
*/
|
|
11
15
|
pruned: number;
|
|
12
16
|
/** More was left than one pass does: run another. */
|
|
13
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;
|
|
14
23
|
}
|
|
15
24
|
/**
|
|
16
|
-
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
19
29
|
*/
|
|
20
30
|
export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
|
|
21
31
|
batches?: number;
|
package/maintenance.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ignoreRejection } from './hooks.js';
|
|
2
2
|
import { calendarEventRollingHorizon } from './store.js';
|
|
3
3
|
/**
|
|
4
|
-
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
8
|
*/
|
|
8
9
|
export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
9
10
|
const batches = options.batches ?? 10;
|
|
@@ -16,31 +17,55 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
16
17
|
let refused = 0;
|
|
17
18
|
let rollCapped = true;
|
|
18
19
|
let after = null;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
30
36
|
}
|
|
31
37
|
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (options.signal?.aborted)
|
|
40
|
+
throw error;
|
|
41
|
+
rollFailure = { error };
|
|
42
|
+
}
|
|
32
43
|
let pruned = 0;
|
|
33
|
-
let
|
|
34
|
-
|
|
44
|
+
let eventsLeft = true;
|
|
45
|
+
let bookingsLeft = true;
|
|
46
|
+
for (let batch = 0; batch < batches && (eventsLeft || bookingsLeft); batch++) {
|
|
35
47
|
options.signal?.throwIfAborted();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
41
57
|
}
|
|
42
58
|
}
|
|
43
|
-
|
|
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
|
+
};
|
|
44
69
|
}
|
|
45
70
|
/** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
|
|
46
71
|
const LOCK_NAMESPACE = 'slotlock.maintenance:';
|
|
@@ -119,9 +144,14 @@ export function startSlotlockMaintenance(options) {
|
|
|
119
144
|
catch {
|
|
120
145
|
// Reporting must never stop maintenance, or make a pass that ran look failed.
|
|
121
146
|
}
|
|
122
|
-
// A capped pass that got somewhere goes round again at once
|
|
123
|
-
//
|
|
124
|
-
|
|
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);
|
|
125
155
|
}
|
|
126
156
|
}
|
|
127
157
|
catch (error) {
|
|
@@ -165,8 +195,10 @@ export function startSlotlockMaintenance(options) {
|
|
|
165
195
|
const previous = served.get(tenantRef);
|
|
166
196
|
served.delete(tenantRef);
|
|
167
197
|
served.set(tenantRef, previous ?? 0);
|
|
168
|
-
while (served.size > maxTenants)
|
|
169
|
-
served.
|
|
198
|
+
while (served.size > maxTenants) {
|
|
199
|
+
const forgotten = served.keys().next().value;
|
|
200
|
+
served.delete(forgotten);
|
|
201
|
+
}
|
|
170
202
|
if (due(tenantRef))
|
|
171
203
|
enqueue(tenantRef);
|
|
172
204
|
},
|
package/node-server.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
/// <reference types="node" preserve="true" />
|
|
2
2
|
import { type Server } from 'node:http';
|
|
3
3
|
export type SlotlockNodeServerErrorPhase = 'client' | 'listen' | 'request' | 'server' | 'shutdown';
|
|
4
|
+
/** What the listener knows about a request's connection, beside the Request itself. */
|
|
5
|
+
export interface SlotlockNodeServerConnection {
|
|
6
|
+
/**
|
|
7
|
+
* The peer's address as the socket reports it (an IPv4 client of a dual-stack listener reads
|
|
8
|
+
* `::ffff:a.b.c.d`). Behind a proxy it is the proxy's; never taken from a request field.
|
|
9
|
+
*/
|
|
10
|
+
remoteAddress?: string;
|
|
11
|
+
}
|
|
4
12
|
/** The narrow Fetch-compatible boundary returned by `createSlotlockAgentServer`. */
|
|
5
13
|
export interface SlotlockNodeServerFetchTarget {
|
|
6
|
-
fetch(request: Request): Promise<Response>;
|
|
14
|
+
fetch(request: Request, connection?: SlotlockNodeServerConnection): Promise<Response>;
|
|
7
15
|
/**
|
|
8
16
|
* Called once when `close()` begins, after the listener stops accepting connections, to end
|
|
9
17
|
* long-lived responses (MCP subscription streams) so they drain within the grace period.
|
package/node-server.js
CHANGED
|
@@ -137,8 +137,9 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
137
137
|
}
|
|
138
138
|
// URL parsing resolves dot segments, `%2E` spellings included: `/v1/resources/%2E%2E/availability`
|
|
139
139
|
// would reach the target as `/v1/availability`, a path parameter of `..` naming another route.
|
|
140
|
+
// It also takes a backslash for a slash, so a segment ends at either.
|
|
140
141
|
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
141
|
-
if (path.split(
|
|
142
|
+
if (path.split(/[\\/]/).some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
142
143
|
throw new Error('invalid_request_target');
|
|
143
144
|
}
|
|
144
145
|
const url = new URL(target, requestOrigin);
|
|
@@ -281,7 +282,11 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
281
282
|
timeout.unref();
|
|
282
283
|
});
|
|
283
284
|
try {
|
|
284
|
-
const
|
|
285
|
+
const connection = {};
|
|
286
|
+
if (request.socket.remoteAddress !== undefined) {
|
|
287
|
+
connection.remoteAddress = request.socket.remoteAddress;
|
|
288
|
+
}
|
|
289
|
+
const outcome = await Promise.race([target.fetch(webRequest, connection), handlerTimeout]);
|
|
285
290
|
if (outcome === HANDLER_TIMEOUT) {
|
|
286
291
|
controller.abort(abortReason('handler_timeout'));
|
|
287
292
|
safeReport(config.onError, new Error('handler_timeout'), 'request');
|