slotlock 0.3.0 → 0.5.1
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 +265 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +591 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +447 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/index.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/// <reference types="node" preserve="true" />
|
|
2
2
|
export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
|
|
3
|
+
export { type SlotlockBookingRule, type SlotlockBookingRules, bookingRuleBroken, bookingSearch, nextStepStart, parseBookingRules, } from './booking-rules.js';
|
|
4
|
+
export { type CreateWebhookEndpointResult, type ListChangesResult, SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, type SlotlockChange, type SlotlockChangeType, type SlotlockEventFeed, type SlotlockWebhookClaim, type SlotlockWebhookEndpoint, } from './event-feed.js';
|
|
5
|
+
export { type SlotlockCloudEvent, type SlotlockWebhookPassResult, type SlotlockWebhookResponse, type SlotlockWebhookScheduler, type SlotlockWebhookSender, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
|
|
6
|
+
export { type SlotlockMetrics, createSlotlockMetrics } from './metrics.js';
|
|
3
7
|
export { expandRules } from './rules.js';
|
|
4
8
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
5
9
|
export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
|
|
10
|
+
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
|
|
11
|
+
export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule, ParsedVAvailability, } from './interop.js';
|
|
6
12
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
7
13
|
export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
|
|
8
14
|
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';
|
|
@@ -16,7 +22,7 @@ export type { SlotlockApplicationBackend, SlotlockApplicationInvocationContext,
|
|
|
16
22
|
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
17
23
|
export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
|
|
18
24
|
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';
|
|
19
|
-
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
25
|
+
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
20
26
|
export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
|
|
21
27
|
export type { CreateSlotlockApiKeyInput, SlotlockApiKey, SlotlockApiKeyAuthenticatorOptions, SlotlockApiKeyIdentity, SlotlockApiKeyScope, SlotlockApiKeyStore, } from './api-keys.js';
|
|
22
28
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
@@ -25,4 +31,4 @@ export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emit
|
|
|
25
31
|
export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ICalendarFeedRead, ICalendarSeriesRefusal, ITipMethod, } from './sync.js';
|
|
26
32
|
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
33
|
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';
|
|
34
|
+
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, SlotlockAgendaEntry, SlotlockHold, SlotlockCalendarEvent, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
|
package/index.js
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// TypeScript 6 default) still loads @types/node.
|
|
5
5
|
/// <reference types="node" preserve="true" />
|
|
6
6
|
export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
|
|
7
|
+
export { bookingRuleBroken, bookingSearch, nextStepStart, parseBookingRules, } from './booking-rules.js';
|
|
8
|
+
export { SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, } from './event-feed.js';
|
|
9
|
+
export { createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
|
|
10
|
+
export { createSlotlockMetrics } from './metrics.js';
|
|
7
11
|
export { expandRules } from './rules.js';
|
|
8
12
|
export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
|
|
13
|
+
export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
|
|
9
14
|
export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
|
|
10
15
|
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
16
|
export { createSlotlockNodeServer } from './node-server.js';
|
|
@@ -13,7 +18,7 @@ export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend,
|
|
|
13
18
|
export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
|
|
14
19
|
export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
15
20
|
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
|
-
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
21
|
+
export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
|
|
17
22
|
export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
|
|
18
23
|
export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
|
|
19
24
|
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';
|
package/interop.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { SlotlockCalendarEvent, WeeklyAvailabilityRule } from './types.js';
|
|
2
|
+
/** The longest VAVAILABILITY text `parseVAvailability` reads. */
|
|
3
|
+
export declare const SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS = 65536;
|
|
4
|
+
export interface EmitVAvailabilityOptions {
|
|
5
|
+
/** The resource's weekly rules, in `timezone`. */
|
|
6
|
+
rules: readonly WeeklyAvailabilityRule[];
|
|
7
|
+
/** The IANA zone the rules are read in: every DTSTART names it as its TZID. */
|
|
8
|
+
timezone: string;
|
|
9
|
+
/** The VAVAILABILITY's UID; each AVAILABLE's is this with its index. */
|
|
10
|
+
uid: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
|
|
14
|
+
* in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
|
|
15
|
+
* two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
|
|
16
|
+
* AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
|
|
17
|
+
*/
|
|
18
|
+
export declare function emitVAvailability({ rules, timezone, uid }: EmitVAvailabilityOptions): string;
|
|
19
|
+
export interface ParsedVAvailability {
|
|
20
|
+
/** The TZID every AVAILABLE named, `UTC` for UTC times, or null when they are all floating. */
|
|
21
|
+
timezone: string | null;
|
|
22
|
+
rules: WeeklyAvailabilityRule[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one VAVAILABILITY, each AVAILABLE a
|
|
26
|
+
* weekly RRULE with a time of day and a length. Refuses (`invalid_availability`) what weekly
|
|
27
|
+
* rules cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, an AVAILABLE
|
|
28
|
+
* without RRULE, with RDATE, EXDATE or RECURRENCE-ID, a whole-day DTSTART, seconds, AVAILABLEs in
|
|
29
|
+
* different zones, and any rule `parseAvailabilityRules` refuses. An RRULE without BYDAY takes
|
|
30
|
+
* DTSTART's day, as RFC 5545 reads it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseVAvailability(text: string): ParsedVAvailability;
|
|
33
|
+
/** A JSCalendar Event object (RFC 8984 §5.1): the properties Slotlock fills. */
|
|
34
|
+
export interface JSCalendarEvent {
|
|
35
|
+
'@type': 'Event';
|
|
36
|
+
uid: string;
|
|
37
|
+
updated: string;
|
|
38
|
+
created: string;
|
|
39
|
+
sequence: number;
|
|
40
|
+
prodId: string;
|
|
41
|
+
title: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
start: string;
|
|
44
|
+
timeZone: string;
|
|
45
|
+
duration: string;
|
|
46
|
+
status: 'confirmed' | 'tentative';
|
|
47
|
+
freeBusyStatus: 'free' | 'busy';
|
|
48
|
+
locations?: Record<string, {
|
|
49
|
+
'@type': 'Location';
|
|
50
|
+
name: string;
|
|
51
|
+
}>;
|
|
52
|
+
participants?: Record<string, JSCalendarParticipant>;
|
|
53
|
+
alerts?: Record<string, JSCalendarAlert>;
|
|
54
|
+
recurrenceRules?: JSCalendarRecurrenceRule[];
|
|
55
|
+
recurrenceOverrides?: Record<string, {
|
|
56
|
+
excluded: true;
|
|
57
|
+
} | {
|
|
58
|
+
start: string;
|
|
59
|
+
duration: string;
|
|
60
|
+
}>;
|
|
61
|
+
/** The RRULE the recurrence was written as, kept verbatim (RFC 8984 §3.3 vendor property). */
|
|
62
|
+
'slotlock.pylota.io:rrule'?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface JSCalendarParticipant {
|
|
65
|
+
'@type': 'Participant';
|
|
66
|
+
name?: string;
|
|
67
|
+
email: string;
|
|
68
|
+
sendTo: {
|
|
69
|
+
imip: string;
|
|
70
|
+
};
|
|
71
|
+
roles: Record<string, true>;
|
|
72
|
+
participationStatus?: 'needs-action' | 'accepted' | 'declined' | 'tentative' | 'delegated';
|
|
73
|
+
expectReply?: boolean;
|
|
74
|
+
}
|
|
75
|
+
export interface JSCalendarAlert {
|
|
76
|
+
'@type': 'Alert';
|
|
77
|
+
trigger: {
|
|
78
|
+
'@type': 'OffsetTrigger';
|
|
79
|
+
offset: string;
|
|
80
|
+
relativeTo: 'start';
|
|
81
|
+
};
|
|
82
|
+
action: 'display' | 'email';
|
|
83
|
+
}
|
|
84
|
+
export interface JSCalendarRecurrenceRule {
|
|
85
|
+
'@type': 'RecurrenceRule';
|
|
86
|
+
frequency: 'yearly' | 'monthly' | 'weekly' | 'daily' | 'hourly' | 'minutely' | 'secondly';
|
|
87
|
+
interval?: number;
|
|
88
|
+
firstDayOfWeek?: string;
|
|
89
|
+
byDay?: {
|
|
90
|
+
'@type': 'NDay';
|
|
91
|
+
day: string;
|
|
92
|
+
nthOfPeriod?: number;
|
|
93
|
+
}[];
|
|
94
|
+
byMonthDay?: number[];
|
|
95
|
+
byMonth?: string[];
|
|
96
|
+
byYearDay?: number[];
|
|
97
|
+
byWeekNo?: number[];
|
|
98
|
+
byHour?: number[];
|
|
99
|
+
byMinute?: number[];
|
|
100
|
+
bySecond?: number[];
|
|
101
|
+
bySetPosition?: number[];
|
|
102
|
+
count?: number;
|
|
103
|
+
until?: string;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* An RRULE as a JSCalendar RecurrenceRule (RFC 8984 §4.3.3), or null when it holds a part the
|
|
107
|
+
* mapping does not know (the verbatim RRULE is exported beside it either way). A UTC UNTIL becomes
|
|
108
|
+
* the wall time in `timezone`; a date UNTIL the end of that day.
|
|
109
|
+
*/
|
|
110
|
+
export declare function recurrenceRuleFromRRule(rrule: string, timezone: string): JSCalendarRecurrenceRule | null;
|
|
111
|
+
/**
|
|
112
|
+
* A Slotlock calendar event as a JSCalendar Event (RFC 8984): its id as `uid`, its revision as
|
|
113
|
+
* `sequence`, start as a LocalDateTime in its zone with an exact duration, the organizer an
|
|
114
|
+
* `owner` participant, reminders `OffsetTrigger` alerts before the start, and the recurrence as
|
|
115
|
+
* `recurrenceRules` with `recurrenceOverrides` keyed by each exception's original start.
|
|
116
|
+
*/
|
|
117
|
+
export declare function toJSCalendarEvent(event: SlotlockCalendarEvent): JSCalendarEvent;
|
package/interop.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// Calendar interchange in the standards' own shapes: a resource's weekly bookable hours as an
|
|
2
|
+
// iCalendar VAVAILABILITY (RFC 7953), read and written, and a calendar event as a JSCalendar Event
|
|
3
|
+
// (RFC 8984). Pure functions: the application API serves them, and an application may call them
|
|
4
|
+
// directly.
|
|
5
|
+
import ICAL from 'ical.js';
|
|
6
|
+
import { zonedDateTimeFields } from './timezone.js';
|
|
7
|
+
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
8
|
+
/** The longest VAVAILABILITY text `parseVAvailability` reads. */
|
|
9
|
+
export const SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS = 65_536;
|
|
10
|
+
const PRODID = '-//Pylota//Slotlock//EN';
|
|
11
|
+
const CRLF = '\r\n';
|
|
12
|
+
const DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
|
|
13
|
+
/** 2024-01-07 is a Sunday: the reference week runs Sunday 7 to Saturday 13 January. */
|
|
14
|
+
const REFERENCE_SUNDAY = Date.UTC(2024, 0, 7);
|
|
15
|
+
const VAVAILABILITY_DTSTAMP = '20240101T000000Z';
|
|
16
|
+
function invalidAvailability(message) {
|
|
17
|
+
return Object.assign(new Error(message), { code: 'invalid_availability' });
|
|
18
|
+
}
|
|
19
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
20
|
+
/** RFC 5545 §3.1: lines of at most 75 octets, continued with a leading space. */
|
|
21
|
+
function fold(line) {
|
|
22
|
+
const bytes = Buffer.from(line, 'utf8');
|
|
23
|
+
if (bytes.length <= 75)
|
|
24
|
+
return line;
|
|
25
|
+
const parts = [];
|
|
26
|
+
let start = 0;
|
|
27
|
+
let limit = 75;
|
|
28
|
+
while (start < bytes.length) {
|
|
29
|
+
let end = Math.min(start + limit, bytes.length);
|
|
30
|
+
// Never split a UTF-8 sequence: back up over continuation bytes.
|
|
31
|
+
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80)
|
|
32
|
+
end -= 1;
|
|
33
|
+
parts.push(bytes.subarray(start, end).toString('utf8'));
|
|
34
|
+
start = end;
|
|
35
|
+
limit = 74;
|
|
36
|
+
}
|
|
37
|
+
return parts.join(`${CRLF} `);
|
|
38
|
+
}
|
|
39
|
+
/** The first day of the reference week the rule's BYDAY names, so DTSTART is its first occurrence. */
|
|
40
|
+
function referenceDay(rrule) {
|
|
41
|
+
const byDay = /(?:^|;)BYDAY=([^;]*)/.exec(rrule)?.[1] ?? '';
|
|
42
|
+
const named = byDay.split(',').map((day) => day.slice(-2));
|
|
43
|
+
const index = DAYS.findIndex((day) => named.includes(day));
|
|
44
|
+
return new Date(REFERENCE_SUNDAY + Math.max(index, 0) * 86_400_000);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
|
|
48
|
+
* in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
|
|
49
|
+
* two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
|
|
50
|
+
* AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
|
|
51
|
+
*/
|
|
52
|
+
export function emitVAvailability({ rules, timezone, uid }) {
|
|
53
|
+
const lines = [
|
|
54
|
+
'BEGIN:VCALENDAR',
|
|
55
|
+
'VERSION:2.0',
|
|
56
|
+
`PRODID:${PRODID}`,
|
|
57
|
+
'BEGIN:VAVAILABILITY',
|
|
58
|
+
`UID:${uid}`,
|
|
59
|
+
`DTSTAMP:${VAVAILABILITY_DTSTAMP}`,
|
|
60
|
+
];
|
|
61
|
+
rules.forEach((rule, index) => {
|
|
62
|
+
const day = referenceDay(rule.rrule);
|
|
63
|
+
const hour = Math.floor(rule.startMinutes / 60);
|
|
64
|
+
const minute = rule.startMinutes % 60;
|
|
65
|
+
lines.push('BEGIN:AVAILABLE', `UID:${uid}-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${day.getUTCFullYear()}${pad(day.getUTCMonth() + 1)}${pad(day.getUTCDate())}T${pad(hour)}${pad(minute)}00`, `DURATION:PT${rule.durationMinutes}M`, `RRULE:${rule.rrule}`, 'END:AVAILABLE');
|
|
66
|
+
});
|
|
67
|
+
lines.push('END:VAVAILABILITY', 'END:VCALENDAR');
|
|
68
|
+
return `${lines.map(fold).join(CRLF)}${CRLF}`;
|
|
69
|
+
}
|
|
70
|
+
/** The one property `name` of `component`, refusing a repeat. */
|
|
71
|
+
function single(component, name) {
|
|
72
|
+
const properties = component.getAllProperties(name);
|
|
73
|
+
if (properties.length > 1)
|
|
74
|
+
throw invalidAvailability(`An AVAILABLE has more than one ${name.toUpperCase()}`);
|
|
75
|
+
return properties[0] ?? null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one VAVAILABILITY, each AVAILABLE a
|
|
79
|
+
* weekly RRULE with a time of day and a length. Refuses (`invalid_availability`) what weekly
|
|
80
|
+
* rules cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, an AVAILABLE
|
|
81
|
+
* without RRULE, with RDATE, EXDATE or RECURRENCE-ID, a whole-day DTSTART, seconds, AVAILABLEs in
|
|
82
|
+
* different zones, and any rule `parseAvailabilityRules` refuses. An RRULE without BYDAY takes
|
|
83
|
+
* DTSTART's day, as RFC 5545 reads it.
|
|
84
|
+
*/
|
|
85
|
+
export function parseVAvailability(text) {
|
|
86
|
+
if (text.length > SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS) {
|
|
87
|
+
throw invalidAvailability(`The iCalendar text is longer than ${SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS} characters`);
|
|
88
|
+
}
|
|
89
|
+
let root;
|
|
90
|
+
try {
|
|
91
|
+
root = new ICAL.Component(ICAL.parse(text));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
throw invalidAvailability('The text is not iCalendar');
|
|
95
|
+
}
|
|
96
|
+
const availabilities = root.name === 'vavailability' ? [root] : root.getAllSubcomponents('vavailability');
|
|
97
|
+
if (availabilities.length !== 1) {
|
|
98
|
+
throw invalidAvailability('The iCalendar text needs exactly one VAVAILABILITY');
|
|
99
|
+
}
|
|
100
|
+
const availability = availabilities[0];
|
|
101
|
+
if (availability.hasProperty('dtstart') || availability.hasProperty('dtend')) {
|
|
102
|
+
throw invalidAvailability('A VAVAILABILITY bounded by DTSTART or DTEND cannot be weekly hours');
|
|
103
|
+
}
|
|
104
|
+
const zones = new Set();
|
|
105
|
+
const rules = availability.getAllSubcomponents('available').map((available, index) => {
|
|
106
|
+
for (const name of ['rdate', 'exdate', 'recurrence-id']) {
|
|
107
|
+
if (available.hasProperty(name)) {
|
|
108
|
+
throw invalidAvailability(`AVAILABLE ${index} has ${name.toUpperCase()}: weekly hours cannot say it`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const dtstart = single(available, 'dtstart');
|
|
112
|
+
const rrule = single(available, 'rrule');
|
|
113
|
+
if (!dtstart || !rrule)
|
|
114
|
+
throw invalidAvailability(`AVAILABLE ${index} needs DTSTART and RRULE`);
|
|
115
|
+
const start = dtstart.getFirstValue();
|
|
116
|
+
if (start.isDate)
|
|
117
|
+
throw invalidAvailability(`AVAILABLE ${index} starts on a date, not a time`);
|
|
118
|
+
if (start.second !== 0)
|
|
119
|
+
throw invalidAvailability(`AVAILABLE ${index} starts at a second`);
|
|
120
|
+
const tzid = dtstart.getParameter('tzid');
|
|
121
|
+
zones.add(typeof tzid === 'string' ? tzid : start.zone === ICAL.Timezone.utcTimezone ? 'UTC' : null);
|
|
122
|
+
let durationMinutes;
|
|
123
|
+
const dtend = single(available, 'dtend');
|
|
124
|
+
const duration = single(available, 'duration');
|
|
125
|
+
if (dtend && duration)
|
|
126
|
+
throw invalidAvailability(`AVAILABLE ${index} has both DTEND and DURATION`);
|
|
127
|
+
if (duration) {
|
|
128
|
+
const seconds = duration.getFirstValue().toSeconds();
|
|
129
|
+
durationMinutes = seconds / 60;
|
|
130
|
+
}
|
|
131
|
+
else if (dtend) {
|
|
132
|
+
if (dtend.getParameter('tzid') !== dtstart.getParameter('tzid')) {
|
|
133
|
+
throw invalidAvailability(`AVAILABLE ${index} ends in another zone than it starts`);
|
|
134
|
+
}
|
|
135
|
+
// Both are wall times in one zone: their difference is the window's length on the clock.
|
|
136
|
+
const end = dtend.getFirstValue();
|
|
137
|
+
durationMinutes = (wallSeconds(end) - wallSeconds(start)) / 60;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
throw invalidAvailability(`AVAILABLE ${index} needs DTEND or DURATION`);
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isInteger(durationMinutes) || durationMinutes < 1) {
|
|
143
|
+
throw invalidAvailability(`AVAILABLE ${index} does not last a whole number of minutes`);
|
|
144
|
+
}
|
|
145
|
+
let value = String(rrule.getFirstValue());
|
|
146
|
+
if (!/(?:^|;)BYDAY=/.test(value)) {
|
|
147
|
+
value = `${value};BYDAY=${DAYS[start.dayOfWeek() - 1]}`;
|
|
148
|
+
}
|
|
149
|
+
return { rrule: value, startMinutes: start.hour * 60 + start.minute, durationMinutes };
|
|
150
|
+
});
|
|
151
|
+
if (zones.size > 1)
|
|
152
|
+
throw invalidAvailability('The AVAILABLEs are in different time zones');
|
|
153
|
+
const [timezone = null] = zones;
|
|
154
|
+
return { timezone, rules: parseAvailabilityRules(rules) };
|
|
155
|
+
}
|
|
156
|
+
const wallSeconds = (time) => Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second) / 1000;
|
|
157
|
+
/** An instant as a LocalDateTime (RFC 8984 §1.4.4) on the wall clock of `timezone`. */
|
|
158
|
+
function localDateTime(instant, timezone) {
|
|
159
|
+
const wall = zonedDateTimeFields(instant, timezone);
|
|
160
|
+
return `${pad(wall.year, 4)}-${pad(wall.month)}-${pad(wall.day)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}`;
|
|
161
|
+
}
|
|
162
|
+
/** An exact length as an RFC 8984 Duration of hours, minutes and seconds. */
|
|
163
|
+
function exactDuration(milliseconds) {
|
|
164
|
+
const total = Math.max(0, Math.round(milliseconds / 1000));
|
|
165
|
+
const hours = Math.floor(total / 3600);
|
|
166
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
167
|
+
const seconds = total % 60;
|
|
168
|
+
if (total === 0)
|
|
169
|
+
return 'PT0S';
|
|
170
|
+
return `PT${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}${seconds ? `${seconds}S` : ''}`;
|
|
171
|
+
}
|
|
172
|
+
const FREQUENCIES = new Set(['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY']);
|
|
173
|
+
const NUMBER_PARTS = {
|
|
174
|
+
BYMONTHDAY: 'byMonthDay',
|
|
175
|
+
BYYEARDAY: 'byYearDay',
|
|
176
|
+
BYWEEKNO: 'byWeekNo',
|
|
177
|
+
BYHOUR: 'byHour',
|
|
178
|
+
BYMINUTE: 'byMinute',
|
|
179
|
+
BYSECOND: 'bySecond',
|
|
180
|
+
BYSETPOS: 'bySetPosition',
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* An RRULE as a JSCalendar RecurrenceRule (RFC 8984 §4.3.3), or null when it holds a part the
|
|
184
|
+
* mapping does not know (the verbatim RRULE is exported beside it either way). A UTC UNTIL becomes
|
|
185
|
+
* the wall time in `timezone`; a date UNTIL the end of that day.
|
|
186
|
+
*/
|
|
187
|
+
export function recurrenceRuleFromRRule(rrule, timezone) {
|
|
188
|
+
const parts = new Map();
|
|
189
|
+
for (const part of rrule.split(';')) {
|
|
190
|
+
const [key, value] = part.split('=');
|
|
191
|
+
if (!key || value === undefined || parts.has(key.toUpperCase()))
|
|
192
|
+
return null;
|
|
193
|
+
parts.set(key.toUpperCase(), value);
|
|
194
|
+
}
|
|
195
|
+
const frequency = parts.get('FREQ');
|
|
196
|
+
if (!frequency || !FREQUENCIES.has(frequency))
|
|
197
|
+
return null;
|
|
198
|
+
const rule = {
|
|
199
|
+
'@type': 'RecurrenceRule',
|
|
200
|
+
frequency: frequency.toLowerCase(),
|
|
201
|
+
};
|
|
202
|
+
const integers = (value) => {
|
|
203
|
+
const list = value.split(',').map(Number);
|
|
204
|
+
return list.every(Number.isInteger) ? list : null;
|
|
205
|
+
};
|
|
206
|
+
for (const [key, value] of parts) {
|
|
207
|
+
if (key === 'FREQ')
|
|
208
|
+
continue;
|
|
209
|
+
if (key === 'INTERVAL' || key === 'COUNT') {
|
|
210
|
+
const amount = Number(value);
|
|
211
|
+
if (!Number.isInteger(amount) || amount < 1)
|
|
212
|
+
return null;
|
|
213
|
+
rule[key === 'INTERVAL' ? 'interval' : 'count'] = amount;
|
|
214
|
+
}
|
|
215
|
+
else if (key === 'WKST') {
|
|
216
|
+
if (!/^(MO|TU|WE|TH|FR|SA|SU)$/.test(value))
|
|
217
|
+
return null;
|
|
218
|
+
rule.firstDayOfWeek = value.toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
else if (key === 'BYDAY') {
|
|
221
|
+
const days = value.split(',').map((entry) => /^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/.exec(entry));
|
|
222
|
+
if (days.some((match) => match === null))
|
|
223
|
+
return null;
|
|
224
|
+
rule.byDay = days.map((match) => {
|
|
225
|
+
const [, nth, day] = match;
|
|
226
|
+
return {
|
|
227
|
+
'@type': 'NDay',
|
|
228
|
+
day: day.toLowerCase(),
|
|
229
|
+
...(nth ? { nthOfPeriod: Number(nth) } : {}),
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
else if (key === 'BYMONTH') {
|
|
234
|
+
if (!/^\d{1,2}(,\d{1,2})*$/.test(value))
|
|
235
|
+
return null;
|
|
236
|
+
rule.byMonth = value.split(',').map((month) => String(Number(month)));
|
|
237
|
+
}
|
|
238
|
+
else if (key in NUMBER_PARTS) {
|
|
239
|
+
const list = integers(value);
|
|
240
|
+
if (!list)
|
|
241
|
+
return null;
|
|
242
|
+
rule[NUMBER_PARTS[key]] = list;
|
|
243
|
+
}
|
|
244
|
+
else if (key === 'UNTIL') {
|
|
245
|
+
const match = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/.exec(value);
|
|
246
|
+
if (!match)
|
|
247
|
+
return null;
|
|
248
|
+
const [, year, month, day, hour, minute, second, utc] = match;
|
|
249
|
+
if (hour === undefined)
|
|
250
|
+
rule.until = `${year}-${month}-${day}T23:59:59`;
|
|
251
|
+
else if (utc) {
|
|
252
|
+
rule.until = localDateTime(new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second))), timezone);
|
|
253
|
+
}
|
|
254
|
+
else
|
|
255
|
+
rule.until = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return rule;
|
|
262
|
+
}
|
|
263
|
+
const ROLES = {
|
|
264
|
+
chair: { attendee: true, chair: true },
|
|
265
|
+
required: { attendee: true },
|
|
266
|
+
optional: { attendee: true, optional: true },
|
|
267
|
+
non_participant: { informational: true },
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* A Slotlock calendar event as a JSCalendar Event (RFC 8984): its id as `uid`, its revision as
|
|
271
|
+
* `sequence`, start as a LocalDateTime in its zone with an exact duration, the organizer an
|
|
272
|
+
* `owner` participant, reminders `OffsetTrigger` alerts before the start, and the recurrence as
|
|
273
|
+
* `recurrenceRules` with `recurrenceOverrides` keyed by each exception's original start.
|
|
274
|
+
*/
|
|
275
|
+
export function toJSCalendarEvent(event) {
|
|
276
|
+
const output = {
|
|
277
|
+
'@type': 'Event',
|
|
278
|
+
uid: event.id,
|
|
279
|
+
updated: event.updatedAt.toISOString(),
|
|
280
|
+
created: event.createdAt.toISOString(),
|
|
281
|
+
sequence: event.revision,
|
|
282
|
+
prodId: PRODID,
|
|
283
|
+
title: event.summary,
|
|
284
|
+
start: localDateTime(event.start, event.timezone),
|
|
285
|
+
timeZone: event.timezone,
|
|
286
|
+
duration: exactDuration(event.end.getTime() - event.start.getTime()),
|
|
287
|
+
status: event.status ?? 'confirmed',
|
|
288
|
+
freeBusyStatus: event.transparency === 'transparent' ? 'free' : 'busy',
|
|
289
|
+
};
|
|
290
|
+
if (event.description !== undefined)
|
|
291
|
+
output.description = event.description;
|
|
292
|
+
if (event.location !== undefined) {
|
|
293
|
+
output.locations = { l1: { '@type': 'Location', name: event.location } };
|
|
294
|
+
}
|
|
295
|
+
const participants = {};
|
|
296
|
+
if (event.organizer) {
|
|
297
|
+
participants.p0 = {
|
|
298
|
+
'@type': 'Participant',
|
|
299
|
+
...(event.organizer.name !== undefined ? { name: event.organizer.name } : {}),
|
|
300
|
+
email: event.organizer.email,
|
|
301
|
+
sendTo: { imip: `mailto:${event.organizer.email}` },
|
|
302
|
+
roles: { owner: true },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
(event.attendees ?? []).forEach((attendee, index) => {
|
|
306
|
+
const participant = {
|
|
307
|
+
'@type': 'Participant',
|
|
308
|
+
...(attendee.name !== undefined ? { name: attendee.name } : {}),
|
|
309
|
+
email: attendee.email,
|
|
310
|
+
sendTo: { imip: `mailto:${attendee.email}` },
|
|
311
|
+
roles: ROLES[attendee.role ?? 'required'] ?? { attendee: true },
|
|
312
|
+
};
|
|
313
|
+
if (attendee.participationStatus !== undefined) {
|
|
314
|
+
participant.participationStatus =
|
|
315
|
+
attendee.participationStatus === 'needs_action' ? 'needs-action' : attendee.participationStatus;
|
|
316
|
+
}
|
|
317
|
+
if (attendee.rsvp !== undefined)
|
|
318
|
+
participant.expectReply = attendee.rsvp;
|
|
319
|
+
participants[`p${index + 1}`] = participant;
|
|
320
|
+
});
|
|
321
|
+
if (Object.keys(participants).length > 0)
|
|
322
|
+
output.participants = participants;
|
|
323
|
+
if (event.reminders && event.reminders.length > 0) {
|
|
324
|
+
output.alerts = Object.fromEntries(event.reminders.map((reminder, index) => [
|
|
325
|
+
`a${index + 1}`,
|
|
326
|
+
{
|
|
327
|
+
'@type': 'Alert',
|
|
328
|
+
trigger: {
|
|
329
|
+
'@type': 'OffsetTrigger',
|
|
330
|
+
offset: reminder.minutesBeforeStart === 0 ? 'PT0S' : `-PT${reminder.minutesBeforeStart}M`,
|
|
331
|
+
relativeTo: 'start',
|
|
332
|
+
},
|
|
333
|
+
action: reminder.action,
|
|
334
|
+
},
|
|
335
|
+
]));
|
|
336
|
+
}
|
|
337
|
+
if (event.recurrence) {
|
|
338
|
+
output['slotlock.pylota.io:rrule'] = event.recurrence.rrule;
|
|
339
|
+
const rule = recurrenceRuleFromRRule(event.recurrence.rrule, event.timezone);
|
|
340
|
+
if (rule)
|
|
341
|
+
output.recurrenceRules = [rule];
|
|
342
|
+
const exceptions = event.recurrence.exceptions ?? [];
|
|
343
|
+
if (exceptions.length > 0) {
|
|
344
|
+
output.recurrenceOverrides = Object.fromEntries(exceptions.map((exception) => [
|
|
345
|
+
localDateTime(exception.recurrenceId, event.timezone),
|
|
346
|
+
exception.cancelled || !exception.start || !exception.end
|
|
347
|
+
? { excluded: true }
|
|
348
|
+
: {
|
|
349
|
+
start: localDateTime(exception.start, event.timezone),
|
|
350
|
+
duration: exactDuration(exception.end.getTime() - exception.start.getTime()),
|
|
351
|
+
},
|
|
352
|
+
]));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return output;
|
|
356
|
+
}
|
package/maintenance.d.ts
CHANGED
|
@@ -9,10 +9,21 @@ export interface SlotlockMaintenanceResult {
|
|
|
9
9
|
refused: number;
|
|
10
10
|
/**
|
|
11
11
|
* Expired holds and the answers kept for keyed ones, and past the retention window event
|
|
12
|
-
* idempotency answers, agents' event cancellations
|
|
13
|
-
* booking, deleted. A cancelled booking's tombstone stays.
|
|
12
|
+
* idempotency answers, agents' event cancellations, booking references retired with no
|
|
13
|
+
* booking and feed changes, deleted. A cancelled booking's tombstone stays.
|
|
14
14
|
*/
|
|
15
15
|
pruned: number;
|
|
16
|
+
/**
|
|
17
|
+
* Future one-off events whose wall time now falls at another instant (time zone rules changed):
|
|
18
|
+
* reported on the feed, or moved with `followZoneRules` (store.followZoneRules).
|
|
19
|
+
*/
|
|
20
|
+
zoneShifts: number;
|
|
21
|
+
/** Coverage sources gone stale since the last pass, now reported on the feed (`coverage.stale`). */
|
|
22
|
+
staleSources: number;
|
|
23
|
+
/** Bookings left unclaimed past their check-in window, released (`reservation.no_show`). */
|
|
24
|
+
noShows: number;
|
|
25
|
+
/** Waitlist entries whose window came free, each now holding it (`waitlist.offered`). */
|
|
26
|
+
waitlistOffers: number;
|
|
16
27
|
/** More was left than one pass does: run another. */
|
|
17
28
|
capped: boolean;
|
|
18
29
|
/**
|
|
@@ -30,6 +41,7 @@ export interface SlotlockMaintenanceResult {
|
|
|
30
41
|
export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
|
|
31
42
|
batches?: number;
|
|
32
43
|
signal?: AbortSignal;
|
|
44
|
+
followZoneRules?: boolean;
|
|
33
45
|
}): Promise<SlotlockMaintenanceResult>;
|
|
34
46
|
/**
|
|
35
47
|
* Run `work` holding the tenant's maintenance lock on a connection of its own, or return
|
package/maintenance.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ignoreRejection } from './hooks.js';
|
|
2
|
-
import { calendarEventRollingHorizon } from './store.js';
|
|
2
|
+
import { SLOTLOCK_MAX_WAITLIST_PER_TENANT, calendarEventRollingHorizon } from './store.js';
|
|
3
3
|
/**
|
|
4
4
|
* One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then purge
|
|
5
5
|
* its expired holds with their answers and prune its events' and bookings' bookkeeping past the
|
|
@@ -43,7 +43,8 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
43
43
|
let pruned = 0;
|
|
44
44
|
let eventsLeft = true;
|
|
45
45
|
let bookingsLeft = true;
|
|
46
|
-
|
|
46
|
+
let changesLeft = true;
|
|
47
|
+
for (let batch = 0; batch < batches && (eventsLeft || bookingsLeft || changesLeft); batch++) {
|
|
47
48
|
options.signal?.throwIfAborted();
|
|
48
49
|
if (eventsLeft) {
|
|
49
50
|
const prune = await store.withTenant(tenantRef, (tenant) => tenant.pruneCalendarEventRetention({ tenantRef }));
|
|
@@ -55,6 +56,29 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
55
56
|
pruned += prune.expiredHoldsDeleted + prune.holdCommandsDeleted + prune.tombstonesDeleted;
|
|
56
57
|
bookingsLeft = prune.hasMore;
|
|
57
58
|
}
|
|
59
|
+
if (changesLeft) {
|
|
60
|
+
const deleted = await store.withTenant(tenantRef, (tenant) => tenant.pruneChanges({ tenantRef, limit: CHANGE_PRUNE_BATCH }));
|
|
61
|
+
pruned += deleted;
|
|
62
|
+
changesLeft = deleted === CHANGE_PRUNE_BATCH;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
options.signal?.throwIfAborted();
|
|
66
|
+
const zones = await store.withTenant(tenantRef, (tenant) => tenant.followZoneRules({ tenantRef, move: options.followZoneRules === true }));
|
|
67
|
+
const staleSources = await store.withTenant(tenantRef, (tenant) => tenant.reportStaleCoverage({ tenantRef }));
|
|
68
|
+
// No-shows first: the time they free is offered to the waitlist in the same pass.
|
|
69
|
+
const noShows = await store.withTenant(tenantRef, (tenant) => tenant.releaseNoShows({ tenantRef }));
|
|
70
|
+
// The whole waitlist, in join order, a page at a time and each page its own transaction, so a
|
|
71
|
+
// later entry whose window is free is offered however many wait before it.
|
|
72
|
+
let waitlistOffers = 0;
|
|
73
|
+
let page = null;
|
|
74
|
+
for (let pages = 0; pages < WAITLIST_PAGES; pages++) {
|
|
75
|
+
options.signal?.throwIfAborted();
|
|
76
|
+
const from = page;
|
|
77
|
+
const offers = await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, ...(from ? { after: from } : {}) }));
|
|
78
|
+
waitlistOffers += offers.offered;
|
|
79
|
+
page = offers.next;
|
|
80
|
+
if (!page)
|
|
81
|
+
break;
|
|
58
82
|
}
|
|
59
83
|
if (rollFailure)
|
|
60
84
|
throw rollFailure.error;
|
|
@@ -63,10 +87,18 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
|
|
|
63
87
|
conflicts,
|
|
64
88
|
refused,
|
|
65
89
|
pruned,
|
|
66
|
-
|
|
90
|
+
zoneShifts: zones.shifted,
|
|
91
|
+
staleSources,
|
|
92
|
+
noShows,
|
|
93
|
+
waitlistOffers,
|
|
94
|
+
capped: rollCapped || eventsLeft || bookingsLeft || changesLeft,
|
|
67
95
|
rollPending: rollCapped && after !== null && (after.rollRetryAt ?? null) === null,
|
|
68
96
|
};
|
|
69
97
|
}
|
|
98
|
+
/** Pages of 50 that cover a tenant's whole waitlist (SLOTLOCK_MAX_WAITLIST_PER_TENANT). */
|
|
99
|
+
const WAITLIST_PAGES = Math.ceil(SLOTLOCK_MAX_WAITLIST_PER_TENANT / 50);
|
|
100
|
+
/** Feed changes past the retention deleted per batch. */
|
|
101
|
+
const CHANGE_PRUNE_BATCH = 1_000;
|
|
70
102
|
/** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
|
|
71
103
|
const LOCK_NAMESPACE = 'slotlock.maintenance:';
|
|
72
104
|
/**
|