slotlock 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +330 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
package/store.js CHANGED
@@ -2,7 +2,8 @@
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
4
  import { createHash, randomUUID } from 'node:crypto';
5
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_TENANT_CONTEXT_SETTING, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, } from './ddl.js';
5
+ import ICAL from 'ical.js';
6
+ 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
7
  import { findNextAvailable, mergeIntervals } from './engine.js';
7
8
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
8
9
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
@@ -30,6 +31,13 @@ export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
30
31
  export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
31
32
  const DAY_MS = 24 * 60 * 60 * 1000;
32
33
  const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
34
+ /**
35
+ * Longest window a recurring series is stored (materialized) for: the 367-day search window plus
36
+ * two days. The rolling horizon starts a day before the current UTC day and must reach 367 days
37
+ * past its end, so that a search of the full 367 days from any moment of the day is complete.
38
+ */
39
+ export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
40
+ const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
33
41
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
34
42
  const MAX_RESOURCE_LIST_LIMIT = 1_000;
35
43
  /** Highest per-tenant resource cap `createResource` accepts. */
@@ -57,13 +65,16 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3
57
65
  const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
58
66
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
59
67
  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. */
68
+ /**
69
+ * Stable daily target shared by the event writer, maintenance worker, and readiness proof: from a
70
+ * day before the current UTC day to 367 days past its end (SLOTLOCK_MATERIALIZATION_WINDOW_DAYS).
71
+ */
61
72
  export function calendarEventRollingHorizon(now = new Date()) {
62
73
  if (!Number.isFinite(now.getTime()))
63
74
  throw invalidWindowError();
64
75
  const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
65
76
  const start = new Date(midnight - DAY_MS);
66
- return { start, end: new Date(start.getTime() + SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS) };
77
+ return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
67
78
  }
