slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -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 +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/store.js CHANGED
@@ -1,8 +1,11 @@
1
1
  // DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
2
2
  // Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
3
3
  // reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
4
+ import { SLOTLOCK_CHANGE_RETENTION_DAYS, slotlockEventFeedMethods } from './event-feed.js';
5
+ import { bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
4
6
  import { createHash, randomUUID } from 'node:crypto';
5
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_TENANT_CONTEXT_SETTING, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, } from './ddl.js';
7
+ import ICAL from 'ical.js';
8
+ import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
6
9
  import { findNextAvailable, mergeIntervals } from './engine.js';
7
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
8
11
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
@@ -30,6 +33,13 @@ export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
30
33
  export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
31
34
  const DAY_MS = 24 * 60 * 60 * 1000;
32
35
  const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
36
+ /**
37
+ * Longest window a recurring series is stored (materialized) for: the 367-day search window plus
38
+ * two days. The rolling horizon starts a day before the current UTC day and must reach 367 days
39
+ * past its end, so that a search of the full 367 days from any moment of the day is complete.
40
+ */
41
+ export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
42
+ const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
33
43
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
34
44
  const MAX_RESOURCE_LIST_LIMIT = 1_000;
35
45
  /** Highest per-tenant resource cap `createResource` accepts. */
@@ -40,6 +50,19 @@ const MAX_COVERAGE_SOURCES = 64;
40
50
  const MAX_COVERAGE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
41
51
  /** Trusted provider/maintenance namespace used by backwards-compatible store callers. */
42
52
  export const SLOTLOCK_INTERNAL_EVENT_OWNER_REF = 'internal';
53
+ /** How many live holds one holder (a keyed hold's principal) may have in a tenant at once. */
54
+ export const SLOTLOCK_MAX_HOLDS_PER_HOLDER = 20;
55
+ /** Windows one holder may wait for at once (joinWaitlist). */
56
+ export const SLOTLOCK_MAX_WAITLIST_PER_HOLDER = 20;
57
+ /** Waiting entries one tenant may have. */
58
+ export const SLOTLOCK_MAX_WAITLIST_PER_TENANT = 10_000;
59
+ /** The longest window one waitlist entry may wait for. */
60
+ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
61
+ const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
62
+ /** How long before its start a booking may be checked in. */
63
+ export const SLOTLOCK_CHECK_IN_OPENS_MINUTES = 60;
64
+ /** Free-looking resources one entry tries before the next entry: a race can take one first. */
65
+ const OFFER_CANDIDATES = 3;
43
66
  /** Default hard ceiling for retained active + tombstoned identities per agent principal. */
44
67
  export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
45
68
  /** Hard retained command ceiling; includes reserved cancellation capacity for every identity. */
@@ -57,13 +80,16 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3
57
80
  const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
58
81
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
59
82
  export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
60
- /** Stable daily target shared by the event writer, maintenance worker, and readiness proof. */
83
+ /**
84
+ * Stable daily target shared by the event writer, maintenance worker, and readiness proof: from a
85
+ * day before the current UTC day to 367 days past its end (SLOTLOCK_MATERIALIZATION_WINDOW_DAYS).
86
+ */
61
87
  export function calendarEventRollingHorizon(now = new Date()) {
62
88
  if (!Number.isFinite(now.getTime()))
63
89
  throw invalidWindowError();
64
90
  const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
65
91
  const start = new Date(midnight - DAY_MS);
66
- return { start, end: new Date(start.getTime() + SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS) };
92
+ return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
67
93
  }
