slotlock 0.1.1 → 0.2.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/dashboard.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // is held in the database too.
15
15
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
16
16
  import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
17
+ import { ignoreRejection } from './hooks.js';
17
18
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
18
19
  /** Resources one dashboard tenant may hold. */
19
20
  export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 100;
@@ -54,6 +55,7 @@ const DAY_MS = 86_400_000;
54
55
  const KEY_ACCESS = Object.freeze({
55
56
  read: ['read'],
56
57
  read_write: ['read', 'write'],
58
+ full: ['read', 'write', 'admin'],
57
59
  });
58
60
  const KEY_EXPIRY_DAYS = Object.freeze({
59
61
  never: null,
@@ -432,7 +434,7 @@ export function createSlotlockDashboard(options) {
432
434
  let signInsInFlight = 0;
433
435
  function report(message) {
434
436
  try {
435
- options.onError?.(new Error(message));
437
+ ignoreRejection(options.onError?.(new Error(message)));
436
438
  }
437
439
  catch {
438
440
  // A failing reporter must not change the response.
@@ -559,7 +561,7 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
559
561
  return html `<tr>
560
562
  <td data-label="Name">${key.name}</td>
561
563
  <td data-label="Key"><code>${key.prefix}…</code></td>
562
- <td data-label="Access">${key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
564
+ <td data-label="Access">${key.scopes.includes('admin') ? 'Full' : key.scopes.includes('write') ? 'Read and write' : 'Read only'}</td>
563
565
  <td data-label="Created">${formatDate(key.createdAt)}</td>
564
566
  <td data-label="Last used">${key.lastUsedAt ? formatDate(key.lastUsedAt) : 'Never'}</td>
565
567
  <td data-label="Expires">${key.expiresAt ? formatDate(key.expiresAt) : 'Never'}</td>
@@ -588,7 +590,7 @@ ${notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``}
588
590
  <input type="hidden" name="csrf" value="${token}">
589
591
  <input type="hidden" name="once" value="${formNonce(session)}">
590
592
  <label>Name<input name="name" required maxlength="100" placeholder="Booking agent"></label>
591
- <label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option></select></label>
593
+ <label>Access<select name="access"><option value="read_write">Read and write</option><option value="read">Read only</option><option value="full">Full, with the application API</option></select></label>
592
594
  <label>Expires<select name="expires"><option value="90">In 90 days</option><option value="30">In 30 days</option><option value="365">In a year</option><option value="never">Never</option></select></label>
593
595
  <button class="primary" type="submit">Create key</button>
594
596
  </form>
@@ -1089,7 +1091,7 @@ ${state.unshown
1089
1091
  }
1090
1092
  catch (error) {
1091
1093
  try {
1092
- options.onError?.(error);
1094
+ ignoreRejection(options.onError?.(error));
1093
1095
  }
1094
1096
  catch {
1095
1097
  // A failing reporter must not change the response.
package/ddl.d.ts CHANGED
@@ -31,7 +31,8 @@ export declare const SLOTLOCK_CORE_DDL: string;
31
31
  export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
32
32
  /**
33
33
  * Every table the store reads or writes at runtime, each under forced tenant RLS.
34
- * `slotlock.rls_policy_contracts` is deployment-only and deliberately absent.
34
+ * `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
35
+ * deployment-only and deliberately absent.
35
36
  */
36
37
  export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage"];
37
38
  /**
package/ddl.js CHANGED
@@ -18,6 +18,58 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
18
18
  `;
19
19
  const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
20
20
  `;
21
+ // applyTenantRls forces row-level security, which binds the table owner too, and the deployment
22
+ // sets no tenant: its data steps (reaping holds and listing conflicts before the overlap
23
+ // constraint, merging duplicate resources, binding archives, backfilling event children) would see
24
+ // no rows, skip what they repair and leave a constraint to meet rows its own check never saw. The
25
+ // deployment lifts FORCE from the tables that have it before its first data step and puts it back
26
+ // after its last, on those tables only. applySchema runs both in one transaction, which holds the
27
+ // tables locked in between; a failure rolls the lift back with everything else. Run statement by
28
+ // statement, the lift commits on its own and a refused data step stops the run before the restore,
29
+ // so the tables lifted are recorded in a table, not the session: the next deployment, from any
30
+ // session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
31
+ // start while a tenant table is left without it.
32
+ const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
33
+ table_name text PRIMARY KEY
34
+ );
35
+ REVOKE ALL ON slotlock.deployment_lifted_row_security FROM PUBLIC;
36
+ DO $slotlock_lift_forced_row_security$
37
+ DECLARE
38
+ forced_table text;
39
+ BEGIN
40
+ INSERT INTO slotlock.deployment_lifted_row_security (table_name)
41
+ SELECT rel.relname::text
42
+ FROM pg_catalog.pg_class rel
43
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
44
+ WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
45
+ ON CONFLICT (table_name) DO NOTHING;
46
+ FOR forced_table IN
47
+ SELECT rel.relname::text
48
+ FROM pg_catalog.pg_class rel
49
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
50
+ WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p') AND rel.relforcerowsecurity
51
+ ORDER BY rel.relname
52
+ LOOP
53
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I NO FORCE ROW LEVEL SECURITY', forced_table);
54
+ END LOOP;
55
+ END
56
+ $slotlock_lift_forced_row_security$;
57
+ `;
58
+ const DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY = `DO $slotlock_restore_forced_row_security$
59
+ DECLARE
60
+ forced_table text;
61
+ BEGIN
62
+ FOR forced_table IN
63
+ SELECT table_name FROM slotlock.deployment_lifted_row_security ORDER BY table_name
64
+ LOOP
65
+ IF pg_catalog.to_regclass(pg_catalog.format('slotlock.%I', forced_table)) IS NOT NULL THEN
66
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.%I FORCE ROW LEVEL SECURITY', forced_table);
67
+ END IF;
68
+ END LOOP;
69
+ DELETE FROM slotlock.deployment_lifted_row_security;
70
+ END
71
+ $slotlock_restore_forced_row_security$;
72
+ `;
21
73
  /** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
22
74
  export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
23
75
  // Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
@@ -191,8 +243,13 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
191
243
  CONSTRAINT slotlock_api_keys_name_valid
192
244
  CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
193
245
  CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_[0-9A-Za-z]{8}$'),
194
- CONSTRAINT slotlock_api_keys_scopes_valid
195
- CHECK (scopes IN (ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['read', 'write']::text[])),
246
+ -- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
247
+ CONSTRAINT slotlock_api_keys_scopes_known
248
+ CHECK (scopes IN (
249
+ ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
250
+ ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
251
+ ARRAY['read', 'write', 'admin']::text[]
252
+ )),
196
253
  CONSTRAINT slotlock_api_keys_created_by_valid
197
254
  CHECK (created_by IS NULL OR (octet_length(created_by) BETWEEN 1 AND 200
198
255
  AND created_by !~ '[[:cntrl:]]')),
@@ -204,6 +261,27 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
204
261
  CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
205
262
  ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
206
263
  ALTER TABLE slotlock.api_keys ENABLE ROW LEVEL SECURITY;
264
+
265
+ -- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
266
+ -- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
267
+ -- transaction.
268
+ DO $slotlock_api_key_scopes$
269
+ BEGIN
270
+ IF NOT EXISTS (
271
+ SELECT 1 FROM pg_constraint
272
+ WHERE conname = 'slotlock_api_keys_scopes_known'
273
+ AND conrelid = 'slotlock.api_keys'::regclass
274
+ ) THEN
275
+ ALTER TABLE slotlock.api_keys
276
+ ADD CONSTRAINT slotlock_api_keys_scopes_known CHECK (scopes IN (
277
+ ARRAY['read']::text[], ARRAY['write']::text[], ARRAY['admin']::text[],
278
+ ARRAY['read', 'write']::text[], ARRAY['read', 'admin']::text[], ARRAY['write', 'admin']::text[],
279
+ ARRAY['read', 'write', 'admin']::text[]
280
+ )) NOT VALID;
281
+ END IF;
282
+ ALTER TABLE slotlock.api_keys DROP CONSTRAINT IF EXISTS slotlock_api_keys_scopes_valid;
283
+ END
284
+ $slotlock_api_key_scopes$;
207
285
  REVOKE ALL ON slotlock.api_keys FROM PUBLIC;
208
286
 
209
287
  CREATE TABLE IF NOT EXISTS slotlock.api_key_retired_digests (
@@ -496,7 +574,7 @@ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
496
574
  export const SLOTLOCK_CORE_DDL = `
497
575
  ${DEPLOYMENT_SEARCH_PATH_PIN}CREATE SCHEMA IF NOT EXISTS slotlock;
498
576
  ${SLOTLOCK_SCHEMA_CONTROL_CHECK}CREATE EXTENSION IF NOT EXISTS btree_gist SCHEMA slotlock;
499
-
577
+ ${DEPLOYMENT_LIFT_FORCED_ROW_SECURITY}
500
578
  -- Records the exact catalog representation installed for each owned RLS policy. Health checks
501
579
  -- compare live policy fingerprints with this contract so ALTER POLICY drift cannot hide behind a
502
580
  -- retained name/comment. This table contains configuration only, never tenant or calendar data.
@@ -552,7 +630,7 @@ CREATE TABLE IF NOT EXISTS slotlock.reservations (
552
630
  ),
553
631
  CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
554
632
  resource_id WITH =,
555
- tstzrange(starts_at, ends_at, '[)') WITH &&
633
+ tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
556
634
  )
557
635
  );
558
636
 
@@ -749,10 +827,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_resources_legacy_external_ref_key
749
827
  ON slotlock.resources (external_ref)
750
828
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL;
751
829
 
752
- CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_tenant_external_ref_key
753
- ON slotlock.reservations (tenant_ref, external_ref)
754
- WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL;
755
-
756
830
  DO $$
757
831
  BEGIN
758
832
  IF NOT EXISTS (
@@ -855,18 +929,62 @@ BEGIN
855
929
  buffer_after_ms >= 0 AND buffer_after_ms <= 2592000000
856
930
  ) NOT VALID;
857
931
  END IF;
932
+ END $$;
933
+
934
+ -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
935
+ -- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
936
+ -- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
937
+ -- reaps expired holds in the way, as the next writer would, then refuses to go on while a live row
938
+ -- is in the way: a constraint is never made to fit by deleting a booking or a live hold. Rows from
939
+ -- before the occupied-end trigger may lack occupied_ends_at; their end is where they stop.
940
+ DO $slotlock_occupied_overlap$
941
+ DECLARE
942
+ conflicts bigint;
943
+ examples text;
944
+ BEGIN
858
945
  IF NOT EXISTS (
859
946
  SELECT 1 FROM pg_constraint
860
- WHERE conname = 'slotlock_reservations_buffered_no_overlap'
947
+ WHERE conname = 'slotlock_reservations_no_overlap'
861
948
  AND conrelid = 'slotlock.reservations'::regclass
949
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%occupied_ends_at%'
862
950
  ) THEN
951
+ DELETE FROM slotlock.reservations hold
952
+ USING slotlock.reservations buffered
953
+ WHERE hold.status = 'held'
954
+ AND hold.expires_at <= now()
955
+ AND buffered.resource_id = hold.resource_id
956
+ AND buffered.id <> hold.id
957
+ AND buffered.buffer_after_ms > 0
958
+ AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
959
+ && tstzrange(hold.starts_at, hold.ends_at, '[)');
960
+ SELECT count(*),
961
+ string_agg(buffered.id::text || ' and ' || other.id::text, ', ' ORDER BY buffered.id, other.id)
962
+ INTO conflicts, examples
963
+ FROM slotlock.reservations buffered
964
+ JOIN slotlock.reservations other
965
+ ON other.resource_id = buffered.resource_id
966
+ AND other.id <> buffered.id
967
+ AND tstzrange(buffered.starts_at, COALESCE(buffered.occupied_ends_at, buffered.ends_at), '[)')
968
+ && tstzrange(other.starts_at, COALESCE(other.occupied_ends_at, other.ends_at), '[)')
969
+ WHERE buffered.buffer_after_ms > 0
970
+ -- A pair of buffered rows counts once.
971
+ AND (other.buffer_after_ms = 0 OR buffered.id < other.id);
972
+ IF conflicts > 0 THEN
973
+ RAISE EXCEPTION 'Slotlock cannot upgrade: % reservations or holds overlap another''s turnaround buffer', conflicts
974
+ USING ERRCODE = '23P01',
975
+ DETAIL = 'Reservation ids, in pairs: ' || left(examples, 2000),
976
+ HINT = 'Move, cancel or release one of each pair (slotlock.reservations), then migrate again.';
977
+ END IF;
978
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_no_overlap;
863
979
  ALTER TABLE slotlock.reservations
864
- ADD CONSTRAINT slotlock_reservations_buffered_no_overlap EXCLUDE USING gist (
980
+ ADD CONSTRAINT slotlock_reservations_no_overlap EXCLUDE USING gist (
865
981
  resource_id WITH =,
866
- tstzrange(starts_at, occupied_ends_at, '[)') WITH &&
867
- ) WHERE (tenant_ref IS NOT NULL);
982
+ tstzrange(starts_at, COALESCE(occupied_ends_at, ends_at), '[)') WITH &&
983
+ );
868
984
  END IF;
869
- END $$;
985
+ ALTER TABLE slotlock.reservations DROP CONSTRAINT IF EXISTS slotlock_reservations_buffered_no_overlap;
986
+ END
987
+ $slotlock_occupied_overlap$;
870
988
 
871
989
  CREATE INDEX IF NOT EXISTS slotlock_reservations_resource_time_idx
872
990
  ON slotlock.reservations (resource_id, starts_at, ends_at);
@@ -1279,6 +1397,15 @@ BEGIN
1279
1397
  END IF;
1280
1398
  END $$;
1281
1399
 
1400
+ -- A booking's reference is unique in its tenant. The rows a calendar event occupies carry a
1401
+ -- reference derived from the event in the same columns, but they are not bookings and are left out:
1402
+ -- a booking with the same reference neither collides with one nor answers for it. Up to 0.1 one
1403
+ -- index covered both; every row it admitted, this one admits.
1404
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_reservations_booking_external_ref_key
1405
+ ON slotlock.reservations (tenant_ref, external_ref)
1406
+ WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL AND calendar_event_id IS NULL;
1407
+ DROP INDEX IF EXISTS slotlock.slotlock_reservations_tenant_external_ref_key;
1408
+
1282
1409
  -- Materialized occurrences point at reservations created under the SAME exclusion arbiter as
1283
1410
  -- bookings and holds. Transparent events retain occurrence visibility with a NULL reservation.
1284
1411
  CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
@@ -1454,11 +1581,12 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
1454
1581
  ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
1455
1582
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
1456
1583
  ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
1457
- ${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
1584
+ ${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${DEPLOYMENT_RESTORE_FORCED_ROW_SECURITY}${DEPLOYMENT_SEARCH_PATH_RESTORE}`;
1458
1585
  export const SLOTLOCK_TENANT_CONTEXT_SETTING = 'slotlock.tenant_ref';
1459
1586
  /**
1460
1587
  * Every table the store reads or writes at runtime, each under forced tenant RLS.
1461
- * `slotlock.rls_policy_contracts` is deployment-only and deliberately absent.
1588
+ * `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
1589
+ * deployment-only and deliberately absent.
1462
1590
  */
1463
1591
  export const SLOTLOCK_TENANT_TABLES = Object.freeze([
1464
1592
  'resources',
package/hooks.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
3
+ * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
4
+ * the process on one by default. A hook that throws is caught where it is called; this is the same
5
+ * for one that rejects.
6
+ */
7
+ export declare function ignoreRejection(result: unknown): void;
package/hooks.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Let a hook's promise fail unseen. `onEvent` and `onError` are typed as returning nothing, but an
3
+ * async one returns a promise, and its rejection, left alone, is an unhandled rejection: Node ends
4
+ * the process on one by default. A hook that throws is caught where it is called; this is the same
5
+ * for one that rejects.
6
+ */
7
+ export function ignoreRejection(result) {
8
+ if ((typeof result === 'object' || typeof result === 'function') &&
9
+ result !== null &&
10
+ typeof result.then === 'function') {
11
+ Promise.resolve(result).catch(() => undefined);
12
+ }
13
+ }
package/index.d.ts CHANGED
@@ -5,12 +5,16 @@ export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SL
5
5
  export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
6
6
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
7
7
  export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
8
- export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
8
+ export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
9
9
  export type { SlotlockAgentCalendarBackend, SlotlockAgentConfirmationOptions, SlotlockAgentErrorContext, SlotlockAgentInvocationContext, SlotlockAgentOperation, SlotlockAgentOperationDispatchOptions, SlotlockAgentOperationDispatchResult, SlotlockAgentPrincipal, SlotlockAgentScope, SlotlockAgentServerEvent, SlotlockAgentServerOAuthOptions, SlotlockAgentServerOptions, SlotlockAgentSubscriptionOptions, SlotlockAgentWriteOperation, SlotlockMcpToolResult, SlotlockTraceContext, } from './agent-server.js';
10
10
  export { createSlotlockNodeServer } from './node-server.js';
11
11
  export type { SlotlockNodeListenOptions, SlotlockNodeServer, SlotlockNodeServerAddress, SlotlockNodeServerCloseResult, SlotlockNodeServerErrorPhase, SlotlockNodeServerFetchTarget, SlotlockNodeServerOptions, } from './node-server.js';
12
- export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
13
- export type { SlotlockStoreAgentBackendOptions } from './agent-store-backend.js';
12
+ export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
13
+ export type { SlotlockStoreAgentBackendOptions, SlotlockStoreApplicationBackendOptions, } from './agent-store-backend.js';
14
+ export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
15
+ export type { SlotlockApplicationBackend, SlotlockApplicationInvocationContext, SlotlockApplicationOperation, SlotlockApplicationOperationDefinition, } from './application-api.js';
16
+ export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
17
+ export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
14
18
  export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
15
19
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
16
20
  export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
@@ -20,5 +24,5 @@ export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-wor
20
24
  export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseTrustedICalendarEvents, } from './sync.js';
21
25
  export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ITipMethod, } from './sync.js';
22
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';
23
- export { calendarEventRollingHorizon, createSlotlockStore, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MAX_EVENT_DURATION_DAYS, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantStore, } from './store.js';
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';
24
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';
package/index.js CHANGED
@@ -7,9 +7,11 @@ export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
7
7
  export { expandRules } from './rules.js';
8
8
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
9
9
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
10
- export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
10
+ export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
11
11
  export { createSlotlockNodeServer } from './node-server.js';
12
- export { createSlotlockStoreAgentBackend, projectSlotlockCalendarEvent } from './agent-store-backend.js';
12
+ export { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, projectSlotlockCalendarEvent, } from './agent-store-backend.js';
13
+ export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, } from './application-api.js';
14
+ export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
13
15
  export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
14
16
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
15
17
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
@@ -0,0 +1,57 @@
1
+ import type { Sql } from 'postgres';
2
+ import { type SlotlockStore } from './store.js';
3
+ export interface SlotlockMaintenanceResult {
4
+ /** Recurring events whose stored occurrences now reach the horizon. */
5
+ extended: number;
6
+ /** Series that could not extend because an occurrence would overlap a booking. */
7
+ conflicts: number;
8
+ /** Series over the expansion budget; they keep their old coverage. */
9
+ refused: number;
10
+ /** Idempotency commands and tombstones deleted. */
11
+ pruned: number;
12
+ /** More was left than one pass does: run another. */
13
+ capped: boolean;
14
+ }
15
+ /**
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.
19
+ */
20
+ export declare function maintainSlotlockTenant(store: SlotlockStore, tenantRef: string, options?: {
21
+ batches?: number;
22
+ signal?: AbortSignal;
23
+ }): Promise<SlotlockMaintenanceResult>;
24
+ /**
25
+ * Run `work` holding the tenant's maintenance lock on a connection of its own, or return
26
+ * `undefined` without running it when another server holds the lock.
27
+ */
28
+ export declare function withSlotlockMaintenanceLock<T>(sql: Sql, tenantRef: string, work: () => Promise<T>): Promise<T | undefined>;
29
+ export interface SlotlockMaintenanceScheduler {
30
+ /**
31
+ * A tenant the server just served. Unless it was maintained within the interval, it is queued
32
+ * now, and from then on maintained every interval with the others.
33
+ */
34
+ touch(tenantRef: string): void;
35
+ /** Stop: no new pass starts, and the one running finishes first. */
36
+ close(): Promise<void>;
37
+ }
38
+ export interface SlotlockMaintenanceSchedulerOptions {
39
+ store: SlotlockStore;
40
+ sql: Sql;
41
+ /** Tenants maintained from the start, whether or not a key of theirs calls. */
42
+ tenants: readonly string[];
43
+ /** How often each tenant is maintained, at most a day. */
44
+ intervalMs: number;
45
+ /** Tenants remembered; the one served longest ago is forgotten first. Default 10,000. */
46
+ maxTenants?: number;
47
+ /** Batches of each task per pass (maintainSlotlockTenant). */
48
+ batches?: number;
49
+ /** A pass that ran: counts only, never the tenant. Not awaited; what it throws is ignored. */
50
+ onPass?: (result: SlotlockMaintenanceResult) => void;
51
+ /**
52
+ * A pass that failed. The scheduler carries on with the next tenant. Not awaited; what it throws,
53
+ * or its promise rejects with, is ignored.
54
+ */
55
+ onError?: (error: unknown) => void;
56
+ }
57
+ export declare function startSlotlockMaintenance(options: SlotlockMaintenanceSchedulerOptions): SlotlockMaintenanceScheduler;
package/maintenance.js ADDED
@@ -0,0 +1,181 @@
1
+ import { ignoreRejection } from './hooks.js';
2
+ import { calendarEventRollingHorizon } from './store.js';
3
+ /**
4
+ * One maintenance pass for a tenant: roll its recurring events to the rolling horizon, then 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.
7
+ */
8
+ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
9
+ const batches = options.batches ?? 10;
10
+ if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
11
+ throw new RangeError('batches must be a whole number from 1 to 100');
12
+ }
13
+ const window = calendarEventRollingHorizon();
14
+ let extended = 0;
15
+ let conflicts = 0;
16
+ let refused = 0;
17
+ let rollCapped = true;
18
+ 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;
30
+ }
31
+ }
32
+ let pruned = 0;
33
+ let pruneCapped = true;
34
+ for (let batch = 0; batch < batches; batch++) {
35
+ 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;
41
+ }
42
+ }
43
+ return { extended, conflicts, refused, pruned, capped: rollCapped || pruneCapped };
44
+ }
45
+ /** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
46
+ const LOCK_NAMESPACE = 'slotlock.maintenance:';
47
+ /**
48
+ * Run `work` holding the tenant's maintenance lock on a connection of its own, or return
49
+ * `undefined` without running it when another server holds the lock.
50
+ */
51
+ export async function withSlotlockMaintenanceLock(sql, tenantRef, work) {
52
+ const reserved = await sql.reserve();
53
+ try {
54
+ const key = `${LOCK_NAMESPACE}${tenantRef}`;
55
+ const [row] = await reserved `
56
+ SELECT pg_catalog.pg_try_advisory_lock(pg_catalog.hashtextextended(${key}, 0)) AS locked`;
57
+ if (!row?.locked)
58
+ return undefined;
59
+ try {
60
+ return await work();
61
+ }
62
+ finally {
63
+ await reserved `SELECT pg_catalog.pg_advisory_unlock(pg_catalog.hashtextextended(${key}, 0))`;
64
+ }
65
+ }
66
+ finally {
67
+ reserved.release();
68
+ }
69
+ }
70
+ const DAY_MS = 86_400_000;
71
+ export function startSlotlockMaintenance(options) {
72
+ const { intervalMs } = options;
73
+ if (!(Number.isSafeInteger(intervalMs) && intervalMs >= 1_000 && intervalMs <= DAY_MS)) {
74
+ throw new RangeError('intervalMs must be from one second to one day');
75
+ }
76
+ const maxTenants = options.maxTenants ?? 10_000;
77
+ /** Configured tenants, never forgotten: tenant reference to when its last pass finished (0: never). */
78
+ const pinned = new Map(options.tenants.map((tenantRef) => [tenantRef, 0]));
79
+ /** Served tenants, in the order last served; the oldest is forgotten past `maxTenants`. */
80
+ const served = new Map();
81
+ const queue = [];
82
+ /** Tenants waiting or in a pass: a tenant served during its own pass is not queued again. */
83
+ const queued = new Set();
84
+ const controller = new AbortController();
85
+ let working = null;
86
+ const lastPass = (tenantRef) => pinned.get(tenantRef) ?? served.get(tenantRef);
87
+ const recordPass = (tenantRef, finishedAt) => {
88
+ if (pinned.has(tenantRef))
89
+ pinned.set(tenantRef, finishedAt);
90
+ else if (served.has(tenantRef))
91
+ served.set(tenantRef, finishedAt);
92
+ };
93
+ const due = (tenantRef) => Date.now() - (lastPass(tenantRef) ?? 0) >= intervalMs;
94
+ const enqueue = (tenantRef) => {
95
+ if (controller.signal.aborted || queued.has(tenantRef))
96
+ return;
97
+ queued.add(tenantRef);
98
+ queue.push(tenantRef);
99
+ if (!working)
100
+ working = drain();
101
+ };
102
+ const drain = async () => {
103
+ try {
104
+ for (;;) {
105
+ const tenantRef = queue.shift();
106
+ if (tenantRef === undefined || controller.signal.aborted)
107
+ return;
108
+ let again = false;
109
+ try {
110
+ const result = await withSlotlockMaintenanceLock(options.sql, tenantRef, () => maintainSlotlockTenant(options.store, tenantRef, {
111
+ ...(options.batches !== undefined ? { batches: options.batches } : {}),
112
+ signal: controller.signal,
113
+ }));
114
+ recordPass(tenantRef, Date.now());
115
+ if (result) {
116
+ try {
117
+ ignoreRejection(options.onPass?.(result));
118
+ }
119
+ catch {
120
+ // Reporting must never stop maintenance, or make a pass that ran look failed.
121
+ }
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;
125
+ }
126
+ }
127
+ catch (error) {
128
+ if (controller.signal.aborted)
129
+ return;
130
+ recordPass(tenantRef, Date.now());
131
+ try {
132
+ ignoreRejection(options.onError?.(error));
133
+ }
134
+ catch {
135
+ // Reporting must never stop maintenance.
136
+ }
137
+ }
138
+ finally {
139
+ queued.delete(tenantRef);
140
+ }
141
+ if (again)
142
+ enqueue(tenantRef);
143
+ }
144
+ }
145
+ finally {
146
+ working = null;
147
+ }
148
+ };
149
+ const tick = () => {
150
+ for (const tenantRef of [...pinned.keys(), ...served.keys()]) {
151
+ if (due(tenantRef))
152
+ enqueue(tenantRef);
153
+ }
154
+ };
155
+ const timer = setInterval(tick, Math.min(intervalMs, 60_000));
156
+ timer.unref();
157
+ tick();
158
+ return {
159
+ touch(tenantRef) {
160
+ if (controller.signal.aborted || pinned.has(tenantRef)) {
161
+ if (pinned.has(tenantRef) && due(tenantRef))
162
+ enqueue(tenantRef);
163
+ return;
164
+ }
165
+ const previous = served.get(tenantRef);
166
+ served.delete(tenantRef);
167
+ served.set(tenantRef, previous ?? 0);
168
+ while (served.size > maxTenants)
169
+ served.delete(served.keys().next().value);
170
+ if (due(tenantRef))
171
+ enqueue(tenantRef);
172
+ },
173
+ async close() {
174
+ clearInterval(timer);
175
+ controller.abort(new Error('Slotlock maintenance stopped'));
176
+ queue.length = 0;
177
+ queued.clear();
178
+ await working;
179
+ },
180
+ };
181
+ }