68
79
  /**
69
80
  * A stored series expanded from the cursor its last expansion left, or `null` when there is none it
@@ -98,15 +109,118 @@ function seriesCursorJson(tx, cursor) {
98
109
  function rolledWindow(stored, horizon) {
99
110
  if (stored.start <= horizon.start && stored.end >= horizon.end)
100
111
  return null;
101
- if (stored.end.getTime() - horizon.start.getTime() > MAX_CALENDAR_WINDOW_MS)
112
+ if (stored.end.getTime() - horizon.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS)
102
113
  return null;
103
114
  return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
104
115
  }
116
+ /**
117
+ * How far past a rule's UNTIL, read as UTC, an occurrence can still end, beyond the series' own
118
+ * length. UNTIL bounds an occurrence's start (RFC 5545 §3.3.10), but a floating or date-only UNTIL
119
+ * 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
120
+ * moves forward; and an occurrence's wall-clock end can pass its exact length by a clock change, a
121
+ * whole day where a zone moved across the date line. Three days covers them together.
122
+ */
123
+ const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
124
+ /** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
125
+ const SERIES_COUNT_SEARCH_WINDOWS = 30;
126
+ /**
127
+ * A time by which every occurrence of a recurring series ends, moved ones included, or `null` when
128
+ * it may go on: its rule has neither UNTIL nor COUNT as ical.js reads it (as the expansion does),
129
+ * or its COUNT occurrences are not all found within SERIES_COUNT_SEARCH_WINDOWS windows from its
130
+ * start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
131
+ * the expansion reads it today, so a later reading of its end may be longer.
132
+ */
133
+ function seriesEndsBy(event) {
134
+ const recurrence = event.recurrence;
135
+ if (!recurrence)
136
+ return null;
137
+ let rule;
138
+ try {
139
+ rule = ICAL.Recur.fromString(recurrence.rrule);
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ let endsBy;
145
+ if (rule.until) {
146
+ // ical.js stops at the first occurrence past UNTIL; a floating or date UNTIL reads as UTC.
147
+ const untilMs = rule.until.toUnixTime() * 1000;
148
+ endsBy = untilMs + (event.end.getTime() - event.start.getTime()) + SERIES_UNTIL_SLACK_MS;
149
+ }
150
+ else if (rule.count) {
151
+ const last = lastCountedOccurrenceEnd(event, rule.count);
152
+ if (last === null)
153
+ return null;
154
+ endsBy = last + DAY_MS;
155
+ }
156
+ else {
157
+ return null;
158
+ }
159
+ for (const exception of recurrence.exceptions ?? []) {
160
+ if (!exception.cancelled && exception.end && exception.end.getTime() > endsBy) {
161
+ endsBy = exception.end.getTime();
162
+ }
163
+ }
164
+ const ends = new Date(endsBy);
165
+ return Number.isFinite(ends.getTime()) ? ends : null;
166
+ }
167
+ /**
168
+ * The latest end among a COUNT series' occurrences, expanded window by window from its start with
169
+ * exceptions left out (so a cancelled or moved one is still found in its own place), or `null` when
170
+ * fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
171
+ * there are no more. An occurrence is counted in the window its start falls in.
172
+ */
173
+ function lastCountedOccurrenceEnd(event, count) {
174
+ const rrule = event.recurrence?.rrule;
175
+ if (rrule === undefined)
176
+ return null;
177
+ const series = { ...event, recurrence: { rrule } };
178
+ let cursor = null;
179
+ let found = 0;
180
+ let lastEnd = Number.NEGATIVE_INFINITY;
181
+ let from = event.start.getTime();
182
+ try {
183
+ for (let window = 0; window < SERIES_COUNT_SEARCH_WINDOWS; window++) {
184
+ const to = from + MAX_MATERIALIZATION_WINDOW_MS;
185
+ const expansion = expandCalendarEventSeries(series, { start: new Date(from), end: new Date(to) }, { cursor, stored: true });
186
+ for (const occurrence of expansion.occurrences) {
187
+ if (occurrence.start.getTime() < from)
188
+ continue;
189
+ found += 1;
190
+ if (occurrence.end.getTime() > lastEnd)
191
+ lastEnd = occurrence.end.getTime();
192
+ }
193
+ if (found >= count)
194
+ return lastEnd;
195
+ cursor = expansion.cursor;
196
+ from = to;
197
+ }
198
+ }
199
+ catch (error) {
200
+ if (error instanceof CalendarExpansionLimitError || error instanceof CalendarContractError) {
201
+ return null;
202
+ }
203
+ throw error;
204
+ }
205
+ return null;
206
+ }
207
+ /**
208
+ * The recurrence_ends_by column for an event, as text: NULL for a one-off, 'infinity' for no known
209
+ * end. Bind it as text (`::text::timestamptz`): postgres.js serializes a timestamptz parameter
210
+ * through Date, which cannot hold 'infinity'.
211
+ */
212
+ function recurrenceEndsByColumn(event) {
213
+ if (!event.recurrence)
214
+ return null;
215
+ return seriesEndsBy(event)?.toISOString() ?? 'infinity';
216
+ }
105
217
  /** The roll's `after`, checked, or `undefined` for a pass's first batch. */
106
218
  function rollCursor(after) {
107
219
  if (after === undefined)
108
220
  return undefined;
109
- if (!(after.materializedEndsAt instanceof Date) ||
221
+ const retryAt = after.rollRetryAt ?? null;
222
+ if ((retryAt !== null && (!(retryAt instanceof Date) || !Number.isFinite(retryAt.getTime()))) ||
223
+ !(after.materializedEndsAt instanceof Date) ||
110
224
  !Number.isFinite(after.materializedEndsAt.getTime()) ||
111
225
  !/^[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
226
  throw Object.assign(new Error('Slotlock horizon cursor must be a previous batch\'s `next`'), {
@@ -156,10 +270,18 @@ async function withPinnedSearchPath(tx, operation) {
156
270
  /**
157
271
  * The deployment DDL refuses a `slotlock` schema that another role controls with SQLSTATE 42501 and
158
272
  * 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.
273
+ * keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
274
+ * migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
160
275
  */
161
276
  function schemaControlRefusal(error) {
162
277
  const failure = error;
278
+ if (failure?.code === '55000' &&
279
+ typeof failure.message === 'string' &&
280
+ failure.message.startsWith(SLOTLOCK_SCHEMA_NEWER_REFUSAL)) {
281
+ return Object.assign(new Error(failure.message, { cause: error }), {
282
+ code: 'schema_newer',
283
+ });
284
+ }
163
285
  if (failure?.code !== '42501' ||
164
286
  typeof failure.message !== 'string' ||
165
287
  !failure.message.startsWith(SLOTLOCK_SCHEMA_CONTROL_REFUSAL)) {
@@ -180,6 +302,236 @@ function schemaControlRefusal(error) {
180
302
  reasons,
181
303
  });
182
304
  }
305
+ /** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
306
+ const LOCK_NOT_AVAILABLE = '55P03';
307
+ const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
308
+ const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
309
+ const DEFAULT_MIGRATION_ATTEMPTS = 5;
310
+ const SLOTLOCK_CORE_DDL_SHA256 = createHash('sha256').update(SLOTLOCK_CORE_DDL).digest('hex');
311
+ function migrationSettings(options) {
312
+ const lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
313
+ const statementTimeoutMs = options?.statementTimeoutMs ?? DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS;
314
+ const attempts = options?.attempts ?? DEFAULT_MIGRATION_ATTEMPTS;
315
+ const whole = (value, max) => Number.isSafeInteger(value) && value >= 1 && value <= max;
316
+ if (!whole(lockTimeoutMs, 600_000) ||
317
+ !whole(statementTimeoutMs, 86_400_000) ||
318
+ !whole(attempts, 20)) {
319
+ 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' });
320
+ }
321
+ return { lockTimeoutMs, statementTimeoutMs, attempts };
322
+ }
323
+ /**
324
+ * Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
325
+ * in pg_catalog only, serialized with every other migration of this database, and with the lock and
326
+ * statement timeouts set. A step that waits past its lock timeout is rolled back and tried again
327
+ * after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
328
+ */
329
+ async function inMigration(executor, options, step) {
330
+ const settings = migrationSettings(options);
331
+ for (let attempt = 1;; attempt++) {
332
+ try {
333
+ return await inTransaction(executor, (tx) => withPinnedSearchPath(tx, async () => {
334
+ // Taken before the timeouts are set: waiting for another migration holds up no query.
335
+ await tx `SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${SCHEMA_APPLY_LOCK}, 0))`;
336
+ const [caller] = await tx `
337
+ SELECT pg_catalog.current_setting('lock_timeout') AS lock,
338
+ pg_catalog.current_setting('statement_timeout') AS statement`;
339
+ await tx `
340
+ SELECT pg_catalog.set_config('lock_timeout', ${`${settings.lockTimeoutMs}ms`}, true),
341
+ pg_catalog.set_config('statement_timeout', ${`${settings.statementTimeoutMs}ms`}, true)`;
342
+ const result = await step(tx);
343
+ await tx `
344
+ SELECT pg_catalog.set_config('lock_timeout', ${caller?.lock ?? '0'}, true),
345
+ pg_catalog.set_config('statement_timeout', ${caller?.statement ?? '0'}, true)`;
346
+ return result;
347
+ }));
348
+ }
349
+ catch (error) {
350
+ if (pgCode(error) !== LOCK_NOT_AVAILABLE)
351
+ throw error;
352
+ if (attempt >= settings.attempts) {
353
+ 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' });
354
+ }
355
+ await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
356
+ }
357
+ }
358
+ }
359
+ /**
360
+ * A digest of what the migration defines in the `slotlock` schema: its tables and columns,
361
+ * constraints (and whether each is validated), indexes, triggers and functions, and whether PUBLIC
362
+ * holds any right on them. Grants to other roles, ownership and row-level security are left out:
363
+ * applyTenantRls and grantApplicationRole change those after applySchema has recorded the digest.
364
+ * Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
365
+ * with it pinned, as the migration does.
366
+ */
367
+ async function slotlockCatalogDigest(tx) {
368
+ const [row] = await tx `
369
+ WITH schema AS (
370
+ SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = 'slotlock'
371
+ ), members AS (
372
+ SELECT objid FROM pg_catalog.pg_depend WHERE deptype = 'e'
373
+ ), items (item) AS (
374
+ SELECT 'relation ' || rel.relname || ' ' || rel.relkind::text || ' '
375
+ || EXISTS (SELECT 1 FROM pg_catalog.aclexplode(rel.relacl) acl WHERE acl.grantee = 0)::text
376
+ FROM pg_catalog.pg_class rel, schema
377
+ WHERE rel.relnamespace = schema.oid AND rel.oid NOT IN (SELECT objid FROM members)
378
+ UNION ALL
379
+ SELECT 'column ' || rel.relname || '.' || att.attname || ' '
380
+ || pg_catalog.format_type(att.atttypid, att.atttypmod) || ' ' || att.attnotnull::text || ' '
381
+ || COALESCE(pg_catalog.pg_get_expr(def.adbin, def.adrelid), '')
382
+ FROM pg_catalog.pg_attribute att
383
+ JOIN pg_catalog.pg_class rel ON rel.oid = att.attrelid
384
+ JOIN schema ON rel.relnamespace = schema.oid
385
+ LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = att.attrelid AND def.adnum = att.attnum
386
+ WHERE rel.relkind IN ('r', 'p') AND att.attnum > 0 AND NOT att.attisdropped
387
+ UNION ALL
388
+ SELECT 'constraint ' || rel.relname || '.' || con.conname || ' '
389
+ || pg_catalog.pg_get_constraintdef(con.oid) || ' ' || con.convalidated::text
390
+ FROM pg_catalog.pg_constraint con
391
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
392
+ JOIN schema ON rel.relnamespace = schema.oid
393
+ UNION ALL
394
+ SELECT 'index ' || pg_catalog.pg_get_indexdef(idx.indexrelid)
395
+ FROM pg_catalog.pg_index idx
396
+ JOIN pg_catalog.pg_class rel ON rel.oid = idx.indrelid
397
+ JOIN schema ON rel.relnamespace = schema.oid
398
+ UNION ALL
399
+ SELECT 'trigger ' || pg_catalog.pg_get_triggerdef(trg.oid) || ' ' || trg.tgenabled::text
400
+ FROM pg_catalog.pg_trigger trg
401
+ JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid
402
+ JOIN schema ON rel.relnamespace = schema.oid
403
+ WHERE NOT trg.tgisinternal
404
+ UNION ALL
405
+ SELECT 'function ' || fn.oid::pg_catalog.regprocedure::text || ' ' || fn.prosecdef::text || ' '
406
+ || fn.provolatile::text || ' ' || COALESCE(fn.proconfig::text, '') || ' '
407
+ || pg_catalog.md5(fn.prosrc) || ' '
408
+ || EXISTS (SELECT 1 FROM pg_catalog.aclexplode(
409
+ COALESCE(fn.proacl, pg_catalog.acldefault('f', fn.proowner))) acl
410
+ WHERE acl.grantee = 0)::text
411
+ FROM pg_catalog.pg_proc fn, schema
412
+ WHERE fn.pronamespace = schema.oid AND fn.oid NOT IN (SELECT objid FROM members)
413
+ UNION ALL
414
+ SELECT 'extension ' || ext.extname || ' ' || ext.extnamespace::pg_catalog.regnamespace::text
415
+ FROM pg_catalog.pg_extension ext
416
+ WHERE ext.extname = 'btree_gist'
417
+ )
418
+ SELECT pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
419
+ COALESCE(pg_catalog.string_agg(item, pg_catalog.chr(10) ORDER BY item), ''),
420
+ 'UTF8')), 'hex') AS digest
421
+ FROM items`;
422
+ return row?.digest ?? '';
423
+ }
424
+ /**
425
+ * Whether the schema is exactly what this release's applySchema last left: the same version, DDL
426
+ * and catalog digest, and no table left without FORCE by an interrupted migration. Reading needs
427
+ * the right to (the deployment role owns the table); without it the full migration runs and refuses
428
+ * what it must.
429
+ */
430
+ async function slotlockSchemaUpToDate(tx) {
431
+ const [present] = await tx `
432
+ SELECT COALESCE(pg_catalog.has_table_privilege(
433
+ pg_catalog.to_regclass('slotlock.schema_version'), 'SELECT'), false) AS versioned,
434
+ COALESCE(pg_catalog.has_table_privilege(
435
+ pg_catalog.to_regclass('slotlock.deployment_lifted_row_security'), 'SELECT'), false)
436
+ AS lifted`;
437
+ if (!present?.versioned || !present.lifted)
438
+ return false;
439
+ const [recorded] = await tx `
440
+ SELECT version, ddl_sha256, catalog_sha256,
441
+ EXISTS (SELECT 1 FROM slotlock.deployment_lifted_row_security) AS lifted
442
+ FROM slotlock.schema_version
443
+ WHERE singleton`;
444
+ if (!recorded ||
445
+ recorded.lifted ||
446
+ recorded.version !== SLOTLOCK_SCHEMA_VERSION ||
447
+ recorded.ddl_sha256 !== SLOTLOCK_CORE_DDL_SHA256 ||
448
+ recorded.catalog_sha256 === null) {
449
+ return false;
450
+ }
451
+ return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
452
+ }
453
+ /** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
454
+ async function slotlockConstraintUnvalidated(tx) {
455
+ const [row] = await tx `
456
+ SELECT EXISTS (
457
+ SELECT 1
458
+ FROM pg_catalog.pg_constraint con
459
+ JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
460
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
461
+ WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
462
+ ) AS unvalidated`;
463
+ return row?.unvalidated === true;
464
+ }
465
+ /**
466
+ * Whether every tenant table has forced RLS and only its tenant policy, bound to `setting`, whose
467
+ * live fingerprint (computed as the RLS DDL computes it) matches the contract the DDL recorded.
468
+ */
469
+ async function tenantRlsUpToDate(tx, setting) {
470
+ const [present] = await tx `
471
+ SELECT COALESCE(pg_catalog.has_table_privilege(
472
+ pg_catalog.to_regclass('slotlock.rls_policy_contracts'), 'SELECT'), false) AS readable`;
473
+ if (!present?.readable)
474
+ return false;
475
+ const tables = [...SLOTLOCK_TENANT_TABLES];
476
+ const [state] = await tx `
477
+ SELECT pg_catalog.bool_and(
478
+ rel.relrowsecurity AND rel.relforcerowsecurity
479
+ AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
480
+ WHERE every.polrelid = rel.oid) = 1
481
+ AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
482
+ AND contract.tenant_context_setting = ${setting}
483
+ AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
484
+ pg_catalog.concat_ws(
485
+ pg_catalog.chr(31), live.policyname, live.permissive, live.roles::text, live.cmd,
486
+ COALESCE(live.qual, ''), COALESCE(live.with_check, '')
487
+ ),
488
+ 'UTF8')), 'hex')
489
+ ) AS current,
490
+ pg_catalog.count(*)::int AS matched
491
+ FROM pg_catalog.unnest(${tables}::text[]) AS wanted(name)
492
+ JOIN pg_catalog.pg_namespace ns ON ns.nspname = 'slotlock'
493
+ JOIN pg_catalog.pg_class rel ON rel.relnamespace = ns.oid AND rel.relname = wanted.name
494
+ JOIN pg_catalog.pg_policy pol ON pol.polrelid = rel.oid AND pol.polname = 'tenant_isolation_v1'
495
+ JOIN pg_catalog.pg_policies live
496
+ ON live.schemaname = 'slotlock' AND live.tablename = wanted.name
497
+ AND live.policyname = 'tenant_isolation_v1'
498
+ JOIN slotlock.rls_policy_contracts contract ON contract.table_name = wanted.name`;
499
+ return state?.current === true && state.matched === tables.length;
500
+ }
501
+ /**
502
+ * The schema version recorded by the last migration, or `null` when there is none this role can
503
+ * read: no schema, a schema from 0.2 or earlier, or a role not yet granted the function (run
504
+ * `slotlock migrate`, which grants it).
505
+ */
506
+ export async function readSlotlockSchemaVersion(sql) {
507
+ const [readable] = await sql `
508
+ SELECT COALESCE(pg_catalog.has_function_privilege(
509
+ pg_catalog.to_regprocedure('slotlock.installed_schema_version()'), 'EXECUTE'), false)
510
+ AS readable`;
511
+ if (!readable?.readable)
512
+ return null;
513
+ const [row] = await sql `
514
+ SELECT slotlock.installed_schema_version() AS version`;
515
+ return row?.version ?? null;
516
+ }
517
+ /**
518
+ * Refuse to serve a schema another release migrated: `schema_outdated` when it is older than this
519
+ * release (or unversioned), so `slotlock migrate` must run first; `schema_newer` when a newer release
520
+ * migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
521
+ */
522
+ export async function assertSlotlockSchemaCurrent(sql) {
523
+ const installed = await readSlotlockSchemaVersion(sql);
524
+ if (installed === SLOTLOCK_SCHEMA_VERSION)
525
+ return;
526
+ const newer = installed !== null && installed > SLOTLOCK_SCHEMA_VERSION;
527
+ throw Object.assign(new Error(newer
528
+ ? `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`
529
+ : `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`), {
530
+ code: newer ? 'schema_newer' : 'schema_outdated',
531
+ installed,
532
+ expected: SLOTLOCK_SCHEMA_VERSION,
533
+ });
534
+ }
183
535
  function isFiniteWindow(window) {
184
536
  return (window.start instanceof Date &&
185
537
  window.end instanceof Date &&
@@ -196,7 +548,15 @@ function isValidEventOwnerRef(value) {
196
548
  function isValidResourceId(value) {
197
549
  return UUID_PATTERN.test(value);
198
550
  }
551
+ function assertValidVisibility(visibility) {
552
+ if (visibility !== undefined && visibility !== 'shared' && visibility !== 'private') {
553
+ throw Object.assign(new Error("Slotlock resource visibility must be 'shared' or 'private'"), {
554
+ code: 'invalid_visibility',
555
+ });
556
+ }
557
+ }
199
558
  function assertValidResourceInput(params) {
559
+ assertValidVisibility(params.visibility);
200
560
  if (params.externalRef !== undefined &&
201
561
  (params.externalRef.trim().length === 0 || Buffer.byteLength(params.externalRef, 'utf8') > 500)) {
202
562
  throw Object.assign(new Error('Slotlock externalRef must be 1-500 UTF-8 bytes'), {
@@ -252,6 +612,41 @@ function toReservation(row) {
252
612
  source: row.source,
253
613
  };
254
614
  }
615
+ function toHold(row) {
616
+ return {
617
+ id: row.id,
618
+ resourceId: row.resource_id,
619
+ start: row.starts_at,
620
+ end: row.ends_at,
621
+ bufferAfterMs: Number(row.buffer_after_ms),
622
+ expiresAt: row.expires_at,
623
+ source: row.source,
624
+ };
625
+ }
626
+ /** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
627
+ function pageCursor(after) {
628
+ if (after !== undefined &&
629
+ (!(after.start instanceof Date) ||
630
+ !Number.isFinite(after.start.getTime()) ||
631
+ !UUID_PATTERN.test(after.id))) {
632
+ throw Object.assign(new Error('Slotlock page cursor is invalid'), {
633
+ code: 'invalid_cursor',
634
+ });
635
+ }
636
+ return { start: after?.start ?? null, id: after?.id ?? null };
637
+ }
638
+ function pageLimit(limit) {
639
+ const value = limit ?? 100;
640
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_CALENDAR_LIST_LIMIT) {
641
+ throw Object.assign(new Error('Slotlock list limit must be 1-1000'), {
642
+ code: 'invalid_limit',
643
+ });
644
+ }
645
+ return value;
646
+ }
647
+ function invalidIdentityError(message) {
648
+ return Object.assign(new Error(message), { code: 'invalid_identity' });
649
+ }
255
650
  function eventExceptionsForJson(exceptions) {
256
651
  return (exceptions ?? []).map((exception) => {
257
652
  const result = {
@@ -487,6 +882,14 @@ async function replaceCalendarEventOccurrences(tx, params) {
487
882
  )`;
488
883
  }
489
884
  }
885
+ /** A recurring series' materialization window: finite, forward, and at most 369 days. */
886
+ function seriesWindow(window) {
887
+ if (!isFiniteWindow(window) ||
888
+ window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
889
+ throw new CalendarContractError('invalid_icalendar');
890
+ }
891
+ return { start: new Date(window.start), end: new Date(window.end) };
892
+ }
490
893
  function calendarWindow(window) {
491
894
  if (!isFiniteWindow(window) ||
492
895
  window.end.getTime() - window.start.getTime() > MAX_CALENDAR_WINDOW_MS) {
@@ -507,7 +910,7 @@ function oneOffEventWindow(event, requested) {
507
910
  if (!isFiniteWindow(window) ||
508
911
  window.start > event.start ||
509
912
  window.end < event.end ||
510
- window.end.getTime() - window.start.getTime() > Math.max(MAX_CALENDAR_WINDOW_MS, duration)) {
913
+ window.end.getTime() - window.start.getTime() > Math.max(MAX_MATERIALIZATION_WINDOW_MS, duration)) {
511
914
  throw new CalendarContractError('invalid_icalendar');
512
915
  }
513
916
  return { start: new Date(window.start), end: new Date(window.end) };
@@ -583,9 +986,55 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
583
986
  }
584
987
  return count;
585
988
  }
586
- async function findCalendarOverlap(sql, resourceId, occurrences) {
989
+ /**
990
+ * The end of every event cancellation, under the event's identity and row locks: remove the event
991
+ * (when there is one) and its occupation, leave the identity's tombstone at `revision`, and record
992
+ * the command in `commandOwnerRef`'s namespace for replay.
993
+ */
994
+ async function finishCalendarCancel(tx, params) {
995
+ const eventId = params.eventId ?? randomUUID();
996
+ if (params.eventId !== null) {
997
+ await tx `
998
+ DELETE FROM slotlock.reservations
999
+ WHERE id IN (
1000
+ SELECT reservation_id FROM slotlock.calendar_event_occurrences
1001
+ WHERE event_id = ${eventId} AND reservation_id IS NOT NULL
1002
+ )`;
1003
+ await tx `
1004
+ DELETE FROM slotlock.calendar_events
1005
+ WHERE id = ${eventId}
1006
+ AND tenant_ref = ${params.tenantRef}
1007
+ AND owner_ref = ${params.ownerRef}`;
1008
+ }
1009
+ await tx `
1010
+ INSERT INTO slotlock.calendar_event_tombstones (
1011
+ tenant_ref, owner_ref, external_ref, event_id, revision
1012
+ ) VALUES (
1013
+ ${params.tenantRef}, ${params.ownerRef}, ${params.externalRef}, ${eventId}, ${params.revision}
1014
+ )`;
1015
+ await tx `
1016
+ INSERT INTO slotlock.calendar_event_commands (
1017
+ tenant_ref, owner_ref, idempotency_key, operation, payload_hash, event_id, external_ref,
1018
+ result_revision, occurrence_count
1019
+ ) VALUES (
1020
+ ${params.tenantRef}, ${params.commandOwnerRef}, ${params.idempotencyKey}, 'cancel',
1021
+ ${params.payloadHash}, ${eventId}, ${params.externalRef}, ${params.revision}, 0
1022
+ )`;
1023
+ return {
1024
+ ok: true,
1025
+ eventId,
1026
+ externalRef: params.externalRef,
1027
+ revision: params.revision,
1028
+ idempotent: false,
1029
+ };
1030
+ }
1031
+ /**
1032
+ * The row an event write lost to. The write was rolled back, so the event's own rows from before
1033
+ * it are still there and are left out: they were to be replaced, and never block the event.
1034
+ */
1035
+ async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
587
1036
  for (const occurrence of occurrences) {
588
- const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end);
1037
+ const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, 0, undefined, eventId);
589
1038
  if (result.conflictingReservationId !== null)
590
1039
  return result;
591
1040
  }
@@ -607,12 +1056,18 @@ async function lockExternalIdentity(tx, tenantRef, externalRef) {
607
1056
  // U+0000). The database hashes it to one transaction-scoped lock key.
608
1057
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
609
1058
  }
610
- async function resourceBelongsToTenant(tx, resourceId, tenantRef) {
611
- const rows = await tx `
612
- SELECT id FROM slotlock.resources
1059
+ /**
1060
+ * Whether the resource is the tenant's (`missing` when not), and whether it is archived. The row is
1061
+ * share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
1062
+ */
1063
+ async function tenantResourceState(tx, resourceId, tenantRef) {
1064
+ const [row] = await tx `
1065
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
613
1066
  WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
614
1067
  FOR SHARE`;
615
- return rows.length === 1;
1068
+ if (!row)
1069
+ return 'missing';
1070
+ return row.archived ? 'archived' : 'active';
616
1071
  }
617
1072
  function pgCode(err) {
618
1073
  let cur = err;
@@ -629,7 +1084,10 @@ function toResource(row) {
629
1084
  externalRef: row.external_ref,
630
1085
  tenantRef: row.tenant_ref,
631
1086
  timezone: row.timezone,
1087
+ visibility: row.visibility,
632
1088
  };
1089
+ if (row.archived_at !== null)
1090
+ resource.archivedAt = row.archived_at;
633
1091
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
634
1092
  // Written through setResourceAvailability, which validates; anything else reads as closed.
635
1093
  try {
@@ -653,42 +1111,452 @@ function availabilityParameter(sql, rules) {
653
1111
  })));
654
1112
  }
655
1113
  async function upsertResource(executor, params) {
1114
+ const visibility = params.visibility ?? 'shared';
1115
+ const asked = params.visibility ?? null;
656
1116
  let rows;
1117
+ // An existing identity keeps the visibility it has: creating it again with none named returns it
1118
+ // as it is, and with another one is refused, so a private one is never made shared that way.
657
1119
  if (params.tenantRef !== undefined && params.externalRef !== undefined) {
658
1120
  rows = await executor `
659
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
660
- VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'})
1121
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1122
+ VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility})
661
1123
  ON CONFLICT (tenant_ref, external_ref)
662
1124
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
663
1125
  DO UPDATE SET timezone = EXCLUDED.timezone
664
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
1126
+ WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1127
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
665
1128
  }
666
1129
  else if (params.externalRef !== undefined) {
667
1130
  rows = await executor `
668
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
669
- VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'})
1131
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1132
+ VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
670
1133
  ON CONFLICT (external_ref)
671
1134
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
672
1135
  DO UPDATE SET external_ref = EXCLUDED.external_ref
673
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
1136
+ WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1137
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
674
1138
  }
675
1139
  else {
676
1140
  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`;
1141
+ INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1142
+ VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1143
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
680
1144
  }
681
1145
  const row = rows[0];
682
1146
  if (!row) {
683
- throw new Error('slotlock: resource upsert returned no row');
1147
+ throw Object.assign(new Error('Slotlock resource visibility cannot be changed by creating it'), {
1148
+ code: 'resource_visibility_conflict',
1149
+ });
684
1150
  }
685
1151
  return toResource(row);
686
1152
  }
687
1153
  /**
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.
1154
+ * Rematerialize one stored recurring master over the window a roll to `horizon` gives it
1155
+ * (rolledWindow): from where its last expansion stopped when that can be trusted, else its start.
1156
+ * False when there is no such master or nothing to roll. A collision throws the database's 23P01,
1157
+ * and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
1158
+ * either rolls back whole.
1159
+ */
1160
+ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1161
+ const [row] = await tx `
1162
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1163
+ summary, description, location, status, transparency, organizer, attendees,
1164
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1165
+ materialized_ends_at, recurrence_cursor, revision, source, created_at,
1166
+ updated_at, recurrence_ends_by IS NULL AS ends_by_missing
1167
+ FROM slotlock.calendar_events
1168
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1169
+ FOR UPDATE`;
1170
+ if (!row || row.recurrence_rule === null)
1171
+ return false;
1172
+ const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, horizon);
1173
+ if (!rolled)
1174
+ return false;
1175
+ const childMap = await loadCalendarEventChildren(tx, [row.id]);
1176
+ const event = toCalendarEvent(row, childMap.get(row.id));
1177
+ const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
1178
+ cursor: row.recurrence_cursor ?? null,
1179
+ stored: true,
1180
+ });
1181
+ await replaceCalendarEventOccurrences(tx, {
1182
+ tenantRef,
1183
+ eventId: row.id,
1184
+ resourceId: row.resource_id,
1185
+ event,
1186
+ occurrences,
1187
+ revision: Number(row.revision),
1188
+ });
1189
+ // A series stored before 0.3 learns when it ends at its first roll.
1190
+ const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
1191
+ await tx `
1192
+ UPDATE slotlock.calendar_events
1193
+ SET materialized_starts_at = ${rolled.start},
1194
+ materialized_ends_at = ${rolled.end},
1195
+ recurrence_cursor = ${seriesCursorJson(tx, cursor)},
1196
+ recurrence_ends_by = COALESCE(recurrence_ends_by, ${endsBy}::text::timestamptz),
1197
+ roll_retry_at = NULL
1198
+ WHERE id = ${row.id} AND tenant_ref = ${tenantRef}`;
1199
+ return true;
1200
+ }
1201
+ /**
1202
+ * Work out and store when a series stored before 0.3 ends (recurrence_ends_by), as its next roll
1203
+ * would; its stored occurrences are left as they are.
1204
+ */
1205
+ async function learnSeriesEnd(tx, tenantRef, eventId) {
1206
+ const [row] = await tx `
1207
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1208
+ summary, description, location, status, transparency, organizer, attendees,
1209
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1210
+ materialized_ends_at, recurrence_cursor, revision, source, created_at,
1211
+ updated_at
1212
+ FROM slotlock.calendar_events
1213
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1214
+ AND recurrence_rule IS NOT NULL AND recurrence_ends_by IS NULL
1215
+ FOR UPDATE`;
1216
+ if (!row)
1217
+ return;
1218
+ const childMap = await loadCalendarEventChildren(tx, [row.id]);
1219
+ const event = toCalendarEvent(row, childMap.get(row.id));
1220
+ await tx `
1221
+ UPDATE slotlock.calendar_events
1222
+ SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
1223
+ WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
1224
+ }
1225
+ /** Put a master the roll could not extend behind the others for an hour. */
1226
+ async function deferRoll(executor, tenantRef, eventId) {
1227
+ await executor `
1228
+ UPDATE slotlock.calendar_events
1229
+ SET roll_retry_at = now() + interval '1 hour'
1230
+ WHERE id = ${eventId} AND tenant_ref = ${tenantRef}`;
1231
+ }
1232
+ /**
1233
+ * Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
1234
+ * part of [start, end) that a roll can ever reach: from the later of `start` and the rolling
1235
+ * horizon's start. A write there must not land where such a series is not stored, or the series'
1236
+ * next roll would collide with it and stop. A series known to end before that part
1237
+ * (recurrence_ends_by) cannot meet the write and is left out, as is one stored through its end. A
1238
+ * series behind is first rolled to the horizon, in a savepoint of the write's transaction, so the
1239
+ * write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
1240
+ * past the horizon and so does the write, it collides, or its rule is over the budget). Series are
1241
+ * rolled in id order; an event's update holds its own row while it rolls the others, so it and a
1242
+ * write rolling the same series in that order can deadlock, which the database breaks (the writer
1243
+ * retries once, then answers an overlap).
1244
+ */
1245
+ async function recurrenceCoversWrite(tx, params) {
1246
+ const horizon = calendarEventRollingHorizon();
1247
+ if (params.end <= horizon.start)
1248
+ return true;
1249
+ const from = params.start > horizon.start ? params.start : horizon.start;
1250
+ // LEAST and GREATEST ignore a NULL argument (PostgreSQL 16, Conditional Expressions): a series
1251
+ // with no known end is behind wherever it is stored short of the write's end.
1252
+ const behind = () => tx `
1253
+ SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
1254
+ COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
1255
+ AS ends_in_horizon
1256
+ FROM slotlock.calendar_events event
1257
+ WHERE event.tenant_ref = ${params.tenantRef}
1258
+ AND event.resource_id = ${params.resourceId}
1259
+ AND event.recurrence_rule IS NOT NULL
1260
+ AND event.transparency = 'opaque'
1261
+ AND event.id IS DISTINCT FROM ${params.exceptEventId ?? null}::uuid
1262
+ AND (event.recurrence_ends_by IS NULL OR event.recurrence_ends_by > ${from}::timestamptz)
1263
+ AND (
1264
+ event.materialized_ends_at < LEAST(${params.end}::timestamptz, event.recurrence_ends_by)
1265
+ OR event.materialized_starts_at > GREATEST(
1266
+ ${from}::timestamptz,
1267
+ LEAST(event.starts_at, COALESCE((
1268
+ SELECT min(exception.starts_at)
1269
+ FROM slotlock.calendar_event_exceptions exception
1270
+ WHERE exception.tenant_ref = event.tenant_ref
1271
+ AND exception.event_id = event.id
1272
+ AND exception.cancelled = false
1273
+ ), event.starts_at))
1274
+ )
1275
+ )
1276
+ ORDER BY event.id`;
1277
+ let due = await behind();
1278
+ if (due.length === 0)
1279
+ return true;
1280
+ if (params.end > horizon.end) {
1281
+ // A series stored before 0.3 has not worked out when it ends; it does so now, and is left out
1282
+ // if it ends before the write or is stored through its end.
1283
+ if (due.some((row) => row.ends_by_unknown)) {
1284
+ for (const { id, ends_by_unknown } of due) {
1285
+ if (ends_by_unknown)
1286
+ await learnSeriesEnd(tx, params.tenantRef, id);
1287
+ }
1288
+ due = await behind();
1289
+ if (due.length === 0)
1290
+ return true;
1291
+ }
1292
+ // No roll stores a series past the horizon: one going on past it stays behind such a write.
1293
+ if (due.some((row) => !row.ends_in_horizon))
1294
+ return false;
1295
+ }
1296
+ for (const { id } of due) {
1297
+ try {
1298
+ await tx.savepoint((sp) => rollStoredSeries(sp, params.tenantRef, id, horizon));
1299
+ }
1300
+ catch (error) {
1301
+ if (error instanceof CalendarExpansionLimitError || pgCode(error) === EXCLUSION_VIOLATION) {
1302
+ return false;
1303
+ }
1304
+ throw error;
1305
+ }
1306
+ }
1307
+ return (await behind()).length === 0;
1308
+ }
1309
+ /**
1310
+ * Whether an event already occupies, on the resource, all the time `occurrences` would: such a
1311
+ * write (a rename, a new description) takes no time a series could later need, so it is not held
1312
+ * to where the series on the resource are stored.
1313
+ */
1314
+ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1315
+ const held = await tx `
1316
+ SELECT reservation.starts_at AS start, reservation.ends_at AS end
1317
+ FROM slotlock.calendar_event_occurrences occurrence
1318
+ JOIN slotlock.reservations reservation ON reservation.id = occurrence.reservation_id
1319
+ WHERE occurrence.event_id = ${eventId}
1320
+ AND reservation.resource_id = ${resourceId}`;
1321
+ return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
1322
+ }
1323
+ /** From the earliest start to the latest end of an event's occurrences, or null for none. */
1324
+ function occurrenceSpan(occurrences) {
1325
+ const [first] = occurrences;
1326
+ if (!first)
1327
+ return null;
1328
+ let start = first.start;
1329
+ let end = first.end;
1330
+ for (const occurrence of occurrences) {
1331
+ if (occurrence.start < start)
1332
+ start = occurrence.start;
1333
+ if (occurrence.end > end)
1334
+ end = occurrence.end;
1335
+ }
1336
+ return { start, end };
1337
+ }
1338
+ /**
1339
+ * After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series on the
1340
+ * resource the write's transaction rolled with it (and lost), so they stay extended and the loss
1341
+ * can name the occurrence that blocked it. A resource with no tenant has no series. Upkeep only: if
1342
+ * it fails, the series are left to maintenance and the write is still answered as a loss.
1343
+ */
1344
+ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
1345
+ try {
1346
+ await inTransaction(executor, async (tx) => {
1347
+ const [resource] = await tx `
1348
+ SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
1349
+ if (!resource?.tenant_ref)
1350
+ return;
1351
+ await recurrenceCoversWrite(tx, {
1352
+ tenantRef: resource.tenant_ref,
1353
+ resourceId,
1354
+ start,
1355
+ end,
1356
+ ...(exceptEventId !== undefined ? { exceptEventId } : {}),
1357
+ });
1358
+ });
1359
+ }
1360
+ catch {
1361
+ // Rolled back whole (its own transaction, or a savepoint of the caller's).
1362
+ }
1363
+ }
1364
+ const MAX_TENANT_LIMIT = 10_000_000;
1365
+ function tenantLimitsOption(limits) {
1366
+ const valid = {};
1367
+ for (const kind of ['resources', 'holds', 'reservations', 'events', 'coverage']) {
1368
+ const value = limits?.[kind];
1369
+ if (value === undefined)
1370
+ continue;
1371
+ const max = kind === 'resources' ? MAX_TENANT_RESOURCE_LIMIT : MAX_TENANT_LIMIT;
1372
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
1373
+ throw Object.assign(new Error(`Slotlock tenant limit ${kind} must be a whole number from 1 to ${max}`), { code: 'invalid_tenant_limits' });
1374
+ }
1375
+ valid[kind] = value;
1376
+ }
1377
+ return valid;
1378
+ }
1379
+ /**
1380
+ * Serialize one tenant's writes that add rows of one limited kind, so each counts what every
1381
+ * earlier one committed. Taken before any row lock, after the write's identity locks.
1382
+ */
1383
+ async function lockTenantLimit(tx, tenantRef, kind) {
1384
+ // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
1385
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
1386
+ }
1387
+ /**
1388
+ * Rows a tenant added within this many days count against its limits whatever time they hold, so
1389
+ * bookings and events dated in the past, and cancellations, add at most the limit in any such
1390
+ * window. Ended history is not pruned: over a longer time it can still grow. The same window
1391
+ * maintenance keeps event idempotency records and stray retirements for.
1392
+ */
1393
+ const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1394
+ /**
1395
+ * The rows a tenant has that its limit of `kind` counts: what is still to come, and what it added
1396
+ * in the last TENANT_LIMIT_RECENT_DAYS days; what ended before then is history and does not count.
1397
+ * Each part stops at `limit` rows, read through an index, so a count costs no more than the limit
1398
+ * however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
1399
+ * at it.
1400
+ */
1401
+ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1402
+ let rows;
1403
+ switch (kind) {
1404
+ case 'holds':
1405
+ // Every held row, expired or not, until it is deleted: an expired hold no rival writes over
1406
+ // stays stored until maintenance or a hold at the limit deletes it. And the answer kept for
1407
+ // each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
1408
+ // is forgotten. An answer whose hold is still a held row is left out: that row counts.
1409
+ rows = await tx `
1410
+ SELECT (
1411
+ (SELECT count(*) FROM (
1412
+ SELECT 1
1413
+ FROM slotlock.reservations hold
1414
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
1415
+ WHERE resource.tenant_ref = ${tenantRef} AND hold.status = 'held'
1416
+ LIMIT ${limit}) held)
1417
+ + (SELECT count(*) FROM (
1418
+ SELECT 1 FROM slotlock.hold_commands answer
1419
+ WHERE answer.tenant_ref = ${tenantRef}
1420
+ AND NOT EXISTS (
1421
+ SELECT 1 FROM slotlock.reservations hold
1422
+ WHERE hold.id = answer.hold_id AND hold.status = 'held'
1423
+ )
1424
+ LIMIT ${limit}) kept)
1425
+ )::int AS count`;
1426
+ break;
1427
+ case 'reservations':
1428
+ // Bookings still to come; bookings that have ended and cancellations, made recently; and
1429
+ // references retired with no booking, until maintenance prunes them. A cancelled booking's
1430
+ // tombstone is kept for good, and stops counting once it is no longer recent.
1431
+ rows = await tx `
1432
+ WITH recent AS (
1433
+ SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
1434
+ )
1435
+ SELECT (
1436
+ (SELECT count(*) FROM (
1437
+ SELECT 1 FROM slotlock.reservations
1438
+ WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
1439
+ AND calendar_event_id IS NULL AND occupied_ends_at > now()
1440
+ LIMIT ${limit}) live)
1441
+ + (SELECT count(*) FROM (
1442
+ SELECT 1 FROM slotlock.reservations, recent
1443
+ WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
1444
+ AND calendar_event_id IS NULL AND created_at > recent.since
1445
+ AND occupied_ends_at <= now()
1446
+ LIMIT ${limit}) ended)
1447
+ + (SELECT count(*) FROM (
1448
+ SELECT 1 FROM slotlock.reservation_tombstones, recent
1449
+ WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
1450
+ LIMIT ${limit}) cancelled)
1451
+ + (SELECT count(*) FROM (
1452
+ SELECT 1 FROM slotlock.reservation_tombstones, recent
1453
+ WHERE tenant_ref = ${tenantRef} AND stray AND cancelled_at <= recent.since
1454
+ LIMIT ${limit}) retired)
1455
+ )::int AS count`;
1456
+ break;
1457
+ case 'events':
1458
+ // One-off events still to come and series not known to have ended; events that have ended
1459
+ // and cancellations of any owner, made recently; and agent cancellations until maintenance
1460
+ // prunes them. The owner identity CHECK makes every owner but 'internal' an 'agent:' one.
1461
+ rows = await tx `
1462
+ WITH recent AS (
1463
+ SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
1464
+ )
1465
+ SELECT (
1466
+ (SELECT count(*) FROM (
1467
+ SELECT 1 FROM slotlock.calendar_events
1468
+ WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NULL AND ends_at > now()
1469
+ LIMIT ${limit}) one_off)
1470
+ + (SELECT count(*) FROM (
1471
+ SELECT 1 FROM slotlock.calendar_events
1472
+ WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NOT NULL
1473
+ AND (recurrence_ends_by IS NULL OR recurrence_ends_by > now())
1474
+ LIMIT ${limit}) series)
1475
+ + (SELECT count(*) FROM (
1476
+ SELECT 1 FROM slotlock.calendar_events, recent
1477
+ WHERE tenant_ref = ${tenantRef} AND created_at > recent.since
1478
+ AND CASE WHEN recurrence_rule IS NULL THEN ends_at <= now()
1479
+ ELSE recurrence_ends_by <= now() END
1480
+ LIMIT ${limit}) ended)
1481
+ + (SELECT count(*) FROM (
1482
+ SELECT 1 FROM slotlock.calendar_event_tombstones, recent
1483
+ WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
1484
+ LIMIT ${limit}) cancelled)
1485
+ + (SELECT count(*) FROM (
1486
+ SELECT 1 FROM slotlock.calendar_event_tombstones, recent
1487
+ WHERE tenant_ref = ${tenantRef} AND owner_ref LIKE 'agent:%'
1488
+ AND cancelled_at <= recent.since
1489
+ LIMIT ${limit}) agent_cancelled)
1490
+ )::int AS count`;
1491
+ break;
1492
+ case 'coverage':
1493
+ rows = await tx `
1494
+ SELECT count(*)::int AS count FROM (
1495
+ SELECT 1 FROM slotlock.calendar_coverage WHERE tenant_ref = ${tenantRef} LIMIT ${limit}
1496
+ ) kept`;
1497
+ break;
1498
+ }
1499
+ const count = Number(rows[0]?.count ?? 0);
1500
+ if (!Number.isSafeInteger(count) || count < 0)
1501
+ throw new Error('slotlock: invalid tenant row count');
1502
+ return count;
1503
+ }
1504
+ /**
1505
+ * Delete up to `limit` of a tenant's holds that have expired, oldest first; the number deleted. A
1506
+ * writer reaping or confirming a hold locks it: such a row is skipped, and the delete repeats the
1507
+ * predicate on the row as it now is.
1508
+ */
1509
+ async function deleteExpiredHolds(tx, tenantRef, limit) {
1510
+ const [row] = await tx `
1511
+ WITH candidates AS MATERIALIZED (
1512
+ SELECT hold.id
1513
+ FROM slotlock.reservations hold
1514
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
1515
+ WHERE resource.tenant_ref = ${tenantRef}
1516
+ AND hold.status = 'held' AND hold.expires_at <= now()
1517
+ ORDER BY hold.expires_at, hold.id
1518
+ LIMIT ${limit}
1519
+ FOR UPDATE OF hold SKIP LOCKED
1520
+ ), deleted AS (
1521
+ DELETE FROM slotlock.reservations hold
1522
+ USING candidates candidate
1523
+ WHERE hold.id = candidate.id
1524
+ AND hold.status = 'held' AND hold.expires_at <= now()
1525
+ RETURNING 1
1526
+ )
1527
+ SELECT count(*)::int AS deleted FROM deleted`;
1528
+ return Number(row?.deleted ?? 0);
1529
+ }
1530
+ /**
1531
+ * Delete up to `limit` of a tenant's keyed hold answers whose hold has expired, so a retry with one
1532
+ * of their keys makes a new hold; answers another transaction holds are skipped. The number deleted.
1533
+ */
1534
+ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1535
+ const [row] = await tx `
1536
+ WITH candidates AS MATERIALIZED (
1537
+ SELECT answer.principal_ref, answer.idempotency_key
1538
+ FROM slotlock.hold_commands answer
1539
+ WHERE answer.tenant_ref = ${tenantRef} AND answer.expires_at <= now()
1540
+ ORDER BY answer.expires_at, answer.principal_ref, answer.idempotency_key
1541
+ LIMIT ${limit}
1542
+ FOR UPDATE OF answer SKIP LOCKED
1543
+ ), deleted AS (
1544
+ DELETE FROM slotlock.hold_commands answer
1545
+ USING candidates candidate
1546
+ WHERE answer.tenant_ref = ${tenantRef}
1547
+ AND answer.principal_ref = candidate.principal_ref
1548
+ AND answer.idempotency_key = candidate.idempotency_key
1549
+ RETURNING 1
1550
+ )
1551
+ SELECT count(*)::int AS deleted FROM deleted`;
1552
+ return Number(row?.deleted ?? 0);
1553
+ }
1554
+ /**
1555
+ * Delete expired held rows whose occupied time (buffer included, as the EXCLUDE constraint reads
1556
+ * it) overlaps the requested window, whose `end` is its own occupied end, in deterministic id
1557
+ * order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
1558
+ * deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
1559
+ * before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
692
1560
  */
693
1561
  async function reapExpiredHolds(tx, resourceId, start, end) {
694
1562
  await tx `
@@ -699,7 +1567,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
699
1567
  AND status = 'held'
700
1568
  AND expires_at <= now()
701
1569
  AND starts_at < ${end}
702
- AND ends_at > ${start}
1570
+ AND COALESCE(occupied_ends_at, ends_at) > ${start}
703
1571
  ORDER BY id
704
1572
  FOR UPDATE
705
1573
  )`;
@@ -710,11 +1578,13 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
710
1578
  * loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
711
1579
  * immediate retry reaps it and wins.
712
1580
  */
713
- async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId) {
1581
+ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
714
1582
  const winners = await sql `
715
1583
  SELECT id FROM slotlock.reservations
716
1584
  WHERE resource_id = ${resourceId}
717
1585
  AND (${excludeReservationId ?? null}::uuid IS NULL OR id <> ${excludeReservationId ?? null}::uuid)
1586
+ AND (${excludeCalendarEventId ?? null}::uuid IS NULL
1587
+ OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
718
1588
  AND tstzrange(
719
1589
  starts_at,
720
1590
  COALESCE(occupied_ends_at, ends_at),
@@ -741,7 +1611,111 @@ function holdTenantRef(options) {
741
1611
  }
742
1612
  return tenantRef;
743
1613
  }
1614
+ /**
1615
+ * At most `max` holders per tenant at once; the rest wait in order. An aborted signal takes a
1616
+ * waiter out of the line, rejecting with its reason. Each holder calls the release it gets once.
1617
+ */
1618
+ export function createTenantSlots(max) {
1619
+ const tenants = new Map();
1620
+ return async (tenantRef, signal) => {
1621
+ signal?.throwIfAborted();
1622
+ let tenant = tenants.get(tenantRef);
1623
+ if (!tenant) {
1624
+ tenant = { running: 0, waiting: [] };
1625
+ tenants.set(tenantRef, tenant);
1626
+ }
1627
+ const slots = tenant;
1628
+ if (slots.running < max) {
1629
+ slots.running += 1;
1630
+ }
1631
+ else {
1632
+ await new Promise((resolve, reject) => {
1633
+ const start = () => {
1634
+ signal?.removeEventListener('abort', abort);
1635
+ resolve();
1636
+ };
1637
+ const abort = () => {
1638
+ slots.waiting.splice(slots.waiting.indexOf(start), 1);
1639
+ reject(signal?.reason);
1640
+ };
1641
+ slots.waiting.push(start);
1642
+ signal?.addEventListener('abort', abort, { once: true });
1643
+ });
1644
+ }
1645
+ let released = false;
1646
+ return () => {
1647
+ if (released)
1648
+ return;
1649
+ released = true;
1650
+ // Handed straight to the next in line, so the count stays as it is.
1651
+ const next = slots.waiting.shift();
1652
+ if (next) {
1653
+ next();
1654
+ return;
1655
+ }
1656
+ slots.running -= 1;
1657
+ if (slots.running === 0)
1658
+ tenants.delete(tenantRef);
1659
+ };
1660
+ };
1661
+ }
1662
+ function lockTimeoutError(cause) {
1663
+ return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
1664
+ }
744
1665
  export function createSlotlockStore(sql, options = {}) {
1666
+ return buildSlotlockStore(sql, options, null);
1667
+ }
1668
+ /**
1669
+ * Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
1670
+ * Maintenance (rolling series, pruning, purging) and erasure are not among them.
1671
+ */
1672
+ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1673
+ 'createResource',
1674
+ 'archiveResource',
1675
+ 'restoreResource',
1676
+ 'deleteResource',
1677
+ 'setResourceAvailability',
1678
+ 'setTenantAvailability',
1679
+ 'createReservation',
1680
+ 'createExternalReservation',
1681
+ 'rescheduleExternalReservation',
1682
+ 'cancelExternalReservation',
1683
+ 'acquireHold',
1684
+ 'confirmHold',
1685
+ 'releaseHold',
1686
+ 'putCalendarEvent',
1687
+ 'cancelCalendarEvent',
1688
+ 'cancelCalendarEventById',
1689
+ 'recordCalendarCoverage',
1690
+ ]);
1691
+ function tenantSuspendedError() {
1692
+ return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
1693
+ code: 'tenant_suspended',
1694
+ });
1695
+ }
1696
+ function staleSuspensionCheckError() {
1697
+ 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' });
1698
+ }
1699
+ /**
1700
+ * Reading a tenant's suspension needs EXECUTE on slotlock.tenant_suspended(text), which PUBLIC does
1701
+ * not hold (SQLSTATE 42501 without it), and a schema this release migrated (42883 before then).
1702
+ * Name the remedy for each; any other failure passes through.
1703
+ */
1704
+ function suspensionCheckRefusal(error) {
1705
+ const code = pgCode(error);
1706
+ if (code === '42501') {
1707
+ 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' });
1708
+ }
1709
+ if (code === '42883') {
1710
+ 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' });
1711
+ }
1712
+ return error;
1713
+ }
1714
+ /**
1715
+ * A store on `sql`. With `refusal`, every method in SLOTLOCK_TENANT_WRITE_METHODS throws what it
1716
+ * returns before any query (a suspended tenant, or a suspension check that could be stale).
1717
+ */
1718
+ function buildSlotlockStore(sql, options, refusal) {
745
1719
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
746
1720
  const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
747
1721
  const configuredAgentOwnerCommandQuota = options.agentOwnerCommandQuota ?? SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA;
@@ -757,56 +1731,125 @@ export function createSlotlockStore(sql, options = {}) {
757
1731
  configuredAgentOwnerCommandQuota > MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA) {
758
1732
  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
1733
  }
1734
+ const configuredTenantLimits = tenantLimitsOption(options.tenantLimits);
1735
+ const { maxTenantTransactions, tenantLockTimeoutMs } = options;
1736
+ const whole = (value, max) => value === undefined || (Number.isSafeInteger(value) && value >= 1 && value <= max);
1737
+ if (!whole(maxTenantTransactions, 1_000) || !whole(tenantLockTimeoutMs, 600_000)) {
1738
+ throw Object.assign(new Error('Slotlock maxTenantTransactions must be 1-1000 and tenantLockTimeoutMs 1-600000, whole numbers'), { code: 'invalid_tenant_transaction_options' });
1739
+ }
1740
+ // Only a client opens transactions of its own; inside a caller's, neither applies.
1741
+ const ownTransactions = 'begin' in sql;
1742
+ const tenantSlots = ownTransactions && maxTenantTransactions !== undefined
1743
+ ? createTenantSlots(maxTenantTransactions)
1744
+ : undefined;
1745
+ const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
760
1746
  createSlotlockTenantRlsDdl(configuredTenantSetting); // Validate once before any scoped query.
761
- return {
762
- async applySchema() {
1747
+ const storeOptions = {
1748
+ tenantContextSetting: configuredTenantSetting,
1749
+ agentOwnerEventQuota: configuredAgentOwnerEventQuota,
1750
+ agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
1751
+ tenantLimits: configuredTenantLimits,
1752
+ };
1753
+ /**
1754
+ * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. No limit for
1755
+ * `kind`, or no tenant, takes no lock. Writes that only lower the count take no lock, so a count
1756
+ * read early stays an upper bound for the rest of the transaction.
1757
+ */
1758
+ async function tenantLimitReached(tx, tenantRef, kind) {
1759
+ const limit = configuredTenantLimits[kind];
1760
+ if (limit === undefined || tenantRef === null)
1761
+ return null;
1762
+ await assertReadCommitted(tx);
1763
+ await lockTenantLimit(tx, tenantRef, kind);
1764
+ if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
1765
+ return null;
1766
+ // A hold, and a keyed hold's answer, keep their place only until the hold expires; at the
1767
+ // limit, the expired holds and their answers are deleted, as maintenance would, and the count
1768
+ // is read again. Counting expired rows until then keeps what a tenant stores within the limit
1769
+ // between maintenance passes, however short its holds.
1770
+ if (kind !== 'holds' ||
1771
+ (await deleteExpiredHolds(tx, tenantRef, limit)) +
1772
+ (await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
1773
+ 0) {
1774
+ return limit;
1775
+ }
1776
+ return (await tenantLimitCount(tx, tenantRef, kind, limit)) >= limit ? limit : null;
1777
+ }
1778
+ /**
1779
+ * The tenant whose limit a write on the resource counts against: the caller's, else the
1780
+ * resource's (a resource never changes tenant, so it is read before any lock). Null with no limit.
1781
+ */
1782
+ async function limitTenant(tx, kind, resourceId, tenantRef) {
1783
+ if (configuredTenantLimits[kind] === undefined)
1784
+ return null;
1785
+ if (tenantRef !== undefined)
1786
+ return tenantRef;
1787
+ const [resource] = await tx `
1788
+ SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
1789
+ return resource?.tenant_ref ?? null;
1790
+ }
1791
+ const store = {
1792
+ async applySchema(migrationOptions) {
763
1793
  // PostgreSQL's IF NOT EXISTS DDL is idempotent after commit but not race-free: two fresh
764
1794
  // 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) => {
1795
+ // inMigration serializes bootstrap per database and the transaction releases the lock on
1796
+ // crashes. A schema this release already left as it is changes nothing and locks no table,
1797
+ // unless old rows kept one of its constraints NOT VALID: then only the validation runs again.
1798
+ const validate = await inMigration(sql, migrationOptions, async (tx) => {
1799
+ await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
769
1800
  throw schemaControlRefusal(error);
770
1801
  });
771
- }));
1802
+ if (await slotlockSchemaUpToDate(tx))
1803
+ return slotlockConstraintUnvalidated(tx);
1804
+ await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
1805
+ throw schemaControlRefusal(error);
1806
+ });
1807
+ return true;
1808
+ });
1809
+ if (!validate)
1810
+ return;
1811
+ // Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
1812
+ // a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
1813
+ // transaction both steps share it, and its locks, until it ends. The digests go last, so a
1814
+ // migration stopped before them runs whole again.
1815
+ await inMigration(sql, migrationOptions, async (tx) => {
1816
+ await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
1817
+ await tx `
1818
+ UPDATE slotlock.schema_version
1819
+ SET ddl_sha256 = ${SLOTLOCK_CORE_DDL_SHA256},
1820
+ catalog_sha256 = ${await slotlockCatalogDigest(tx)}
1821
+ WHERE singleton`;
1822
+ });
772
1823
  },