68
94
  /**
69
95
  * A stored series expanded from the cursor its last expansion left, or `null` when there is none it
@@ -98,15 +124,118 @@ function seriesCursorJson(tx, cursor) {
98
124
  function rolledWindow(stored, horizon) {
99
125
  if (stored.start <= horizon.start && stored.end >= horizon.end)
100
126
  return null;
101
- if (stored.end.getTime() - horizon.start.getTime() > MAX_CALENDAR_WINDOW_MS)
127
+ if (stored.end.getTime() - horizon.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS)
102
128
  return null;
103
129
  return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
104
130
  }
131
+ /**
132
+ * How far past a rule's UNTIL, read as UTC, an occurrence can still end, beyond the series' own
133
+ * length. UNTIL bounds an occurrence's start (RFC 5545 §3.3.10), but a floating or date-only UNTIL
134
+ * is a wall time in a zone from UTC-12 to UTC+14, or a whole day; a start in an hour the clocks skip
135
+ * moves forward; and an occurrence's wall-clock end can pass its exact length by a clock change, a
136
+ * whole day where a zone moved across the date line. Three days covers them together.
137
+ */
138
+ const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
139
+ /** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
140
+ const SERIES_COUNT_SEARCH_WINDOWS = 30;
141
+ /**
142
+ * A time by which every occurrence of a recurring series ends, moved ones included, or `null` when
143
+ * it may go on: its rule has neither UNTIL nor COUNT as ical.js reads it (as the expansion does),
144
+ * or its COUNT occurrences are not all found within SERIES_COUNT_SEARCH_WINDOWS windows from its
145
+ * start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
146
+ * the expansion reads it today, so a later reading of its end may be longer.
147
+ */
148
+ function seriesEndsBy(event) {
149
+ const recurrence = event.recurrence;
150
+ if (!recurrence)
151
+ return null;
152
+ let rule;
153
+ try {
154
+ rule = ICAL.Recur.fromString(recurrence.rrule);
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ let endsBy;
160
+ if (rule.until) {
161
+ // ical.js stops at the first occurrence past UNTIL; a floating or date UNTIL reads as UTC.
162
+ const untilMs = rule.until.toUnixTime() * 1000;
163
+ endsBy = untilMs + (event.end.getTime() - event.start.getTime()) + SERIES_UNTIL_SLACK_MS;
164
+ }
165
+ else if (rule.count) {
166
+ const last = lastCountedOccurrenceEnd(event, rule.count);
167
+ if (last === null)
168
+ return null;
169
+ endsBy = last + DAY_MS;
170
+ }
171
+ else {
172
+ return null;
173
+ }
174
+ for (const exception of recurrence.exceptions ?? []) {
175
+ if (!exception.cancelled && exception.end && exception.end.getTime() > endsBy) {
176
+ endsBy = exception.end.getTime();
177
+ }
178
+ }
179
+ const ends = new Date(endsBy);
180
+ return Number.isFinite(ends.getTime()) ? ends : null;
181
+ }
182
+ /**
183
+ * The latest end among a COUNT series' occurrences, expanded window by window from its start with
184
+ * exceptions left out (so a cancelled or moved one is still found in its own place), or `null` when
185
+ * fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
186
+ * there are no more. An occurrence is counted in the window its start falls in.
187
+ */
188
+ function lastCountedOccurrenceEnd(event, count) {
189
+ const rrule = event.recurrence?.rrule;
190
+ if (rrule === undefined)
191
+ return null;
192
+ const series = { ...event, recurrence: { rrule } };
193
+ let cursor = null;
194
+ let found = 0;
195
+ let lastEnd = Number.NEGATIVE_INFINITY;
196
+ let from = event.start.getTime();
197
+ try {
198
+ for (let window = 0; window < SERIES_COUNT_SEARCH_WINDOWS; window++) {
199
+ const to = from + MAX_MATERIALIZATION_WINDOW_MS;
200
+ const expansion = expandCalendarEventSeries(series, { start: new Date(from), end: new Date(to) }, { cursor, stored: true });
201
+ for (const occurrence of expansion.occurrences) {
202
+ if (occurrence.start.getTime() < from)
203
+ continue;
204
+ found += 1;
205
+ if (occurrence.end.getTime() > lastEnd)
206
+ lastEnd = occurrence.end.getTime();
207
+ }
208
+ if (found >= count)
209
+ return lastEnd;
210
+ cursor = expansion.cursor;
211
+ from = to;
212
+ }
213
+ }
214
+ catch (error) {
215
+ if (error instanceof CalendarExpansionLimitError || error instanceof CalendarContractError) {
216
+ return null;
217
+ }
218
+ throw error;
219
+ }
220
+ return null;
221
+ }
222
+ /**
223
+ * The recurrence_ends_by column for an event, as text: NULL for a one-off, 'infinity' for no known
224
+ * end. Bind it as text (`::text::timestamptz`): postgres.js serializes a timestamptz parameter
225
+ * through Date, which cannot hold 'infinity'.
226
+ */
227
+ function recurrenceEndsByColumn(event) {
228
+ if (!event.recurrence)
229
+ return null;
230
+ return seriesEndsBy(event)?.toISOString() ?? 'infinity';
231
+ }
105
232
  /** The roll's `after`, checked, or `undefined` for a pass's first batch. */
106
233
  function rollCursor(after) {
107
234
  if (after === undefined)
108
235
  return undefined;
109
- if (!(after.materializedEndsAt instanceof Date) ||
236
+ const retryAt = after.rollRetryAt ?? null;
237
+ if ((retryAt !== null && (!(retryAt instanceof Date) || !Number.isFinite(retryAt.getTime()))) ||
238
+ !(after.materializedEndsAt instanceof Date) ||
110
239
  !Number.isFinite(after.materializedEndsAt.getTime()) ||
111
240
  !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(after.eventId)) {
112
241
  throw Object.assign(new Error('Slotlock horizon cursor must be a previous batch\'s `next`'), {
@@ -156,10 +285,18 @@ async function withPinnedSearchPath(tx, operation) {
156
285
  /**
157
286
  * The deployment DDL refuses a `slotlock` schema that another role controls with SQLSTATE 42501 and
158
287
  * its findings as a JSON array in DETAIL. Surface that as `unsafe_slotlock_schema` with `reasons`,
159
- * keeping the database's error (and its HINT) as `cause`; any other failure passes through unchanged.
288
+ * keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
289
+ * migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
160
290
  */
161
291
  function schemaControlRefusal(error) {
162
292
  const failure = error;
293
+ if (failure?.code === '55000' &&
294
+ typeof failure.message === 'string' &&
295
+ failure.message.startsWith(SLOTLOCK_SCHEMA_NEWER_REFUSAL)) {
296
+ return Object.assign(new Error(failure.message, { cause: error }), {
297
+ code: 'schema_newer',
298
+ });
299
+ }
163
300
  if (failure?.code !== '42501' ||
164
301
  typeof failure.message !== 'string' ||
165
302
  !failure.message.startsWith(SLOTLOCK_SCHEMA_CONTROL_REFUSAL)) {
@@ -180,6 +317,236 @@ function schemaControlRefusal(error) {
180
317
  reasons,
181
318
  });
182
319
  }
320
+ /** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
321
+ const LOCK_NOT_AVAILABLE = '55P03';
322
+ const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
323
+ const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
324
+ const DEFAULT_MIGRATION_ATTEMPTS = 5;
325
+ const SLOTLOCK_CORE_DDL_SHA256 = createHash('sha256').update(SLOTLOCK_CORE_DDL).digest('hex');
326
+ function migrationSettings(options) {
327
+ const lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
328
+ const statementTimeoutMs = options?.statementTimeoutMs ?? DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS;
329
+ const attempts = options?.attempts ?? DEFAULT_MIGRATION_ATTEMPTS;
330
+ const whole = (value, max) => Number.isSafeInteger(value) && value >= 1 && value <= max;
331
+ if (!whole(lockTimeoutMs, 600_000) ||
332
+ !whole(statementTimeoutMs, 86_400_000) ||
333
+ !whole(attempts, 20)) {
334
+ throw Object.assign(new Error('Slotlock migration lockTimeoutMs must be 1-600000, statementTimeoutMs 1-86400000 and attempts 1-20, whole numbers'), { code: 'invalid_migration_options' });
335
+ }
336
+ return { lockTimeoutMs, statementTimeoutMs, attempts };
337
+ }
338
+ /**
339
+ * Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
340
+ * in pg_catalog only, serialized with every other migration of this database, and with the lock and
341
+ * statement timeouts set. A step that waits past its lock timeout is rolled back and tried again
342
+ * after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
343
+ */
344
+ async function inMigration(executor, options, step) {
345
+ const settings = migrationSettings(options);
346
+ for (let attempt = 1;; attempt++) {
347
+ try {
348
+ return await inTransaction(executor, (tx) => withPinnedSearchPath(tx, async () => {
349
+ // Taken before the timeouts are set: waiting for another migration holds up no query.
350
+ await tx `SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${SCHEMA_APPLY_LOCK}, 0))`;
351
+ const [caller] = await tx `
352
+ SELECT pg_catalog.current_setting('lock_timeout') AS lock,
353
+ pg_catalog.current_setting('statement_timeout') AS statement`;
354
+ await tx `
355
+ SELECT pg_catalog.set_config('lock_timeout', ${`${settings.lockTimeoutMs}ms`}, true),
356
+ pg_catalog.set_config('statement_timeout', ${`${settings.statementTimeoutMs}ms`}, true)`;
357
+ const result = await step(tx);
358
+ await tx `
359
+ SELECT pg_catalog.set_config('lock_timeout', ${caller?.lock ?? '0'}, true),
360
+ pg_catalog.set_config('statement_timeout', ${caller?.statement ?? '0'}, true)`;
361
+ return result;
362
+ }));
363
+ }
364
+ catch (error) {
365
+ if (pgCode(error) !== LOCK_NOT_AVAILABLE)
366
+ throw error;
367
+ if (attempt >= settings.attempts) {
368
+ throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
369
+ }
370
+ await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
371
+ }
372
+ }
373
+ }
374
+ /**
375
+ * A digest of what the migration defines in the `slotlock` schema: its tables and columns,
376
+ * constraints (and whether each is validated), indexes, triggers and functions, and whether PUBLIC
377
+ * holds any right on them. Grants to other roles, ownership and row-level security are left out:
378
+ * applyTenantRls and grantApplicationRole change those after applySchema has recorded the digest.
379
+ * Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
380
+ * with it pinned, as the migration does.
381
+ */
382
+ async function slotlockCatalogDigest(tx) {
383
+ const [row] = await tx `
384
+ WITH schema AS (
385
+ SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = 'slotlock'
386
+ ), members AS (
387
+ SELECT objid FROM pg_catalog.pg_depend WHERE deptype = 'e'
388
+ ), items (item) AS (
389
+ SELECT 'relation ' || rel.relname || ' ' || rel.relkind::text || ' '
390
+ || EXISTS (SELECT 1 FROM pg_catalog.aclexplode(rel.relacl) acl WHERE acl.grantee = 0)::text
391
+ FROM pg_catalog.pg_class rel, schema
392
+ WHERE rel.relnamespace = schema.oid AND rel.oid NOT IN (SELECT objid FROM members)
393
+ UNION ALL
394
+ SELECT 'column ' || rel.relname || '.' || att.attname || ' '
395
+ || pg_catalog.format_type(att.atttypid, att.atttypmod) || ' ' || att.attnotnull::text || ' '
396
+ || COALESCE(pg_catalog.pg_get_expr(def.adbin, def.adrelid), '')
397
+ FROM pg_catalog.pg_attribute att
398
+ JOIN pg_catalog.pg_class rel ON rel.oid = att.attrelid
399
+ JOIN schema ON rel.relnamespace = schema.oid
400
+ LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = att.attrelid AND def.adnum = att.attnum
401
+ WHERE rel.relkind IN ('r', 'p') AND att.attnum > 0 AND NOT att.attisdropped
402
+ UNION ALL
403
+ SELECT 'constraint ' || rel.relname || '.' || con.conname || ' '
404
+ || pg_catalog.pg_get_constraintdef(con.oid) || ' ' || con.convalidated::text
405
+ FROM pg_catalog.pg_constraint con
406
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
407
+ JOIN schema ON rel.relnamespace = schema.oid
408
+ UNION ALL
409
+ SELECT 'index ' || pg_catalog.pg_get_indexdef(idx.indexrelid)
410
+ FROM pg_catalog.pg_index idx
411
+ JOIN pg_catalog.pg_class rel ON rel.oid = idx.indrelid
412
+ JOIN schema ON rel.relnamespace = schema.oid
413
+ UNION ALL
414
+ SELECT 'trigger ' || pg_catalog.pg_get_triggerdef(trg.oid) || ' ' || trg.tgenabled::text
415
+ FROM pg_catalog.pg_trigger trg
416
+ JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid
417
+ JOIN schema ON rel.relnamespace = schema.oid
418
+ WHERE NOT trg.tgisinternal
419
+ UNION ALL
420
+ SELECT 'function ' || fn.oid::pg_catalog.regprocedure::text || ' ' || fn.prosecdef::text || ' '
421
+ || fn.provolatile::text || ' ' || COALESCE(fn.proconfig::text, '') || ' '
422
+ || pg_catalog.md5(fn.prosrc) || ' '
423
+ || EXISTS (SELECT 1 FROM pg_catalog.aclexplode(
424
+ COALESCE(fn.proacl, pg_catalog.acldefault('f', fn.proowner))) acl
425
+ WHERE acl.grantee = 0)::text
426
+ FROM pg_catalog.pg_proc fn, schema
427
+ WHERE fn.pronamespace = schema.oid AND fn.oid NOT IN (SELECT objid FROM members)
428
+ UNION ALL
429
+ SELECT 'extension ' || ext.extname || ' ' || ext.extnamespace::pg_catalog.regnamespace::text
430
+ FROM pg_catalog.pg_extension ext
431
+ WHERE ext.extname = 'btree_gist'
432
+ )
433
+ SELECT pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
434
+ COALESCE(pg_catalog.string_agg(item, pg_catalog.chr(10) ORDER BY item), ''),
435
+ 'UTF8')), 'hex') AS digest
436
+ FROM items`;
437
+ return row?.digest ?? '';
438
+ }
439
+ /**
440
+ * Whether the schema is exactly what this release's applySchema last left: the same version, DDL
441
+ * and catalog digest, and no table left without FORCE by an interrupted migration. Reading needs
442
+ * the right to (the deployment role owns the table); without it the full migration runs and refuses
443
+ * what it must.
444
+ */
445
+ async function slotlockSchemaUpToDate(tx) {
446
+ const [present] = await tx `
447
+ SELECT COALESCE(pg_catalog.has_table_privilege(
448
+ pg_catalog.to_regclass('slotlock.schema_version'), 'SELECT'), false) AS versioned,
449
+ COALESCE(pg_catalog.has_table_privilege(
450
+ pg_catalog.to_regclass('slotlock.deployment_lifted_row_security'), 'SELECT'), false)
451
+ AS lifted`;
452
+ if (!present?.versioned || !present.lifted)
453
+ return false;
454
+ const [recorded] = await tx `
455
+ SELECT version, ddl_sha256, catalog_sha256,
456
+ EXISTS (SELECT 1 FROM slotlock.deployment_lifted_row_security) AS lifted
457
+ FROM slotlock.schema_version
458
+ WHERE singleton`;
459
+ if (!recorded ||
460
+ recorded.lifted ||
461
+ recorded.version !== SLOTLOCK_SCHEMA_VERSION ||
462
+ recorded.ddl_sha256 !== SLOTLOCK_CORE_DDL_SHA256 ||
463
+ recorded.catalog_sha256 === null) {
464
+ return false;
465
+ }
466
+ return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
467
+ }
468
+ /** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
469
+ async function slotlockConstraintUnvalidated(tx) {
470
+ const [row] = await tx `
471
+ SELECT EXISTS (
472
+ SELECT 1
473
+ FROM pg_catalog.pg_constraint con
474
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
475
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
476
+ WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
477
+ ) AS unvalidated`;
478
+ return row?.unvalidated === true;
479
+ }
480
+ /**
481
+ * Whether every tenant table has forced RLS and only its tenant policy, bound to `setting`, whose
482
+ * live fingerprint (computed as the RLS DDL computes it) matches the contract the DDL recorded.
483
+ */
484
+ async function tenantRlsUpToDate(tx, setting) {
485
+ const [present] = await tx `
486
+ SELECT COALESCE(pg_catalog.has_table_privilege(
487
+ pg_catalog.to_regclass('slotlock.rls_policy_contracts'), 'SELECT'), false) AS readable`;
488
+ if (!present?.readable)
489
+ return false;
490
+ const tables = [...SLOTLOCK_TENANT_TABLES];
491
+ const [state] = await tx `
492
+ SELECT pg_catalog.bool_and(
493
+ rel.relrowsecurity AND rel.relforcerowsecurity
494
+ AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
495
+ WHERE every.polrelid = rel.oid) = 1
496
+ AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
497
+ AND contract.tenant_context_setting = ${setting}
498
+ AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
499
+ pg_catalog.concat_ws(
500
+ pg_catalog.chr(31), live.policyname, live.permissive, live.roles::text, live.cmd,
501
+ COALESCE(live.qual, ''), COALESCE(live.with_check, '')
502
+ ),
503
+ 'UTF8')), 'hex')
504
+ ) AS current,
505
+ pg_catalog.count(*)::int AS matched
506
+ FROM pg_catalog.unnest(${tables}::text[]) AS wanted(name)
507
+ JOIN pg_catalog.pg_namespace ns ON ns.nspname = 'slotlock'
508
+ JOIN pg_catalog.pg_class rel ON rel.relnamespace = ns.oid AND rel.relname = wanted.name
509
+ JOIN pg_catalog.pg_policy pol ON pol.polrelid = rel.oid AND pol.polname = 'tenant_isolation_v1'
510
+ JOIN pg_catalog.pg_policies live
511
+ ON live.schemaname = 'slotlock' AND live.tablename = wanted.name
512
+ AND live.policyname = 'tenant_isolation_v1'
513
+ JOIN slotlock.rls_policy_contracts contract ON contract.table_name = wanted.name`;
514
+ return state?.current === true && state.matched === tables.length;
515
+ }
516
+ /**
517
+ * The schema version recorded by the last migration, or `null` when there is none this role can
518
+ * read: no schema, a schema from 0.2 or earlier, or a role not yet granted the function (run
519
+ * `slotlock migrate`, which grants it).
520
+ */
521
+ export async function readSlotlockSchemaVersion(sql) {
522
+ const [readable] = await sql `
523
+ SELECT COALESCE(pg_catalog.has_function_privilege(
524
+ pg_catalog.to_regprocedure('slotlock.installed_schema_version()'), 'EXECUTE'), false)
525
+ AS readable`;
526
+ if (!readable?.readable)
527
+ return null;
528
+ const [row] = await sql `
529
+ SELECT slotlock.installed_schema_version() AS version`;
530
+ return row?.version ?? null;
531
+ }
532
+ /**
533
+ * Refuse to serve a schema another release migrated: `schema_outdated` when it is older than this
534
+ * release (or unversioned), so `slotlock migrate` must run first; `schema_newer` when a newer release
535
+ * migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
536
+ */
537
+ export async function assertSlotlockSchemaCurrent(sql) {
538
+ const installed = await readSlotlockSchemaVersion(sql);
539
+ if (installed === SLOTLOCK_SCHEMA_VERSION)
540
+ return;
541
+ const newer = installed !== null && installed > SLOTLOCK_SCHEMA_VERSION;
542
+ throw Object.assign(new Error(newer
543
+ ? `The slotlock schema is version ${installed}, migrated by a newer release; this release serves version ${SLOTLOCK_SCHEMA_VERSION}. Serve it with that release or a later one`
544
+ : `The slotlock schema is ${installed === null ? 'unversioned (0.2 or earlier)' : `version ${installed}`}; this release serves version ${SLOTLOCK_SCHEMA_VERSION}. Run \`slotlock migrate\` with this release first`), {
545
+ code: newer ? 'schema_newer' : 'schema_outdated',
546
+ installed,
547
+ expected: SLOTLOCK_SCHEMA_VERSION,
548
+ });
549
+ }
183
550
  function isFiniteWindow(window) {
184
551
  return (window.start instanceof Date &&
185
552
  window.end instanceof Date &&
@@ -196,7 +563,15 @@ function isValidEventOwnerRef(value) {
196
563
  function isValidResourceId(value) {
197
564
  return UUID_PATTERN.test(value);
198
565
  }
566
+ function assertValidVisibility(visibility) {
567
+ if (visibility !== undefined && visibility !== 'shared' && visibility !== 'private') {
568
+ throw Object.assign(new Error("Slotlock resource visibility must be 'shared' or 'private'"), {
569
+ code: 'invalid_visibility',
570
+ });
571
+ }
572
+ }
199
573
  function assertValidResourceInput(params) {
574
+ assertValidVisibility(params.visibility);
200
575
  if (params.externalRef !== undefined &&
201
576
  (params.externalRef.trim().length === 0 || Buffer.byteLength(params.externalRef, 'utf8') > 500)) {
202
577
  throw Object.assign(new Error('Slotlock externalRef must be 1-500 UTF-8 bytes'), {
@@ -252,6 +627,42 @@ function toReservation(row) {
252
627
  source: row.source,
253
628
  };
254
629
  }
630
+ function toHold(row) {
631
+ return {
632
+ id: row.id,
633
+ resourceId: row.resource_id,
634
+ start: row.starts_at,
635
+ end: row.ends_at,
636
+ bufferAfterMs: Number(row.buffer_after_ms),
637
+ expiresAt: row.expires_at,
638
+ source: row.source,
639
+ ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
640
+ };
641
+ }
642
+ /** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
643
+ function pageCursor(after) {
644
+ if (after !== undefined &&
645
+ (!(after.start instanceof Date) ||
646
+ !Number.isFinite(after.start.getTime()) ||
647
+ !UUID_PATTERN.test(after.id))) {
648
+ throw Object.assign(new Error('Slotlock page cursor is invalid'), {
649
+ code: 'invalid_cursor',
650
+ });
651
+ }
652
+ return { start: after?.start ?? null, id: after?.id ?? null };
653
+ }
654
+ function pageLimit(limit) {
655
+ const value = limit ?? 100;
656
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_CALENDAR_LIST_LIMIT) {
657
+ throw Object.assign(new Error('Slotlock list limit must be 1-1000'), {
658
+ code: 'invalid_limit',
659
+ });
660
+ }
661
+ return value;
662
+ }
663
+ function invalidIdentityError(message) {
664
+ return Object.assign(new Error(message), { code: 'invalid_identity' });
665
+ }
255
666
  function eventExceptionsForJson(exceptions) {
256
667
  return (exceptions ?? []).map((exception) => {
257
668
  const result = {
@@ -459,7 +870,8 @@ async function replaceCalendarEventOccurrences(tx, params) {
459
870
  for (const occurrence of params.occurrences) {
460
871
  let reservationId = null;
461
872
  if ((params.event.transparency ?? 'opaque') === 'opaque') {
462
- await reapExpiredHolds(tx, params.resourceId, occurrence.start, occurrence.end);
873
+ const occupied = new Date(occurrence.end.getTime() + (params.bufferAfterMs ?? 0));
874
+ await reapExpiredHolds(tx, params.resourceId, occurrence.start, occupied);
463
875
  const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
464
876
  .update(occurrence.recurrenceId)
465
877
  .digest('hex')}`;
@@ -469,7 +881,7 @@ async function replaceCalendarEventOccurrences(tx, params) {
469
881
  buffer_after_ms, revision, source, calendar_event_id
470
882
  ) VALUES (
471
883
  ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
472
- ${occurrence.start}, ${occurrence.end}, 0, ${params.revision},
884
+ ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0}, ${params.revision},
473
885
  ${`calendar-event:${params.eventId}`}, ${params.eventId}
474
886
  )
475
887
  RETURNING id`;
@@ -487,6 +899,14 @@ async function replaceCalendarEventOccurrences(tx, params) {
487
899
  )`;
488
900
  }
489
901
  }
902
+ /** A recurring series' materialization window: finite, forward, and at most 369 days. */
903
+ function seriesWindow(window) {
904
+ if (!isFiniteWindow(window) ||
905
+ window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
906
+ throw new CalendarContractError('invalid_icalendar');
907
+ }
908
+ return { start: new Date(window.start), end: new Date(window.end) };
909
+ }
490
910
  function calendarWindow(window) {
491
911
  if (!isFiniteWindow(window) ||
492
912
  window.end.getTime() - window.start.getTime() > MAX_CALENDAR_WINDOW_MS) {
@@ -507,7 +927,7 @@ function oneOffEventWindow(event, requested) {
507
927
  if (!isFiniteWindow(window) ||
508
928
  window.start > event.start ||
509
929
  window.end < event.end ||
510
- window.end.getTime() - window.start.getTime() > Math.max(MAX_CALENDAR_WINDOW_MS, duration)) {
930
+ window.end.getTime() - window.start.getTime() > Math.max(MAX_MATERIALIZATION_WINDOW_MS, duration)) {
511
931
  throw new CalendarContractError('invalid_icalendar');
512
932
  }
513
933
  return { start: new Date(window.start), end: new Date(window.end) };
@@ -515,8 +935,30 @@ function oneOffEventWindow(event, requested) {
515
935
  function calendarCommandHash(value) {
516
936
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
517
937
  }
938
+ /**
939
+ * What makes a put the same request: its identity and the event it asks for. Not the window its
940
+ * occurrences are stored over, which for a series is the rolling horizon from the current UTC
941
+ * midnight: with it in the hash, a retry after midnight was refused as a different request.
942
+ */
518
943
  function putCommandHash(params) {
944
+ return calendarCommandHash(putCommandValue(params));
945
+ }
946
+ /**
947
+ * The hash 0.3.0 and earlier stored, with the window in it, so a command stored before the upgrade
948
+ * still replays: always for a one-off event, whose window is its own span, and on the same UTC day
949
+ * for a series. Remove once no stored command can be that old (commands are pruned after 30 days).
950
+ */
951
+ export function legacyPutCommandHash(params) {
519
952
  return calendarCommandHash({
953
+ ...putCommandValue(params),
954
+ materializationWindow: {
955
+ start: params.window.start.toISOString(),
956
+ end: params.window.end.toISOString(),
957
+ },
958
+ });
959
+ }
960
+ function putCommandValue(params) {
961
+ return {
520
962
  operation: 'put',
521
963
  ownerRef: params.ownerRef,
522
964
  externalRef: params.command.externalRef,
@@ -524,6 +966,8 @@ function putCommandHash(params) {
524
966
  expectedRevision: params.command.expectedRevision,
525
967
  resourceId: params.command.resourceId,
526
968
  source: params.command.source ?? null,
969
+ // Only when present, so a create without a hold keeps the hash it always had.
970
+ ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
527
971
  event: {
528
972
  ...params.event,
529
973
  start: params.event.start.toISOString(),
@@ -535,11 +979,7 @@ function putCommandHash(params) {
535
979
  }
536
980
  : null,
537
981
  },
538
- materializationWindow: {
539
- start: params.window.start.toISOString(),
540
- end: params.window.end.toISOString(),
541
- },
542
- });
982
+ };
543
983
  }
544
984
  function cancelCommandHash(params) {
545
985
  return calendarCommandHash({ operation: 'cancel', ...params });
@@ -583,19 +1023,133 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
583
1023
  }
584
1024
  return count;
585
1025
  }
586
- async function findCalendarOverlap(sql, resourceId, occurrences) {
1026
+ /**
1027
+ * The end of every event cancellation, under the event's identity and row locks: remove the event
1028
+ * (when there is one) and its occupation, leave the identity's tombstone at `revision`, and record
1029
+ * the command in `commandOwnerRef`'s namespace for replay.
1030
+ */
1031
+ async function finishCalendarCancel(tx, params) {
1032
+ const eventId = params.eventId ?? randomUUID();
1033
+ if (params.eventId !== null) {
1034
+ await tx `
1035
+ DELETE FROM slotlock.reservations
1036
+ WHERE id IN (
1037
+ SELECT reservation_id FROM slotlock.calendar_event_occurrences
1038
+ WHERE event_id = ${eventId} AND reservation_id IS NOT NULL
1039
+ )`;
1040
+ await tx `
1041
+ DELETE FROM slotlock.calendar_events
1042
+ WHERE id = ${eventId}
1043
+ AND tenant_ref = ${params.tenantRef}
1044
+ AND owner_ref = ${params.ownerRef}`;
1045
+ }
1046
+ await tx `
1047
+ INSERT INTO slotlock.calendar_event_tombstones (
1048
+ tenant_ref, owner_ref, external_ref, event_id, revision
1049
+ ) VALUES (
1050
+ ${params.tenantRef}, ${params.ownerRef}, ${params.externalRef}, ${eventId}, ${params.revision}
1051
+ )`;
1052
+ await tx `
1053
+ INSERT INTO slotlock.calendar_event_commands (
1054
+ tenant_ref, owner_ref, idempotency_key, operation, payload_hash, event_id, external_ref,
1055
+ result_revision, occurrence_count
1056
+ ) VALUES (
1057
+ ${params.tenantRef}, ${params.commandOwnerRef}, ${params.idempotencyKey}, 'cancel',
1058
+ ${params.payloadHash}, ${eventId}, ${params.externalRef}, ${params.revision}, 0
1059
+ )`;
1060
+ return {
1061
+ ok: true,
1062
+ eventId,
1063
+ externalRef: params.externalRef,
1064
+ revision: params.revision,
1065
+ idempotent: false,
1066
+ };
1067
+ }
1068
+ /**
1069
+ * The row an event write lost to. The write was rolled back, so the event's own rows from before
1070
+ * it are still there and are left out: they were to be replaced, and never block the event.
1071
+ */
1072
+ async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
587
1073
  for (const occurrence of occurrences) {
588
- const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end);
1074
+ const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, 0, undefined, eventId);
589
1075
  if (result.conflictingReservationId !== null)
590
1076
  return result;
591
1077
  }
592
1078
  return { ok: false, code: 'overlap', conflictingReservationId: null };
593
1079
  }
594
- function sameExternalPayload(row, params) {
1080
+ /**
1081
+ * What the resource's booking rules say about a booking of `interval` made now (by the database's
1082
+ * clock, at this statement): the rule it breaks, if any, and the turnaround it must keep after it.
1083
+ * A resource without its own hours is judged against `defaultHours`.
1084
+ */
1085
+ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1086
+ const judge = await bookingJudge(tx, resourceId, defaultHours);
1087
+ return { rule: judge.broken(interval), turnaroundMs: judge.turnaroundMs };
1088
+ }
1089
+ /** How recently a sync must have read a resource whose booking rules require certainty. */
1090
+ const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1091
+ /**
1092
+ * The resource's booking rules, read once, as a check of any interval booked now (by the database's
1093
+ * clock at this statement) and the turnaround a booking keeps.
1094
+ */
1095
+ async function bookingJudge(tx, resourceId, defaultHours) {
1096
+ const [row] = await tx `
1097
+ SELECT booking_rules, availability_rules, timezone, statement_timestamp() AS now
1098
+ FROM slotlock.resources WHERE id = ${resourceId}`;
1099
+ if (!row || row.booking_rules === null || row.booking_rules === undefined) {
1100
+ return { broken: () => null, turnaroundMs: 0 };
1101
+ }
1102
+ const { bookingRules: rules, availabilityRules } = toResource({
1103
+ id: resourceId,
1104
+ external_ref: null,
1105
+ tenant_ref: null,
1106
+ timezone: row.timezone,
1107
+ availability_rules: row.availability_rules,
1108
+ booking_rules: row.booking_rules,
1109
+ pool_ref: null,
1110
+ visibility: 'shared',
1111
+ archived_at: null,
1112
+ });
1113
+ const hours = availabilityRules ?? defaultHours;
1114
+ // Every sync covering the resource, and whether it was read within a day, by the same clock.
1115
+ const coverage = rules?.requireCertain === true
1116
+ ? await tx `
1117
+ SELECT starts_at, ends_at,
1118
+ observed_at >= statement_timestamp()
1119
+ - make_interval(secs => ${CERTAIN_COVERAGE_MAX_AGE_MS / 1000}) AS fresh
1120
+ FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
1121
+ : [];
1122
+ const turnaroundMs = bookingBufferAfterMs(rules);
1123
+ // The booking and the turnaround it keeps, as a search judges the slot.
1124
+ const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1125
+ source.starts_at > interval.start ||
1126
+ source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
1127
+ // A later occurrence of a series is judged on its step and hours only: notice and advance are
1128
+ // about when the booking was made, which its first occurrence answers.
1129
+ const later = rules === undefined
1130
+ ? undefined
1131
+ : {
1132
+ ...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
1133
+ ...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
1134
+ };
1135
+ return {
1136
+ broken: (interval, checks = { first: true }) => bookingRuleBroken({
1137
+ rules: checks.first ? rules : later,
1138
+ timezone: row.timezone,
1139
+ hours,
1140
+ interval,
1141
+ now: row.now,
1142
+ }) ?? (uncertain(interval) ? 'uncertain' : null),
1143
+ turnaroundMs,
1144
+ };
1145
+ }
1146
+ function sameExternalPayload(row, params, turnaroundMs = 0) {
1147
+ const buffer = Number(row.buffer_after_ms);
595
1148
  return (row.resource_id === params.resourceId &&
596
1149
  row.starts_at.getTime() === params.start.getTime() &&
597
1150
  row.ends_at.getTime() === params.end.getTime() &&
598
- Number(row.buffer_after_ms) === params.bufferAfterMs &&
1151
+ // As asked, or raised to the resource's turnaround when it was stored.
1152
+ (buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
599
1153
  Number(row.revision) === params.revision &&
600
1154
  row.source === (params.source ?? null));
601
1155
  }
@@ -607,12 +1161,18 @@ async function lockExternalIdentity(tx, tenantRef, externalRef) {
607
1161
  // U+0000). The database hashes it to one transaction-scoped lock key.
608
1162
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
609
1163
  }
610
- async function resourceBelongsToTenant(tx, resourceId, tenantRef) {
611
- const rows = await tx `
612
- SELECT id FROM slotlock.resources
1164
+ /**
1165
+ * Whether the resource is the tenant's (`missing` when not), and whether it is archived. The row is
1166
+ * share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
1167
+ */
1168
+ async function tenantResourceState(tx, resourceId, tenantRef) {
1169
+ const [row] = await tx `
1170
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
613
1171
  WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
614
1172
  FOR SHARE`;
615
- return rows.length === 1;
1173
+ if (!row)
1174
+ return 'missing';
1175
+ return row.archived ? 'archived' : 'active';
616
1176
  }
617
1177
  function pgCode(err) {
618
1178
  let cur = err;
@@ -623,13 +1183,45 @@ function pgCode(err) {
623
1183
  }
624
1184
  return undefined;
625
1185
  }
1186
+ /** Members a pool booking or search considers at most, in id order. */
1187
+ export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1188
+ const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at';
1189
+ function toWaitlistEntry(row) {
1190
+ return {
1191
+ id: row.id,
1192
+ resourceId: row.resource_id,
1193
+ pool: row.pool_ref,
1194
+ start: row.starts_at,
1195
+ end: row.ends_at,
1196
+ holderRef: row.holder_ref,
1197
+ offerTtlMs: Number(row.offer_ttl_ms),
1198
+ status: row.status,
1199
+ holdId: row.hold_id,
1200
+ offeredResourceId: row.offered_resource_id,
1201
+ offeredAt: row.offered_at,
1202
+ createdAt: row.created_at,
1203
+ };
1204
+ }
1205
+ export function isPoolName(value) {
1206
+ return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,99}$/.test(value);
1207
+ }
1208
+ function invalidPoolError() {
1209
+ return Object.assign(new Error('Slotlock pool must be 1-100 of a-z, 0-9, ".", "_" and "-"'), {
1210
+ code: 'invalid_pool',
1211
+ });
1212
+ }
626
1213
  function toResource(row) {
627
1214
  const resource = {
628
1215
  id: row.id,
629
1216
  externalRef: row.external_ref,
630
1217
  tenantRef: row.tenant_ref,
631
1218
  timezone: row.timezone,
1219
+ visibility: row.visibility,
632
1220
  };
1221
+ if (row.archived_at !== null)
1222
+ resource.archivedAt = row.archived_at;
1223
+ if (row.pool_ref !== null && row.pool_ref !== undefined)
1224
+ resource.pool = row.pool_ref;
633
1225
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
634
1226
  // Written through setResourceAvailability, which validates; anything else reads as closed.
635
1227
  try {
@@ -639,6 +1231,16 @@ function toResource(row) {
639
1231
  resource.availabilityRules = [];
640
1232
  }
641
1233
  }
1234
+ if (row.booking_rules !== null && row.booking_rules !== undefined) {
1235
+ // Written through setResourceBookingRules, which validates; anything else reads as refusing
1236
+ // every booking, never as no rule.
1237
+ try {
1238
+ resource.bookingRules = parseBookingRules(row.booking_rules);
1239
+ }
1240
+ catch {
1241
+ resource.bookingRules = { maxAdvanceDays: 1, minNoticeMinutes: 525_600 };
1242
+ }
1243
+ }
642
1244
  return resource;
643
1245
  }
644
1246
  /** Validated rules as a jsonb parameter, or null for "use the default". */
@@ -653,42 +1255,453 @@ function availabilityParameter(sql, rules) {
653
1255
  })));
654
1256
  }
655
1257
  async function upsertResource(executor, params) {
1258
+ const visibility = params.visibility ?? 'shared';
1259
+ const asked = params.visibility ?? null;
656
1260
  let rows;
1261
+ // An existing identity keeps the visibility it has: creating it again with none named returns it
1262
+ // as it is, and with another one is refused, so a private one is never made shared that way.
657
1263
  if (params.tenantRef !== undefined && params.externalRef !== undefined) {
658
1264
  rows = await executor `
659
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
660
- VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'})
1265
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1266
+ VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility})
661
1267
  ON CONFLICT (tenant_ref, external_ref)
662
1268
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
663
1269
  DO UPDATE SET timezone = EXCLUDED.timezone
664
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
1270
+ WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1271
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
665
1272
  }
666
1273
  else if (params.externalRef !== undefined) {
667
1274
  rows = await executor `
668
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
669
- VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'})
1275
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1276
+ VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
670
1277
  ON CONFLICT (external_ref)
671
1278
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
672
1279
  DO UPDATE SET external_ref = EXCLUDED.external_ref
673
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
1280
+ WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1281
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
674
1282
  }
675
1283
  else {
676
1284
  rows = await executor `
677
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
678
- VALUES (NULL, NULL, ${params.timezone ?? 'UTC'})
679
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
1285
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1286
+ VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1287
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
680
1288
  }
681
1289
  const row = rows[0];
682
1290
  if (!row) {
683
- throw new Error('slotlock: resource upsert returned no row');
1291
+ throw Object.assign(new Error('Slotlock resource visibility cannot be changed by creating it'), {
1292
+ code: 'resource_visibility_conflict',
1293
+ });
684
1294
  }
685
1295
  return toResource(row);
686
1296
  }
687
1297
  /**
688
- * Delete expired held rows overlapping the requested window, in deterministic id order under
689
- * FOR UPDATE — two concurrent reapers lock the same rows in the same order (no deadlock), the
690
- * loser simply finds them gone. Runs inside the caller's transaction, immediately before its
691
- * INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
1298
+ * Rematerialize one stored recurring master over the window a roll to `horizon` gives it
1299
+ * (rolledWindow): from where its last expansion stopped when that can be trusted, else its start.
1300
+ * False when there is no such master or nothing to roll. A collision throws the database's 23P01,
1301
+ * and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
1302
+ * either rolls back whole.
1303
+ */
1304
+ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1305
+ const [row] = await tx `
1306
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1307
+ summary, description, location, status, transparency, organizer, attendees,
1308
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1309
+ materialized_ends_at, recurrence_cursor, revision, source, created_at,
1310
+ updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
1311
+ FROM slotlock.calendar_events
1312
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1313
+ FOR UPDATE`;
1314
+ if (!row || row.recurrence_rule === null)
1315
+ return false;
1316
+ const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, horizon);
1317
+ if (!rolled)
1318
+ return false;
1319
+ const childMap = await loadCalendarEventChildren(tx, [row.id]);
1320
+ const event = toCalendarEvent(row, childMap.get(row.id));
1321
+ const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
1322
+ cursor: row.recurrence_cursor ?? null,
1323
+ stored: true,
1324
+ });
1325
+ await replaceCalendarEventOccurrences(tx, {
1326
+ tenantRef,
1327
+ eventId: row.id,
1328
+ resourceId: row.resource_id,
1329
+ event,
1330
+ occurrences,
1331
+ revision: Number(row.revision),
1332
+ bufferAfterMs: Number(row.buffer_after_ms ?? 0),
1333
+ });
1334
+ // A series stored before 0.3 learns when it ends at its first roll.
1335
+ const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
1336
+ await tx `
1337
+ UPDATE slotlock.calendar_events
1338
+ SET materialized_starts_at = ${rolled.start},
1339
+ materialized_ends_at = ${rolled.end},
1340
+ recurrence_cursor = ${seriesCursorJson(tx, cursor)},
1341
+ recurrence_ends_by = COALESCE(recurrence_ends_by, ${endsBy}::text::timestamptz),
1342
+ roll_retry_at = NULL
1343
+ WHERE id = ${row.id} AND tenant_ref = ${tenantRef}`;
1344
+ return true;
1345
+ }
1346
+ /**
1347
+ * Work out and store when a series stored before 0.3 ends (recurrence_ends_by), as its next roll
1348
+ * would; its stored occurrences are left as they are.
1349
+ */
1350
+ async function learnSeriesEnd(tx, tenantRef, eventId) {
1351
+ const [row] = await tx `
1352
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1353
+ summary, description, location, status, transparency, organizer, attendees,
1354
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1355
+ materialized_ends_at, recurrence_cursor, revision, source, created_at,
1356
+ updated_at
1357
+ FROM slotlock.calendar_events
1358
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1359
+ AND recurrence_rule IS NOT NULL AND recurrence_ends_by IS NULL
1360
+ FOR UPDATE`;
1361
+ if (!row)
1362
+ return;
1363
+ const childMap = await loadCalendarEventChildren(tx, [row.id]);
1364
+ const event = toCalendarEvent(row, childMap.get(row.id));
1365
+ await tx `
1366
+ UPDATE slotlock.calendar_events
1367
+ SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
1368
+ WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
1369
+ }
1370
+ /** Put a master the roll could not extend behind the others for an hour. */
1371
+ async function deferRoll(executor, tenantRef, eventId) {
1372
+ await executor `
1373
+ UPDATE slotlock.calendar_events
1374
+ SET roll_retry_at = now() + interval '1 hour'
1375
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}`;
1376
+ }
1377
+ /**
1378
+ * Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
1379
+ * part of [start, end) that a roll can ever reach: from the later of `start` and the rolling
1380
+ * horizon's start. A write there must not land where such a series is not stored, or the series'
1381
+ * next roll would collide with it and stop. A series known to end before that part
1382
+ * (recurrence_ends_by) cannot meet the write and is left out, as is one stored through its end. A
1383
+ * series behind is first rolled to the horizon, in a savepoint of the write's transaction, so the
1384
+ * write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
1385
+ * past the horizon and so does the write, it collides, or its rule is over the budget). Series are
1386
+ * rolled in id order; an event's update holds its own row while it rolls the others, so it and a
1387
+ * write rolling the same series in that order can deadlock, which the database breaks (the writer
1388
+ * retries once, then answers an overlap).
1389
+ */
1390
+ async function recurrenceCoversWrite(tx, params) {
1391
+ const horizon = calendarEventRollingHorizon();
1392
+ if (params.end <= horizon.start)
1393
+ return true;
1394
+ const from = params.start > horizon.start ? params.start : horizon.start;
1395
+ // LEAST and GREATEST ignore a NULL argument (PostgreSQL 16, Conditional Expressions): a series
1396
+ // with no known end is behind wherever it is stored short of the write's end.
1397
+ const behind = () => tx `
1398
+ SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
1399
+ COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
1400
+ AS ends_in_horizon
1401
+ FROM slotlock.calendar_events event
1402
+ WHERE event.tenant_ref = ${params.tenantRef}
1403
+ AND event.resource_id = ${params.resourceId}
1404
+ AND event.recurrence_rule IS NOT NULL
1405
+ AND event.transparency = 'opaque'
1406
+ AND event.id IS DISTINCT FROM ${params.exceptEventId ?? null}::uuid
1407
+ AND (event.recurrence_ends_by IS NULL OR event.recurrence_ends_by > ${from}::timestamptz)
1408
+ AND (
1409
+ event.materialized_ends_at < LEAST(${params.end}::timestamptz, event.recurrence_ends_by)
1410
+ OR event.materialized_starts_at > GREATEST(
1411
+ ${from}::timestamptz,
1412
+ LEAST(event.starts_at, COALESCE((
1413
+ SELECT min(exception.starts_at)
1414
+ FROM slotlock.calendar_event_exceptions exception
1415
+ WHERE exception.tenant_ref = event.tenant_ref
1416
+ AND exception.event_id = event.id
1417
+ AND exception.cancelled = false
1418
+ ), event.starts_at))
1419
+ )
1420
+ )
1421
+ ORDER BY event.id`;
1422
+ let due = await behind();
1423
+ if (due.length === 0)
1424
+ return true;
1425
+ if (params.end > horizon.end) {
1426
+ // A series stored before 0.3 has not worked out when it ends; it does so now, and is left out
1427
+ // if it ends before the write or is stored through its end.
1428
+ if (due.some((row) => row.ends_by_unknown)) {
1429
+ for (const { id, ends_by_unknown } of due) {
1430
+ if (ends_by_unknown)
1431
+ await learnSeriesEnd(tx, params.tenantRef, id);
1432
+ }
1433
+ due = await behind();
1434
+ if (due.length === 0)
1435
+ return true;
1436
+ }
1437
+ // No roll stores a series past the horizon: one going on past it stays behind such a write.
1438
+ if (due.some((row) => !row.ends_in_horizon))
1439
+ return false;
1440
+ }
1441
+ for (const { id } of due) {
1442
+ try {
1443
+ await tx.savepoint((sp) => rollStoredSeries(sp, params.tenantRef, id, horizon));
1444
+ }
1445
+ catch (error) {
1446
+ if (error instanceof CalendarExpansionLimitError || pgCode(error) === EXCLUSION_VIOLATION) {
1447
+ return false;
1448
+ }
1449
+ throw error;
1450
+ }
1451
+ }
1452
+ return (await behind()).length === 0;
1453
+ }
1454
+ /**
1455
+ * Whether an event already occupies, on the resource, all the time `occurrences` would: such a
1456
+ * write (a rename, a new description) takes no time a series could later need, so it is not held
1457
+ * to where the series on the resource are stored.
1458
+ */
1459
+ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1460
+ const held = await tx `
1461
+ SELECT reservation.starts_at AS start, reservation.ends_at AS end
1462
+ FROM slotlock.calendar_event_occurrences occurrence
1463
+ JOIN slotlock.reservations reservation ON reservation.id = occurrence.reservation_id
1464
+ WHERE occurrence.event_id = ${eventId}
1465
+ AND reservation.resource_id = ${resourceId}`;
1466
+ return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
1467
+ }
1468
+ /** From the earliest start to the latest end of an event's occurrences, or null for none. */
1469
+ function occurrenceSpan(occurrences) {
1470
+ const [first] = occurrences;
1471
+ if (!first)
1472
+ return null;
1473
+ let start = first.start;
1474
+ let end = first.end;
1475
+ for (const occurrence of occurrences) {
1476
+ if (occurrence.start < start)
1477
+ start = occurrence.start;
1478
+ if (occurrence.end > end)
1479
+ end = occurrence.end;
1480
+ }
1481
+ return { start, end };
1482
+ }
1483
+ /**
1484
+ * After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series on the
1485
+ * resource the write's transaction rolled with it (and lost), so they stay extended and the loss
1486
+ * can name the occurrence that blocked it. A resource with no tenant has no series. Upkeep only: if
1487
+ * it fails, the series are left to maintenance and the write is still answered as a loss.
1488
+ */
1489
+ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
1490
+ try {
1491
+ await inTransaction(executor, async (tx) => {
1492
+ const [resource] = await tx `
1493
+ SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
1494
+ if (!resource?.tenant_ref)
1495
+ return;
1496
+ await recurrenceCoversWrite(tx, {
1497
+ tenantRef: resource.tenant_ref,
1498
+ resourceId,
1499
+ start,
1500
+ end,
1501
+ ...(exceptEventId !== undefined ? { exceptEventId } : {}),
1502
+ });
1503
+ });
1504
+ }
1505
+ catch {
1506
+ // Rolled back whole (its own transaction, or a savepoint of the caller's).
1507
+ }
1508
+ }
1509
+ const MAX_TENANT_LIMIT = 10_000_000;
1510
+ function tenantLimitsOption(limits) {
1511
+ const valid = {};
1512
+ for (const kind of ['resources', 'holds', 'reservations', 'events', 'coverage']) {
1513
+ const value = limits?.[kind];
1514
+ if (value === undefined)
1515
+ continue;
1516
+ const max = kind === 'resources' ? MAX_TENANT_RESOURCE_LIMIT : MAX_TENANT_LIMIT;
1517
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
1518
+ throw Object.assign(new Error(`Slotlock tenant limit ${kind} must be a whole number from 1 to ${max}`), { code: 'invalid_tenant_limits' });
1519
+ }
1520
+ valid[kind] = value;
1521
+ }
1522
+ return valid;
1523
+ }
1524
+ /**
1525
+ * Serialize one tenant's writes that add rows of one limited kind, so each counts what every
1526
+ * earlier one committed. Taken before any row lock, after the write's identity locks.
1527
+ */
1528
+ async function lockTenantLimit(tx, tenantRef, kind) {
1529
+ // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
1530
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
1531
+ }
1532
+ /**
1533
+ * Rows a tenant added within this many days count against its limits whatever time they hold, so
1534
+ * bookings and events dated in the past, and cancellations, add at most the limit in any such
1535
+ * window. Ended history is not pruned: over a longer time it can still grow. Maintenance keeps
1536
+ * event idempotency records and stray retirements at least as long (the store's `retentionDays`).
1537
+ */
1538
+ const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1539
+ /**
1540
+ * The rows a tenant has that its limit of `kind` counts: what is still to come, and what it added
1541
+ * in the last TENANT_LIMIT_RECENT_DAYS days; what ended before then is history and does not count.
1542
+ * Each part stops at `limit` rows, read through an index, so a count costs no more than the limit
1543
+ * however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
1544
+ * at it.
1545
+ */
1546
+ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1547
+ let rows;
1548
+ switch (kind) {
1549
+ case 'holds':
1550
+ // Every held row, expired or not, until it is deleted: an expired hold no rival writes over
1551
+ // stays stored until maintenance or a hold at the limit deletes it. And the answer kept for
1552
+ // each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
1553
+ // is forgotten. An answer whose hold is still a held row is left out: that row counts.
1554
+ rows = await tx `
1555
+ SELECT (
1556
+ (SELECT count(*) FROM (
1557
+ SELECT 1
1558
+ FROM slotlock.reservations hold
1559
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
1560
+ WHERE resource.tenant_ref = ${tenantRef} AND hold.status = 'held'
1561
+ LIMIT ${limit}) held)
1562
+ + (SELECT count(*) FROM (
1563
+ SELECT 1 FROM slotlock.hold_commands answer
1564
+ WHERE answer.tenant_ref = ${tenantRef}
1565
+ AND NOT EXISTS (
1566
+ SELECT 1 FROM slotlock.reservations hold
1567
+ WHERE hold.id = answer.hold_id AND hold.status = 'held'
1568
+ )
1569
+ LIMIT ${limit}) kept)
1570
+ )::int AS count`;
1571
+ break;
1572
+ case 'reservations':
1573
+ // Bookings still to come; bookings that have ended and cancellations, made recently; and
1574
+ // references retired with no booking, until maintenance prunes them. A cancelled booking's
1575
+ // tombstone is kept for good, and stops counting once it is no longer recent.
1576
+ rows = await tx `
1577
+ WITH recent AS (
1578
+ SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
1579
+ )
1580
+ SELECT (
1581
+ (SELECT count(*) FROM (
1582
+ SELECT 1 FROM slotlock.reservations
1583
+ WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
1584
+ AND calendar_event_id IS NULL AND occupied_ends_at > now()
1585
+ LIMIT ${limit}) live)
1586
+ + (SELECT count(*) FROM (
1587
+ SELECT 1 FROM slotlock.reservations, recent
1588
+ WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
1589
+ AND calendar_event_id IS NULL AND created_at > recent.since
1590
+ AND occupied_ends_at <= now()
1591
+ LIMIT ${limit}) ended)
1592
+ + (SELECT count(*) FROM (
1593
+ SELECT 1 FROM slotlock.reservation_tombstones, recent
1594
+ WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
1595
+ LIMIT ${limit}) cancelled)
1596
+ + (SELECT count(*) FROM (
1597
+ SELECT 1 FROM slotlock.reservation_tombstones, recent
1598
+ WHERE tenant_ref = ${tenantRef} AND stray AND cancelled_at <= recent.since
1599
+ LIMIT ${limit}) retired)
1600
+ )::int AS count`;
1601
+ break;
1602
+ case 'events':
1603
+ // One-off events still to come and series not known to have ended; events that have ended
1604
+ // and cancellations of any owner, made recently; and agent cancellations until maintenance
1605
+ // prunes them. The owner identity CHECK makes every owner but 'internal' an 'agent:' one.
1606
+ rows = await tx `
1607
+ WITH recent AS (
1608
+ SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
1609
+ )
1610
+ SELECT (
1611
+ (SELECT count(*) FROM (
1612
+ SELECT 1 FROM slotlock.calendar_events
1613
+ WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NULL AND ends_at > now()
1614
+ LIMIT ${limit}) one_off)
1615
+ + (SELECT count(*) FROM (
1616
+ SELECT 1 FROM slotlock.calendar_events
1617
+ WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NOT NULL
1618
+ AND (recurrence_ends_by IS NULL OR recurrence_ends_by > now())
1619
+ LIMIT ${limit}) series)
1620
+ + (SELECT count(*) FROM (
1621
+ SELECT 1 FROM slotlock.calendar_events, recent
1622
+ WHERE tenant_ref = ${tenantRef} AND created_at > recent.since
1623
+ AND CASE WHEN recurrence_rule IS NULL THEN ends_at <= now()
1624
+ ELSE recurrence_ends_by <= now() END
1625
+ LIMIT ${limit}) ended)
1626
+ + (SELECT count(*) FROM (
1627
+ SELECT 1 FROM slotlock.calendar_event_tombstones, recent
1628
+ WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
1629
+ LIMIT ${limit}) cancelled)
1630
+ + (SELECT count(*) FROM (
1631
+ SELECT 1 FROM slotlock.calendar_event_tombstones, recent
1632
+ WHERE tenant_ref = ${tenantRef} AND owner_ref LIKE 'agent:%'
1633
+ AND cancelled_at <= recent.since
1634
+ LIMIT ${limit}) agent_cancelled)
1635
+ )::int AS count`;
1636
+ break;
1637
+ case 'coverage':
1638
+ rows = await tx `
1639
+ SELECT count(*)::int AS count FROM (
1640
+ SELECT 1 FROM slotlock.calendar_coverage WHERE tenant_ref = ${tenantRef} LIMIT ${limit}
1641
+ ) kept`;
1642
+ break;
1643
+ }
1644
+ const count = Number(rows[0]?.count ?? 0);
1645
+ if (!Number.isSafeInteger(count) || count < 0)
1646
+ throw new Error('slotlock: invalid tenant row count');
1647
+ return count;
1648
+ }
1649
+ /**
1650
+ * Delete up to `limit` of a tenant's holds that have expired, oldest first; the number deleted. A
1651
+ * writer reaping or confirming a hold locks it: such a row is skipped, and the delete repeats the
1652
+ * predicate on the row as it now is.
1653
+ */
1654
+ async function deleteExpiredHolds(tx, tenantRef, limit) {
1655
+ const [row] = await tx `
1656
+ WITH candidates AS MATERIALIZED (
1657
+ SELECT hold.id
1658
+ FROM slotlock.reservations hold
1659
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
1660
+ WHERE resource.tenant_ref = ${tenantRef}
1661
+ AND hold.status = 'held' AND hold.expires_at <= statement_timestamp()
1662
+ ORDER BY hold.expires_at, hold.id
1663
+ LIMIT ${limit}
1664
+ FOR UPDATE OF hold SKIP LOCKED
1665
+ ), deleted AS (
1666
+ DELETE FROM slotlock.reservations hold
1667
+ USING candidates candidate
1668
+ WHERE hold.id = candidate.id
1669
+ AND hold.status = 'held' AND hold.expires_at <= statement_timestamp()
1670
+ RETURNING 1
1671
+ )
1672
+ SELECT count(*)::int AS deleted FROM deleted`;
1673
+ return Number(row?.deleted ?? 0);
1674
+ }
1675
+ /**
1676
+ * Delete up to `limit` of a tenant's keyed hold answers whose hold has expired, so a retry with one
1677
+ * of their keys makes a new hold; answers another transaction holds are skipped. The number deleted.
1678
+ */
1679
+ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1680
+ const [row] = await tx `
1681
+ WITH candidates AS MATERIALIZED (
1682
+ SELECT answer.principal_ref, answer.idempotency_key
1683
+ FROM slotlock.hold_commands answer
1684
+ WHERE answer.tenant_ref = ${tenantRef} AND answer.expires_at <= statement_timestamp()
1685
+ ORDER BY answer.expires_at, answer.principal_ref, answer.idempotency_key
1686
+ LIMIT ${limit}
1687
+ FOR UPDATE OF answer SKIP LOCKED
1688
+ ), deleted AS (
1689
+ DELETE FROM slotlock.hold_commands answer
1690
+ USING candidates candidate
1691
+ WHERE answer.tenant_ref = ${tenantRef}
1692
+ AND answer.principal_ref = candidate.principal_ref
1693
+ AND answer.idempotency_key = candidate.idempotency_key
1694
+ RETURNING 1
1695
+ )
1696
+ SELECT count(*)::int AS deleted FROM deleted`;
1697
+ return Number(row?.deleted ?? 0);
1698
+ }
1699
+ /**
1700
+ * Delete expired held rows whose occupied time (buffer included, as the EXCLUDE constraint reads
1701
+ * it) overlaps the requested window, whose `end` is its own occupied end, in deterministic id
1702
+ * order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
1703
+ * deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
1704
+ * before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
692
1705
  */
693
1706
  async function reapExpiredHolds(tx, resourceId, start, end) {
694
1707
  await tx `
@@ -697,9 +1710,9 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
697
1710
  SELECT id FROM slotlock.reservations
698
1711
  WHERE resource_id = ${resourceId}
699
1712
  AND status = 'held'
700
- AND expires_at <= now()
1713
+ AND expires_at <= statement_timestamp()
701
1714
  AND starts_at < ${end}
702
- AND ends_at > ${start}
1715
+ AND COALESCE(occupied_ends_at, ends_at) > ${start}
703
1716
  ORDER BY id
704
1717
  FOR UPDATE
705
1718
  )`;
@@ -710,11 +1723,13 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
710
1723
  * loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
711
1724
  * immediate retry reaps it and wins.
712
1725
  */
713
- async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId) {
1726
+ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
714
1727
  const winners = await sql `
715
1728
  SELECT id FROM slotlock.reservations
716
1729
  WHERE resource_id = ${resourceId}
717
1730
  AND (${excludeReservationId ?? null}::uuid IS NULL OR id <> ${excludeReservationId ?? null}::uuid)
1731
+ AND (${excludeCalendarEventId ?? null}::uuid IS NULL
1732
+ OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
718
1733
  AND tstzrange(
719
1734
  starts_at,
720
1735
  COALESCE(occupied_ends_at, ends_at),
@@ -724,7 +1739,7 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
724
1739
  ${end} + ${bufferAfterMs} * interval '1 millisecond',
725
1740
  '[)'
726
1741
  )
727
- AND (status <> 'held' OR expires_at > now())
1742
+ AND (status <> 'held' OR expires_at > statement_timestamp())
728
1743
  ORDER BY starts_at
729
1744
  LIMIT 1`;
730
1745
  return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
@@ -741,8 +1756,123 @@ function holdTenantRef(options) {
741
1756
  }
742
1757
  return tenantRef;
743
1758
  }
1759
+ /**
1760
+ * At most `max` holders per tenant at once; the rest wait in order. An aborted signal takes a
1761
+ * waiter out of the line, rejecting with its reason. Each holder calls the release it gets once.
1762
+ */
1763
+ export function createTenantSlots(max) {
1764
+ const tenants = new Map();
1765
+ return async (tenantRef, signal) => {
1766
+ signal?.throwIfAborted();
1767
+ let tenant = tenants.get(tenantRef);
1768
+ if (!tenant) {
1769
+ tenant = { running: 0, waiting: [] };
1770
+ tenants.set(tenantRef, tenant);
1771
+ }
1772
+ const slots = tenant;
1773
+ if (slots.running < max) {
1774
+ slots.running += 1;
1775
+ }
1776
+ else {
1777
+ await new Promise((resolve, reject) => {
1778
+ const start = () => {
1779
+ signal?.removeEventListener('abort', abort);
1780
+ resolve();
1781
+ };
1782
+ const abort = () => {
1783
+ slots.waiting.splice(slots.waiting.indexOf(start), 1);
1784
+ reject(signal?.reason);
1785
+ };
1786
+ slots.waiting.push(start);
1787
+ signal?.addEventListener('abort', abort, { once: true });
1788
+ });
1789
+ }
1790
+ let released = false;
1791
+ return () => {
1792
+ if (released)
1793
+ return;
1794
+ released = true;
1795
+ // Handed straight to the next in line, so the count stays as it is.
1796
+ const next = slots.waiting.shift();
1797
+ if (next) {
1798
+ next();
1799
+ return;
1800
+ }
1801
+ slots.running -= 1;
1802
+ if (slots.running === 0)
1803
+ tenants.delete(tenantRef);
1804
+ };
1805
+ };
1806
+ }
1807
+ function lockTimeoutError(cause) {
1808
+ return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
1809
+ }
744
1810
  export function createSlotlockStore(sql, options = {}) {
1811
+ return buildSlotlockStore(sql, options, null);
1812
+ }
1813
+ /**
1814
+ * Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
1815
+ * Maintenance (rolling series, pruning, purging) and erasure are not among them.
1816
+ */
1817
+ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1818
+ 'createResource',
1819
+ 'archiveResource',
1820
+ 'restoreResource',
1821
+ 'deleteResource',
1822
+ 'setResourceAvailability',
1823
+ 'setResourceBookingRules',
1824
+ 'setResourcePool',
1825
+ 'setTenantAvailability',
1826
+ 'createReservation',
1827
+ 'createExternalReservation',
1828
+ 'rescheduleExternalReservation',
1829
+ 'cancelExternalReservation',
1830
+ 'acquireHold',
1831
+ 'confirmHold',
1832
+ 'releaseHold',
1833
+ 'putCalendarEvent',
1834
+ 'cancelCalendarEvent',
1835
+ 'cancelCalendarEventById',
1836
+ 'recordCalendarCoverage',
1837
+ 'createWebhookEndpoint',
1838
+ 'deleteWebhookEndpoint',
1839
+ 'enableWebhookEndpoint',
1840
+ 'recordCoverageFailure',
1841
+ 'deleteCoverage',
1842
+ 'checkInReservation',
1843
+ 'joinWaitlist',
1844
+ 'leaveWaitlist',
1845
+ ]);
1846
+ function tenantSuspendedError() {
1847
+ return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
1848
+ code: 'tenant_suspended',
1849
+ });
1850
+ }
1851
+ function staleSuspensionCheckError() {
1852
+ return Object.assign(new Error('Slotlock writes for a tenant only in a READ COMMITTED transaction, whose suspension check sees every suspension committed before the tenant lock'), { code: 'invalid_transaction_isolation' });
1853
+ }
1854
+ /**
1855
+ * Reading a tenant's suspension needs EXECUTE on slotlock.tenant_suspended(text), which PUBLIC does
1856
+ * not hold (SQLSTATE 42501 without it), and a schema this release migrated (42883 before then).
1857
+ * Name the remedy for each; any other failure passes through.
1858
+ */
1859
+ function suspensionCheckRefusal(error) {
1860
+ const code = pgCode(error);
1861
+ if (code === '42501') {
1862
+ return Object.assign(new Error('Slotlock serving role may not run slotlock.tenant_suspended(text): grant it what this release needs with grantApplicationRole (`slotlock migrate` does), once per release', { cause: error }), { code: 'application_role_not_granted' });
1863
+ }
1864
+ if (code === '42883') {
1865
+ return Object.assign(new Error('The slotlock schema predates this release (it has no slotlock.tenant_suspended(text)): run applySchema (`slotlock migrate`) with this release first', { cause: error }), { code: 'schema_outdated' });
1866
+ }
1867
+ return error;
1868
+ }
1869
+ /**
1870
+ * A store on `sql`. With `refusal`, every method in SLOTLOCK_TENANT_WRITE_METHODS throws what it
1871
+ * returns before any query (a suspended tenant, or a suspension check that could be stale).
1872
+ */
1873
+ function buildSlotlockStore(sql, options, refusal) {
745
1874
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
1875
+ const defaultHours = parseAvailabilityRules([...(options.defaultAvailabilityRules ?? [])]);
746
1876
  const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
747
1877
  const configuredAgentOwnerCommandQuota = options.agentOwnerCommandQuota ?? SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA;
748
1878
  if (!Number.isSafeInteger(configuredAgentOwnerEventQuota) ||
@@ -757,56 +1887,145 @@ export function createSlotlockStore(sql, options = {}) {
757
1887
  configuredAgentOwnerCommandQuota > MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA) {
758
1888
  throw Object.assign(new Error('Slotlock agent owner command quota must exceed its event quota and be at most 1000000'), { code: 'invalid_agent_owner_command_quota' });
759
1889
  }
1890
+ const configuredTenantLimits = tenantLimitsOption(options.tenantLimits);
1891
+ const { maxTenantTransactions, tenantLockTimeoutMs, maxHeldHoursPerHolder } = options;
1892
+ const retentionDays = options.retentionDays ?? SLOTLOCK_CHANGE_RETENTION_DAYS;
1893
+ if (!Number.isSafeInteger(retentionDays) || retentionDays < 1 || retentionDays > 3_650) {
1894
+ throw Object.assign(new Error('Slotlock retentionDays must be a whole number from 1 to 3650'), {
1895
+ code: 'invalid_retention',
1896
+ });
1897
+ }
1898
+ const maxHoldsPerHolder = options.maxHoldsPerHolder ?? SLOTLOCK_MAX_HOLDS_PER_HOLDER;
1899
+ if (!Number.isSafeInteger(maxHoldsPerHolder) ||
1900
+ maxHoldsPerHolder < 1 ||
1901
+ maxHoldsPerHolder > 10_000 ||
1902
+ (maxHeldHoursPerHolder !== undefined &&
1903
+ (!Number.isSafeInteger(maxHeldHoursPerHolder) ||
1904
+ maxHeldHoursPerHolder < 1 ||
1905
+ maxHeldHoursPerHolder > 876_000))) {
1906
+ throw Object.assign(new Error('Slotlock maxHoldsPerHolder must be 1-10000 and maxHeldHoursPerHolder 1-876000, whole numbers'), { code: 'invalid_holder_limits' });
1907
+ }
1908
+ const whole = (value, max) => value === undefined || (Number.isSafeInteger(value) && value >= 1 && value <= max);
1909
+ if (!whole(maxTenantTransactions, 1_000) || !whole(tenantLockTimeoutMs, 600_000)) {
1910
+ throw Object.assign(new Error('Slotlock maxTenantTransactions must be 1-1000 and tenantLockTimeoutMs 1-600000, whole numbers'), { code: 'invalid_tenant_transaction_options' });
1911
+ }
1912
+ // Only a client opens transactions of its own; inside a caller's, neither applies.
1913
+ const ownTransactions = 'begin' in sql;
1914
+ const tenantSlots = ownTransactions && maxTenantTransactions !== undefined
1915
+ ? createTenantSlots(maxTenantTransactions)
1916
+ : undefined;
1917
+ const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
760
1918
  createSlotlockTenantRlsDdl(configuredTenantSetting); // Validate once before any scoped query.
761
- return {
762
- async applySchema() {
1919
+ const storeOptions = {
1920
+ tenantContextSetting: configuredTenantSetting,
1921
+ agentOwnerEventQuota: configuredAgentOwnerEventQuota,
1922
+ agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
1923
+ tenantLimits: configuredTenantLimits,
1924
+ retentionDays,
1925
+ maxHoldsPerHolder,
1926
+ ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
1927
+ };
1928
+ /**
1929
+ * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. No limit for
1930
+ * `kind`, or no tenant, takes no lock. Writes that only lower the count take no lock, so a count
1931
+ * read early stays an upper bound for the rest of the transaction.
1932
+ */
1933
+ async function tenantLimitReached(tx, tenantRef, kind) {
1934
+ const limit = configuredTenantLimits[kind];
1935
+ if (limit === undefined || tenantRef === null)
1936
+ return null;
1937
+ await assertReadCommitted(tx);
1938
+ await lockTenantLimit(tx, tenantRef, kind);
1939
+ if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
1940
+ return null;
1941
+ // A hold, and a keyed hold's answer, keep their place only until the hold expires; at the
1942
+ // limit, the expired holds and their answers are deleted, as maintenance would, and the count
1943
+ // is read again. Counting expired rows until then keeps what a tenant stores within the limit
1944
+ // between maintenance passes, however short its holds.
1945
+ if (kind !== 'holds' ||
1946
+ (await deleteExpiredHolds(tx, tenantRef, limit)) +
1947
+ (await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
1948
+ 0) {
1949
+ return limit;
1950
+ }
1951
+ return (await tenantLimitCount(tx, tenantRef, kind, limit)) >= limit ? limit : null;
1952
+ }
1953
+ /**
1954
+ * The tenant whose limit a write on the resource counts against: the caller's, else the
1955
+ * resource's (a resource never changes tenant, so it is read before any lock). Null with no limit.
1956
+ */
1957
+ async function limitTenant(tx, kind, resourceId, tenantRef) {
1958
+ if (configuredTenantLimits[kind] === undefined)
1959
+ return null;
1960
+ if (tenantRef !== undefined)
1961
+ return tenantRef;
1962
+ const [resource] = await tx `
1963
+ SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
1964
+ return resource?.tenant_ref ?? null;
1965
+ }
1966
+ const store = {
1967
+ ...slotlockEventFeedMethods(sql, retentionDays),
1968
+ async applySchema(migrationOptions) {
763
1969
  // PostgreSQL's IF NOT EXISTS DDL is idempotent after commit but not race-free: two fresh
764
1970
  // application instances can still collide on pg_namespace/pg_extension unique indexes.
765
- // Serialize bootstrap per database and let the transaction release the lock on crashes.
766
- await inTransaction(sql, (tx) => withPinnedSearchPath(tx, async () => {
767
- await tx `SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${SCHEMA_APPLY_LOCK}, 0))`;
768
- await tx.unsafe(SLOTLOCK_CORE_DDL).catch((error) => {
1971
+ // inMigration serializes bootstrap per database and the transaction releases the lock on
1972
+ // crashes. A schema this release already left as it is changes nothing and locks no table,
1973
+ // unless old rows kept one of its constraints NOT VALID: then only the validation runs again.
1974
+ const validate = await inMigration(sql, migrationOptions, async (tx) => {
1975
+ await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
769
1976
  throw schemaControlRefusal(error);
770
1977
  });
771
- }));
1978
+ if (await slotlockSchemaUpToDate(tx))
1979
+ return slotlockConstraintUnvalidated(tx);
1980
+ await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
1981
+ throw schemaControlRefusal(error);
1982
+ });
1983
+ return true;
1984
+ });
1985
+ if (!validate)
1986
+ return;
1987
+ // Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
1988
+ // a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
1989
+ // transaction both steps share it, and its locks, until it ends. The digests go last, so a
1990
+ // migration stopped before them runs whole again.
1991
+ await inMigration(sql, migrationOptions, async (tx) => {
1992
+ await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
1993
+ await tx `
1994
+ UPDATE slotlock.schema_version
1995
+ SET ddl_sha256 = ${SLOTLOCK_CORE_DDL_SHA256},
1996
+ catalog_sha256 = ${await slotlockCatalogDigest(tx)}
1997
+ WHERE singleton`;
1998
+ });
772
1999
  },
773
2000
  async applyTenantRls(applyOptions) {
774
2001
  const ddl = createSlotlockTenantRlsDdl(configuredTenantSetting);
775
- await inTransaction(sql, (tx) => withPinnedSearchPath(tx, async () => {
776
- await tx `SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${SCHEMA_APPLY_LOCK}, 0))`;
2002
+ const { allowRebind, ...migrationOptions } = applyOptions ?? {};
2003
+ await inMigration(sql, migrationOptions, async (tx) => {
2004
+ await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
2005
+ throw schemaControlRefusal(error);
2006
+ });
2007
+ if (await tenantRlsUpToDate(tx, configuredTenantSetting))
2008
+ return;
777
2009
  const bindings = await tx `
778
- SELECT DISTINCT obj_description(policy.oid, 'pg_policy') AS binding
779
- FROM pg_policy policy
780
- JOIN pg_class table_class ON table_class.oid = policy.polrelid
781
- JOIN pg_namespace namespace ON namespace.oid = table_class.relnamespace
782
- WHERE namespace.nspname = 'slotlock'
783
- AND table_class.relname IN (
784
- 'resources',
785
- 'reservations',
786
- 'reservation_tombstones',
787
- 'reservation_conflict_archive',
788
- 'calendar_events',
789
- 'calendar_event_attendees',
790
- 'calendar_event_reminders',
791
- 'calendar_event_exceptions',
792
- 'calendar_event_occurrences',
793
- 'calendar_event_tombstones',
794
- 'calendar_event_commands',
795
- 'calendar_coverage'
796
- )
797
- AND obj_description(policy.oid, 'pg_policy') LIKE 'slotlock:tenant-context:%'`;
2010
+ SELECT DISTINCT obj_description(policy.oid, 'pg_policy') AS binding
2011
+ FROM pg_policy policy
2012
+ JOIN pg_class table_class ON table_class.oid = policy.polrelid
2013
+ JOIN pg_namespace namespace ON namespace.oid = table_class.relnamespace
2014
+ WHERE namespace.nspname = 'slotlock'
2015
+ AND table_class.relname IN ${tx([...SLOTLOCK_TENANT_TABLES])}
2016
+ AND obj_description(policy.oid, 'pg_policy') LIKE 'slotlock:tenant-context:%'`;
798
2017
  const conflictingBinding = bindings.find(({ binding }) => binding !== `slotlock:tenant-context:${configuredTenantSetting}`);
799
- if (conflictingBinding && applyOptions?.allowRebind !== true) {
2018
+ if (conflictingBinding && allowRebind !== true) {
800
2019
  throw Object.assign(new Error(`Slotlock tenant RLS is already bound to ${conflictingBinding.binding.slice('slotlock:tenant-context:'.length)}`), { code: 'tenant_context_conflict' });
801
2020
  }
802
2021
  await tx.unsafe(ddl).catch((error) => {
803
2022
  throw schemaControlRefusal(error);
804
2023
  });
805
- }));
2024
+ });
806
2025
  },
807
- async grantApplicationRole(role) {
2026
+ async grantApplicationRole(role, migrationOptions) {
808
2027
  const ddl = createSlotlockApplicationRoleGrantsDdl(role);
809
- await inTransaction(sql, (tx) => withPinnedSearchPath(tx, async () => {
2028
+ await inMigration(sql, migrationOptions, (tx) => (async () => {
810
2029
  // Every role the target belongs to counts, not only the target: a member can SET ROLE to
811
2030
  // it or use its privileges, and one holding only ADMIN OPTION can grant itself either.
812
2031
  // MEMBER also counts a PostgreSQL 16 grant made with none of ADMIN, INHERIT or SET, which
@@ -910,7 +2129,7 @@ export function createSlotlockStore(sql, options = {}) {
910
2129
  throw Object.assign(new Error(`Slotlock application role ${role} could get around forced RLS or the exclusion arbiter, itself, through a role it belongs to or through PUBLIC (${check.reasons.join('; ')}); use a plain NOBYPASSRLS login role that owns nothing and can create nothing`), { code: 'unsafe_application_role', reasons: check.reasons });
911
2130
  }
912
2131
  await tx.unsafe(ddl);
913
- }));
2132
+ })());
914
2133
  },
915
2134
  async withTenant(tenantRef, callback, options) {
916
2135
  if (!isValidIdentity(tenantRef)) {
@@ -921,45 +2140,99 @@ export function createSlotlockStore(sql, options = {}) {
921
2140
  const isolation = options?.isolation;
922
2141
  if (isolation !== undefined && isolation !== 'read committed')
923
2142
  throw invalidIsolationError();
2143
+ const actor = options?.actor;
2144
+ if (actor !== undefined && !/^[^\u0000-\u001f\u007f]{1,200}$/u.test(actor)) {
2145
+ throw Object.assign(new Error('Slotlock actor must be 1-200 characters without control characters'), {
2146
+ code: 'invalid_identity',
2147
+ });
2148
+ }
2149
+ const release = await tenantSlots?.(tenantRef, options?.signal);
2150
+ // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
2151
+ // another error in its place: noted here as it happens.
2152
+ let timedOut = false;
924
2153
  return inTransaction(sql, async (tx) => {
2154
+ if (lockTimeout !== undefined) {
2155
+ await tx `SELECT set_config('lock_timeout', ${lockTimeout}, true)`;
2156
+ }
925
2157
  if (isolation !== undefined)
926
2158
  await assertReadCommitted(tx);
2159
+ // Held shared until the transaction ends: suspension and erasure, which take it
2160
+ // exclusively, wait for this transaction, and one that starts after them reads them below
2161
+ // (READ COMMITTED: a statement after the lock sees what committed before it was granted).
927
2162
  const previous = await tx `
928
- SELECT current_setting(${configuredTenantSetting}, true) AS value`;
929
- await tx `SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true)`;
2163
+ SELECT current_setting(${configuredTenantSetting}, true) AS value,
2164
+ current_setting('slotlock.actor', true) AS actor,
2165
+ current_setting('transaction_isolation') AS isolation,
2166
+ pg_advisory_xact_lock_shared(
2167
+ hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
2168
+ ) AS locked`;
2169
+ const [context] = await tx `
2170
+ SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
2171
+ set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
2172
+ slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2173
+ throw suspensionCheckRefusal(error);
2174
+ });
2175
+ // A caller's REPEATABLE READ or SERIALIZABLE snapshot was taken before the lock was
2176
+ // granted, so it may not show a suspension that committed while this transaction waited:
2177
+ // its writes are refused, and it may read.
2178
+ const refusal = context?.suspended === true
2179
+ ? tenantSuspendedError
2180
+ : previous[0]?.isolation !== 'read committed'
2181
+ ? staleSuspensionCheckError
2182
+ : null;
930
2183
  try {
931
- return await callback(createSlotlockStore(tx, {
932
- tenantContextSetting: configuredTenantSetting,
933
- agentOwnerEventQuota: configuredAgentOwnerEventQuota,
934
- agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
935
- }));
2184
+ return await callback(buildSlotlockStore(tx, storeOptions, refusal));
2185
+ }
2186
+ catch (error) {
2187
+ if (pgCode(error) === LOCK_NOT_AVAILABLE)
2188
+ timedOut = true;
2189
+ throw error;
936
2190
  }
937
2191
  finally {
938
- await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true)`;
2192
+ await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2193
+ set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)`;
2194
+ }
2195
+ }, 'read committed')
2196
+ .catch((error) => {
2197
+ if (lockTimeout !== undefined && (timedOut || pgCode(error) === LOCK_NOT_AVAILABLE)) {
2198
+ throw lockTimeoutError(error);
939
2199
  }
940
- }, isolation);
2200
+ throw error;
2201
+ })
2202
+ .finally(() => release?.());
941
2203
  },
942
2204
  async createResource(params) {
943
2205
  assertValidResourceInput(params);
944
- const max = params.maxTenantResources;
945
- if (max === undefined)
2206
+ const requested = params.maxTenantResources;
2207
+ const configured = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2208
+ if (requested === undefined && configured === undefined)
946
2209
  return upsertResource(sql, params);
947
- if (!Number.isSafeInteger(max) ||
948
- max < 1 ||
949
- max > MAX_TENANT_RESOURCE_LIMIT ||
950
- params.tenantRef === undefined ||
951
- params.externalRef === undefined) {
2210
+ if (requested !== undefined &&
2211
+ (!Number.isSafeInteger(requested) ||
2212
+ requested < 1 ||
2213
+ requested > MAX_TENANT_RESOURCE_LIMIT ||
2214
+ params.tenantRef === undefined ||
2215
+ params.externalRef === undefined)) {
952
2216
  throw Object.assign(new Error(`Slotlock maxTenantResources must be a whole number from 1 to ${MAX_TENANT_RESOURCE_LIMIT}, for a tenant-owned resource`), { code: 'invalid_resource_limit' });
953
2217
  }
954
- const { tenantRef, externalRef } = params;
2218
+ const max = requested === undefined
2219
+ ? configured
2220
+ : configured === undefined
2221
+ ? requested
2222
+ : Math.min(requested, configured);
2223
+ const tenantRef = params.tenantRef;
2224
+ const externalRef = params.externalRef ?? null;
955
2225
  return inTransaction(sql, async (tx) => {
956
2226
  await assertReadCommitted(tx);
957
2227
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
958
2228
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
959
2229
  // READ COMMITTED: each statement below sees every create committed before the lock.
960
- const known = await tx `
961
- SELECT id FROM slotlock.resources
962
- WHERE tenant_ref = ${tenantRef} AND external_ref = ${externalRef}`;
2230
+ // Re-creating a resource the tenant has (by its reference) adds none.
2231
+ const known = externalRef === null
2232
+ ? []
2233
+ : await tx `
2234
+ SELECT id FROM slotlock.resources
2235
+ WHERE tenant_ref = ${tenantRef} AND external_ref = ${externalRef}`;
963
2236
  if (known.length === 0) {
964
2237
  const [counted] = await tx `
965
2238
  SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${tenantRef}`;
@@ -971,15 +2244,17 @@ export function createSlotlockStore(sql, options = {}) {
971
2244
  }, 'read committed');
972
2245
  },
973
2246
  async getResource(params) {
2247
+ assertValidVisibility(params.visibility);
974
2248
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
975
2249
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
976
2250
  code: 'invalid_identity',
977
2251
  });
978
2252
  }
979
2253
  const rows = await sql `
980
- SELECT id, external_ref, tenant_ref, timezone, availability_rules
2254
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
981
2255
  FROM slotlock.resources
982
- WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}`;
2256
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2257
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
983
2258
  const row = rows[0];
984
2259
  return row ? toResource(row) : null;
985
2260
  },
@@ -994,10 +2269,87 @@ export function createSlotlockStore(sql, options = {}) {
994
2269
  UPDATE slotlock.resources
995
2270
  SET availability_rules = ${rules}::jsonb
996
2271
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
997
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
2272
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
998
2273
  const row = rows[0];
999
2274
  return row ? toResource(row) : null;
1000
2275
  },
2276
+ async setResourceBookingRules(params) {
2277
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2278
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2279
+ code: 'invalid_identity',
2280
+ });
2281
+ }
2282
+ let rules = null;
2283
+ if (params.rules !== null) {
2284
+ try {
2285
+ rules = parseBookingRules(params.rules);
2286
+ }
2287
+ catch (error) {
2288
+ throw Object.assign(new Error(error.message), {
2289
+ code: 'invalid_booking_rules',
2290
+ });
2291
+ }
2292
+ }
2293
+ const rows = await sql `
2294
+ UPDATE slotlock.resources
2295
+ SET booking_rules = ${rules === null ? null : sql.json({ ...rules })}::jsonb,
2296
+ -- Kept while check-in stays asked for, from the moment it first was; cleared without.
2297
+ check_in_since = CASE
2298
+ WHEN ${rules?.checkInMinutes ?? null}::int IS NULL THEN NULL
2299
+ WHEN booking_rules ? 'checkInMinutes' THEN COALESCE(check_in_since, statement_timestamp())
2300
+ ELSE statement_timestamp()
2301
+ END
2302
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2303
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2304
+ visibility, archived_at`;
2305
+ const row = rows[0];
2306
+ return row ? toResource(row) : null;
2307
+ },
2308
+ async setResourcePool(params) {
2309
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2310
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2311
+ code: 'invalid_identity',
2312
+ });
2313
+ }
2314
+ if (params.pool !== null && !isPoolName(params.pool))
2315
+ throw invalidPoolError();
2316
+ const rows = await sql `
2317
+ UPDATE slotlock.resources SET pool_ref = ${params.pool}
2318
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2319
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2320
+ visibility, archived_at`;
2321
+ const row = rows[0];
2322
+ return row ? toResource(row) : null;
2323
+ },
2324
+ async listPoolMembers(params) {
2325
+ if (!isValidIdentity(params.tenantRef))
2326
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2327
+ if (!isPoolName(params.pool))
2328
+ throw invalidPoolError();
2329
+ const rows = await sql `
2330
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2331
+ visibility, archived_at
2332
+ FROM slotlock.resources
2333
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2334
+ AND archived_at IS NULL
2335
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2336
+ ORDER BY id
2337
+ LIMIT ${SLOTLOCK_MAX_POOL_MEMBERS}`;
2338
+ return rows.map(toResource);
2339
+ },
2340
+ async listPools(params) {
2341
+ if (!isValidIdentity(params.tenantRef))
2342
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2343
+ const rows = await sql `
2344
+ SELECT pool_ref AS pool, count(*)::int AS capacity
2345
+ FROM slotlock.resources
2346
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref IS NOT NULL AND archived_at IS NULL
2347
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2348
+ GROUP BY pool_ref
2349
+ ORDER BY pool_ref
2350
+ LIMIT 1000`;
2351
+ return rows;
2352
+ },
1001
2353
  async setTenantAvailability(params) {
1002
2354
  if (!isValidIdentity(params.tenantRef)) {
1003
2355
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -1029,42 +2381,130 @@ export function createSlotlockStore(sql, options = {}) {
1029
2381
  });
1030
2382
  }
1031
2383
  const rows = await sql `
1032
- SELECT id, external_ref, tenant_ref, timezone, availability_rules
2384
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
1033
2385
  FROM slotlock.resources
1034
2386
  WHERE tenant_ref = ${params.tenantRef}
2387
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2388
+ AND (${params.includeArchived === true} OR archived_at IS NULL)
1035
2389
  AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
1036
2390
  ORDER BY id
1037
2391
  LIMIT ${limit}`;
1038
2392
  return rows.map(toResource);
1039
2393
  },
2394
+ async archiveResource(params) {
2395
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2396
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2397
+ }
2398
+ // Its clock time, not the transaction's start: a booking that committed holding the row's
2399
+ // share lock went in before this.
2400
+ const [row] = await sql `
2401
+ UPDATE slotlock.resources
2402
+ SET archived_at = COALESCE(archived_at, clock_timestamp())
2403
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2404
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2405
+ return row ? toResource(row) : null;
2406
+ },
2407
+ async restoreResource(params) {
2408
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2409
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2410
+ }
2411
+ const [row] = await sql `
2412
+ UPDATE slotlock.resources
2413
+ SET archived_at = NULL
2414
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2415
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2416
+ return row ? toResource(row) : null;
2417
+ },
2418
+ async deleteResource(params) {
2419
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2420
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2421
+ }
2422
+ return inTransaction(sql, async (tx) => {
2423
+ // The row lock waits for writers holding its share lock and keeps new ones out until done.
2424
+ const [resource] = await tx `
2425
+ SELECT id FROM slotlock.resources
2426
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2427
+ FOR UPDATE`;
2428
+ if (!resource)
2429
+ return { ok: true, deleted: false };
2430
+ const [live] = await tx `
2431
+ SELECT EXISTS (
2432
+ SELECT 1 FROM slotlock.reservations
2433
+ WHERE resource_id = ${params.id}
2434
+ AND calendar_event_id IS NULL
2435
+ AND (CASE WHEN status = 'held' THEN expires_at > statement_timestamp()
2436
+ ELSE COALESCE(occupied_ends_at, ends_at) > now() END)
2437
+ )
2438
+ OR EXISTS (
2439
+ SELECT 1 FROM slotlock.calendar_events
2440
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.id}
2441
+ AND (recurrence_rule IS NOT NULL OR ends_at > now())
2442
+ ) AS live`;
2443
+ if (live?.live)
2444
+ return { ok: false, code: 'resource_in_use' };
2445
+ // Its rows go with it: reservations, events and their children, coverage and hold answers
2446
+ // all cascade from the resource.
2447
+ await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
2448
+ return { ok: true, deleted: true };
2449
+ });
2450
+ },
1040
2451
  async createReservation(params) {
1041
2452
  if (!isFiniteWindow({ start: params.start, end: params.end })) {
1042
2453
  return { ok: false, code: 'invalid_window' };
1043
2454
  }
2455
+ if (!isValidResourceId(params.resourceId))
2456
+ return { ok: false, code: 'resource_not_found' };
2457
+ if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
2458
+ return { ok: false, code: 'resource_not_found' };
2459
+ }
2460
+ // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2461
+ // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
2462
+ const id = randomUUID();
1044
2463
  for (let attempt = 1;; attempt++) {
1045
2464
  try {
1046
- const rows = await inTransaction(sql, async (tx) => {
1047
- await reapExpiredHolds(tx, params.resourceId, params.start, params.end);
1048
- return tx `
1049
- INSERT INTO slotlock.reservations (resource_id, starts_at, ends_at, source)
1050
- VALUES (${params.resourceId}, ${params.start}, ${params.end}, ${params.source ?? null})
1051
- RETURNING id, resource_id, starts_at, ends_at, source`;
2465
+ return await inTransaction(sql, async (tx) => {
2466
+ const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
2467
+ const [resource] = await tx `
2468
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2469
+ WHERE id = ${params.resourceId}
2470
+ AND (${params.tenantRef ?? null}::text IS NULL
2471
+ OR tenant_ref = ${params.tenantRef ?? null})
2472
+ FOR SHARE`;
2473
+ if (!resource)
2474
+ return { ok: false, code: 'resource_not_found' };
2475
+ if (resource.archived)
2476
+ return { ok: false, code: 'resource_archived' };
2477
+ if (bookingLimit !== null && resource.tenant_ref !== null) {
2478
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2479
+ }
2480
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2481
+ if (ruling.rule)
2482
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2483
+ const occupied = new Date(params.end.getTime() + ruling.turnaroundMs);
2484
+ if (resource.tenant_ref !== null &&
2485
+ !(await recurrenceCoversWrite(tx, {
2486
+ tenantRef: resource.tenant_ref,
2487
+ resourceId: params.resourceId,
2488
+ start: params.start,
2489
+ end: occupied,
2490
+ }))) {
2491
+ return { ok: false, code: 'recurrence_not_materialized' };
2492
+ }
2493
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2494
+ const [row] = await tx `
2495
+ INSERT INTO slotlock.reservations (
2496
+ id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms, source
2497
+ ) VALUES (
2498
+ ${id}, ${params.resourceId}, ${resource.tenant_ref},
2499
+ ${resource.tenant_ref === null ? null : id},
2500
+ ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${params.source ?? null}
2501
+ )
2502
+ RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2503
+ buffer_after_ms, revision, source`;
2504
+ if (!row)
2505
+ throw new Error('slotlock: reservation insert returned no row');
2506
+ return { ok: true, reservation: toReservation(row) };
1052
2507
  });
1053
- const row = rows[0];
1054
- if (!row)
1055
- throw new Error('slotlock: reservation insert returned no row');
1056
- const reservation = {
1057
- id: row.id,
1058
- resourceId: row.resource_id,
1059
- tenantRef: null,
1060
- externalRef: null,
1061
- start: row.starts_at,
1062
- end: row.ends_at,
1063
- bufferAfterMs: 0,
1064
- revision: 1,
1065
- source: row.source,
1066
- };
1067
- return { ok: true, reservation };
1068
2508
  }
1069
2509
  catch (err) {
1070
2510
  const code = pgCode(err);
@@ -1078,6 +2518,7 @@ export function createSlotlockStore(sql, options = {}) {
1078
2518
  }
1079
2519
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
1080
2520
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2521
+ await rollSeriesBehind(sql, params.resourceId, params.start, params.end);
1081
2522
  return overlapLoss(sql, params.resourceId, params.start, params.end);
1082
2523
  }
1083
2524
  }
@@ -1088,6 +2529,7 @@ export function createSlotlockStore(sql, options = {}) {
1088
2529
  return invalid;
1089
2530
  if (params.revision !== 1)
1090
2531
  return { ok: false, code: 'invalid_revision' };
2532
+ let written = params.bufferAfterMs;
1091
2533
  for (let attempt = 1;; attempt++) {
1092
2534
  try {
1093
2535
  return await inTransaction(sql, async (tx) => {
@@ -1108,27 +2550,47 @@ export function createSlotlockStore(sql, options = {}) {
1108
2550
  AND calendar_event_id IS NULL
1109
2551
  FOR UPDATE`;
1110
2552
  const found = existing[0];
2553
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
1111
2554
  if (found) {
1112
2555
  // The booking has moved on since: a delayed create is stale, as a delayed move is.
1113
2556
  if (params.revision < Number(found.revision)) {
1114
2557
  return { ok: false, code: 'stale_revision' };
1115
2558
  }
1116
- if (!sameExternalPayload(found, params)) {
2559
+ if (!sameExternalPayload(found, params, ruling.turnaroundMs)) {
1117
2560
  return { ok: false, code: 'idempotency_conflict' };
1118
2561
  }
1119
2562
  return { ok: true, reservation: toReservation(found), idempotent: true };
1120
2563
  }
1121
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
2564
+ const bookingLimit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
2565
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
2566
+ if (resourceState === 'missing')
1122
2567
  return { ok: false, code: 'resource_tenant_mismatch' };
2568
+ if (resourceState === 'archived')
2569
+ return { ok: false, code: 'resource_archived' };
2570
+ if (bookingLimit !== null) {
2571
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2572
+ }
2573
+ if (ruling.rule)
2574
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2575
+ const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2576
+ written = bufferAfterMs;
2577
+ const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2578
+ if (!(await recurrenceCoversWrite(tx, {
2579
+ tenantRef: params.tenantRef,
2580
+ resourceId: params.resourceId,
2581
+ start: params.start,
2582
+ end: occupied,
2583
+ }))) {
2584
+ return { ok: false, code: 'recurrence_not_materialized' };
1123
2585
  }
1124
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
2586
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
1125
2587
  const rows = await tx `
1126
2588
  INSERT INTO slotlock.reservations (
1127
2589
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
1128
2590
  revision, source
1129
2591
  ) VALUES (
1130
2592
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
1131
- ${params.end}, ${params.bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2593
+ ${params.end}, ${bufferAfterMs}, ${params.revision}, ${params.source ?? null}
1132
2594
  )
1133
2595
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
1134
2596
  buffer_after_ms, revision, source`;
@@ -1148,7 +2610,8 @@ export function createSlotlockStore(sql, options = {}) {
1148
2610
  code !== SERIALIZATION_FAILURE) {
1149
2611
  throw err;
1150
2612
  }
1151
- return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs);
2613
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2614
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written);
1152
2615
  }
1153
2616
  }
1154
2617
  },
@@ -1157,6 +2620,7 @@ export function createSlotlockStore(sql, options = {}) {
1157
2620
  if (invalid)
1158
2621
  return invalid;
1159
2622
  let currentReservationId;
2623
+ let bufferAfterMs = params.bufferAfterMs;
1160
2624
  for (let attempt = 1;; attempt++) {
1161
2625
  try {
1162
2626
  return await inTransaction(sql, async (tx) => {
@@ -1169,6 +2633,7 @@ export function createSlotlockStore(sql, options = {}) {
1169
2633
  AND calendar_event_id IS NULL
1170
2634
  FOR UPDATE`;
1171
2635
  const found = existing[0];
2636
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
1172
2637
  if (!found) {
1173
2638
  const cancelled = await tx `
1174
2639
  SELECT revision FROM slotlock.reservation_tombstones
@@ -1181,7 +2646,7 @@ export function createSlotlockStore(sql, options = {}) {
1181
2646
  const currentRevision = Number(found.revision);
1182
2647
  if (params.revision < currentRevision)
1183
2648
  return { ok: false, code: 'stale_revision' };
1184
- if (sameExternalPayload(found, params)) {
2649
+ if (sameExternalPayload(found, params, ruling.turnaroundMs)) {
1185
2650
  return { ok: true, reservation: toReservation(found), idempotent: true };
1186
2651
  }
1187
2652
  if (params.revision === currentRevision) {
@@ -1190,18 +2655,36 @@ export function createSlotlockStore(sql, options = {}) {
1190
2655
  if (params.revision !== currentRevision + 1) {
1191
2656
  return { ok: false, code: 'revision_conflict' };
1192
2657
  }
1193
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
2658
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
2659
+ if (resourceState === 'missing')
1194
2660
  return { ok: false, code: 'resource_tenant_mismatch' };
2661
+ if (resourceState === 'archived')
2662
+ return { ok: false, code: 'resource_archived' };
2663
+ if (ruling.rule)
2664
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2665
+ bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2666
+ if (!(await recurrenceCoversWrite(tx, {
2667
+ tenantRef: params.tenantRef,
2668
+ resourceId: params.resourceId,
2669
+ start: params.start,
2670
+ end: occupiedEnd({ end: params.end, bufferAfterMs }),
2671
+ }))) {
2672
+ return { ok: false, code: 'recurrence_not_materialized' };
1195
2673
  }
1196
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
2674
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs }));
1197
2675
  const rows = await tx `
1198
2676
  UPDATE slotlock.reservations
1199
2677
  SET resource_id = ${params.resourceId},
1200
2678
  starts_at = ${params.start},
1201
2679
  ends_at = ${params.end},
1202
- buffer_after_ms = ${params.bufferAfterMs},
2680
+ buffer_after_ms = ${bufferAfterMs},
1203
2681
  revision = ${params.revision},
1204
- source = ${params.source ?? null}
2682
+ source = ${params.source ?? null},
2683
+ -- A check-in is for the time it was made for: a move to another start clears it.
2684
+ checked_in_at = CASE
2685
+ WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
2686
+ THEN checked_in_at
2687
+ END
1205
2688
  WHERE id = ${found.id}
1206
2689
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
1207
2690
  buffer_after_ms, revision, source`;
@@ -1221,7 +2704,9 @@ export function createSlotlockStore(sql, options = {}) {
1221
2704
  code !== SERIALIZATION_FAILURE) {
1222
2705
  throw err;
1223
2706
  }
1224
- return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs, currentReservationId);
2707
+ const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2708
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
2709
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId);
1225
2710
  }
1226
2711
  }
1227
2712
  },
@@ -1262,9 +2747,26 @@ export function createSlotlockStore(sql, options = {}) {
1262
2747
  return { ok: false, code: 'revision_conflict' };
1263
2748
  }
1264
2749
  }
2750
+ if (!found) {
2751
+ // A calendar event's occupation row carries this reference: it is the event's, not a
2752
+ // booking's. Nothing is cancelled, and nothing retired: a tombstone would only grow.
2753
+ const [occupation] = await tx `
2754
+ SELECT id FROM slotlock.reservations
2755
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
2756
+ AND calendar_event_id IS NOT NULL
2757
+ LIMIT 1`;
2758
+ if (occupation)
2759
+ return { ok: true, cancelled: false, revision: params.revision };
2760
+ // Retiring a reference no booking holds adds a row; cancelling a booking replaces one.
2761
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
2762
+ if (limit !== null)
2763
+ return { ok: false, code: 'reservation_limit_reached', limit };
2764
+ }
2765
+ // A reference no booking holds yet is retired too, so a create delivered after its cancel
2766
+ // cannot bring the booking back. Only such a stray retirement is pruned past retention.
1265
2767
  await tx `
1266
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision)
1267
- VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision})`;
2768
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
2769
+ VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
1268
2770
  if (!found)
1269
2771
  return { ok: true, cancelled: false, revision: params.revision };
1270
2772
  const deleted = await tx `
@@ -1282,31 +2784,151 @@ export function createSlotlockStore(sql, options = {}) {
1282
2784
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
1283
2785
  return { ok: false, code: 'invalid_ttl' };
1284
2786
  }
2787
+ const bufferAfterMs = params.bufferAfterMs ?? 0;
2788
+ if (!Number.isSafeInteger(bufferAfterMs) ||
2789
+ bufferAfterMs < 0 ||
2790
+ bufferAfterMs > MAX_BUFFER_AFTER_MS) {
2791
+ return { ok: false, code: 'invalid_buffer' };
2792
+ }
2793
+ const { tenantRef, principalRef, idempotencyKey } = params;
2794
+ if (tenantRef !== undefined && !isValidIdentity(tenantRef)) {
2795
+ return { ok: false, code: 'invalid_identity' };
2796
+ }
2797
+ const keyed = idempotencyKey !== undefined || principalRef !== undefined;
2798
+ if (keyed &&
2799
+ (tenantRef === undefined || !isValidIdentity(principalRef) || !isValidIdentity(idempotencyKey))) {
2800
+ return { ok: false, code: 'invalid_identity' };
2801
+ }
2802
+ if (!isValidResourceId(params.resourceId))
2803
+ return { ok: false, code: 'resource_not_found' };
2804
+ if (params.groupId !== undefined && !isValidResourceId(params.groupId)) {
2805
+ return { ok: false, code: 'invalid_identity' };
2806
+ }
2807
+ const { holderRef } = params;
2808
+ if (holderRef !== undefined && (tenantRef === undefined || !isValidIdentity(holderRef))) {
2809
+ return { ok: false, code: 'invalid_identity' };
2810
+ }
2811
+ const payloadHash = calendarCommandHash({
2812
+ operation: 'hold',
2813
+ resourceId: params.resourceId,
2814
+ start: params.start.toISOString(),
2815
+ end: params.end.toISOString(),
2816
+ ttlMs: params.ttlMs,
2817
+ bufferAfterMs,
2818
+ source: params.source ?? null,
2819
+ ...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
2820
+ ...(holderRef !== undefined ? { holderRef } : {}),
2821
+ });
1285
2822
  const ttlSeconds = params.ttlMs / 1000;
2823
+ // The buffer written: the one asked for, raised to the resource's turnaround.
2824
+ let written = bufferAfterMs;
1286
2825
  for (let attempt = 1;; attempt++) {
1287
2826
  try {
1288
- const rows = await inTransaction(sql, async (tx) => {
1289
- await reapExpiredHolds(tx, params.resourceId, params.start, params.end);
1290
- return tx `
1291
- INSERT INTO slotlock.reservations (resource_id, starts_at, ends_at, status, expires_at, source)
1292
- VALUES (${params.resourceId}, ${params.start}, ${params.end}, 'held',
1293
- now() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null})
1294
- RETURNING id, resource_id, starts_at, ends_at, expires_at, source`;
2827
+ return await inTransaction(sql, async (tx) => {
2828
+ if (keyed) {
2829
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
2830
+ 'slotlock-hold-command',
2831
+ tenantRef,
2832
+ principalRef,
2833
+ idempotencyKey,
2834
+ ])}, 0))`;
2835
+ const [prior] = await tx `
2836
+ SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
2837
+ source, payload_hash,
2838
+ (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id
2839
+ FROM slotlock.hold_commands
2840
+ WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
2841
+ AND idempotency_key = ${idempotencyKey ?? null}`;
2842
+ if (prior) {
2843
+ return prior.payload_hash === payloadHash
2844
+ ? { ok: true, hold: toHold(prior), idempotent: true }
2845
+ : { ok: false, code: 'idempotency_conflict' };
2846
+ }
2847
+ }
2848
+ const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
2849
+ const [resource] = await tx `
2850
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2851
+ WHERE id = ${params.resourceId}
2852
+ AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
2853
+ FOR SHARE`;
2854
+ if (!resource)
2855
+ return { ok: false, code: 'resource_not_found' };
2856
+ if (resource.archived)
2857
+ return { ok: false, code: 'resource_archived' };
2858
+ if (holdLimit !== null)
2859
+ return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
2860
+ // Seconds the holder already holds, turnaround included, when its time is capped.
2861
+ let heldSeconds = null;
2862
+ if (holderRef !== undefined) {
2863
+ // One holder's live holds, counted under a lock on the holder so racing holds stop
2864
+ // at the cap: an agent cannot hold a resource's whole week.
2865
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
2866
+ 'slotlock-holder',
2867
+ tenantRef,
2868
+ holderRef,
2869
+ ])}, 0))`;
2870
+ const [held] = await tx `
2871
+ SELECT count(*)::int AS count,
2872
+ COALESCE(sum(extract(epoch FROM
2873
+ ends_at + buffer_after_ms * interval '1 millisecond' - starts_at)), 0)::float8
2874
+ AS seconds
2875
+ FROM slotlock.reservations
2876
+ WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
2877
+ AND expires_at > statement_timestamp()
2878
+ AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
2879
+ if ((held?.count ?? 0) >= maxHoldsPerHolder) {
2880
+ return { ok: false, code: 'hold_limit_reached', limit: maxHoldsPerHolder };
2881
+ }
2882
+ if (maxHeldHoursPerHolder !== undefined)
2883
+ heldSeconds = held?.seconds ?? 0;
2884
+ }
2885
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2886
+ if (ruling.rule)
2887
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2888
+ written = Math.max(bufferAfterMs, ruling.turnaroundMs);
2889
+ const reach = occupiedEnd({ end: params.end, bufferAfterMs: written });
2890
+ if (heldSeconds !== null &&
2891
+ maxHeldHoursPerHolder !== undefined &&
2892
+ heldSeconds + (reach.getTime() - params.start.getTime()) / 1000 >
2893
+ maxHeldHoursPerHolder * 3_600) {
2894
+ return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
2895
+ }
2896
+ if (resource.tenant_ref !== null &&
2897
+ !(await recurrenceCoversWrite(tx, {
2898
+ tenantRef: resource.tenant_ref,
2899
+ resourceId: params.resourceId,
2900
+ start: params.start,
2901
+ end: reach,
2902
+ }))) {
2903
+ return { ok: false, code: 'recurrence_not_materialized' };
2904
+ }
2905
+ await reapExpiredHolds(tx, params.resourceId, params.start, reach);
2906
+ const [row] = await tx `
2907
+ INSERT INTO slotlock.reservations (
2908
+ resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source,
2909
+ holder_ref, hold_group_id
2910
+ ) VALUES (
2911
+ ${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
2912
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
2913
+ ${holderRef ?? null}, ${params.groupId ?? null}
2914
+ )
2915
+ RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
2916
+ hold_group_id`;
2917
+ if (!row)
2918
+ throw new Error('slotlock: hold insert returned no row');
2919
+ if (keyed) {
2920
+ await tx `
2921
+ INSERT INTO slotlock.hold_commands (
2922
+ tenant_ref, principal_ref, idempotency_key, payload_hash, hold_id, resource_id,
2923
+ starts_at, ends_at, buffer_after_ms, expires_at, source
2924
+ ) VALUES (
2925
+ ${tenantRef ?? null}, ${principalRef ?? null}, ${idempotencyKey ?? null},
2926
+ ${payloadHash}, ${row.id}, ${row.resource_id}, ${row.starts_at}, ${row.ends_at},
2927
+ ${row.buffer_after_ms}, ${row.expires_at}, ${row.source}
2928
+ )`;
2929
+ }
2930
+ return { ok: true, hold: toHold(row), idempotent: false };
1295
2931
  });
