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.
Files changed (51) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. 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 orderedWindows = mergeIntervals(windows);
51
- for (const window of orderedWindows) {
52
- for (const free of subtractBusy(window, busy)) {
53
- if (free.end.getTime() - free.start.getTime() >= durationMs) {
54
- return { start: free.start, end: new Date(free.start.getTime() + durationMs) };
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>;
@@ -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();
@@ -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
- : parseTrustedICalendarEvents(request.input, request.calendarTimezone);
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: three times the parser's
33
- * own limit, with room for a new worker to start.
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` and `parseTrustedICalendarEvents` (the same
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` and `parseTrustedICalendarEvents` (the same
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 === 'changes' &&
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
- /** Idempotency commands and tombstones deleted. */
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 prune
17
- * past the retention window, each in at most `batches` batches (1-100, default 10), every batch its
18
- * own transaction. Aborting `signal` stops it between batches with the signal's reason.
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 prune
5
- * past the retention window, each in at most `batches` batches (1-100, default 10), every batch its
6
- * own 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 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
- for (let batch = 0; batch < batches; batch++) {
20
- options.signal?.throwIfAborted();
21
- const from = after;
22
- const roll = await store.withTenant(tenantRef, (tenant) => tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }));
23
- extended += roll.extended;
24
- conflicts += roll.conflicts;
25
- refused += roll.refused;
26
- after = roll.next;
27
- if (!after) {
28
- rollCapped = false;
29
- break;
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 pruneCapped = true;
34
- for (let batch = 0; batch < batches; batch++) {
44
+ let eventsLeft = true;
45
+ let bookingsLeft = true;
46
+ for (let batch = 0; batch < batches && (eventsLeft || bookingsLeft); batch++) {
35
47
  options.signal?.throwIfAborted();
36
- const prune = await store.withTenant(tenantRef, (tenant) => tenant.pruneCalendarEventRetention({ tenantRef }));
37
- pruned += prune.commandsDeleted + prune.tombstonesDeleted;
38
- if (!prune.hasMore) {
39
- pruneCapped = false;
40
- break;
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
- return { extended, conflicts, refused, pruned, capped: rollCapped || pruneCapped };
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; one that only met series it
123
- // cannot extend waits for the next interval rather than examine them again and again.
124
- again = result.capped && result.extended + result.pruned > 0;
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.delete(served.keys().next().value);
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('/').some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
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 outcome = await Promise.race([target.fetch(webRequest), handlerTimeout]);
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');