773
1824
  async applyTenantRls(applyOptions) {
774
1825
  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))`;
1826
+ const { allowRebind, ...migrationOptions } = applyOptions ?? {};
1827
+ await inMigration(sql, migrationOptions, async (tx) => {
1828
+ await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
1829
+ throw schemaControlRefusal(error);
1830
+ });
1831
+ if (await tenantRlsUpToDate(tx, configuredTenantSetting))
1832
+ return;
777
1833
  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:%'`;
1834
+ SELECT DISTINCT obj_description(policy.oid, 'pg_policy') AS binding
1835
+ FROM pg_policy policy
1836
+ JOIN pg_class table_class ON table_class.oid = policy.polrelid
1837
+ JOIN pg_namespace namespace ON namespace.oid = table_class.relnamespace
1838
+ WHERE namespace.nspname = 'slotlock'
1839
+ AND table_class.relname IN ${tx([...SLOTLOCK_TENANT_TABLES])}
1840
+ AND obj_description(policy.oid, 'pg_policy') LIKE 'slotlock:tenant-context:%'`;
798
1841
  const conflictingBinding = bindings.find(({ binding }) => binding !== `slotlock:tenant-context:${configuredTenantSetting}`);
799
- if (conflictingBinding && applyOptions?.allowRebind !== true) {
1842
+ if (conflictingBinding && allowRebind !== true) {
800
1843
  throw Object.assign(new Error(`Slotlock tenant RLS is already bound to ${conflictingBinding.binding.slice('slotlock:tenant-context:'.length)}`), { code: 'tenant_context_conflict' });
801
1844
  }
802
1845
  await tx.unsafe(ddl).catch((error) => {
803
1846
  throw schemaControlRefusal(error);
804
1847
  });
805
- }));
1848
+ });
806
1849
  },
807
- async grantApplicationRole(role) {
1850
+ async grantApplicationRole(role, migrationOptions) {
808
1851
  const ddl = createSlotlockApplicationRoleGrantsDdl(role);
809
- await inTransaction(sql, (tx) => withPinnedSearchPath(tx, async () => {
1852
+ await inMigration(sql, migrationOptions, (tx) => (async () => {
810
1853
  // Every role the target belongs to counts, not only the target: a member can SET ROLE to
811
1854
  // it or use its privileges, and one holding only ADMIN OPTION can grant itself either.
812
1855
  // MEMBER also counts a PostgreSQL 16 grant made with none of ADMIN, INHERIT or SET, which
@@ -910,7 +1953,7 @@ export function createSlotlockStore(sql, options = {}) {
910
1953
  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
1954
  }
912
1955
  await tx.unsafe(ddl);
913
- }));
1956
+ })());
914
1957
  },
915
1958
  async withTenant(tenantRef, callback, options) {
916
1959
  if (!isValidIdentity(tenantRef)) {
@@ -921,45 +1964,90 @@ export function createSlotlockStore(sql, options = {}) {
921
1964
  const isolation = options?.isolation;
922
1965
  if (isolation !== undefined && isolation !== 'read committed')
923
1966
  throw invalidIsolationError();
1967
+ const release = await tenantSlots?.(tenantRef, options?.signal);
1968
+ // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
1969
+ // another error in its place: noted here as it happens.
1970
+ let timedOut = false;
924
1971
  return inTransaction(sql, async (tx) => {
1972
+ if (lockTimeout !== undefined) {
1973
+ await tx `SELECT set_config('lock_timeout', ${lockTimeout}, true)`;
1974
+ }
925
1975
  if (isolation !== undefined)
926
1976
  await assertReadCommitted(tx);
1977
+ // Held shared until the transaction ends: suspension and erasure, which take it
1978
+ // exclusively, wait for this transaction, and one that starts after them reads them below
1979
+ // (READ COMMITTED: a statement after the lock sees what committed before it was granted).
927
1980
  const previous = await tx `