1296
- const row = rows[0];
1297
- if (!row)
1298
- throw new Error('slotlock: hold insert returned no row');
1299
- return {
1300
- ok: true,
1301
- hold: {
1302
- id: row.id,
1303
- resourceId: row.resource_id,
1304
- start: row.starts_at,
1305
- end: row.ends_at,
1306
- expiresAt: row.expires_at,
1307
- source: row.source,
1308
- },
1309
- };
1310
2932
  }
1311
2933
  catch (err) {
1312
2934
  const code = pgCode(err);
@@ -1320,74 +2942,224 @@ export function createSlotlockStore(sql, options = {}) {
1320
2942
  }
1321
2943
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
1322
2944
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
1323
- return overlapLoss(sql, params.resourceId, params.start, params.end);
2945
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2946
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written);
1324
2947
  }
1325
2948
  }
1326
2949
  },
1327
- async confirmHold(holdId, options = {}) {
1328
- const tenantRef = holdTenantRef(options);
2950
+ async getHold(params) {
2951
+ if (!isValidIdentity(params.tenantRef)) {
2952
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2953
+ }
2954
+ if (!isValidResourceId(params.holdId))
2955
+ return null;
2956
+ const [row] = await sql `
2957
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
2958
+ hold.expires_at, hold.source
2959
+ FROM slotlock.reservations hold
2960
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
2961
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
2962
+ AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
2963
+ return row ? toHold(row) : null;
2964
+ },
2965
+ async listHolds(params) {
2966
+ if (!isValidIdentity(params.tenantRef)) {
2967
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2968
+ }
2969
+ if (!isValidResourceId(params.resourceId)) {
2970
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
2971
+ }
2972
+ const limit = pageLimit(params.limit);
2973
+ const after = pageCursor(params.after);
1329
2974
  const rows = await sql `
1330
- UPDATE slotlock.reservations
1331
- SET status = 'confirmed', expires_at = NULL
1332
- WHERE id = ${holdId} AND status = 'held' AND expires_at > now()
1333
- AND (${tenantRef}::text IS NULL
1334
- OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
1335
- RETURNING id, resource_id, starts_at, ends_at, source`;
1336
- const row = rows[0];
1337
- if (row) {
1338
- return {
1339
- ok: true,
1340
- reservation: {
1341
- id: row.id,
1342
- resourceId: row.resource_id,
1343
- tenantRef: null,
1344
- externalRef: null,
1345
- start: row.starts_at,
1346
- end: row.ends_at,
1347
- bufferAfterMs: 0,
1348
- revision: 1,
1349
- source: row.source,
1350
- },
1351
- };
2975
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
2976
+ hold.expires_at, hold.source
2977
+ FROM slotlock.reservations hold
2978
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
2979
+ WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
2980
+ AND hold.status = 'held' AND hold.expires_at > statement_timestamp()
2981
+ AND (${after.start}::timestamptz IS NULL
2982
+ OR (hold.starts_at, hold.id) > (${after.start}::timestamptz, ${after.id}::uuid))
2983
+ ORDER BY hold.starts_at, hold.id
2984
+ LIMIT ${limit}`;
2985
+ return rows.map(toHold);
2986
+ },
2987
+ async listAgenda(params) {
2988
+ if (!isValidIdentity(params.tenantRef)) {
2989
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
1352
2990
  }
1353
- const present = await sql `
1354
- SELECT status, (status = 'held' AND expires_at <= now()) AS expired,
1355
- resource_id, starts_at, ends_at, source
2991
+ const window = calendarWindow(params.window);
2992
+ const limit = pageLimit(params.limit);
2993
+ // A confirmed row without a tenant is a parity shadow, not a booking.
2994
+ const rows = await sql `
2995
+ SELECT entry.id, entry.resource_id, entry.starts_at, entry.ends_at, entry.status,
2996
+ entry.external_ref, entry.expires_at, entry.source, entry.calendar_event_id,
2997
+ event.summary
2998
+ FROM slotlock.reservations entry
2999
+ JOIN slotlock.resources resource ON resource.id = entry.resource_id
3000
+ LEFT JOIN slotlock.calendar_events event ON event.id = entry.calendar_event_id
3001
+ WHERE resource.tenant_ref = ${params.tenantRef}
3002
+ AND entry.starts_at < ${window.end} AND entry.ends_at > ${window.start}
3003
+ AND ((entry.status = 'held' AND entry.expires_at > statement_timestamp())
3004
+ OR (entry.status = 'confirmed' AND entry.tenant_ref = ${params.tenantRef}))
3005
+ ORDER BY entry.starts_at, entry.id
3006
+ LIMIT ${limit}`;
3007
+ return rows.map((row) => ({
3008
+ id: row.id,
3009
+ resourceId: row.resource_id,
3010
+ start: row.starts_at,
3011
+ end: row.ends_at,
3012
+ kind: row.status === 'held' ? 'hold' : row.calendar_event_id ? 'event' : 'booking',
3013
+ reference: row.status === 'held' || row.calendar_event_id ? null : row.external_ref,
3014
+ summary: row.summary,
3015
+ expiresAt: row.status === 'held' ? row.expires_at : null,
3016
+ source: row.source,
3017
+ }));
3018
+ },
3019
+ async getReservation(params) {
3020
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
3021
+ throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
3022
+ }
3023
+ const [row] = await sql `
3024
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3025
+ revision, source
1356
3026
  FROM slotlock.reservations
1357
- WHERE id = ${holdId}
1358
- -- A hold has no external identity, before or after it is confirmed; a booking that has
1359
- -- one is not a hold, and confirming it again would describe it wrongly.
1360
- AND tenant_ref IS NULL
1361
- AND (${tenantRef}::text IS NULL
1362
- OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))`;
1363
- const found = present[0];
1364
- // Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
1365
- // SUCCESS — the booking exists; reporting failure would make callers double-book elsewhere.
1366
- if (found && found.status === 'confirmed') {
1367
- return {
1368
- ok: true,
1369
- reservation: {
1370
- id: holdId,
1371
- resourceId: found.resource_id,
1372
- tenantRef: null,
1373
- externalRef: null,
1374
- start: found.starts_at,
1375
- end: found.ends_at,
1376
- bufferAfterMs: 0,
1377
- revision: 1,
1378
- source: found.source,
1379
- },
1380
- };
3027
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3028
+ AND calendar_event_id IS NULL`;
3029
+ return row ? toReservation(row) : null;
3030
+ },
3031
+ async listReservations(params) {
3032
+ if (!isValidIdentity(params.tenantRef)) {
3033
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
1381
3034
  }
1382
- if (found?.expired)
1383
- return { ok: false, code: 'hold_expired' };
1384
- // NOTE: a hold reaped mid-flight by a rival (expired while this confirm waited on the
1385
- // reaper's lock) reads as hold_not_found — the row is gone and the space was legitimately
1386
- // taken; both refusal codes are terminal for the caller.
1387
- return { ok: false, code: 'hold_not_found' };
3035
+ if (!isValidResourceId(params.resourceId)) {
3036
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
3037
+ }
3038
+ const window = calendarWindow(params.window);
3039
+ const limit = pageLimit(params.limit);
3040
+ const after = pageCursor(params.after);
3041
+ const rows = await sql `
3042
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3043
+ revision, source
3044
+ FROM slotlock.reservations
3045
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
3046
+ AND calendar_event_id IS NULL
3047
+ AND starts_at < ${window.end}
3048
+ AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3049
+ AND (${after.start}::timestamptz IS NULL
3050
+ OR (starts_at, id) > (${after.start}::timestamptz, ${after.id}::uuid))
3051
+ ORDER BY starts_at, id
3052
+ LIMIT ${limit}`;
3053
+ return rows.map(toReservation);
3054
+ },
3055
+ async confirmHold(holdId, options = {}) {
3056
+ const tenantRef = holdTenantRef(options);
3057
+ // Not an id the store issues: it names no hold (and would be a raw 22P02 in the query).
3058
+ if (!isValidResourceId(holdId))
3059
+ return { ok: false, code: 'hold_not_found' };
3060
+ return inTransaction(sql, async (tx) => {
3061
+ // The tenant whose booking the hold becomes is its resource's; a hold never changes
3062
+ // resource, so it is read before the identity lock, which is taken before the row lock as
3063
+ // every booking write takes them.
3064
+ const [owner] = await tx `
3065
+ SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
3066
+ FROM slotlock.reservations hold
3067
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
3068
+ WHERE hold.id = ${holdId} AND hold.calendar_event_id IS NULL
3069
+ AND (${tenantRef}::text IS NULL OR resource.tenant_ref = ${tenantRef})`;
3070
+ if (!owner)
3071
+ return { ok: false, code: 'hold_not_found' };
3072
+ const bookingTenant = owner.resource_tenant;
3073
+ if (bookingTenant !== null)
3074
+ await lockExternalIdentity(tx, bookingTenant, holdId);
3075
+ const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
3076
+ // The resource's share lock, as every booking write takes it: an archive waits for this
3077
+ // confirmation, and one that committed first is read here.
3078
+ const [resource] = await tx `
3079
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
3080
+ WHERE id = ${owner.resource_id}
3081
+ FOR SHARE`;
3082
+ const [row] = await tx `
3083
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3084
+ revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live
3085
+ FROM slotlock.reservations
3086
+ WHERE id = ${holdId}
3087
+ FOR UPDATE`;
3088
+ if (!row) {
3089
+ // NOTE: a hold reaped mid-flight by a rival (expired while this confirm waited on the
3090
+ // reaper's lock) reads as hold_not_found: the row is gone and the space was legitimately
3091
+ // taken; both refusal codes are terminal for the caller.
3092
+ return { ok: false, code: 'hold_not_found' };
3093
+ }
3094
+ if (row.status === 'confirmed') {
3095
+ // Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
3096
+ // SUCCESS, the booking as it now is; reporting failure would make callers double-book
3097
+ // elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
3098
+ // booking with another reference was never this hold.
3099
+ if (row.external_ref !== null && row.external_ref !== row.id) {
3100
+ return { ok: false, code: 'hold_not_found' };
3101
+ }
3102
+ return { ok: true, reservation: toReservation(row) };
3103
+ }
3104
+ if (!row.live)
3105
+ return { ok: false, code: 'hold_expired' };
3106
+ // Archived since the hold was taken: a confirmation would be a new booking on it.
3107
+ if (resource?.archived !== false)
3108
+ return { ok: false, code: 'resource_archived' };
3109
+ if (bookingTenant !== null) {
3110
+ const [retired] = await tx `
3111
+ SELECT revision FROM slotlock.reservation_tombstones
3112
+ WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}`;
3113
+ if (retired)
3114
+ return { ok: false, code: 'reservation_cancelled' };
3115
+ const [taken] = await tx `
3116
+ SELECT id FROM slotlock.reservations
3117
+ WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}
3118
+ AND calendar_event_id IS NULL`;
3119
+ if (taken)
3120
+ return { ok: false, code: 'idempotency_conflict' };
3121
+ }
3122
+ if (bookingLimit !== null) {
3123
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
3124
+ }
3125
+ // The occupied end is already the end plus the hold's buffer (the trigger set it); the
3126
+ // tenant and reference make the hold a booking that cancel and reschedule reach.
3127
+ const [confirmed] = await tx `
3128
+ UPDATE slotlock.reservations
3129
+ SET status = 'confirmed', expires_at = NULL,
3130
+ tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
3131
+ WHERE id = ${holdId}
3132
+ RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3133
+ buffer_after_ms, revision, source`;
3134
+ if (!confirmed)
3135
+ throw new Error('slotlock: hold confirmation returned no row');
3136
+ return { ok: true, reservation: toReservation(confirmed) };
3137
+ });
1388
3138
  },
1389
3139
  async releaseHold(holdId, options = {}) {
1390
3140
  const tenantRef = holdTenantRef(options);
3141
+ if (!isValidResourceId(holdId))
3142
+ return { ok: true, released: false };
3143
+ if (options.holderRef !== undefined) {
3144
+ if (!isValidIdentity(options.holderRef))
3145
+ return { ok: true, released: false };
3146
+ const released = await sql `
3147
+ WITH taken AS (
3148
+ SELECT id, hold_group_id FROM slotlock.reservations
3149
+ WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${options.holderRef}
3150
+ AND (${tenantRef}::text IS NULL
3151
+ OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
3152
+ )
3153
+ DELETE FROM slotlock.reservations hold
3154
+ USING taken
3155
+ WHERE hold.status = 'held' AND hold.holder_ref = ${options.holderRef}
3156
+ AND (${tenantRef}::text IS NULL
3157
+ OR hold.resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
3158
+ AND (hold.id = taken.id
3159
+ OR (taken.hold_group_id IS NOT NULL AND hold.hold_group_id = taken.hold_group_id))
3160
+ RETURNING hold.id`;
3161
+ return { ok: true, released: released.some(({ id }) => id === holdId) };
3162
+ }
1391
3163
  const rows = await sql `
1392
3164
  DELETE FROM slotlock.reservations
1393
3165
  WHERE id = ${holdId} AND status = 'held'
@@ -1396,9 +3168,30 @@ export function createSlotlockStore(sql, options = {}) {
1396
3168
  RETURNING id`;
1397
3169
  return { ok: true, released: rows.length > 0 };
1398
3170
  },
1399
- async listBusy(resourceId, window) {
3171
+ async listBusy(resourceId, window, options = {}) {
1400
3172
  if (!isFiniteWindow(window))
1401
3173
  throw invalidWindowError();
3174
+ if (!isValidResourceId(resourceId)) {
3175
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
3176
+ }
3177
+ if (options.limit !== undefined && !(Number.isSafeInteger(options.limit) && options.limit > 0)) {
3178
+ throw Object.assign(new Error('Slotlock busy read limit must be a positive whole number'), {
3179
+ code: 'invalid_limit',
3180
+ });
3181
+ }
3182
+ if (options.tenantRef !== undefined) {
3183
+ if (!isValidIdentity(options.tenantRef)) {
3184
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3185
+ }
3186
+ const [owned] = await sql `
3187
+ SELECT id FROM slotlock.resources
3188
+ WHERE id = ${resourceId} AND tenant_ref = ${options.tenantRef}`;
3189
+ if (!owned) {
3190
+ throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
3191
+ code: 'resource_not_found',
3192
+ });
3193
+ }
3194
+ }
1402
3195
  // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
1403
3196
  // space (the acquiring transaction reaps the row itself when it takes the window).
1404
3197
  const rows = await sql `
@@ -1407,12 +3200,16 @@ export function createSlotlockStore(sql, options = {}) {
1407
3200
  WHERE resource_id = ${resourceId}
1408
3201
  AND starts_at < ${window.end}
1409
3202
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
1410
- AND (status <> 'held' OR expires_at > now())
1411
- ORDER BY starts_at`;
3203
+ AND (status <> 'held' OR expires_at > statement_timestamp())
3204
+ ORDER BY starts_at
3205
+ LIMIT ${options.limit ?? null}`;
1412
3206
  return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
1413
3207
  },
1414
3208
  async putCalendarEvent(params) {
1415
3209
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
3210
+ if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
3211
+ return { ok: false, code: 'invalid_identity' };
3212
+ }
1416
3213
  if (!isValidIdentity(params.tenantRef) ||
1417
3214
  !isValidEventOwnerRef(ownerRef) ||
1418
3215
  !isValidIdentity(params.externalRef) ||
@@ -1432,7 +3229,7 @@ export function createSlotlockStore(sql, options = {}) {
1432
3229
  if (event.recurrence) {
1433
3230
  if (!params.materializationWindow)
1434
3231
  return { ok: false, code: 'invalid_event' };
1435
- materializationWindow = calendarWindow(params.materializationWindow);
3232
+ materializationWindow = seriesWindow(params.materializationWindow);
1436
3233
  }
1437
3234
  else {
1438
3235
  materializationWindow = oneOffEventWindow(event, params.materializationWindow);
@@ -1455,13 +3252,16 @@ export function createSlotlockStore(sql, options = {}) {
1455
3252
  return { ok: false, code: 'invalid_event' };
1456
3253
  throw error;
1457
3254
  }
1458
- const payloadHash = putCommandHash({
3255
+ const payloadHash = putCommandHash({ command: params, ownerRef, event });
3256
+ const legacyPayloadHash = legacyPutCommandHash({
1459
3257
  command: params,
1460
3258
  ownerRef,
1461
3259
  event,
1462
3260
  window: materializationWindow,
1463
3261
  });
3262
+ const recurrenceEndsBy = recurrenceEndsByColumn(event);
1464
3263
  let attempted = expanded?.occurrences ?? [];
3264
+ let storedEventId;
1465
3265
  for (let attempt = 1;; attempt++) {
1466
3266
  try {
1467
3267
  return await inTransaction(sql, async (tx) => {
@@ -1477,7 +3277,9 @@ export function createSlotlockStore(sql, options = {}) {
1477
3277
  FOR UPDATE`;
1478
3278
  const priorCommand = commands[0];
1479
3279
  if (priorCommand) {
1480
- if (priorCommand.operation !== 'put' || priorCommand.payload_hash !== payloadHash) {
3280
+ if (priorCommand.operation !== 'put' ||
3281
+ (priorCommand.payload_hash !== payloadHash &&
3282
+ priorCommand.payload_hash !== legacyPayloadHash)) {
1481
3283
  return { ok: false, code: 'idempotency_conflict' };
1482
3284
  }
1483
3285
  return commandResult(priorCommand, true);
@@ -1509,9 +3311,11 @@ export function createSlotlockStore(sql, options = {}) {
1509
3311
  currentRevision: Number(tombstones[0].revision),
1510
3312
  };
1511
3313
  }
1512
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
3314
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
3315
+ if (resourceState === 'missing')
1513
3316
  return { ok: false, code: 'resource_tenant_mismatch' };
1514
- }
3317
+ if (resourceState === 'archived')
3318
+ return { ok: false, code: 'resource_archived' };
1515
3319
  const existing = await tx `
1516
3320
  SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1517
3321
  summary, description, location, status, transparency, organizer, attendees,
@@ -1523,6 +3327,11 @@ export function createSlotlockStore(sql, options = {}) {
1523
3327
  AND external_ref = ${params.externalRef}
1524
3328
  FOR UPDATE`;
1525
3329
  const found = existing[0];
3330
+ storedEventId = found?.id;
3331
+ if (params.expectedResourceId !== undefined &&
3332
+ found?.resource_id !== params.expectedResourceId) {
3333
+ return { ok: false, code: 'resource_tenant_mismatch' };
3334
+ }
1526
3335
  const currentRevision = found ? Number(found.revision) : 0;
1527
3336
  if (params.expectedRevision !== currentRevision) {
1528
3337
  return { ok: false, code: 'revision_conflict', currentRevision };
@@ -1553,8 +3362,66 @@ export function createSlotlockStore(sql, options = {}) {
1553
3362
  };
1554
3363
  }
1555
3364
  }