928
- SELECT current_setting(${configuredTenantSetting}, true) AS value`;
929
- await tx `SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true)`;
1981
+ SELECT current_setting(${configuredTenantSetting}, true) AS value,
1982
+ current_setting('transaction_isolation') AS isolation,
1983
+ pg_advisory_xact_lock_shared(
1984
+ hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
1985
+ ) AS locked`;
1986
+ const [context] = await tx `
1987
+ SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
1988
+ slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
1989
+ throw suspensionCheckRefusal(error);
1990
+ });
1991
+ // A caller's REPEATABLE READ or SERIALIZABLE snapshot was taken before the lock was
1992
+ // granted, so it may not show a suspension that committed while this transaction waited:
1993
+ // its writes are refused, and it may read.
1994
+ const refusal = context?.suspended === true
1995
+ ? tenantSuspendedError
1996
+ : previous[0]?.isolation !== 'read committed'
1997
+ ? staleSuspensionCheckError
1998
+ : null;
930
1999
  try {
931
- return await callback(createSlotlockStore(tx, {
932
- tenantContextSetting: configuredTenantSetting,
933
- agentOwnerEventQuota: configuredAgentOwnerEventQuota,
934
- agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
935
- }));
2000
+ return await callback(buildSlotlockStore(tx, storeOptions, refusal));
2001
+ }
2002
+ catch (error) {
2003
+ if (pgCode(error) === LOCK_NOT_AVAILABLE)
2004
+ timedOut = true;
2005
+ throw error;
936
2006
  }
937
2007
  finally {
938
2008
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true)`;
939
2009
  }