3365
+ if (!found) {
3366
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'events');
3367
+ if (limit !== null)
3368
+ return { ok: false, code: 'event_limit_reached', limit };
3369
+ }
1556
3370
  const eventId = found?.id ?? randomUUID();
1557
3371
  const revision = currentRevision + 1;
3372
+ if (params.fromHold) {
3373
+ const { holdId, holderRef } = params.fromHold;
3374
+ if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
3375
+ return { ok: false, code: 'hold_not_found' };
3376
+ }
3377
+ const [hold] = await tx `
3378
+ SELECT resource_id, starts_at, ends_at, hold_group_id FROM slotlock.reservations
3379
+ WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
3380
+ AND expires_at > statement_timestamp()
3381
+ FOR UPDATE`;
3382
+ if (!hold)
3383
+ return { ok: false, code: 'hold_not_found' };
3384
+ const [only] = occurrences;
3385
+ if (hold.resource_id !== params.resourceId ||
3386
+ occurrences.length !== 1 ||
3387
+ !only ||
3388
+ only.start < hold.starts_at ||
3389
+ only.end > hold.ends_at) {
3390
+ return { ok: false, code: 'hold_mismatch' };
3391
+ }
3392
+ // The hold and its alternatives give way to the event in this transaction.
3393
+ await tx `
3394
+ DELETE FROM slotlock.reservations
3395
+ WHERE status = 'held' AND holder_ref = ${holderRef}
3396
+ AND (id = ${holdId}
3397
+ OR (${hold.hold_group_id}::uuid IS NOT NULL
3398
+ AND hold_group_id = ${hold.hold_group_id}::uuid))`;
3399
+ }
3400
+ let turnaroundMs = 0;
3401
+ if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
3402
+ const judge = await bookingJudge(tx, params.resourceId, defaultHours);
3403
+ for (const [index, occurrence] of occurrences.entries()) {
3404
+ // A held time's notice and advance were judged when it was held.
3405
+ const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
3406
+ if (rule)
3407
+ return { ok: false, code: 'booking_rule_violated', rule };
3408
+ }
3409
+ turnaroundMs = judge.turnaroundMs;
3410
+ }
3411
+ const span = occurrenceSpan(occurrences);
3412
+ const reach = span && { start: span.start, end: new Date(span.end.getTime() + turnaroundMs) };
3413
+ if ((event.transparency ?? 'opaque') === 'opaque' &&
3414
+ reach &&
3415
+ !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
3416
+ if (!(await recurrenceCoversWrite(tx, {
3417
+ tenantRef: params.tenantRef,
3418
+ resourceId: params.resourceId,
3419
+ ...reach,
3420
+ ...(found ? { exceptEventId: found.id } : {}),
3421
+ }))) {
3422
+ return { ok: false, code: 'recurrence_not_materialized' };
3423
+ }
3424
+ }
1558
3425
  if (found) {
1559
3426
  await tx `
1560
3427
  UPDATE slotlock.calendar_events
@@ -1575,8 +3442,11 @@ export function createSlotlockStore(sql, options = {}) {
1575
3442
  materialized_starts_at = ${materializationWindow.start},
1576
3443
  materialized_ends_at = ${materializationWindow.end},
1577
3444
  recurrence_cursor = ${seriesCursorJson(tx, series.cursor)},
3445
+ recurrence_ends_by = ${recurrenceEndsBy}::text::timestamptz,
3446
+ roll_retry_at = NULL,
1578
3447
  revision = ${revision},
1579
3448
  source = ${params.source ?? null},
3449
+ buffer_after_ms = ${turnaroundMs},
1580
3450
  updated_at = now()
1581
3451
  WHERE id = ${eventId}
1582
3452
  AND tenant_ref = ${params.tenantRef}
@@ -1588,7 +3458,8 @@ export function createSlotlockStore(sql, options = {}) {
1588
3458
  id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1589
3459
  summary, description, location, status, transparency, organizer, attendees,
1590
3460
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1591
- materialized_ends_at, recurrence_cursor, revision, source
3461
+ materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
3462
+ buffer_after_ms
1592
3463
  ) VALUES (
1593
3464
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
1594
3465
  ${params.resourceId},
@@ -1601,8 +3472,8 @@ export function createSlotlockStore(sql, options = {}) {
1601
3472
  ${event.recurrence?.rrule ?? null},
1602
3473
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
1603
3474
  ${materializationWindow.start}, ${materializationWindow.end},
1604
- ${seriesCursorJson(tx, series.cursor)}, ${revision},
1605
- ${params.source ?? null}
3475
+ ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3476
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}
1606
3477
  )`;
1607
3478
  }
1608
3479
  // The normalized children are the queryable/canonical representation. The JSON
@@ -1615,6 +3486,7 @@ export function createSlotlockStore(sql, options = {}) {
1615
3486
  event,
1616
3487
  occurrences,
1617
3488
  revision,
3489
+ bufferAfterMs: turnaroundMs,
1618
3490
  });
1619
3491
  await tx `
1620
3492
  INSERT INTO slotlock.calendar_event_commands (
@@ -1645,7 +3517,11 @@ export function createSlotlockStore(sql, options = {}) {
1645
3517
  code !== SERIALIZATION_FAILURE) {
1646
3518
  throw error;
1647
3519
  }
1648
- return findCalendarOverlap(sql, params.resourceId, attempted);
3520
+ const reach = occurrenceSpan(attempted);
3521
+ if (reach) {
3522
+ await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
3523
+ }
3524
+ return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
1649
3525
  }
1650
3526
  }
1651
3527
  },
@@ -1673,7 +3549,31 @@ export function createSlotlockStore(sql, options = {}) {
1673
3549
  const children = await loadCalendarEventChildren(sql, [row.id]);
1674
3550
  return toCalendarEvent(row, children.get(row.id));
1675
3551
  },
3552
+ async getCalendarEventById(params) {
3553
+ if (!isValidIdentity(params.tenantRef)) {
3554
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3555
+ }
3556
+ if (!isValidResourceId(params.eventId)) {
3557
+ throw invalidIdentityError('Slotlock event id must be a UUID');
3558
+ }
3559
+ const rows = await sql `
3560
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
3561
+ summary, description, location, status, transparency, organizer, attendees,
3562
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3563
+ materialized_ends_at, revision, source, created_at, updated_at
3564
+ FROM slotlock.calendar_events
3565
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
3566
+ const row = rows[0];
3567
+ if (!row)
3568
+ return null;
3569
+ const children = await loadCalendarEventChildren(sql, [row.id]);
3570
+ return toCalendarEvent(row, children.get(row.id));
3571
+ },
1676
3572
  async listCalendarEvents(params) {
3573
+ if (params.allOwners === true && params.ownerRef !== undefined) {
3574
+ throw invalidIdentityError('Slotlock lists one owner or every owner, not both');
3575
+ }
3576
+ const allOwners = params.allOwners === true;
1677
3577
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
1678
3578
  if (!isValidIdentity(params.tenantRef) || !isValidEventOwnerRef(ownerRef)) {
1679
3579
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -1711,7 +3611,7 @@ export function createSlotlockStore(sql, options = {}) {
1711
3611
  event.source, event.created_at, event.updated_at
1712
3612
  FROM slotlock.calendar_events event
1713
3613
  WHERE event.tenant_ref = ${params.tenantRef}
1714
- AND event.owner_ref = ${ownerRef}
3614
+ AND (${allOwners}::boolean OR event.owner_ref = ${ownerRef})
1715
3615
  AND event.resource_id = ${params.resourceId}
1716
3616
  AND EXISTS (
1717
3617
  SELECT 1 FROM slotlock.calendar_event_occurrences occurrence
@@ -1738,7 +3638,7 @@ export function createSlotlockStore(sql, options = {}) {
1738
3638
  code: 'invalid_identity',
1739
3639
  });
1740
3640
  }
1741
- const window = calendarWindow(params.window);
3641
+ const window = seriesWindow(params.window);
1742
3642
  const limit = params.limit ?? 100;
1743
3643
  if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
1744
3644
  throw Object.assign(new Error('Slotlock horizon batch limit must be 1-1000'), {
@@ -1747,9 +3647,12 @@ export function createSlotlockStore(sql, options = {}) {
1747
3647
  }
1748
3648
  const after = rollCursor(params.after);
1749
3649
  // Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
1750
- // of its start.
3650
+ // of its start. Those it has never put back come first, then those it has, longest waiting
3651
+ // first: however many it cannot extend, they never stand in front of one it has not tried,
3652
+ // so every pass that is not capped by such new ones reaches the healthy series, and the ones
3653
+ // put back take turns.
1751
3654
  const candidates = await sql `
1752
- SELECT id, materialized_ends_at
3655
+ SELECT id, materialized_ends_at, roll_retry_at
1753
3656
  FROM slotlock.calendar_events
1754
3657
  WHERE tenant_ref = ${params.tenantRef}
1755
3658
  AND recurrence_rule IS NOT NULL
@@ -1757,71 +3660,40 @@ export function createSlotlockStore(sql, options = {}) {
1757
3660
  materialized_starts_at > ${window.start}
1758
3661
  OR materialized_ends_at < ${window.end}
1759
3662
  )
1760
- AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_CALENDAR_WINDOW_MS)}
3663
+ AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_MATERIALIZATION_WINDOW_MS)}
3664
+ -- One it could not extend waits its turn behind the ones it can.
3665
+ AND (roll_retry_at IS NULL OR roll_retry_at <= now())
1761
3666
  AND (
1762
3667
  ${after?.materializedEndsAt ?? null}::timestamptz IS NULL
1763
- OR (materialized_ends_at, id) >
1764
- (${after?.materializedEndsAt ?? null}::timestamptz, ${after?.eventId ?? null}::uuid)
3668
+ OR (COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id) >
3669
+ (COALESCE(${after?.rollRetryAt ?? null}::timestamptz, '-infinity'),
3670
+ ${after?.materializedEndsAt ?? null}::timestamptz, ${after?.eventId ?? null}::uuid)
1765
3671
  )
1766
- ORDER BY materialized_ends_at, id
3672
+ ORDER BY COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id
1767
3673
  LIMIT ${limit + 1}`;
1768
3674
  const due = candidates.slice(0, limit);
1769
3675
  let extended = 0;
1770
3676
  let conflicts = 0;
1771
3677
  let refused = 0;
1772
3678
  const refusedEventIds = [];
3679
+ const conflictEventIds = [];
1773
3680
  for (const candidate of due) {
1774
3681
  for (let attempt = 1; attempt <= 2; attempt++) {
1775
3682
  try {
1776
- const changed = await inTransaction(sql, async (tx) => {
1777
- const [row] = await tx `
1778
- SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1779
- summary, description, location, status, transparency, organizer, attendees,
1780
- reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1781
- materialized_ends_at, recurrence_cursor, revision, source, created_at,
1782
- updated_at
1783
- FROM slotlock.calendar_events
1784
- WHERE id = ${candidate.id} AND tenant_ref = ${params.tenantRef}
1785
- FOR UPDATE`;
1786
- if (!row || row.recurrence_rule === null)
1787
- return false;
1788
- const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, window);
1789
- if (!rolled)
1790
- return false;
1791
- const childMap = await loadCalendarEventChildren(tx, [row.id]);
1792
- const event = toCalendarEvent(row, childMap.get(row.id));
1793
- // From where its last expansion stopped, when that can be trusted; else its start.
1794
- const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
1795
- cursor: row.recurrence_cursor ?? null,
1796
- stored: true,
1797
- });
1798
- await replaceCalendarEventOccurrences(tx, {
1799
- tenantRef: params.tenantRef,
1800
- eventId: row.id,
1801
- resourceId: row.resource_id,
1802
- event,
1803
- occurrences,
1804
- revision: Number(row.revision),
1805
- });
1806
- await tx `
1807
- UPDATE slotlock.calendar_events
1808
- SET materialized_starts_at = ${rolled.start},
1809
- materialized_ends_at = ${rolled.end},
1810
- recurrence_cursor = ${seriesCursorJson(tx, cursor)}
1811
- WHERE id = ${row.id} AND tenant_ref = ${params.tenantRef}`;
1812
- return true;
1813
- });
3683
+ const changed = await inTransaction(sql, (tx) => rollStoredSeries(tx, params.tenantRef, candidate.id, window));
1814
3684
  if (changed)
1815
3685
  extended += 1;
1816
3686
  break;
1817
3687
  }
1818
3688
  catch (error) {
1819
3689
  if (error instanceof CalendarExpansionLimitError) {
1820
- // It takes more than Slotlock expands: a rule that never occurs again, or one with no
1821
- // cursor to resume from whose search from its start has outgrown the budget. Nothing
1822
- // was written; its old coverage stays, and the masters after it still roll.
3690
+ // It takes more than Slotlock expands: a rule that never occurs again, one with no
3691
+ // cursor to resume from whose search from its start has outgrown the budget, or one an
3692
+ // earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
3693
+ // stays, and the masters after it still roll.
1823
3694
  refused += 1;
1824
3695
  refusedEventIds.push(candidate.id);
3696
+ await deferRoll(sql, params.tenantRef, candidate.id);
1825
3697
  break;
1826
3698
  }
1827
3699
  const code = pgCode(error);
@@ -1834,9 +3706,17 @@ export function createSlotlockStore(sql, options = {}) {
1834
3706
  // The savepoint rolled every delete/insert back, so the previous materialized
1835
3707
  // coverage remains truthful and callers will fail closed beyond it.
1836
3708
  conflicts += 1;
3709
+ conflictEventIds.push(candidate.id);
3710
+ await deferRoll(sql, params.tenantRef, candidate.id);
1837
3711
  break;
1838
3712
  }
1839
- throw error;
3713
+ // Anything else this one series meets (a row no Slotlock would write, a constraint it
3714
+ // trips) was rolled back with its savepoint too: it is put back like one over the
3715
+ // budget, so no single row stops the tenant's roll.
3716
+ refused += 1;
3717
+ refusedEventIds.push(candidate.id);
3718
+ await deferRoll(sql, params.tenantRef, candidate.id);
3719
+ break;
1840
3720
  }
1841
3721
  }
1842
3722
  }
@@ -1846,14 +3726,24 @@ export function createSlotlockStore(sql, options = {}) {
1846
3726
  examined: due.length,
1847
3727
  extended,
1848
3728
  conflicts,
3729
+ conflictEventIds,
1849
3730
  refused,
1850
3731
  refusedEventIds,
1851
3732
  hasMore,
1852
- next: hasMore && last ? { materializedEndsAt: last.materialized_ends_at, eventId: last.id } : null,
3733
+ next: hasMore && last
3734
+ ? {
3735
+ rollRetryAt: last.roll_retry_at,
3736
+ materializedEndsAt: last.materialized_ends_at,
3737
+ eventId: last.id,
3738
+ }
3739
+ : null,
1853
3740
  };
1854
3741
  },