940
- }, isolation);
2010
+ }, 'read committed')
2011
+ .catch((error) => {
2012
+ if (lockTimeout !== undefined && (timedOut || pgCode(error) === LOCK_NOT_AVAILABLE)) {
2013
+ throw lockTimeoutError(error);
2014
+ }
2015
+ throw error;
2016
+ })
2017
+ .finally(() => release?.());
941
2018
  },
942
2019
  async createResource(params) {
943
2020
  assertValidResourceInput(params);
944
- const max = params.maxTenantResources;
945
- if (max === undefined)
2021
+ const requested = params.maxTenantResources;
2022
+ const configured = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2023
+ if (requested === undefined && configured === undefined)
946
2024
  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) {
2025
+ if (requested !== undefined &&
2026
+ (!Number.isSafeInteger(requested) ||
2027
+ requested < 1 ||
2028
+ requested > MAX_TENANT_RESOURCE_LIMIT ||
2029
+ params.tenantRef === undefined ||
2030
+ params.externalRef === undefined)) {
952
2031
  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
2032
  }
954
- const { tenantRef, externalRef } = params;
2033
+ const max = requested === undefined
2034
+ ? configured
2035
+ : configured === undefined
2036
+ ? requested
2037
+ : Math.min(requested, configured);
2038
+ const tenantRef = params.tenantRef;
2039
+ const externalRef = params.externalRef ?? null;
955
2040
  return inTransaction(sql, async (tx) => {
956
2041
  await assertReadCommitted(tx);
957
2042
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
958
2043
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
959
2044
  // 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}`;
2045
+ // Re-creating a resource the tenant has (by its reference) adds none.
2046
+ const known = externalRef === null
2047
+ ? []
2048
+ : await tx `
2049
+ SELECT id FROM slotlock.resources
2050
+ WHERE tenant_ref = ${tenantRef} AND external_ref = ${externalRef}`;
963
2051
  if (known.length === 0) {
964
2052
  const [counted] = await tx `
965
2053
  SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${tenantRef}`;
@@ -971,15 +2059,17 @@ export function createSlotlockStore(sql, options = {}) {
971
2059
  }, 'read committed');
972
2060
  },
973
2061
  async getResource(params) {
2062
+ assertValidVisibility(params.visibility);
974
2063
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
975
2064
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
976
2065
  code: 'invalid_identity',
977
2066
  });
978
2067
  }
979
2068
  const rows = await sql `
980
- SELECT id, external_ref, tenant_ref, timezone, availability_rules
2069
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
981
2070
  FROM slotlock.resources
982
- WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}`;
2071
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2072
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
983
2073
  const row = rows[0];
984
2074
  return row ? toResource(row) : null;
985
2075
  },
@@ -994,7 +2084,7 @@ export function createSlotlockStore(sql, options = {}) {
994
2084
  UPDATE slotlock.resources
995
2085
  SET availability_rules = ${rules}::jsonb
996
2086
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
997
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
2087
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
998
2088
  const row = rows[0];
999
2089
  return row ? toResource(row) : null;
1000
2090
  },
@@ -1029,42 +2119,126 @@ export function createSlotlockStore(sql, options = {}) {
1029
2119
  });
1030
2120
  }
1031
2121
  const rows = await sql `
1032
- SELECT id, external_ref, tenant_ref, timezone, availability_rules
2122
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
1033
2123
  FROM slotlock.resources
1034
2124
  WHERE tenant_ref = ${params.tenantRef}
2125
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2126
+ AND (${params.includeArchived === true} OR archived_at IS NULL)
1035
2127
  AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
1036
2128
  ORDER BY id
1037
2129
  LIMIT ${limit}`;
1038
2130
  return rows.map(toResource);
1039
2131
  },
2132
+ async archiveResource(params) {
2133
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2134
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2135
+ }
2136
+ // Its clock time, not the transaction's start: a booking that committed holding the row's
2137
+ // share lock went in before this.
2138
+ const [row] = await sql `
2139
+ UPDATE slotlock.resources
2140
+ SET archived_at = COALESCE(archived_at, clock_timestamp())
2141
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2142
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
2143
+ return row ? toResource(row) : null;
2144
+ },
2145
+ async restoreResource(params) {
2146
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2147
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2148
+ }
2149
+ const [row] = await sql `
2150
+ UPDATE slotlock.resources
2151
+ SET archived_at = NULL
2152
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2153
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
2154
+ return row ? toResource(row) : null;
2155
+ },
2156
+ async deleteResource(params) {
2157
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2158
+ throw invalidIdentityError('Slotlock resource identity is invalid');
2159
+ }
2160
+ return inTransaction(sql, async (tx) => {
2161
+ // The row lock waits for writers holding its share lock and keeps new ones out until done.
2162
+ const [resource] = await tx `
2163
+ SELECT id FROM slotlock.resources
2164
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2165
+ FOR UPDATE`;
2166
+ if (!resource)
2167
+ return { ok: true, deleted: false };
2168
+ const [live] = await tx `
2169
+ SELECT EXISTS (
2170
+ SELECT 1 FROM slotlock.reservations
2171
+ WHERE resource_id = ${params.id}
2172
+ AND calendar_event_id IS NULL
2173
+ AND (CASE WHEN status = 'held' THEN expires_at > now()
2174
+ ELSE COALESCE(occupied_ends_at, ends_at) > now() END)
2175
+ )
2176
+ OR EXISTS (
2177
+ SELECT 1 FROM slotlock.calendar_events
2178
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.id}
2179
+ AND (recurrence_rule IS NOT NULL OR ends_at > now())
2180
+ ) AS live`;
2181
+ if (live?.live)
2182
+ return { ok: false, code: 'resource_in_use' };
2183
+ // Its rows go with it: reservations, events and their children, coverage and hold answers
2184
+ // all cascade from the resource.
2185
+ await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
2186
+ return { ok: true, deleted: true };
2187
+ });
2188
+ },
1040
2189
  async createReservation(params) {
1041
2190
  if (!isFiniteWindow({ start: params.start, end: params.end })) {
1042
2191
  return { ok: false, code: 'invalid_window' };
1043
2192
  }
2193
+ if (!isValidResourceId(params.resourceId))
2194
+ return { ok: false, code: 'resource_not_found' };
2195
+ if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
2196
+ return { ok: false, code: 'resource_not_found' };
2197
+ }
2198
+ // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2199
+ // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
2200
+ const id = randomUUID();
1044
2201
  for (let attempt = 1;; attempt++) {
1045
2202
  try {
1046
- const rows = await inTransaction(sql, async (tx) => {
2203
+ return await inTransaction(sql, async (tx) => {
2204
+ const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
2205
+ const [resource] = await tx `
2206
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2207
+ WHERE id = ${params.resourceId}
2208
+ AND (${params.tenantRef ?? null}::text IS NULL
2209
+ OR tenant_ref = ${params.tenantRef ?? null})
2210
+ FOR SHARE`;
2211
+ if (!resource)
2212
+ return { ok: false, code: 'resource_not_found' };
2213
+ if (resource.archived)
2214
+ return { ok: false, code: 'resource_archived' };
2215
+ if (bookingLimit !== null && resource.tenant_ref !== null) {
2216
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2217
+ }
2218
+ if (resource.tenant_ref !== null &&
2219
+ !(await recurrenceCoversWrite(tx, {
2220
+ tenantRef: resource.tenant_ref,
2221
+ resourceId: params.resourceId,
2222
+ start: params.start,
2223
+ end: params.end,
2224
+ }))) {
2225
+ return { ok: false, code: 'recurrence_not_materialized' };
2226
+ }
1047
2227
  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`;
2228
+ const [row] = await tx `
2229
+ INSERT INTO slotlock.reservations (
2230
+ id, resource_id, tenant_ref, external_ref, starts_at, ends_at, source
2231
+ ) VALUES (
2232
+ ${id}, ${params.resourceId}, ${resource.tenant_ref},
2233
+ ${resource.tenant_ref === null ? null : id},
2234
+ ${params.start}, ${params.end}, ${params.source ?? null}
2235
+ )
2236
+ RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2237
+ buffer_after_ms, revision, source`;
2238
+ if (!row)
2239
+ throw new Error('slotlock: reservation insert returned no row');
2240
+ return { ok: true, reservation: toReservation(row) };
1052
2241
  });
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
2242
  }
1069
2243
  catch (err) {
1070
2244
  const code = pgCode(err);
@@ -1078,6 +2252,7 @@ export function createSlotlockStore(sql, options = {}) {
1078
2252
  }
1079
2253
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
1080
2254
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2255
+ await rollSeriesBehind(sql, params.resourceId, params.start, params.end);
1081
2256
  return overlapLoss(sql, params.resourceId, params.start, params.end);
1082
2257
  }
1083
2258
  }
@@ -1118,8 +2293,22 @@ export function createSlotlockStore(sql, options = {}) {
1118
2293
  }
1119
2294
  return { ok: true, reservation: toReservation(found), idempotent: true };
1120
2295
  }
1121
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
2296
+ const bookingLimit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
2297
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
2298
+ if (resourceState === 'missing')
1122
2299
  return { ok: false, code: 'resource_tenant_mismatch' };
2300
+ if (resourceState === 'archived')
2301
+ return { ok: false, code: 'resource_archived' };
2302
+ if (bookingLimit !== null) {
2303
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2304
+ }
2305
+ if (!(await recurrenceCoversWrite(tx, {
2306
+ tenantRef: params.tenantRef,
2307
+ resourceId: params.resourceId,
2308
+ start: params.start,
2309
+ end: occupiedEnd(params),
2310
+ }))) {
2311
+ return { ok: false, code: 'recurrence_not_materialized' };
1123
2312
  }
1124
2313
  await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
1125
2314
  const rows = await tx `
@@ -1148,6 +2337,7 @@ export function createSlotlockStore(sql, options = {}) {
1148
2337
  code !== SERIALIZATION_FAILURE) {
1149
2338
  throw err;
1150
2339
  }
2340
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
1151
2341
  return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs);
1152
2342
  }
1153
2343
  }
@@ -1190,8 +2380,18 @@ export function createSlotlockStore(sql, options = {}) {
1190
2380
  if (params.revision !== currentRevision + 1) {
1191
2381
  return { ok: false, code: 'revision_conflict' };
1192
2382
  }
1193
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
2383
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
2384
+ if (resourceState === 'missing')
1194
2385
  return { ok: false, code: 'resource_tenant_mismatch' };
2386
+ if (resourceState === 'archived')
2387
+ return { ok: false, code: 'resource_archived' };
2388
+ if (!(await recurrenceCoversWrite(tx, {
2389
+ tenantRef: params.tenantRef,
2390
+ resourceId: params.resourceId,
2391
+ start: params.start,
2392
+ end: occupiedEnd(params),
2393
+ }))) {
2394
+ return { ok: false, code: 'recurrence_not_materialized' };
1195
2395
  }
1196
2396
  await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
1197
2397
  const rows = await tx `
@@ -1221,6 +2421,7 @@ export function createSlotlockStore(sql, options = {}) {
1221
2421
  code !== SERIALIZATION_FAILURE) {
1222
2422
  throw err;
1223
2423
  }
2424
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
1224
2425
  return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs, currentReservationId);
1225
2426
  }
1226
2427
  }
@@ -1262,9 +2463,26 @@ export function createSlotlockStore(sql, options = {}) {
1262
2463
  return { ok: false, code: 'revision_conflict' };
1263
2464
  }
1264
2465
  }
2466
+ if (!found) {
2467
+ // A calendar event's occupation row carries this reference: it is the event's, not a
2468
+ // booking's. Nothing is cancelled, and nothing retired: a tombstone would only grow.
2469
+ const [occupation] = await tx `
2470
+ SELECT id FROM slotlock.reservations
2471
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
2472
+ AND calendar_event_id IS NOT NULL
2473
+ LIMIT 1`;
2474
+ if (occupation)
2475
+ return { ok: true, cancelled: false, revision: params.revision };
2476
+ // Retiring a reference no booking holds adds a row; cancelling a booking replaces one.
2477
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
2478
+ if (limit !== null)
2479
+ return { ok: false, code: 'reservation_limit_reached', limit };
2480
+ }
2481
+ // A reference no booking holds yet is retired too, so a create delivered after its cancel
2482
+ // cannot bring the booking back. Only such a stray retirement is pruned past retention.
1265
2483
  await tx `
1266
- INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision)
1267
- VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision})`;
2484
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
2485
+ VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
1268
2486
  if (!found)
1269
2487
  return { ok: true, cancelled: false, revision: params.revision };
1270
2488
  const deleted = await tx `
@@ -1282,31 +2500,101 @@ export function createSlotlockStore(sql, options = {}) {
1282
2500
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
1283
2501
  return { ok: false, code: 'invalid_ttl' };
1284
2502
  }
2503
+ const bufferAfterMs = params.bufferAfterMs ?? 0;
2504
+ if (!Number.isSafeInteger(bufferAfterMs) ||
2505
+ bufferAfterMs < 0 ||
2506
+ bufferAfterMs > MAX_BUFFER_AFTER_MS) {
2507
+ return { ok: false, code: 'invalid_buffer' };
2508
+ }
2509
+ const { tenantRef, principalRef, idempotencyKey } = params;
2510
+ if (tenantRef !== undefined && !isValidIdentity(tenantRef)) {
2511
+ return { ok: false, code: 'invalid_identity' };
2512
+ }
2513
+ const keyed = idempotencyKey !== undefined || principalRef !== undefined;
2514
+ if (keyed &&
2515
+ (tenantRef === undefined || !isValidIdentity(principalRef) || !isValidIdentity(idempotencyKey))) {
2516
+ return { ok: false, code: 'invalid_identity' };
2517
+ }
2518
+ if (!isValidResourceId(params.resourceId))
2519
+ return { ok: false, code: 'resource_not_found' };
2520
+ const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2521
+ const payloadHash = calendarCommandHash({
2522
+ operation: 'hold',
2523
+ resourceId: params.resourceId,
2524
+ start: params.start.toISOString(),
2525
+ end: params.end.toISOString(),
2526
+ ttlMs: params.ttlMs,
2527
+ bufferAfterMs,
2528
+ source: params.source ?? null,
2529
+ });
1285
2530
  const ttlSeconds = params.ttlMs / 1000;
1286
2531
  for (let attempt = 1;; attempt++) {
1287
2532
  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`;
2533
+ return await inTransaction(sql, async (tx) => {
2534
+ if (keyed) {
2535
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
2536
+ 'slotlock-hold-command',
2537
+ tenantRef,
2538
+ principalRef,
2539
+ idempotencyKey,
2540
+ ])}, 0))`;
2541
+ const [prior] = await tx `
2542
+ SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
2543
+ source, payload_hash
2544
+ FROM slotlock.hold_commands
2545
+ WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
2546
+ AND idempotency_key = ${idempotencyKey ?? null}`;
2547
+ if (prior) {
2548
+ return prior.payload_hash === payloadHash
2549
+ ? { ok: true, hold: toHold(prior), idempotent: true }
2550
+ : { ok: false, code: 'idempotency_conflict' };
2551
+ }
2552
+ }
2553
+ const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
2554
+ const [resource] = await tx `
2555
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2556
+ WHERE id = ${params.resourceId}
2557
+ AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
2558
+ FOR SHARE`;
2559
+ if (!resource)
2560
+ return { ok: false, code: 'resource_not_found' };
2561
+ if (resource.archived)
2562
+ return { ok: false, code: 'resource_archived' };
2563
+ if (holdLimit !== null)
2564
+ return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
2565
+ if (resource.tenant_ref !== null &&
2566
+ !(await recurrenceCoversWrite(tx, {
2567
+ tenantRef: resource.tenant_ref,
2568
+ resourceId: params.resourceId,
2569
+ start: params.start,
2570
+ end: occupied,
2571
+ }))) {
2572
+ return { ok: false, code: 'recurrence_not_materialized' };
2573
+ }
2574
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2575
+ const [row] = await tx `
2576
+ INSERT INTO slotlock.reservations (
2577
+ resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source
2578
+ ) VALUES (
2579
+ ${params.resourceId}, ${params.start}, ${params.end}, ${bufferAfterMs}, 'held',
2580
+ now() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null}
2581
+ )
2582
+ RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source`;
2583
+ if (!row)
2584
+ throw new Error('slotlock: hold insert returned no row');
2585
+ if (keyed) {
2586
+ await tx `
2587
+ INSERT INTO slotlock.hold_commands (
2588
+ tenant_ref, principal_ref, idempotency_key, payload_hash, hold_id, resource_id,
2589
+ starts_at, ends_at, buffer_after_ms, expires_at, source
2590
+ ) VALUES (
2591
+ ${tenantRef ?? null}, ${principalRef ?? null}, ${idempotencyKey ?? null},
2592
+ ${payloadHash}, ${row.id}, ${row.resource_id}, ${row.starts_at}, ${row.ends_at},
2593
+ ${row.buffer_after_ms}, ${row.expires_at}, ${row.source}
2594
+ )`;
2595
+ }
2596
+ return { ok: true, hold: toHold(row), idempotent: false };
1295
2597
  });
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
2598
  }
1311
2599
  catch (err) {
1312
2600
  const code = pgCode(err);
@@ -1320,74 +2608,172 @@ export function createSlotlockStore(sql, options = {}) {
1320
2608
  }
1321
2609
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
1322
2610
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
1323
- return overlapLoss(sql, params.resourceId, params.start, params.end);
2611
+ await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
2612
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs);
1324
2613
  }
1325
2614
  }
1326
2615
  },
1327
- async confirmHold(holdId, options = {}) {
1328
- const tenantRef = holdTenantRef(options);
2616
+ async getHold(params) {
2617
+ if (!isValidIdentity(params.tenantRef)) {
2618
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2619
+ }
2620
+ if (!isValidResourceId(params.holdId))
2621
+ return null;
2622
+ const [row] = await sql `
2623
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
2624
+ hold.expires_at, hold.source
2625
+ FROM slotlock.reservations hold
2626
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
2627
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
2628
+ AND hold.status = 'held' AND hold.expires_at > now()`;
2629
+ return row ? toHold(row) : null;
2630
+ },
2631
+ async listHolds(params) {
2632
+ if (!isValidIdentity(params.tenantRef)) {
2633
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2634
+ }
2635
+ if (!isValidResourceId(params.resourceId)) {
2636
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
2637
+ }
2638
+ const limit = pageLimit(params.limit);
2639
+ const after = pageCursor(params.after);
1329
2640
  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
- };
2641
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
2642
+ hold.expires_at, hold.source
2643
+ FROM slotlock.reservations hold
2644
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
2645
+ WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
2646
+ AND hold.status = 'held' AND hold.expires_at > now()
2647
+ AND (${after.start}::timestamptz IS NULL
2648
+ OR (hold.starts_at, hold.id) > (${after.start}::timestamptz, ${after.id}::uuid))
2649
+ ORDER BY hold.starts_at, hold.id
2650
+ LIMIT ${limit}`;
2651
+ return rows.map(toHold);
2652
+ },
2653
+ async getReservation(params) {
2654
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
2655
+ throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
1352
2656
  }
1353
- const present = await sql `
1354
- SELECT status, (status = 'held' AND expires_at <= now()) AS expired,
1355
- resource_id, starts_at, ends_at, source
2657
+ const [row] = await sql `
2658
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2659
+ revision, source
1356
2660
  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
- };
2661
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
2662
+ AND calendar_event_id IS NULL`;
2663
+ return row ? toReservation(row) : null;
2664
+ },
2665
+ async listReservations(params) {
2666
+ if (!isValidIdentity(params.tenantRef)) {
2667
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
1381
2668
  }
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' };
2669
+ if (!isValidResourceId(params.resourceId)) {
2670
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
2671
+ }
2672
+ const window = calendarWindow(params.window);
2673
+ const limit = pageLimit(params.limit);
2674
+ const after = pageCursor(params.after);
2675
+ const rows = await sql `
2676
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2677
+ revision, source
2678
+ FROM slotlock.reservations
2679
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
2680
+ AND calendar_event_id IS NULL
2681
+ AND starts_at < ${window.end}
2682
+ AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
2683
+ AND (${after.start}::timestamptz IS NULL
2684
+ OR (starts_at, id) > (${after.start}::timestamptz, ${after.id}::uuid))
2685
+ ORDER BY starts_at, id
2686
+ LIMIT ${limit}`;
2687
+ return rows.map(toReservation);
2688
+ },
2689
+ async confirmHold(holdId, options = {}) {
2690
+ const tenantRef = holdTenantRef(options);
2691
+ // Not an id the store issues: it names no hold (and would be a raw 22P02 in the query).
2692
+ if (!isValidResourceId(holdId))
2693
+ return { ok: false, code: 'hold_not_found' };
2694
+ return inTransaction(sql, async (tx) => {
2695
+ // The tenant whose booking the hold becomes is its resource's; a hold never changes
2696
+ // resource, so it is read before the identity lock, which is taken before the row lock as
2697
+ // every booking write takes them.
2698
+ const [owner] = await tx `
2699
+ SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
2700
+ FROM slotlock.reservations hold
2701
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
2702
+ WHERE hold.id = ${holdId} AND hold.calendar_event_id IS NULL
2703
+ AND (${tenantRef}::text IS NULL OR resource.tenant_ref = ${tenantRef})`;
2704
+ if (!owner)
2705
+ return { ok: false, code: 'hold_not_found' };
2706
+ const bookingTenant = owner.resource_tenant;
2707
+ if (bookingTenant !== null)
2708
+ await lockExternalIdentity(tx, bookingTenant, holdId);
2709
+ const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
2710
+ // The resource's share lock, as every booking write takes it: an archive waits for this
2711
+ // confirmation, and one that committed first is read here.
2712
+ const [resource] = await tx `
2713
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
2714
+ WHERE id = ${owner.resource_id}
2715
+ FOR SHARE`;
2716
+ const [row] = await tx `
2717
+ SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2718
+ revision, source, status, (status = 'held' AND expires_at > now()) AS live
2719
+ FROM slotlock.reservations
2720
+ WHERE id = ${holdId}
2721
+ FOR UPDATE`;
2722
+ if (!row) {
2723
+ // NOTE: a hold reaped mid-flight by a rival (expired while this confirm waited on the
2724
+ // reaper's lock) reads as hold_not_found: the row is gone and the space was legitimately
2725
+ // taken; both refusal codes are terminal for the caller.
2726
+ return { ok: false, code: 'hold_not_found' };
2727
+ }
2728
+ if (row.status === 'confirmed') {
2729
+ // Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
2730
+ // SUCCESS, the booking as it now is; reporting failure would make callers double-book
2731
+ // elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
2732
+ // booking with another reference was never this hold.
2733
+ if (row.external_ref !== null && row.external_ref !== row.id) {
2734
+ return { ok: false, code: 'hold_not_found' };
2735
+ }
2736
+ return { ok: true, reservation: toReservation(row) };
2737
+ }
2738
+ if (!row.live)
2739
+ return { ok: false, code: 'hold_expired' };
2740
+ // Archived since the hold was taken: a confirmation would be a new booking on it.
2741
+ if (resource?.archived !== false)
2742
+ return { ok: false, code: 'resource_archived' };
2743
+ if (bookingTenant !== null) {
2744
+ const [retired] = await tx `
2745
+ SELECT revision FROM slotlock.reservation_tombstones
2746
+ WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}`;
2747
+ if (retired)
2748
+ return { ok: false, code: 'reservation_cancelled' };
2749
+ const [taken] = await tx `
2750
+ SELECT id FROM slotlock.reservations
2751
+ WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}
2752
+ AND calendar_event_id IS NULL`;
2753
+ if (taken)
2754
+ return { ok: false, code: 'idempotency_conflict' };
2755
+ }
2756
+ if (bookingLimit !== null) {
2757
+ return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2758
+ }
2759
+ // The occupied end is already the end plus the hold's buffer (the trigger set it); the
2760
+ // tenant and reference make the hold a booking that cancel and reschedule reach.
2761
+ const [confirmed] = await tx `
2762
+ UPDATE slotlock.reservations
2763
+ SET status = 'confirmed', expires_at = NULL,
2764
+ tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
2765
+ WHERE id = ${holdId}
2766
+ RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2767
+ buffer_after_ms, revision, source`;
2768
+ if (!confirmed)
2769
+ throw new Error('slotlock: hold confirmation returned no row');
2770
+ return { ok: true, reservation: toReservation(confirmed) };
2771
+ });
1388
2772
  },
1389
2773
  async releaseHold(holdId, options = {}) {
1390
2774
  const tenantRef = holdTenantRef(options);
2775
+ if (!isValidResourceId(holdId))
2776
+ return { ok: true, released: false };
1391
2777
  const rows = await sql `
1392
2778
  DELETE FROM slotlock.reservations
1393
2779
  WHERE id = ${holdId} AND status = 'held'
@@ -1396,9 +2782,30 @@ export function createSlotlockStore(sql, options = {}) {
1396
2782
  RETURNING id`;
1397
2783
  return { ok: true, released: rows.length > 0 };
1398
2784
  },
1399
- async listBusy(resourceId, window) {
2785
+ async listBusy(resourceId, window, options = {}) {
1400
2786
  if (!isFiniteWindow(window))
1401
2787
  throw invalidWindowError();
2788
+ if (!isValidResourceId(resourceId)) {
2789
+ throw invalidIdentityError('Slotlock resource id must be a UUID');
2790
+ }
2791
+ if (options.limit !== undefined && !(Number.isSafeInteger(options.limit) && options.limit > 0)) {
2792
+ throw Object.assign(new Error('Slotlock busy read limit must be a positive whole number'), {
2793
+ code: 'invalid_limit',
2794
+ });
2795
+ }
2796
+ if (options.tenantRef !== undefined) {
2797
+ if (!isValidIdentity(options.tenantRef)) {
2798
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2799
+ }
2800
+ const [owned] = await sql `
2801
+ SELECT id FROM slotlock.resources
2802
+ WHERE id = ${resourceId} AND tenant_ref = ${options.tenantRef}`;
2803
+ if (!owned) {
2804
+ throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
2805
+ code: 'resource_not_found',
2806
+ });
2807
+ }
2808
+ }
1402
2809
  // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
1403
2810
  // space (the acquiring transaction reaps the row itself when it takes the window).