1855
3742
  async cancelCalendarEvent(params) {
1856
3743
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
3744
+ if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
3745
+ return { ok: false, code: 'invalid_identity' };
3746
+ }
1857
3747
  if (!isValidIdentity(params.tenantRef) ||
1858
3748
  !isValidEventOwnerRef(ownerRef) ||
1859
3749
  !isValidIdentity(params.externalRef) ||
@@ -1918,12 +3808,16 @@ export function createSlotlockStore(sql, options = {}) {
1918
3808
  };
1919
3809
  }
1920
3810
  const events = await tx `
1921
- SELECT id, revision FROM slotlock.calendar_events
3811
+ SELECT id, resource_id, revision FROM slotlock.calendar_events
1922
3812
  WHERE tenant_ref = ${params.tenantRef}
1923
3813
  AND owner_ref = ${ownerRef}
1924
3814
  AND external_ref = ${params.externalRef}
1925
3815
  FOR UPDATE`;
1926
3816
  const found = events[0];
3817
+ if (params.expectedResourceId !== undefined &&
3818
+ found?.resource_id !== params.expectedResourceId) {
3819
+ return { ok: false, code: 'event_not_found' };
3820
+ }
1927
3821
  const currentRevision = found ? Number(found.revision) : 0;
1928
3822
  if (!found && ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
1929
3823
  return { ok: false, code: 'event_not_found' };
@@ -1931,41 +3825,266 @@ export function createSlotlockStore(sql, options = {}) {
1931
3825
  if (params.expectedRevision !== currentRevision) {
1932
3826
  return { ok: false, code: 'revision_conflict', currentRevision };
1933
3827
  }
1934
- const eventId = found?.id ?? randomUUID();
1935
- const revision = currentRevision + 1;
1936
- if (found) {
3828
+ return finishCalendarCancel(tx, {
3829
+ tenantRef: params.tenantRef,
3830
+ ownerRef,
3831
+ externalRef: params.externalRef,
3832
+ eventId: found?.id ?? null,
3833
+ revision: currentRevision + 1,
3834
+ commandOwnerRef: ownerRef,
3835
+ idempotencyKey: params.idempotencyKey,
3836
+ payloadHash,
3837
+ });
3838
+ });
3839
+ },
3840
+ async cancelCalendarEventById(params) {
3841
+ if (!isValidIdentity(params.tenantRef) ||
3842
+ !isValidResourceId(params.eventId) ||
3843
+ !isValidIdentity(params.idempotencyKey) ||
3844
+ (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId))) {
3845
+ return { ok: false, code: 'invalid_identity' };
3846
+ }
3847
+ if (!Number.isSafeInteger(params.expectedRevision) || params.expectedRevision < 0) {
3848
+ return { ok: false, code: 'invalid_revision' };
3849
+ }
3850
+ const commandOwnerRef = SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
3851
+ // Its own operation in the hash: an owner-scoped cancel under the same key never replays it.
3852
+ const payloadHash = calendarCommandHash({
3853
+ operation: 'cancel-by-id',
3854
+ eventId: params.eventId,
3855
+ idempotencyKey: params.idempotencyKey,
3856
+ expectedRevision: params.expectedRevision,
3857
+ expectedResourceId: params.expectedResourceId ?? null,
3858
+ });
3859
+ return inTransaction(sql, async (tx) => {
3860
+ await lockCalendarIdempotency(tx, params.tenantRef, commandOwnerRef, params.idempotencyKey);
3861
+ const commands = await tx `
3862
+ SELECT operation, payload_hash, event_id, external_ref,
3863
+ result_revision, occurrence_count
3864
+ FROM slotlock.calendar_event_commands
3865
+ WHERE tenant_ref = ${params.tenantRef}
3866
+ AND owner_ref = ${commandOwnerRef}
3867
+ AND idempotency_key = ${params.idempotencyKey}
3868
+ FOR UPDATE`;
3869
+ const priorCommand = commands[0];
3870
+ if (priorCommand) {
3871
+ if (priorCommand.operation !== 'cancel' || priorCommand.payload_hash !== payloadHash) {
3872
+ return { ok: false, code: 'idempotency_conflict' };
3873
+ }
3874
+ return {
3875
+ ok: true,
3876
+ eventId: priorCommand.event_id,
3877
+ externalRef: priorCommand.external_ref,
3878
+ revision: Number(priorCommand.result_revision),
3879
+ idempotent: true,
3880
+ };
3881
+ }
3882
+ // An event's owner and reference never change, so they are read before its identity lock,
3883
+ // which is taken before its row lock as every event write takes them.
3884
+ const [identity] = await tx `
3885
+ SELECT owner_ref, external_ref FROM slotlock.calendar_events
3886
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
3887
+ if (!identity)
3888
+ return { ok: false, code: 'event_not_found' };
3889
+ await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
3890
+ const [found] = await tx `
3891
+ SELECT id, resource_id, revision FROM slotlock.calendar_events
3892
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
3893
+ FOR UPDATE`;
3894
+ // Cancelled while this waited on its lock.
3895
+ if (!found)
3896
+ return { ok: false, code: 'event_not_found' };
3897
+ if (params.expectedResourceId !== undefined &&
3898
+ found.resource_id !== params.expectedResourceId) {
3899
+ return { ok: false, code: 'event_not_found' };
3900
+ }
3901
+ const currentRevision = Number(found.revision);
3902
+ if (params.expectedRevision !== currentRevision) {
3903
+ return { ok: false, code: 'revision_conflict', currentRevision };
3904
+ }
3905
+ return finishCalendarCancel(tx, {
3906
+ tenantRef: params.tenantRef,
3907
+ ownerRef: identity.owner_ref,
3908
+ externalRef: identity.external_ref,
3909
+ eventId: found.id,
3910
+ revision: currentRevision + 1,
3911
+ commandOwnerRef,
3912
+ idempotencyKey: params.idempotencyKey,
3913
+ payloadHash,
3914
+ });
3915
+ });
3916
+ },
3917
+ async followZoneRules(params) {
3918
+ if (!isValidIdentity(params.tenantRef)) {
3919
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3920
+ }
3921
+ const limit = params.limit ?? 100;
3922
+ if (!(Number.isSafeInteger(limit) && limit >= 1 && limit <= 1_000)) {
3923
+ throw new RangeError('Slotlock zone-rule batch must be 1-1,000 events');
3924
+ }
3925
+ const shifted = await sql `
3926
+ SELECT event.id, event.resource_id, event.transparency, event.revision, event.starts_at, event.ends_at,
3927
+ slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
3928
+ slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
3929
+ COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
3930
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer
3931
+ FROM slotlock.calendar_events event
3932
+ WHERE event.tenant_ref = ${params.tenantRef}
3933
+ AND event.recurrence_rule IS NULL
3934
+ AND event.starts_at > now()
3935
+ AND event.zone_shift_reported_at IS NULL
3936
+ AND slotlock.zoned_instant(event.wall_starts_at, event.timezone) IS NOT NULL
3937
+ AND slotlock.zoned_instant(event.wall_ends_at, event.timezone) IS NOT NULL
3938
+ AND (slotlock.zoned_instant(event.wall_starts_at, event.timezone) <> event.starts_at
3939
+ OR slotlock.zoned_instant(event.wall_ends_at, event.timezone) <> event.ends_at)
3940
+ ORDER BY event.starts_at, event.id
3941
+ LIMIT ${limit}
3942
+ FOR UPDATE OF event SKIP LOCKED`;
3943
+ let moved = 0;
3944
+ let conflicts = 0;
3945
+ const report = async (event, type) => {
3946
+ await sql `UPDATE slotlock.calendar_events SET zone_shift_reported_at = now() WHERE id = ${event.id}`;
3947
+ await sql `
3948
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
3949
+ VALUES (${params.tenantRef}, ${event.resource_id}, ${type}, ${event.id},
3950
+ ${sql.json({
3951
+ id: event.id,
3952
+ resource_id: event.resource_id,
3953
+ start: event.starts_at.toISOString(),
3954
+ end: event.ends_at.toISOString(),
3955
+ zone_start: event.moved_start.toISOString(),
3956
+ zone_end: event.moved_end.toISOString(),
3957
+ })}, 'slotlock:tzdata')`;
3958
+ };
3959
+ for (const event of shifted) {
3960
+ if (!params.move) {
3961
+ await report(event, 'event.timezone_shifted');
3962
+ continue;
3963
+ }
3964
+ try {
3965
+ await inTransaction(sql, async (tx) => {
3966
+ const revision = Number(event.revision) + 1;
3967
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
3968
+ await tx `
3969
+ UPDATE slotlock.calendar_events
3970
+ SET starts_at = ${event.moved_start}, ends_at = ${event.moved_end},
3971
+ materialized_starts_at = ${event.moved_start}, materialized_ends_at = ${event.moved_end},
3972
+ revision = ${revision}, updated_at = now()
3973
+ WHERE id = ${event.id}`;
3974
+ await replaceCalendarEventOccurrences(tx, {
3975
+ tenantRef: params.tenantRef,
3976
+ eventId: event.id,
3977
+ resourceId: event.resource_id,
3978
+ event: { transparency: event.transparency },
3979
+ occurrences: [
3980
+ { recurrenceId: event.moved_start.toISOString(), start: event.moved_start, end: event.moved_end },
3981
+ ],
3982
+ revision,
3983
+ bufferAfterMs: Number(event.buffer),
3984
+ });
3985
+ });
3986
+ moved++;
3987
+ }
3988
+ catch (error) {
3989
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
3990
+ throw error;
3991
+ // It stays where it was; whoever owns it decides.
3992
+ await report(event, 'event.timezone_conflict');
3993
+ conflicts++;
3994
+ }
3995
+ }
3996
+ return { shifted: shifted.length, moved, conflicts };
3997
+ },
3998
+ async pruneBookingRetention(params) {
3999
+ if (!isValidIdentity(params.tenantRef)) {
4000
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4001
+ }
4002
+ const limit = params.limit ?? 100;
4003
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
4004
+ throw Object.assign(new Error('Slotlock retention batch limit must be 1-1000'), {
4005
+ code: 'invalid_limit',
4006
+ });
4007
+ }
4008
+ // Never under the default: a shorter retention trims the feed, not how long a retried write
4009
+ // is answered as the first was.
4010
+ const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4011
+ if (!Number.isSafeInteger(keepDays) ||
4012
+ keepDays < 1 ||
4013
+ keepDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
4014
+ throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
4015
+ code: 'invalid_retention_window',
4016
+ });
4017
+ }
4018
+ return inTransaction(sql, async (tx) => {
4019
+ // A 0.2 server still serving after the migration wrote its confirmed holds (and
4020
+ // createReservation bookings) on the tenant's resources without the tenant or a reference,
4021
+ // so no cancel or move reaches them; the migration gives those it finds the tenant and
4022
+ // their own id, and this gives later ones the same. Each takes its reference's identity lock
4023
+ // first, as every booking write does; a tombstone of its id retired nothing and goes.
4024
+ const legacy = await tx `
4025
+ SELECT booking.id
4026
+ FROM slotlock.reservations booking
4027
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
4028
+ WHERE resource.tenant_ref = ${params.tenantRef}
4029
+ AND booking.tenant_ref IS NULL AND booking.status = 'confirmed'
4030
+ AND booking.calendar_event_id IS NULL
4031
+ AND NOT EXISTS (
4032
+ SELECT 1 FROM slotlock.reservations taken
4033
+ WHERE taken.tenant_ref = ${params.tenantRef}
4034
+ AND taken.external_ref = booking.id::text
4035
+ AND taken.calendar_event_id IS NULL
4036
+ )
4037
+ ORDER BY booking.id
4038
+ LIMIT ${limit}`;
4039
+ for (const { id } of legacy) {
4040
+ await lockExternalIdentity(tx, params.tenantRef, id);
4041
+ const [taken] = await tx `
4042
+ SELECT id FROM slotlock.reservations
4043
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}
4044
+ AND calendar_event_id IS NULL`;
4045
+ if (taken)
4046
+ continue;
1937
4047
  await tx `
1938
- DELETE FROM slotlock.reservations
1939
- WHERE id IN (
1940
- SELECT reservation_id FROM slotlock.calendar_event_occurrences
1941
- WHERE event_id = ${eventId} AND reservation_id IS NOT NULL
1942
- )`;
4048
+ DELETE FROM slotlock.reservation_tombstones
4049
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}`;
1943
4050
  await tx `
1944
- DELETE FROM slotlock.calendar_events
1945
- WHERE id = ${eventId}
1946
- AND tenant_ref = ${params.tenantRef}
1947
- AND owner_ref = ${ownerRef}`;
4051
+ UPDATE slotlock.reservations
4052
+ SET tenant_ref = ${params.tenantRef}, external_ref = id::text,
4053
+ occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
4054
+ WHERE id = ${id} AND tenant_ref IS NULL AND status = 'confirmed'`;
1948
4055
  }
1949
- await tx `
1950
- INSERT INTO slotlock.calendar_event_tombstones (
1951
- tenant_ref, owner_ref, external_ref, event_id, revision
1952
- ) VALUES (
1953
- ${params.tenantRef}, ${ownerRef}, ${params.externalRef}, ${eventId}, ${revision}
1954
- )`;
1955
- await tx `
1956
- INSERT INTO slotlock.calendar_event_commands (
1957
- tenant_ref, owner_ref, idempotency_key, operation, payload_hash, event_id, external_ref,
1958
- result_revision, occurrence_count
1959
- ) VALUES (
1960
- ${params.tenantRef}, ${ownerRef}, ${params.idempotencyKey}, 'cancel', ${payloadHash},
1961
- ${eventId}, ${params.externalRef}, ${revision}, 0
1962
- )`;
4056
+ const expiredHoldsDeleted = await deleteExpiredHolds(tx, params.tenantRef, limit);
4057
+ const holdCommandsDeleted = await forgetExpiredHoldAnswers(tx, params.tenantRef, limit);
4058
+ // Only references retired with no booking: a cancelled booking's tombstone is kept.
4059
+ const [tombstones] = await tx `
4060
+ WITH candidates AS MATERIALIZED (
4061
+ SELECT tombstone.external_ref
4062
+ FROM slotlock.reservation_tombstones tombstone
4063
+ WHERE tombstone.tenant_ref = ${params.tenantRef}
4064
+ AND tombstone.stray
4065
+ AND tombstone.cancelled_at <
4066
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
4067
+ ORDER BY tombstone.cancelled_at, tombstone.external_ref
4068
+ LIMIT ${limit}
4069
+ FOR UPDATE OF tombstone SKIP LOCKED
4070
+ ), deleted AS (
4071
+ DELETE FROM slotlock.reservation_tombstones tombstone
4072
+ USING candidates candidate
4073
+ WHERE tombstone.tenant_ref = ${params.tenantRef}
4074
+ AND tombstone.external_ref = candidate.external_ref
4075
+ AND tombstone.stray
4076
+ RETURNING 1
4077
+ )
4078
+ SELECT count(*)::int AS deleted FROM deleted`;
4079
+ const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
1963
4080
  return {
1964
- ok: true,
1965
- eventId,
1966
- externalRef: params.externalRef,
1967
- revision,
1968
- idempotent: false,
4081
+ expiredHoldsDeleted,
4082
+ holdCommandsDeleted,
4083
+ tombstonesDeleted,
4084
+ hasMore: legacy.length === limit ||
4085
+ expiredHoldsDeleted === limit ||
4086
+ holdCommandsDeleted === limit ||
4087
+ tombstonesDeleted === limit,
1969
4088
  };
1970
4089
  });
1971
4090
  },
@@ -1981,10 +4100,12 @@ export function createSlotlockStore(sql, options = {}) {
1981
4100
  code: 'invalid_limit',
1982
4101
  });
1983
4102
  }
1984
- const retentionDays = params.retentionDays ?? SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1985
- if (!Number.isSafeInteger(retentionDays) ||
1986
- retentionDays < 1 ||
1987
- retentionDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
4103
+ // Never under the default: a shorter retention trims the feed, not how long a retried write
4104
+ // is answered as the first was.
4105
+ const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4106
+ if (!Number.isSafeInteger(keepDays) ||
4107
+ keepDays < 1 ||
4108
+ keepDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
1988
4109
  throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
1989
4110
  code: 'invalid_retention_window',
1990
4111
  });
@@ -1998,7 +4119,7 @@ export function createSlotlockStore(sql, options = {}) {
1998
4119
  FROM slotlock.calendar_event_commands command
1999
4120
  WHERE command.tenant_ref = ${params.tenantRef}
2000
4121
  AND command.created_at <
2001
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4122
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
2002
4123
  ORDER BY command.created_at, command.owner_ref, command.idempotency_key
2003
4124
  LIMIT ${limit}
2004
4125
  FOR UPDATE OF command SKIP LOCKED
@@ -2021,7 +4142,7 @@ export function createSlotlockStore(sql, options = {}) {
2021
4142
  WHERE tombstone.tenant_ref = ${params.tenantRef}
2022
4143
  AND tombstone.owner_ref LIKE 'agent:%'
2023
4144
  AND tombstone.cancelled_at <
2024
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4145
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
2025
4146
  AND NOT EXISTS (
2026
4147
  SELECT 1 FROM slotlock.calendar_event_commands command
2027
4148
  WHERE command.tenant_ref = tombstone.tenant_ref
@@ -2040,7 +4161,7 @@ export function createSlotlockStore(sql, options = {}) {
2040
4161
  AND tombstone.external_ref = candidate.external_ref
2041
4162
  AND tombstone.owner_ref LIKE 'agent:%'
2042
4163
  AND tombstone.cancelled_at <
2043
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4164
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
2044
4165
  AND NOT EXISTS (
2045
4166
  SELECT 1 FROM slotlock.calendar_event_commands command
2046
4167
  WHERE command.tenant_ref = tombstone.tenant_ref
@@ -2084,7 +4205,8 @@ export function createSlotlockStore(sql, options = {}) {
2084
4205
  }
2085
4206
  return inTransaction(sql, async (tx) => {
2086
4207
  await lockExternalIdentity(tx, params.tenantRef, `calendar-coverage:${params.resourceId}:${params.source}`);
2087
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
4208
+ // Coverage of an archived resource may still be recorded: it books nothing.
4209
+ if ((await tenantResourceState(tx, params.resourceId, params.tenantRef)) === 'missing') {
2088
4210
  return { ok: false, code: 'resource_tenant_mismatch' };
2089
4211
  }
2090
4212
  const rows = await tx `
@@ -2108,10 +4230,11 @@ export function createSlotlockStore(sql, options = {}) {
2108
4230
  if (params.revision !== currentRevision + 1) {
2109
4231
  return { ok: false, code: 'revision_conflict' };
2110
4232
  }
4233
+ // A fresh read ends a staleness: the next one is reported again.
2111
4234
  await tx `
2112
4235
  UPDATE slotlock.calendar_coverage
2113
4236
  SET starts_at = ${window.start}, ends_at = ${window.end},
2114
- revision = ${params.revision}, observed_at = now()
4237
+ revision = ${params.revision}, observed_at = now(), stale_reported_at = NULL
2115
4238
  WHERE tenant_ref = ${params.tenantRef}
2116
4239
  AND resource_id = ${params.resourceId}
2117
4240
  AND source = ${params.source}`;
@@ -2119,6 +4242,9 @@ export function createSlotlockStore(sql, options = {}) {
2119
4242
  else {
2120
4243
  if (params.revision !== 1)
2121
4244
  return { ok: false, code: 'revision_conflict' };
4245
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'coverage');
4246
+ if (limit !== null)
4247
+ return { ok: false, code: 'coverage_limit_reached', limit };
2122
4248
  await tx `
2123
4249
  INSERT INTO slotlock.calendar_coverage (
2124
4250
  tenant_ref, resource_id, source, starts_at, ends_at, revision
@@ -2130,6 +4256,382 @@ export function createSlotlockStore(sql, options = {}) {
2130
4256
  return { ok: true, revision: params.revision, idempotent: false };
2131
4257
  });
2132
4258
  },
4259
+ async recordCoverageFailure(params) {
4260
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
4261
+ return { ok: false, code: 'invalid_identity' };
4262
+ }
4263
+ const rows = await sql `
4264
+ UPDATE slotlock.calendar_coverage
4265
+ SET last_error = left(${String(params.error).replace(/[\u0000-\u001f\u007f]/g, ' ')}, 500),
4266
+ last_error_at = now()
4267
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4268
+ AND source = ${params.source}
4269
+ RETURNING source`;
4270
+ return rows.length > 0 ? { ok: true } : { ok: false, code: 'coverage_not_found' };
4271
+ },
4272
+ async deleteCoverage(params) {
4273
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
4274
+ return false;
4275
+ }
4276
+ const rows = await sql `
4277
+ DELETE FROM slotlock.calendar_coverage
4278
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4279
+ AND source = ${params.source}
4280
+ RETURNING source`;
4281
+ return rows.length > 0;
4282
+ },
4283
+ async listCoverage(params) {
4284
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.resourceId)) {
4285
+ throw invalidIdentityError('Slotlock coverage identity is invalid');
4286
+ }
4287
+ const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4288
+ const rows = await sql `
4289
+ SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4290
+ observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
4291
+ FROM slotlock.calendar_coverage
4292
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4293
+ ORDER BY source`;
4294
+ return rows.map((row) => ({
4295
+ source: row.source,
4296
+ start: row.starts_at,
4297
+ end: row.ends_at,
4298
+ revision: Number(row.revision),
4299
+ observedAt: row.observed_at,
4300
+ lastError: row.last_error,
4301
+ lastErrorAt: row.last_error_at,
4302
+ stale: row.stale,
4303
+ }));
4304
+ },
4305
+ async reportStaleCoverage(params) {
4306
+ if (!isValidIdentity(params.tenantRef)) {
4307
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4308
+ }
4309
+ const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4310
+ const rows = await sql `
4311
+ WITH stale AS (
4312
+ UPDATE slotlock.calendar_coverage coverage SET stale_reported_at = now()
4313
+ WHERE (coverage.tenant_ref, coverage.resource_id, coverage.source) IN (
4314
+ SELECT tenant_ref, resource_id, source FROM slotlock.calendar_coverage
4315
+ WHERE tenant_ref = ${params.tenantRef} AND stale_reported_at IS NULL
4316
+ AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4317
+ ORDER BY observed_at LIMIT ${params.limit ?? 100}
4318
+ FOR UPDATE SKIP LOCKED)
4319
+ RETURNING coverage.resource_id, coverage.source, coverage.observed_at, coverage.last_error
4320
+ ), reported AS (
4321
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4322
+ SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source,
4323
+ jsonb_strip_nulls(jsonb_build_object(
4324
+ 'resource_id', stale.resource_id, 'source', stale.source,
4325
+ 'observed_at', stale.observed_at, 'last_error', stale.last_error)),
4326
+ 'slotlock:maintenance'
4327
+ FROM stale
4328
+ RETURNING 1
4329
+ )
4330
+ SELECT count(*)::int AS count FROM reported`;
4331
+ return rows[0]?.count ?? 0;
4332
+ },
4333
+ async checkInReservation(params) {
4334
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
4335
+ return { ok: false, code: 'invalid_identity' };
4336
+ }
4337
+ return inTransaction(sql, async (tx) => {
4338
+ const [row] = await tx `
4339
+ WITH booking AS (
4340
+ SELECT id, checked_in_at, ends_at <= statement_timestamp() AS ended,
4341
+ starts_at - make_interval(mins => ${SLOTLOCK_CHECK_IN_OPENS_MINUTES}) > statement_timestamp() AS early
4342
+ FROM slotlock.reservations
4343
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4344
+ AND calendar_event_id IS NULL AND status = 'confirmed'
4345
+ FOR UPDATE
4346
+ )
4347
+ UPDATE slotlock.reservations reservation
4348
+ SET checked_in_at = COALESCE(reservation.checked_in_at, statement_timestamp())
4349
+ FROM booking
4350
+ WHERE reservation.id = booking.id AND NOT booking.ended AND NOT booking.early
4351
+ RETURNING reservation.id, reservation.resource_id, reservation.checked_in_at,
4352
+ false AS ended, booking.checked_in_at IS NULL AS first`;
4353
+ if (!row) {
4354
+ const [booking] = await tx `
4355
+ SELECT ends_at <= statement_timestamp() AS ended FROM slotlock.reservations
4356
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4357
+ AND calendar_event_id IS NULL AND status = 'confirmed'`;
4358
+ if (!booking)
4359
+ return { ok: false, code: 'reservation_not_found' };
4360
+ return { ok: false, code: booking.ended ? 'reservation_ended' : 'check_in_not_open' };
4361
+ }
4362
+ if (row.first) {
4363
+ await tx `
4364
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4365
+ VALUES (${params.tenantRef}, ${row.resource_id}, 'reservation.checked_in', ${params.externalRef},
4366
+ jsonb_build_object('id', ${row.id}::uuid, 'reference', ${params.externalRef}::text,
4367
+ 'resource_id', ${row.resource_id}::uuid,
4368
+ 'checked_in_at', ${row.checked_in_at}::timestamptz),
4369
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
4370
+ }
4371
+ return { ok: true, reference: params.externalRef, checkedInAt: row.checked_in_at };
4372
+ });
4373
+ },
4374
+ async releaseNoShows(params) {
4375
+ if (!isValidIdentity(params.tenantRef)) {
4376
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4377
+ }
4378
+ const limit = params.limit ?? 100;
4379
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
4380
+ throw invalidIdentityError('Slotlock no-show limit must be 1-1,000');
4381
+ }
4382
+ // A suspended tenant's bookings stay as they are.
4383
+ if (refusal)
4384
+ return 0;
4385
+ const tenantRef = params.tenantRef;
4386
+ // The same test twice: to find them, then under each reference's lock, as a cancel takes it.
4387
+ const due = (tx, externalRef) => tx `
4388
+ SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
4389
+ (resource.booking_rules->>'checkInMinutes')::int AS minutes
4390
+ FROM slotlock.reservations reservation
4391
+ JOIN slotlock.resources resource ON resource.id = reservation.resource_id
4392
+ WHERE reservation.tenant_ref = ${tenantRef} AND resource.tenant_ref = ${tenantRef}
4393
+ AND (${externalRef}::text IS NULL OR reservation.external_ref = ${externalRef})
4394
+ AND reservation.status = 'confirmed' AND reservation.calendar_event_id IS NULL
4395
+ AND reservation.checked_in_at IS NULL
4396
+ AND resource.check_in_since IS NOT NULL AND resource.booking_rules ? 'checkInMinutes'
4397
+ AND reservation.created_at >= resource.check_in_since
4398
+ -- A booking made after its start (a walk-in) has its window from when it was made.
4399
+ AND GREATEST(reservation.starts_at, reservation.created_at)
4400
+ + make_interval(mins => (resource.booking_rules->>'checkInMinutes')::int)
4401
+ <= statement_timestamp()
4402
+ AND reservation.ends_at > statement_timestamp()
4403
+ ORDER BY reservation.starts_at, reservation.id
4404
+ LIMIT ${externalRef === null ? limit : 1}
4405
+ ${externalRef === null ? tx `` : tx `FOR UPDATE OF reservation`}`;
4406
+ let released = 0;
4407
+ for (const candidate of await due(sql, null)) {
4408
+ const done = await inTransaction(sql, async (tx) => {
4409
+ await lockExternalIdentity(tx, tenantRef, candidate.external_ref);
4410
+ const [row] = await due(tx, candidate.external_ref);
4411
+ if (!row || row.id !== candidate.id)
4412
+ return false;
4413
+ const revision = Number(row.revision) + 1;
4414
+ await tx `
4415
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
4416
+ VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
4417
+ await tx `DELETE FROM slotlock.reservations WHERE id = ${row.id}`;
4418
+ await tx `
4419
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4420
+ VALUES (${tenantRef}, ${row.resource_id}, 'reservation.no_show', ${row.external_ref},
4421
+ jsonb_build_object('id', ${row.id}::uuid, 'reference', ${row.external_ref}::text,
4422
+ 'resource_id', ${row.resource_id}::uuid, 'revision', ${revision}::bigint,
4423
+ 'check_in_minutes', ${row.minutes}::int),
4424
+ 'slotlock:maintenance')`;
4425
+ return true;
4426
+ });
4427
+ if (done)
4428
+ released += 1;
4429
+ }
4430
+ return released;
4431
+ },
4432
+ async joinWaitlist(params) {
4433
+ const { tenantRef, holderRef } = params;
4434
+ if (!isValidIdentity(tenantRef) || !isValidIdentity(holderRef))
4435
+ return { ok: false, code: 'invalid_identity' };
4436
+ if ((params.resourceId === undefined) === (params.pool === undefined)) {
4437
+ return { ok: false, code: 'invalid_identity' };
4438
+ }
4439
+ if (params.resourceId !== undefined && !isValidResourceId(params.resourceId)) {
4440
+ return { ok: false, code: 'resource_not_found' };
4441
+ }
4442
+ if (params.pool !== undefined && !isPoolName(params.pool))
4443
+ return { ok: false, code: 'invalid_pool' };
4444
+ const window = { start: params.start, end: params.end };
4445
+ if (!isFiniteWindow(window) ||
4446
+ window.end.getTime() - window.start.getTime() > MAX_WAITLIST_WINDOW_MS) {
4447
+ return { ok: false, code: 'invalid_window' };
4448
+ }
4449
+ const offerTtlMs = params.offerTtlMs ?? DEFAULT_OFFER_TTL_MS;
4450
+ if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
4451
+ return { ok: false, code: 'invalid_ttl' };
4452
+ }
4453
+ return inTransaction(sql, async (tx) => {
4454
+ if (params.resourceId !== undefined) {
4455
+ const [resource] = await tx `
4456
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
4457
+ WHERE id = ${params.resourceId} AND tenant_ref = ${tenantRef}`;
4458
+ if (!resource)
4459
+ return { ok: false, code: 'resource_not_found' };
4460
+ if (resource.archived)
4461
+ return { ok: false, code: 'resource_archived' };
4462
+ }
4463
+ else {
4464
+ const [member] = await tx `
4465
+ SELECT 1 FROM slotlock.resources
4466
+ WHERE tenant_ref = ${tenantRef} AND pool_ref = ${params.pool ?? null} AND archived_at IS NULL
4467
+ LIMIT 1`;
4468
+ if (!member)
4469
+ return { ok: false, code: 'pool_not_found' };
4470
+ }
4471
+ // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at the cap.
4472
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
4473
+ const [counts] = await tx `
4474
+ SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
4475
+ FROM slotlock.waitlist_entries WHERE tenant_ref = ${tenantRef} AND status = 'waiting'`;
4476
+ if ((counts?.holder ?? 0) >= SLOTLOCK_MAX_WAITLIST_PER_HOLDER ||
4477
+ (counts?.tenant ?? 0) >= SLOTLOCK_MAX_WAITLIST_PER_TENANT) {
4478
+ return { ok: false, code: 'waitlist_limit_reached' };
4479
+ }
4480
+ const [row] = await tx `
4481
+ INSERT INTO slotlock.waitlist_entries
4482
+ (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms)
4483
+ VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
4484
+ ${window.end}, ${holderRef}, ${offerTtlMs})
4485
+ RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
4486
+ return { ok: true, entry: toWaitlistEntry(row) };
4487
+ });
4488
+ },
4489
+ async listWaitlist(params) {
4490
+ if (!isValidIdentity(params.tenantRef))
4491
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4492
+ if (params.resourceId !== undefined && !isValidResourceId(params.resourceId))
4493
+ return [];
4494
+ const limit = params.limit ?? 100;
4495
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
4496
+ throw invalidIdentityError('Slotlock waitlist limit must be 1-1,000');
4497
+ }
4498
+ const rows = await sql `
4499
+ SELECT ${sql.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
4500
+ WHERE tenant_ref = ${params.tenantRef}
4501
+ AND (${params.resourceId ?? null}::uuid IS NULL OR resource_id = ${params.resourceId ?? null}::uuid)
4502
+ AND (${params.pool ?? null}::text IS NULL OR pool_ref = ${params.pool ?? null})
4503
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
4504
+ ORDER BY created_at, id
4505
+ LIMIT ${limit}`;
4506
+ return rows.map(toWaitlistEntry);
4507
+ },
4508
+ async leaveWaitlist(params) {
4509
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
4510
+ return false;
4511
+ return inTransaction(sql, async (tx) => {
4512
+ // Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
4513
+ const [left] = await tx `
4514
+ DELETE FROM slotlock.waitlist_entries
4515
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
4516
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
4517
+ RETURNING hold_id`;
4518
+ if (!left)
4519
+ return false;
4520
+ // An offer not yet taken goes too, freeing the time for the next who waits; a confirmed
4521
+ // one is a booking now and stays.
4522
+ if (left.hold_id) {
4523
+ await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
4524
+ }
4525
+ return true;
4526
+ });
4527
+ },
4528
+ async offerWaitlist(params) {
4529
+ if (!isValidIdentity(params.tenantRef)) {
4530
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4531
+ }
4532
+ const limit = params.limit ?? 50;
4533
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
4534
+ throw invalidIdentityError('Slotlock waitlist offer limit must be 1-100');
4535
+ }
4536
+ // A suspended tenant gets no new holds.
4537
+ if (refusal)
4538
+ return { offered: 0, expired: 0, next: null };
4539
+ const tenantRef = params.tenantRef;
4540
+ const after = params.after ?? null;
4541
+ return inTransaction(sql, async (tx) => {
4542
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
4543
+ let expired = 0;
4544
+ if (!after) {
4545
+ const [gone] = await tx `
4546
+ WITH gone AS (
4547
+ UPDATE slotlock.waitlist_entries SET status = 'expired'
4548
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting' AND starts_at <= statement_timestamp()
4549
+ RETURNING id, resource_id, pool_ref, starts_at, ends_at, holder_ref
4550
+ ), reported AS (
4551
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4552
+ SELECT ${tenantRef}, gone.resource_id, 'waitlist.expired', gone.id::text,
4553
+ jsonb_strip_nulls(jsonb_build_object(
4554
+ 'id', gone.id, 'resource_id', gone.resource_id, 'pool', gone.pool_ref,
4555
+ 'start', gone.starts_at, 'end', gone.ends_at, 'holder_ref', gone.holder_ref)),
4556
+ 'slotlock:maintenance'
4557
+ FROM gone
4558
+ RETURNING 1
4559
+ )
4560
+ SELECT count(*)::int AS count FROM reported`;
4561
+ expired = gone?.count ?? 0;
4562
+ await tx `
4563
+ DELETE FROM slotlock.waitlist_entries
4564
+ WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
4565
+ AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
4566
+ }
4567
+ // One page in join order; the caller passes `next` back for the rest.
4568
+ const waiting = await tx `
4569
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
4570
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
4571
+ AND (${after?.createdAt ?? null}::timestamptz IS NULL
4572
+ OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
4573
+ ORDER BY created_at, id
4574
+ LIMIT ${limit}`;
4575
+ // Holds made through a store on this transaction, each in a savepoint of its own.
4576
+ const inner = buildSlotlockStore(tx, options, null);
4577
+ let offered = 0;
4578
+ for (const entry of waiting) {
4579
+ // Locked so a leave waits for this offer, and skipped if it left first.
4580
+ const [still] = await tx `
4581
+ SELECT 1 FROM slotlock.waitlist_entries
4582
+ WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
4583
+ if (!still)
4584
+ continue;
4585
+ // Only resources whose window looks free are tried: a hold refused keeps its locks
4586
+ // until the transaction ends, so trying every member of a full pool would pile them up.
4587
+ const candidates = await tx `
4588
+ SELECT resource.id FROM slotlock.resources resource
4589
+ WHERE resource.tenant_ref = ${tenantRef} AND resource.archived_at IS NULL
4590
+ AND (resource.id = ${entry.resource_id}::uuid
4591
+ OR (${entry.resource_id}::uuid IS NULL AND resource.pool_ref = ${entry.pool_ref}))
4592
+ AND NOT EXISTS (
4593
+ SELECT 1 FROM slotlock.reservations taken
4594
+ WHERE taken.resource_id = resource.id
4595
+ AND tstzrange(taken.starts_at, COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
4596
+ && tstzrange(${entry.starts_at}::timestamptz, ${entry.ends_at}::timestamptz, '[)')
4597
+ AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
4598
+ ORDER BY resource.id
4599
+ LIMIT ${OFFER_CANDIDATES}`;
4600
+ for (const { id: resourceId } of candidates) {
4601
+ const hold = await inner.acquireHold({
4602
+ resourceId,
4603
+ start: entry.starts_at,
4604
+ end: entry.ends_at,
4605
+ ttlMs: Number(entry.offer_ttl_ms),
4606
+ tenantRef,
4607
+ holderRef: entry.holder_ref,
4608
+ source: 'slotlock:waitlist',
4609
+ });
4610
+ if (!hold.ok)
4611
+ continue;
4612
+ await tx `
4613
+ UPDATE slotlock.waitlist_entries
4614
+ SET status = 'offered', hold_id = ${hold.hold.id}, offered_resource_id = ${resourceId},
4615
+ offered_at = statement_timestamp()
4616
+ WHERE id = ${entry.id}`;
4617
+ await tx `
4618
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4619
+ VALUES (${tenantRef}, ${resourceId}, 'waitlist.offered', ${entry.id},
4620
+ jsonb_strip_nulls(jsonb_build_object(
4621
+ 'id', ${entry.id}::uuid, 'hold_id', ${hold.hold.id}::uuid,
4622
+ 'resource_id', ${resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
4623
+ 'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
4624
+ 'expires_at', ${hold.hold.expiresAt}::timestamptz,
4625
+ 'holder_ref', ${entry.holder_ref}::text)),
4626
+ 'slotlock:maintenance')`;
4627
+ offered += 1;
4628
+ break;
4629
+ }
4630
+ }
4631
+ const last = waiting.length === limit ? waiting[waiting.length - 1] : undefined;
4632
+ return { offered, expired, next: last ? { createdAt: last.created_at, id: last.id } : null };
4633
+ });
4634
+ },
2133
4635
  async getFreeBusy(params) {
2134
4636
  if (!isValidIdentity(params.tenantRef)) {
2135
4637
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -2158,7 +4660,8 @@ export function createSlotlockStore(sql, options = {}) {
2158
4660
  });
2159
4661
  }