1404
2811
  const rows = await sql `
@@ -1408,11 +2815,15 @@ export function createSlotlockStore(sql, options = {}) {
1408
2815
  AND starts_at < ${window.end}
1409
2816
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
1410
2817
  AND (status <> 'held' OR expires_at > now())
1411
- ORDER BY starts_at`;
2818
+ ORDER BY starts_at
2819
+ LIMIT ${options.limit ?? null}`;
1412
2820
  return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
1413
2821
  },
1414
2822
  async putCalendarEvent(params) {
1415
2823
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
2824
+ if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
2825
+ return { ok: false, code: 'invalid_identity' };
2826
+ }
1416
2827
  if (!isValidIdentity(params.tenantRef) ||
1417
2828
  !isValidEventOwnerRef(ownerRef) ||
1418
2829
  !isValidIdentity(params.externalRef) ||
@@ -1432,7 +2843,7 @@ export function createSlotlockStore(sql, options = {}) {
1432
2843
  if (event.recurrence) {
1433
2844
  if (!params.materializationWindow)
1434
2845
  return { ok: false, code: 'invalid_event' };
1435
- materializationWindow = calendarWindow(params.materializationWindow);
2846
+ materializationWindow = seriesWindow(params.materializationWindow);
1436
2847
  }
1437
2848
  else {
1438
2849
  materializationWindow = oneOffEventWindow(event, params.materializationWindow);
@@ -1461,7 +2872,9 @@ export function createSlotlockStore(sql, options = {}) {
1461
2872
  event,
1462
2873
  window: materializationWindow,
1463
2874
  });
2875
+ const recurrenceEndsBy = recurrenceEndsByColumn(event);
1464
2876
  let attempted = expanded?.occurrences ?? [];
2877
+ let storedEventId;
1465
2878
  for (let attempt = 1;; attempt++) {
1466
2879
  try {
1467
2880
  return await inTransaction(sql, async (tx) => {
@@ -1509,9 +2922,11 @@ export function createSlotlockStore(sql, options = {}) {
1509
2922
  currentRevision: Number(tombstones[0].revision),
1510
2923
  };
1511
2924
  }
1512
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
2925
+ const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
2926
+ if (resourceState === 'missing')
1513
2927
  return { ok: false, code: 'resource_tenant_mismatch' };
1514
- }
2928
+ if (resourceState === 'archived')
2929
+ return { ok: false, code: 'resource_archived' };
1515
2930
  const existing = await tx `
1516
2931
  SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1517
2932
  summary, description, location, status, transparency, organizer, attendees,
@@ -1523,6 +2938,11 @@ export function createSlotlockStore(sql, options = {}) {
1523
2938
  AND external_ref = ${params.externalRef}
1524
2939
  FOR UPDATE`;
1525
2940
  const found = existing[0];
2941
+ storedEventId = found?.id;
2942
+ if (params.expectedResourceId !== undefined &&
2943
+ found?.resource_id !== params.expectedResourceId) {
2944
+ return { ok: false, code: 'resource_tenant_mismatch' };
2945
+ }
1526
2946
  const currentRevision = found ? Number(found.revision) : 0;
1527
2947
  if (params.expectedRevision !== currentRevision) {
1528
2948
  return { ok: false, code: 'revision_conflict', currentRevision };
@@ -1553,8 +2973,26 @@ export function createSlotlockStore(sql, options = {}) {
1553
2973
  };
1554
2974
  }
1555
2975
  }
2976
+ if (!found) {
2977
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'events');
2978
+ if (limit !== null)
2979
+ return { ok: false, code: 'event_limit_reached', limit };
2980
+ }
1556
2981
  const eventId = found?.id ?? randomUUID();
1557
2982
  const revision = currentRevision + 1;
2983
+ const reach = occurrenceSpan(occurrences);
2984
+ if ((event.transparency ?? 'opaque') === 'opaque' &&
2985
+ reach &&
2986
+ !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
2987
+ if (!(await recurrenceCoversWrite(tx, {
2988
+ tenantRef: params.tenantRef,
2989
+ resourceId: params.resourceId,
2990
+ ...reach,
2991
+ ...(found ? { exceptEventId: found.id } : {}),
2992
+ }))) {
2993
+ return { ok: false, code: 'recurrence_not_materialized' };
2994
+ }
2995
+ }
1558
2996
  if (found) {
1559
2997
  await tx `
1560
2998
  UPDATE slotlock.calendar_events
@@ -1575,6 +3013,8 @@ export function createSlotlockStore(sql, options = {}) {
1575
3013
  materialized_starts_at = ${materializationWindow.start},
1576
3014
  materialized_ends_at = ${materializationWindow.end},
1577
3015
  recurrence_cursor = ${seriesCursorJson(tx, series.cursor)},
3016
+ recurrence_ends_by = ${recurrenceEndsBy}::text::timestamptz,
3017
+ roll_retry_at = NULL,
1578
3018
  revision = ${revision},
1579
3019
  source = ${params.source ?? null},
1580
3020
  updated_at = now()
@@ -1588,7 +3028,7 @@ export function createSlotlockStore(sql, options = {}) {
1588
3028
  id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
1589
3029
  summary, description, location, status, transparency, organizer, attendees,
1590
3030
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1591
- materialized_ends_at, recurrence_cursor, revision, source
3031
+ materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source
1592
3032
  ) VALUES (
1593
3033
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
1594
3034
  ${params.resourceId},
@@ -1601,8 +3041,8 @@ export function createSlotlockStore(sql, options = {}) {
1601
3041
  ${event.recurrence?.rrule ?? null},
1602
3042
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
1603
3043
  ${materializationWindow.start}, ${materializationWindow.end},
1604
- ${seriesCursorJson(tx, series.cursor)}, ${revision},
1605
- ${params.source ?? null}
3044
+ ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3045
+ ${revision}, ${params.source ?? null}
1606
3046
  )`;
1607
3047
  }
1608
3048
  // The normalized children are the queryable/canonical representation. The JSON
@@ -1645,7 +3085,11 @@ export function createSlotlockStore(sql, options = {}) {
1645
3085
  code !== SERIALIZATION_FAILURE) {
1646
3086
  throw error;
1647
3087
  }
1648
- return findCalendarOverlap(sql, params.resourceId, attempted);
3088
+ const reach = occurrenceSpan(attempted);
3089
+ if (reach) {
3090
+ await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
3091
+ }
3092
+ return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
1649
3093
  }
1650
3094
  }
1651
3095
  },
@@ -1673,7 +3117,31 @@ export function createSlotlockStore(sql, options = {}) {
1673
3117
  const children = await loadCalendarEventChildren(sql, [row.id]);
1674
3118
  return toCalendarEvent(row, children.get(row.id));
1675
3119
  },
3120
+ async getCalendarEventById(params) {
3121
+ if (!isValidIdentity(params.tenantRef)) {
3122
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3123
+ }
3124
+ if (!isValidResourceId(params.eventId)) {
3125
+ throw invalidIdentityError('Slotlock event id must be a UUID');
3126
+ }
3127
+ const rows = await sql `
3128
+ SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
3129
+ summary, description, location, status, transparency, organizer, attendees,
3130
+ reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3131
+ materialized_ends_at, revision, source, created_at, updated_at
3132
+ FROM slotlock.calendar_events
3133
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
3134
+ const row = rows[0];
3135
+ if (!row)
3136
+ return null;
3137
+ const children = await loadCalendarEventChildren(sql, [row.id]);
3138
+ return toCalendarEvent(row, children.get(row.id));
3139
+ },
1676
3140
  async listCalendarEvents(params) {
3141
+ if (params.allOwners === true && params.ownerRef !== undefined) {
3142
+ throw invalidIdentityError('Slotlock lists one owner or every owner, not both');
3143
+ }
3144
+ const allOwners = params.allOwners === true;
1677
3145
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
1678
3146
  if (!isValidIdentity(params.tenantRef) || !isValidEventOwnerRef(ownerRef)) {
1679
3147
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -1711,7 +3179,7 @@ export function createSlotlockStore(sql, options = {}) {
1711
3179
  event.source, event.created_at, event.updated_at
1712
3180
  FROM slotlock.calendar_events event
1713
3181
  WHERE event.tenant_ref = ${params.tenantRef}
1714
- AND event.owner_ref = ${ownerRef}
3182
+ AND (${allOwners}::boolean OR event.owner_ref = ${ownerRef})
1715
3183
  AND event.resource_id = ${params.resourceId}
1716
3184
  AND EXISTS (
1717
3185
  SELECT 1 FROM slotlock.calendar_event_occurrences occurrence
@@ -1738,7 +3206,7 @@ export function createSlotlockStore(sql, options = {}) {
1738
3206
  code: 'invalid_identity',
1739
3207
  });
1740
3208
  }
1741
- const window = calendarWindow(params.window);
3209
+ const window = seriesWindow(params.window);
1742
3210
  const limit = params.limit ?? 100;
1743
3211
  if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
1744
3212
  throw Object.assign(new Error('Slotlock horizon batch limit must be 1-1000'), {
@@ -1747,9 +3215,12 @@ export function createSlotlockStore(sql, options = {}) {
1747
3215
  }
1748
3216
  const after = rollCursor(params.after);
1749
3217
  // Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
1750
- // of its start.
3218
+ // of its start. Those it has never put back come first, then those it has, longest waiting
3219
+ // first: however many it cannot extend, they never stand in front of one it has not tried,
3220
+ // so every pass that is not capped by such new ones reaches the healthy series, and the ones
3221
+ // put back take turns.
1751
3222
  const candidates = await sql `
1752
- SELECT id, materialized_ends_at
3223
+ SELECT id, materialized_ends_at, roll_retry_at
1753
3224
  FROM slotlock.calendar_events
1754
3225
  WHERE tenant_ref = ${params.tenantRef}
1755
3226
  AND recurrence_rule IS NOT NULL
@@ -1757,71 +3228,40 @@ export function createSlotlockStore(sql, options = {}) {
1757
3228
  materialized_starts_at > ${window.start}
1758
3229
  OR materialized_ends_at < ${window.end}
1759
3230
  )
1760
- AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_CALENDAR_WINDOW_MS)}
3231
+ AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_MATERIALIZATION_WINDOW_MS)}
3232
+ -- One it could not extend waits its turn behind the ones it can.
3233
+ AND (roll_retry_at IS NULL OR roll_retry_at <= now())
1761
3234
  AND (
1762
3235
  ${after?.materializedEndsAt ?? null}::timestamptz IS NULL
1763
- OR (materialized_ends_at, id) >
1764
- (${after?.materializedEndsAt ?? null}::timestamptz, ${after?.eventId ?? null}::uuid)
3236
+ OR (COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id) >
3237
+ (COALESCE(${after?.rollRetryAt ?? null}::timestamptz, '-infinity'),
3238
+ ${after?.materializedEndsAt ?? null}::timestamptz, ${after?.eventId ?? null}::uuid)
1765
3239
  )
1766
- ORDER BY materialized_ends_at, id
3240
+ ORDER BY COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id
1767
3241
  LIMIT ${limit + 1}`;
1768
3242
  const due = candidates.slice(0, limit);
1769
3243
  let extended = 0;
1770
3244
  let conflicts = 0;
1771
3245
  let refused = 0;
1772
3246
  const refusedEventIds = [];
3247
+ const conflictEventIds = [];
1773
3248
  for (const candidate of due) {
1774
3249
  for (let attempt = 1; attempt <= 2; attempt++) {
1775
3250
  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
- });
3251
+ const changed = await inTransaction(sql, (tx) => rollStoredSeries(tx, params.tenantRef, candidate.id, window));
1814
3252
  if (changed)
1815
3253
  extended += 1;
1816
3254
  break;
1817
3255
  }
1818
3256
  catch (error) {
1819
3257
  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.
3258
+ // It takes more than Slotlock expands: a rule that never occurs again, one with no
3259
+ // cursor to resume from whose search from its start has outgrown the budget, or one an
3260
+ // earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
3261
+ // stays, and the masters after it still roll.
1823
3262
  refused += 1;
1824
3263
  refusedEventIds.push(candidate.id);
3264
+ await deferRoll(sql, params.tenantRef, candidate.id);
1825
3265
  break;
1826
3266
  }
1827
3267
  const code = pgCode(error);
@@ -1834,9 +3274,17 @@ export function createSlotlockStore(sql, options = {}) {
1834
3274
  // The savepoint rolled every delete/insert back, so the previous materialized
1835
3275
  // coverage remains truthful and callers will fail closed beyond it.
1836
3276
  conflicts += 1;
3277
+ conflictEventIds.push(candidate.id);
3278
+ await deferRoll(sql, params.tenantRef, candidate.id);
1837
3279
  break;
1838
3280
  }
1839
- throw error;
3281
+ // Anything else this one series meets (a row no Slotlock would write, a constraint it
3282
+ // trips) was rolled back with its savepoint too: it is put back like one over the
3283
+ // budget, so no single row stops the tenant's roll.
3284
+ refused += 1;
3285
+ refusedEventIds.push(candidate.id);
3286
+ await deferRoll(sql, params.tenantRef, candidate.id);
3287
+ break;
1840
3288
  }
1841
3289
  }
1842
3290
  }
@@ -1846,14 +3294,24 @@ export function createSlotlockStore(sql, options = {}) {
1846
3294
  examined: due.length,
1847
3295
  extended,
1848
3296
  conflicts,
3297
+ conflictEventIds,
1849
3298
  refused,
1850
3299
  refusedEventIds,
1851
3300
  hasMore,
1852
- next: hasMore && last ? { materializedEndsAt: last.materialized_ends_at, eventId: last.id } : null,
3301
+ next: hasMore && last
3302
+ ? {
3303
+ rollRetryAt: last.roll_retry_at,
3304
+ materializedEndsAt: last.materialized_ends_at,
3305
+ eventId: last.id,
3306
+ }
3307
+ : null,
1853
3308
  };
1854
3309
  },
1855
3310
  async cancelCalendarEvent(params) {
1856
3311
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
3312
+ if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
3313
+ return { ok: false, code: 'invalid_identity' };
3314
+ }
1857
3315
  if (!isValidIdentity(params.tenantRef) ||
1858
3316
  !isValidEventOwnerRef(ownerRef) ||
1859
3317
  !isValidIdentity(params.externalRef) ||
@@ -1918,12 +3376,16 @@ export function createSlotlockStore(sql, options = {}) {
1918
3376
  };
1919
3377
  }
1920
3378
  const events = await tx `
1921
- SELECT id, revision FROM slotlock.calendar_events
3379
+ SELECT id, resource_id, revision FROM slotlock.calendar_events
1922
3380
  WHERE tenant_ref = ${params.tenantRef}
1923
3381
  AND owner_ref = ${ownerRef}
1924
3382
  AND external_ref = ${params.externalRef}
1925
3383
  FOR UPDATE`;
1926
3384
  const found = events[0];
3385
+ if (params.expectedResourceId !== undefined &&
3386
+ found?.resource_id !== params.expectedResourceId) {
3387
+ return { ok: false, code: 'event_not_found' };
3388
+ }
1927
3389
  const currentRevision = found ? Number(found.revision) : 0;
1928
3390
  if (!found && ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
1929
3391
  return { ok: false, code: 'event_not_found' };
@@ -1931,41 +3393,183 @@ export function createSlotlockStore(sql, options = {}) {
1931
3393
  if (params.expectedRevision !== currentRevision) {
1932
3394
  return { ok: false, code: 'revision_conflict', currentRevision };
1933
3395
  }
1934
- const eventId = found?.id ?? randomUUID();
1935
- const revision = currentRevision + 1;
1936
- if (found) {
3396
+ return finishCalendarCancel(tx, {
3397
+ tenantRef: params.tenantRef,
3398
+ ownerRef,
3399
+ externalRef: params.externalRef,
3400
+ eventId: found?.id ?? null,
3401
+ revision: currentRevision + 1,
3402
+ commandOwnerRef: ownerRef,
3403
+ idempotencyKey: params.idempotencyKey,
3404
+ payloadHash,
3405
+ });
3406
+ });
3407
+ },
3408
+ async cancelCalendarEventById(params) {
3409
+ if (!isValidIdentity(params.tenantRef) ||
3410
+ !isValidResourceId(params.eventId) ||
3411
+ !isValidIdentity(params.idempotencyKey) ||
3412
+ (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId))) {
3413
+ return { ok: false, code: 'invalid_identity' };
3414
+ }
3415
+ if (!Number.isSafeInteger(params.expectedRevision) || params.expectedRevision < 0) {
3416
+ return { ok: false, code: 'invalid_revision' };
3417
+ }
3418
+ const commandOwnerRef = SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
3419
+ // Its own operation in the hash: an owner-scoped cancel under the same key never replays it.
3420
+ const payloadHash = calendarCommandHash({
3421
+ operation: 'cancel-by-id',
3422
+ eventId: params.eventId,
3423
+ idempotencyKey: params.idempotencyKey,
3424
+ expectedRevision: params.expectedRevision,
3425
+ expectedResourceId: params.expectedResourceId ?? null,
3426
+ });
3427
+ return inTransaction(sql, async (tx) => {
3428
+ await lockCalendarIdempotency(tx, params.tenantRef, commandOwnerRef, params.idempotencyKey);
3429
+ const commands = await tx `
3430
+ SELECT operation, payload_hash, event_id, external_ref,
3431
+ result_revision, occurrence_count
3432
+ FROM slotlock.calendar_event_commands
3433
+ WHERE tenant_ref = ${params.tenantRef}
3434
+ AND owner_ref = ${commandOwnerRef}
3435
+ AND idempotency_key = ${params.idempotencyKey}
3436
+ FOR UPDATE`;
3437
+ const priorCommand = commands[0];
3438
+ if (priorCommand) {
3439
+ if (priorCommand.operation !== 'cancel' || priorCommand.payload_hash !== payloadHash) {
3440
+ return { ok: false, code: 'idempotency_conflict' };
3441
+ }
3442
+ return {
3443
+ ok: true,
3444
+ eventId: priorCommand.event_id,
3445
+ externalRef: priorCommand.external_ref,
3446
+ revision: Number(priorCommand.result_revision),
3447
+ idempotent: true,
3448
+ };
3449
+ }
3450
+ // An event's owner and reference never change, so they are read before its identity lock,
3451
+ // which is taken before its row lock as every event write takes them.
3452
+ const [identity] = await tx `
3453
+ SELECT owner_ref, external_ref FROM slotlock.calendar_events
3454
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
3455
+ if (!identity)
3456
+ return { ok: false, code: 'event_not_found' };
3457
+ await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
3458
+ const [found] = await tx `
3459
+ SELECT id, resource_id, revision FROM slotlock.calendar_events
3460
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
3461
+ FOR UPDATE`;
3462
+ // Cancelled while this waited on its lock.
3463
+ if (!found)
3464
+ return { ok: false, code: 'event_not_found' };
3465
+ if (params.expectedResourceId !== undefined &&
3466
+ found.resource_id !== params.expectedResourceId) {
3467
+ return { ok: false, code: 'event_not_found' };
3468
+ }
3469
+ const currentRevision = Number(found.revision);
3470
+ if (params.expectedRevision !== currentRevision) {
3471
+ return { ok: false, code: 'revision_conflict', currentRevision };
3472
+ }
3473
+ return finishCalendarCancel(tx, {
3474
+ tenantRef: params.tenantRef,
3475
+ ownerRef: identity.owner_ref,
3476
+ externalRef: identity.external_ref,
3477
+ eventId: found.id,
3478
+ revision: currentRevision + 1,
3479
+ commandOwnerRef,
3480
+ idempotencyKey: params.idempotencyKey,
3481
+ payloadHash,
3482
+ });
3483
+ });
3484
+ },
3485
+ async pruneBookingRetention(params) {
3486
+ if (!isValidIdentity(params.tenantRef)) {
3487
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3488
+ }
3489
+ const limit = params.limit ?? 100;
3490
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
3491
+ throw Object.assign(new Error('Slotlock retention batch limit must be 1-1000'), {
3492
+ code: 'invalid_limit',
3493
+ });
3494
+ }
3495
+ const retentionDays = params.retentionDays ?? SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
3496
+ if (!Number.isSafeInteger(retentionDays) ||
3497
+ retentionDays < 1 ||
3498
+ retentionDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
3499
+ throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
3500
+ code: 'invalid_retention_window',
3501
+ });
3502
+ }
3503
+ return inTransaction(sql, async (tx) => {
3504
+ // A 0.2 server still serving after the migration wrote its confirmed holds (and
3505
+ // createReservation bookings) on the tenant's resources without the tenant or a reference,
3506
+ // so no cancel or move reaches them; the migration gives those it finds the tenant and
3507
+ // their own id, and this gives later ones the same. Each takes its reference's identity lock
3508
+ // first, as every booking write does; a tombstone of its id retired nothing and goes.
3509
+ const legacy = await tx `
3510
+ SELECT booking.id
3511
+ FROM slotlock.reservations booking
3512
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
3513
+ WHERE resource.tenant_ref = ${params.tenantRef}
3514
+ AND booking.tenant_ref IS NULL AND booking.status = 'confirmed'
3515
+ AND booking.calendar_event_id IS NULL
3516
+ AND NOT EXISTS (
3517
+ SELECT 1 FROM slotlock.reservations taken
3518
+ WHERE taken.tenant_ref = ${params.tenantRef}
3519
+ AND taken.external_ref = booking.id::text
3520
+ AND taken.calendar_event_id IS NULL
3521
+ )
3522
+ ORDER BY booking.id
3523
+ LIMIT ${limit}`;
3524
+ for (const { id } of legacy) {
3525
+ await lockExternalIdentity(tx, params.tenantRef, id);
3526
+ const [taken] = await tx `
3527
+ SELECT id FROM slotlock.reservations
3528
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}
3529
+ AND calendar_event_id IS NULL`;
3530
+ if (taken)
3531
+ continue;
1937
3532
  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
- )`;
3533
+ DELETE FROM slotlock.reservation_tombstones
3534
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}`;
1943
3535
  await tx `
1944
- DELETE FROM slotlock.calendar_events
1945
- WHERE id = ${eventId}
1946
- AND tenant_ref = ${params.tenantRef}
1947
- AND owner_ref = ${ownerRef}`;
3536
+ UPDATE slotlock.reservations
3537
+ SET tenant_ref = ${params.tenantRef}, external_ref = id::text,
3538
+ occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
3539
+ WHERE id = ${id} AND tenant_ref IS NULL AND status = 'confirmed'`;
1948
3540
  }
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
- )`;
3541
+ const expiredHoldsDeleted = await deleteExpiredHolds(tx, params.tenantRef, limit);
3542
+ const holdCommandsDeleted = await forgetExpiredHoldAnswers(tx, params.tenantRef, limit);
3543
+ // Only references retired with no booking: a cancelled booking's tombstone is kept.
3544
+ const [tombstones] = await tx `
3545
+ WITH candidates AS MATERIALIZED (
3546
+ SELECT tombstone.external_ref
3547
+ FROM slotlock.reservation_tombstones tombstone
3548
+ WHERE tombstone.tenant_ref = ${params.tenantRef}
3549
+ AND tombstone.stray
3550
+ AND tombstone.cancelled_at <
3551
+ clock_timestamp() - (${retentionDays}::int * interval '1 day')
3552
+ ORDER BY tombstone.cancelled_at, tombstone.external_ref
3553
+ LIMIT ${limit}
3554
+ FOR UPDATE OF tombstone SKIP LOCKED
3555
+ ), deleted AS (
3556
+ DELETE FROM slotlock.reservation_tombstones tombstone
3557
+ USING candidates candidate
3558
+ WHERE tombstone.tenant_ref = ${params.tenantRef}
3559
+ AND tombstone.external_ref = candidate.external_ref
3560
+ AND tombstone.stray
3561
+ RETURNING 1
3562
+ )
3563
+ SELECT count(*)::int AS deleted FROM deleted`;
3564
+ const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
1963
3565
  return {
1964
- ok: true,
1965
- eventId,
1966
- externalRef: params.externalRef,
1967
- revision,
1968
- idempotent: false,
3566
+ expiredHoldsDeleted,
3567
+ holdCommandsDeleted,
3568
+ tombstonesDeleted,
3569
+ hasMore: legacy.length === limit ||
3570
+ expiredHoldsDeleted === limit ||
3571
+ holdCommandsDeleted === limit ||
3572
+ tombstonesDeleted === limit,
1969
3573
  };
1970
3574
  });
1971
3575
  },
@@ -2084,7 +3688,8 @@ export function createSlotlockStore(sql, options = {}) {
2084
3688
  }
2085
3689
  return inTransaction(sql, async (tx) => {
2086
3690
  await lockExternalIdentity(tx, params.tenantRef, `calendar-coverage:${params.resourceId}:${params.source}`);
2087
- if (!(await resourceBelongsToTenant(tx, params.resourceId, params.tenantRef))) {
3691
+ // Coverage of an archived resource may still be recorded: it books nothing.
3692
+ if ((await tenantResourceState(tx, params.resourceId, params.tenantRef)) === 'missing') {
2088
3693
  return { ok: false, code: 'resource_tenant_mismatch' };
2089
3694
  }
2090
3695
  const rows = await tx `
@@ -2119,6 +3724,9 @@ export function createSlotlockStore(sql, options = {}) {
2119
3724
  else {
2120
3725
  if (params.revision !== 1)
2121
3726
  return { ok: false, code: 'revision_conflict' };
3727
+ const limit = await tenantLimitReached(tx, params.tenantRef, 'coverage');
3728
+ if (limit !== null)
3729
+ return { ok: false, code: 'coverage_limit_reached', limit };
2122
3730
  await tx `
2123
3731
  INSERT INTO slotlock.calendar_coverage (
2124
3732
  tenant_ref, resource_id, source, starts_at, ends_at, revision
@@ -2261,17 +3869,148 @@ export function createSlotlockStore(sql, options = {}) {
2261
3869
  };
2262
3870
  },
2263
3871
  async findNextAvailableFor(params) {
2264
- if (!isFiniteWindow(params.searchWindow))
3872
+ const window = params.searchWindow;
3873
+ if (!isFiniteWindow(window))
2265
3874
  throw invalidWindowError();
2266
- assertBoundedSearchWindow(params.searchWindow);
3875
+ assertBoundedSearchWindow(window);
3876
+ const { tenantRef } = params;
3877
+ if (tenantRef !== null && !isValidIdentity(tenantRef)) {
3878
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3879
+ }
3880
+ const bufferAfterMs = params.bufferAfterMs ?? 0;
3881
+ if (!Number.isSafeInteger(bufferAfterMs) ||
3882
+ bufferAfterMs < 0 ||
3883
+ bufferAfterMs > MAX_BUFFER_AFTER_MS) {
3884
+ throw Object.assign(new Error('Slotlock bufferAfterMs must be a whole 0-30 days'), {
3885
+ code: 'invalid_buffer',
3886
+ });
3887
+ }
3888
+ const notFound = () => Object.assign(new Error('Slotlock resource not found in this tenant'), {
3889
+ code: 'resource_not_found',
3890
+ });
3891
+ if (!isValidResourceId(params.resourceId))
3892
+ throw notFound();
3893
+ const [row] = await sql `
3894
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
3895
+ FROM slotlock.resources
3896
+ WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
3897
+ if (!row)
3898
+ throw notFound();
2267
3899
  if (!Number.isFinite(params.durationMs) || params.durationMs <= 0)
2268
3900
  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 });
3901
+ const resource = toResource(row);
3902
+ const rules = params.rules ?? resource.availabilityRules ?? [];
3903
+ // A slot's buffer may run past the window: the time it can reach must be proven free too.
3904
+ const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
3905
+ let busy;
3906
+ if (tenantRef === null) {
3907
+ // No event, coverage or series can belong to a resource with no tenant: only its rows.
3908
+ busy = await this.listBusy(params.resourceId, reach);
3909
+ }
3910
+ else {
3911
+ busy = [];
3912
+ const parts = bufferAfterMs > 0 ? [window, { start: window.end, end: reach.end }] : [window];
3913
+ for (const part of parts) {
3914
+ const freeBusy = await this.getFreeBusy({
3915
+ tenantRef,
3916
+ resourceId: params.resourceId,
3917
+ window: part,
3918
+ ...(params.requiredSources !== undefined
3919
+ ? { requiredSources: params.requiredSources }
3920
+ : {}),
3921
+ ...(params.coverageMaxAgeMs !== undefined
3922
+ ? { coverageMaxAgeMs: params.coverageMaxAgeMs }
3923
+ : {}),
3924
+ });
3925
+ // Absence is never treated as free time: what cannot be proven gives no slot.
3926
+ if (freeBusy.coverage.state !== 'complete')
3927
+ return null;
3928
+ busy.push(...freeBusy.busy);
3929
+ }
3930
+ }
3931
+ // A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
3932
+ return findNextAvailable({
3933
+ busy: busy.map((interval) => ({
3934
+ start: new Date(interval.start.getTime() - bufferAfterMs),
3935
+ end: interval.end,
3936
+ })),
3937
+ windows: expandRules(rules, window, resource.timezone),
3938
+ durationMs: params.durationMs,
3939
+ });
3940
+ },
3941
+ async suspendTenant(params) {
3942
+ if (!isValidIdentity(params.tenantRef)) {
3943
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3944
+ }
3945
+ const reason = params.reason;
3946
+ if (reason !== undefined &&
3947
+ (typeof reason !== 'string' ||
3948
+ reason.length === 0 ||
3949
+ Array.from(reason).length > 500 ||
3950
+ Array.from(reason).some((character) => {
3951
+ const codePoint = character.codePointAt(0) ?? 0;
3952
+ return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
3953
+ }))) {
3954
+ throw Object.assign(new Error('Slotlock suspension reason must be 1-500 characters without control characters'), { code: 'invalid_suspension_reason' });
3955
+ }
3956
+ try {
3957
+ const [row] = await sql `
3958
+ SELECT slotlock.suspend_tenant(${params.tenantRef}::text, ${reason ?? null}::text) AS suspended`;
3959
+ return { suspended: row?.suspended === true };
3960
+ }
3961
+ catch (error) {
3962
+ throw suspensionRefusal(error);
3963
+ }
3964
+ },
3965
+ async unsuspendTenant(params) {
3966
+ if (!isValidIdentity(params.tenantRef)) {
3967
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3968
+ }
3969
+ try {
3970
+ const [row] = await sql `
3971
+ SELECT slotlock.unsuspend_tenant(${params.tenantRef}::text) AS unsuspended`;
3972
+ return { unsuspended: row?.unsuspended === true };
3973
+ }
3974
+ catch (error) {
3975
+ throw suspensionRefusal(error);
3976
+ }
3977
+ },
3978
+ async isTenantSuspended(params) {
3979
+ if (!isValidIdentity(params.tenantRef)) {
3980
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3981
+ }
3982
+ const [row] = await sql `
3983
+ SELECT slotlock.tenant_suspended(${params.tenantRef}::text) AS suspended`.catch((error) => {
3984
+ throw suspensionCheckRefusal(error);
3985
+ });
3986
+ return row?.suspended === true;
3987
+ },
3988
+ async eraseTenant(params) {
3989
+ if (!isValidIdentity(params.tenantRef)) {
3990
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3991
+ }
3992
+ const erased = await sql `
3993
+ SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`;
3994
+ const rows = {};
3995
+ let total = 0;
3996
+ for (const row of erased) {
3997
+ const count = Number(row.erased_rows);
3998
+ rows[row.erased_table] = count;
3999
+ total += count;
4000
+ }
4001
+ return { rows, total };
2275
4002
  },
2276
4003
  };
4004
+ if (refusal === null)
4005
+ return store;
4006
+ const refuse = async () => {
4007
+ throw refusal();
4008
+ };
4009
+ return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
4010
+ }
4011
+ /** The deployment role alone may suspend: another role is refused EXECUTE on the functions. */
4012
+ function suspensionRefusal(error) {
4013
+ if (pgCode(error) !== '42501')
4014
+ return error;
4015
+ return Object.assign(new Error('Slotlock suspends tenants and lifts suspensions only as the deployment role'), { code: 'tenant_suspension_not_permitted', cause: error });
2277
4016
  }