2160
4662
  const owner = await sql `
2161
- SELECT id FROM slotlock.resources
4663
+ SELECT id, COALESCE((booking_rules->>'requireCertain')::boolean, false) AS require_certain
4664
+ FROM slotlock.resources
2162
4665
  WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
2163
4666
  if (!owner[0]) {
2164
4667
  throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
@@ -2172,7 +4675,7 @@ export function createSlotlockStore(sql, options = {}) {
2172
4675
  WHERE reservation.resource_id = ${params.resourceId}
2173
4676
  AND reservation.starts_at < ${window.end}
2174
4677
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
2175
- AND (reservation.status <> 'held' OR reservation.expires_at > now())
4678
+ AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
2176
4679
  ORDER BY reservation.starts_at
2177
4680
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
2178
4681
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
@@ -2215,7 +4718,15 @@ export function createSlotlockStore(sql, options = {}) {
2215
4718
  FROM recurring_sources
2216
4719
  WHERE earliest_possible_at < ${window.end}`;
2217
4720
  const localRecurrenceRequired = Number(localRecurrenceCoverage?.source_count ?? 0) > 0;
2218
- const providerRequiredSources = callerRequiredSources.filter((source) => source !== SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
4721
+ // A resource that requires certainty requires every sync recording coverage of it, as its
4722
+ // writes do (bookingJudge), and judges them by the same day.
4723
+ const requireCertain = owner[0].require_certain;
4724
+ const recordedSources = requireCertain
4725
+ ? (await sql `
4726
+ SELECT source FROM slotlock.calendar_coverage
4727
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}`).map(({ source }) => source)
4728
+ : [];
4729
+ const providerRequiredSources = [...new Set([...callerRequiredSources, ...recordedSources])].filter((source) => source !== SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
2219
4730
  if (!localRecurrenceRequired && providerRequiredSources.length === 0) {
2220
4731
  return {
2221
4732
  busy: mergeIntervals(busyRows.map((row) => ({
@@ -2225,7 +4736,7 @@ export function createSlotlockStore(sql, options = {}) {
2225
4736
  coverage: { state: 'complete', missingSources: [] },
2226
4737
  };
2227
4738
  }
2228
- const ageSeconds = coverageMaxAgeMs / 1000;
4739
+ const ageSeconds = (requireCertain ? Math.min(coverageMaxAgeMs, CERTAIN_COVERAGE_MAX_AGE_MS) : coverageMaxAgeMs) / 1000;
2229
4740
  const coverageRows = providerRequiredSources.length === 0
2230
4741
  ? []
2231
4742
  : await sql `
@@ -2261,17 +4772,161 @@ export function createSlotlockStore(sql, options = {}) {
2261
4772
  };
2262
4773
  },
2263
4774
  async findNextAvailableFor(params) {
2264
- if (!isFiniteWindow(params.searchWindow))
4775
+ const asked = params.searchWindow;
4776
+ if (!isFiniteWindow(asked))
2265
4777
  throw invalidWindowError();
2266
- assertBoundedSearchWindow(params.searchWindow);
4778
+ assertBoundedSearchWindow(asked);
4779
+ const { tenantRef } = params;
4780
+ if (tenantRef !== null && !isValidIdentity(tenantRef)) {
4781
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4782
+ }
4783
+ const askedBufferMs = params.bufferAfterMs ?? 0;
4784
+ if (!Number.isSafeInteger(askedBufferMs) ||
4785
+ askedBufferMs < 0 ||
4786
+ askedBufferMs > MAX_BUFFER_AFTER_MS) {
4787
+ throw Object.assign(new Error('Slotlock bufferAfterMs must be a whole 0-30 days'), {
4788
+ code: 'invalid_buffer',
4789
+ });
4790
+ }
4791
+ const notFound = () => Object.assign(new Error('Slotlock resource not found in this tenant'), {
4792
+ code: 'resource_not_found',
4793
+ });
4794
+ if (!isValidResourceId(params.resourceId))
4795
+ throw notFound();
4796
+ const [row] = await sql `
4797
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
4798
+ archived_at, statement_timestamp() AS now
4799
+ FROM slotlock.resources
4800
+ WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
4801
+ if (!row)
4802
+ throw notFound();
2267
4803
  if (!Number.isFinite(params.durationMs) || params.durationMs <= 0)
2268
4804
  return null;
2269
- // Rules are authored in the resource's zone — expansion must honour it (DST-correct).
2270
- const tzRows = await sql `
2271
- SELECT timezone FROM slotlock.resources WHERE id = ${params.resourceId}`;
2272
- const busy = await this.listBusy(params.resourceId, params.searchWindow);
2273
- const windows = expandRules(params.rules, params.searchWindow, tzRows[0]?.timezone ?? 'UTC');
2274
- return findNextAvailable({ busy, windows, durationMs: params.durationMs });
4805
+ const resource = toResource(row);
4806
+ const rules = params.rules ?? resource.availabilityRules ?? [];
4807
+ // Only what a booking made now may take: the resource's booking rules bound the search.
4808
+ const search = bookingSearch({
4809
+ rules: resource.bookingRules,
4810
+ timezone: resource.timezone,
4811
+ window: asked,
4812
+ now: row.now,
4813
+ bufferAfterMs: askedBufferMs,
4814
+ });
4815
+ if (!search)
4816
+ return null;
4817
+ const { window, bufferAfterMs } = search;
4818
+ // A slot's buffer may run past the window: the time it can reach must be proven free too.
4819
+ const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
4820
+ let busy;
4821
+ if (tenantRef === null) {
4822
+ // No event, coverage or series can belong to a resource with no tenant: only its rows.
4823
+ busy = await this.listBusy(params.resourceId, reach);
4824
+ }
4825
+ else {
4826
+ busy = [];
4827
+ const parts = bufferAfterMs > 0 ? [window, { start: window.end, end: reach.end }] : [window];
4828
+ for (const part of parts) {
4829
+ const freeBusy = await this.getFreeBusy({
4830
+ tenantRef,
4831
+ resourceId: params.resourceId,
4832
+ window: part,
4833
+ ...(params.requiredSources !== undefined
4834
+ ? { requiredSources: params.requiredSources }
4835
+ : {}),
4836
+ ...(params.coverageMaxAgeMs !== undefined
4837
+ ? { coverageMaxAgeMs: params.coverageMaxAgeMs }
4838
+ : {}),
4839
+ });
4840
+ // Absence is never treated as free time: what cannot be proven gives no slot.
4841
+ if (freeBusy.coverage.state !== 'complete')
4842
+ return null;
4843
+ busy.push(...freeBusy.busy);
4844
+ }
4845
+ }
4846
+ // A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
4847
+ return findNextAvailable({
4848
+ busy: busy.map((interval) => ({
4849
+ start: new Date(interval.start.getTime() - bufferAfterMs),
4850
+ end: interval.end,
4851
+ })),
4852
+ windows: expandRules(rules, window, resource.timezone),
4853
+ durationMs: params.durationMs,
4854
+ ...(search.align ? { align: search.align } : {}),
4855
+ });
4856
+ },
4857
+ async suspendTenant(params) {
4858
+ if (!isValidIdentity(params.tenantRef)) {
4859
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4860
+ }
4861
+ const reason = params.reason;
4862
+ if (reason !== undefined &&
4863
+ (typeof reason !== 'string' ||
4864
+ reason.length === 0 ||
4865
+ Array.from(reason).length > 500 ||
4866
+ Array.from(reason).some((character) => {
4867
+ const codePoint = character.codePointAt(0) ?? 0;
4868
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
4869
+ }))) {
4870
+ throw Object.assign(new Error('Slotlock suspension reason must be 1-500 characters without control characters'), { code: 'invalid_suspension_reason' });
4871
+ }
4872
+ try {
4873
+ const [row] = await sql `
4874
+ SELECT slotlock.suspend_tenant(${params.tenantRef}::text, ${reason ?? null}::text) AS suspended`;
4875
+ return { suspended: row?.suspended === true };
4876
+ }
4877
+ catch (error) {
4878
+ throw suspensionRefusal(error);
4879
+ }
4880
+ },
4881
+ async unsuspendTenant(params) {
4882
+ if (!isValidIdentity(params.tenantRef)) {
4883
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4884
+ }
4885
+ try {
4886
+ const [row] = await sql `
4887
+ SELECT slotlock.unsuspend_tenant(${params.tenantRef}::text) AS unsuspended`;
4888
+ return { unsuspended: row?.unsuspended === true };
4889
+ }
4890
+ catch (error) {
4891
+ throw suspensionRefusal(error);
4892
+ }
4893
+ },
4894
+ async isTenantSuspended(params) {
4895
+ if (!isValidIdentity(params.tenantRef)) {
4896
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4897
+ }
4898
+ const [row] = await sql `
4899
+ SELECT slotlock.tenant_suspended(${params.tenantRef}::text) AS suspended`.catch((error) => {
4900
+ throw suspensionCheckRefusal(error);
4901
+ });
4902
+ return row?.suspended === true;
4903
+ },
4904
+ async eraseTenant(params) {
4905
+ if (!isValidIdentity(params.tenantRef)) {
4906
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4907
+ }
4908
+ const erased = await sql `
4909
+ SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`;
4910
+ const rows = {};
4911
+ let total = 0;
4912
+ for (const row of erased) {
4913
+ const count = Number(row.erased_rows);
4914
+ rows[row.erased_table] = count;
4915
+ total += count;
4916
+ }
4917
+ return { rows, total };
2275
4918
  },
2276
4919
  };
4920
+ if (refusal === null)
4921
+ return store;
4922
+ const refuse = async () => {
4923
+ throw refusal();
4924
+ };
4925
+ return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
4926
+ }
4927
+ /** The deployment role alone may suspend: another role is refused EXECUTE on the functions. */
4928
+ function suspensionRefusal(error) {
4929
+ if (pgCode(error) !== '42501')
4930
+ return error;
4931
+ return Object.assign(new Error('Slotlock suspends tenants and lifts suspensions only as the deployment role'), { code: 'tenant_suspension_not_permitted', cause: error });
2277
4932
  }