slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/store.js CHANGED
@@ -1,53 +1,52 @@
1
- // DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
2
- // Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
3
- // reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
1
+ // DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
2
+ import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
3
+ import { readSlotlockTenantUsage } from './usage.js';
4
+ import { slotlockPlanReadRefusal } from './plans.js';
4
5
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
5
6
  import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
6
7
  import { createHash, randomUUID } from 'node:crypto';
7
8
  import ICAL from 'ical.js';
8
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, 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, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
9
+ import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
9
10
  import { findNextAvailable, mergeIntervals } from './engine.js';
10
11
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
11
12
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
12
13
  import { parseAvailabilityRules } from './weekly-hours.js';
13
- /** Postgres exclusion_violation — the EXCLUDE constraint fired. */
14
+ import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
14
15
  const EXCLUSION_VIOLATION = '23P01';
16
+ /** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
17
+ const SLOTLOCK_MAX_APPROVAL_HOLDS = 20;
18
+ /** Approvals one requester, and one tenant, may have waiting at once. */
19
+ const SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER = 50;
20
+ const SLOTLOCK_MAX_PENDING_APPROVALS = 500;
15
21
  /**
16
- * Concurrent inserts checking the same EXCLUDE range can deadlock inside the constraint check
17
- * itself (probed at ~30-40% under two-way contention — intrinsic to Postgres EXCLUDE, present
18
- * for raw inserts too). A 40P01 victim's transaction is fully rolled back, so one retry either
19
- * wins cleanly or loses with a proper 23P01 — both structured. Never surfaced raw.
22
+ * Concurrent inserts on the same EXCLUDE range can deadlock inside the constraint check itself
23
+ * (intrinsic to Postgres EXCLUDE). The victim is fully rolled back, so one retry either wins or
24
+ * loses with a structured 23P01.
20
25
  */
21
26
  const DEADLOCK_DETECTED = '40P01';
22
27
  const SERIALIZATION_FAILURE = '40001';
23
- /** Holds are negotiation-scoped; a week is already generous. Beyond it is a caller bug. */
28
+ /** Holds are negotiation-scoped; a week is already generous. */
24
29
  const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
25
- /** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
30
+ /** Guards against a caller unit error occupying a resource indefinitely. */
26
31
  const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
27
- /**
28
- * The longest leading buffer a row keeps (a day, as bufferBeforeMinutes allows): a row's occupied
29
- * time starts at most this long before its start, so a read by start can still use the index.
30
- */
32
+ /** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
31
33
  const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
32
34
  export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
33
35
  /**
34
- * Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
35
- * or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
36
- * series is bounded by its 367-day materialization window instead.
36
+ * Longest one-off event (ten years and some leap days); a recurring series is bounded by its
37
+ * materialization window instead.
37
38
  */
38
39
  export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
39
40
  const DAY_MS = 24 * 60 * 60 * 1000;
40
41
  const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
41
42
  /**
42
- * Longest window a recurring series is stored (materialized) for: the 367-day search window plus
43
- * two days. The rolling horizon starts a day before the current UTC day and must reach 367 days
44
- * past its end, so that a search of the full 367 days from any moment of the day is complete.
43
+ * The 367-day search window plus two days: the rolling horizon starts a day before the current UTC
44
+ * day and must reach 367 days past its end.
45
45
  */
46
46
  export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
47
47
  const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
48
48
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
49
49
  const MAX_RESOURCE_LIST_LIMIT = 1_000;
50
- /** Highest per-tenant resource cap `createResource` accepts. */
51
50
  const MAX_TENANT_RESOURCE_LIMIT = 100_000;
52
51
  const MAX_CALENDAR_LIST_LIMIT = 1_000;
53
52
  const MAX_FREE_BUSY_INTERVALS = 10_000;
@@ -66,7 +65,7 @@ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
66
65
  const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
67
66
  /** How long before its start a booking may be checked in. */
68
67
  export const SLOTLOCK_CHECK_IN_OPENS_MINUTES = 60;
69
- /** Free-looking resources one entry tries before the next entry: a race can take one first. */
68
+ /** Free-looking resources one entry tries before the next: a race can take one first. */
70
69
  const OFFER_CANDIDATES = 3;
71
70
  /** Default hard ceiling for retained active + tombstoned identities per agent principal. */
72
71
  export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
@@ -86,8 +85,8 @@ const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
86
85
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
87
86
  export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
88
87
  /**
89
- * Stable daily target shared by the event writer, maintenance worker, and readiness proof: from a
90
- * day before the current UTC day to 367 days past its end (SLOTLOCK_MATERIALIZATION_WINDOW_DAYS).
88
+ * From a day before the current UTC day to SLOTLOCK_MATERIALIZATION_WINDOW_DAYS past its end;
89
+ * shared by the writer, maintenance and readiness.
91
90
  */
92
91
  export function calendarEventRollingHorizon(now = new Date()) {
93
92
  if (!Number.isFinite(now.getTime()))
@@ -97,8 +96,8 @@ export function calendarEventRollingHorizon(now = new Date()) {
97
96
  return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
98
97
  }
99
98
  /**
100
- * A stored series expanded from the cursor its last expansion left, or `null` when there is none it
101
- * can use or the expansion from it is refused: the write's own search from the start was refused.
99
+ * A stored series expanded from its last cursor, or `null` when there is no usable cursor or the
100
+ * expansion is refused.
102
101
  */
103
102
  function resumeStoredSeries(event, window, found) {
104
103
  if (!found?.recurrence_cursor)
@@ -116,15 +115,13 @@ function resumeStoredSeries(event, window, found) {
116
115
  throw error;
117
116
  }
118
117
  }
119
- /** A series cursor as the jsonb column takes it. */
120
118
  function seriesCursorJson(tx, cursor) {
121
119
  return cursor === null ? null : tx.json(cursor);
122
120
  }
123
121
  /**
124
- * The window a roll to `horizon` gives a series stored for `stored`: from the horizon's start to
125
- * the later of the two ends, so its end never moves back. `null` keeps the stored window: it covers
126
- * the horizon already, or it ends so far past it that no window from the horizon's start to its end
127
- * fits the 367-day cap (it rolls once the horizon is close enough).
122
+ * From the horizon's start to the later of the two ends, so the end never moves back. `null` keeps
123
+ * the stored window: it already covers the horizon, or no window from the horizon's start fits the
124
+ * 367-day cap yet.
128
125
  */
129
126
  function rolledWindow(stored, horizon) {
130
127
  if (stored.start <= horizon.start && stored.end >= horizon.end)
@@ -134,21 +131,18 @@ function rolledWindow(stored, horizon) {
134
131
  return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
135
132
  }
136
133
  /**
137
- * How far past a rule's UNTIL, read as UTC, an occurrence can still end, beyond the series' own
138
- * length. UNTIL bounds an occurrence's start (RFC 5545 §3.3.10), but a floating or date-only UNTIL
139
- * 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
140
- * moves forward; and an occurrence's wall-clock end can pass its exact length by a clock change, a
141
- * whole day where a zone moved across the date line. Three days covers them together.
134
+ * How far past a rule's UNTIL, read as UTC, an occurrence can still end. UNTIL bounds starts (RFC
135
+ * 5545 §3.3.10), but a floating or date-only UNTIL is a wall time in any zone (UTC-12 to UTC+14),
136
+ * skipped hours move starts forward, and clock changes stretch ends: three days covers them
137
+ * together.
142
138
  */
143
139
  const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
144
140
  /** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
145
141
  const SERIES_COUNT_SEARCH_WINDOWS = 30;
146
142
  /**
147
- * A time by which every occurrence of a recurring series ends, moved ones included, or `null` when
148
- * it may go on: its rule has neither UNTIL nor COUNT as ical.js reads it (as the expansion does),
149
- * or its COUNT occurrences are not all found within SERIES_COUNT_SEARCH_WINDOWS windows from its
150
- * start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
151
- * the expansion reads it today, so a later reading of its end may be longer.
143
+ * A time by which every occurrence of a series ends, or `null` when it may go on: no UNTIL or COUNT
144
+ * as ical.js reads it, or the COUNT occurrences are not all found within
145
+ * SERIES_COUNT_SEARCH_WINDOWS windows or the search budget.
152
146
  */
153
147
  function seriesEndsBy(event) {
154
148
  const recurrence = event.recurrence;
@@ -185,10 +179,8 @@ function seriesEndsBy(event) {
185
179
  return Number.isFinite(ends.getTime()) ? ends : null;
186
180
  }
187
181
  /**
188
- * The latest end among a COUNT series' occurrences, expanded window by window from its start with
189
- * exceptions left out (so a cancelled or moved one is still found in its own place), or `null` when
190
- * fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
191
- * there are no more. An occurrence is counted in the window its start falls in.
182
+ * The latest end among a COUNT series' occurrences, expanded window by window with exceptions left
183
+ * out (so a moved one is found in its own place), or `null` when fewer than `count` turn up.
192
184
  */
193
185
  function lastCountedOccurrenceEnd(event, count) {
194
186
  const rrule = event.recurrence?.rrule;
@@ -225,16 +217,15 @@ function lastCountedOccurrenceEnd(event, count) {
225
217
  return null;
226
218
  }
227
219
  /**
228
- * The recurrence_ends_by column for an event, as text: NULL for a one-off, 'infinity' for no known
229
- * end. Bind it as text (`::text::timestamptz`): postgres.js serializes a timestamptz parameter
230
- * through Date, which cannot hold 'infinity'.
220
+ * recurrence_ends_by as text: NULL for a one-off, 'infinity' for no known end. Bind it as text
221
+ * (`::text::timestamptz`): postgres.js serializes timestamptz through Date, which cannot hold
222
+ * 'infinity'.
231
223
  */
232
224
  function recurrenceEndsByColumn(event) {
233
225
  if (!event.recurrence)
234
226
  return null;
235
227
  return seriesEndsBy(event)?.toISOString() ?? 'infinity';
236
228
  }
237
- /** The roll's `after`, checked, or `undefined` for a pass's first batch. */
238
229
  function rollCursor(after) {
239
230
  if (after === undefined)
240
231
  return undefined;
@@ -250,8 +241,8 @@ function rollCursor(after) {
250
241
  return after;
251
242
  }
252
243
  /**
253
- * Run `callback` in a transaction: a new one on a client (READ COMMITTED when asked, else the role's
254
- * default), or a savepoint inside the caller's, whose isolation cannot change.
244
+ * A new transaction on a client (READ COMMITTED when asked), or a savepoint inside the caller's,
245
+ * whose isolation cannot change.
255
246
  */
256
247
  async function inTransaction(executor, callback, isolation) {
257
248
  if ('begin' in executor) {
@@ -272,12 +263,9 @@ async function assertReadCommitted(tx) {
272
263
  throw invalidIsolationError();
273
264
  }
274
265
  /**
275
- * Run deployment SQL resolving names only in pg_catalog, then the session's temporary schema. The
276
- * deployment role owns every table, so a function or operator it found through a schema another
277
- * role can create objects in ("$user", public) would run with that ownership. The DDL strings pin
278
- * themselves as well; this covers the statements the store sends around them. Every name read
279
- * before the pin is schema-qualified. The caller's search_path is restored afterwards; a failure
280
- * aborts the transaction, which reverts the pin as well.
266
+ * Run deployment SQL with names resolved only in pg_catalog and pg_temp. The deployment role owns
267
+ * every table, so a function found in a schema another role can write to ("$user", public) would
268
+ * run with that ownership. The caller's search_path is restored afterwards.
281
269
  */
282
270
  async function withPinnedSearchPath(tx, operation) {
283
271
  const [caller] = await tx `
@@ -288,10 +276,8 @@ async function withPinnedSearchPath(tx, operation) {
288
276
  return result;
289
277
  }
290
278
  /**
291
- * The deployment DDL refuses a `slotlock` schema that another role controls with SQLSTATE 42501 and
292
- * its findings as a JSON array in DETAIL. Surface that as `unsafe_slotlock_schema` with `reasons`,
293
- * keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
294
- * migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
279
+ * Map the DDL's refusals: SQLSTATE 42501 (another role controls the schema; findings as JSON in
280
+ * DETAIL) to `unsafe_slotlock_schema`, and 55000 to `schema_newer`. Anything else passes through.
295
281
  */
296
282
  function schemaControlRefusal(error) {
297
283
  const failure = error;
@@ -322,7 +308,6 @@ function schemaControlRefusal(error) {
322
308
  reasons,
323
309
  });
324
310
  }
325
- /** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
326
311
  const LOCK_NOT_AVAILABLE = '55P03';
327
312
  const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
328
313
  const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
@@ -341,10 +326,10 @@ function migrationSettings(options) {
341
326
  return { lockTimeoutMs, statementTimeoutMs, attempts };
342
327
  }
343
328
  /**
344
- * Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
345
- * in pg_catalog only, serialized with every other migration of this database, and with the lock and
346
- * statement timeouts set. A step that waits past its lock timeout, or is ended as a deadlock's
347
- * victim, is rolled back and tried again after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
329
+ * One migration step: in a transaction (or savepoint), names pinned to pg_catalog, serialized with
330
+ * every other migration, under the lock and statement timeouts. A lock timeout or deadlock is
331
+ * rolled back and retried up to `attempts` times. The caller's own timeouts are restored
332
+ * afterwards.
348
333
  */
349
334
  async function inMigration(executor, options, step) {
350
335
  const settings = migrationSettings(options);
@@ -367,9 +352,8 @@ async function inMigration(executor, options, step) {
367
352
  }));
368
353
  }
369
354
  catch (error) {
370
- // A deadlock's victim (DEADLOCK_DETECTED: a caller's transaction took the outbox, then a
371
- // coverage row, while a rename batch took them in the other order, say) lost its locks with
372
- // it, so it is tried again like a step that timed out waiting.
355
+ // A deadlock victim (a writer took outbox then coverage, a rename batch the other order) lost
356
+ // its locks, so it retries like a lock timeout.
373
357
  const code = pgCode(error);
374
358
  if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
375
359
  throw error;
@@ -381,12 +365,10 @@ async function inMigration(executor, options, step) {
381
365
  }
382
366
  }
383
367
  /**
384
- * A digest of what the migration defines in the `slotlock` schema: its tables and columns,
385
- * constraints (and whether each is validated), indexes, triggers and functions, and whether PUBLIC
386
- * holds any right on them. Grants to other roles, ownership and row-level security are left out:
387
- * applyTenantRls and grantApplicationRole change those after applySchema has recorded the digest.
388
- * Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
389
- * with it pinned, as the migration does.
368
+ * A digest of what the migration defines in the `slotlock` schema: tables, columns, constraints
369
+ * (and whether validated), indexes, triggers, functions and PUBLIC grants. Grants to other roles,
370
+ * ownership and RLS are left out: applyTenantRls and grantApplicationRole change those later. Runs
371
+ * with search_path pinned, as deparsed definitions depend on it.
390
372
  */
391
373
  async function slotlockCatalogDigest(tx) {
392
374
  const [row] = await tx `
@@ -446,10 +428,9 @@ async function slotlockCatalogDigest(tx) {
446
428
  return row?.digest ?? '';
447
429
  }
448
430
  /**
449
- * Whether the schema is exactly what this release's applySchema last left: the same version, DDL
450
- * and catalog digest, and no table left without FORCE by an interrupted migration. Reading needs
451
- * the right to (the deployment role owns the table); without it the full migration runs and refuses
452
- * what it must.
431
+ * Whether the schema is exactly what this release's applySchema last left: same version, DDL and
432
+ * catalog digest, and no table left unforced by an interrupted migration. Without the right to read
433
+ * it, the full migration runs instead.
453
434
  */
454
435
  async function slotlockSchemaUpToDate(tx) {
455
436
  const [present] = await tx `
@@ -476,29 +457,21 @@ async function slotlockSchemaUpToDate(tx) {
476
457
  }
477
458
  /**
478
459
  * Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
479
- * in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, each a transaction of its own,
480
- * from where the last pass stopped (`schema_version.coverage_changes_renamed_through`, saved with
481
- * each batch) up to the last change when this pass starts. The first pass is the 0.6 upgrade's; an
482
- * earlier release's server still running then can write more, so every later migration runs a pass
483
- * too, until one finds nothing to rename: that marks the rename done and no migration reads the
484
- * outbox for it again.
460
+ * in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, resuming from
461
+ * `schema_version.coverage_changes_renamed_through`. An older server still running can write more,
462
+ * so every migration runs a pass until one finds nothing.
485
463
  *
486
- * The head is read only once every transaction already writing to the outbox has ended, so no
487
- * change at or below it can commit after the pass has read past it. Forced RLS would hide every
488
- * tenant's rows from the deployment role, so a role it binds lifts FORCE from calendar_coverage and
489
- * outbox for each batch and puts it back before the batch commits: each batch holds ACCESS
490
- * EXCLUSIVE on both, taken in the order writers take them (a source, then its change), for one
491
- * primary-key range of the outbox. A table an operator left unforced stays so. A superuser or
492
- * BYPASSRLS role reads every row as it is: it holds SHARE ROW EXCLUSIVE on the outbox (which waits
493
- * for the writes in flight and holds off new ones, but no read) only for the short transaction that
494
- * reads the head. Changes an earlier release's server writes after the upgrade are put in shape as
495
- * they are inserted (ddl.ts, slotlock.outbox_coverage_in_shape); the passes are for those before.
464
+ * The head is read once every transaction already writing to the outbox has ended, so nothing at or
465
+ * below it commits later. Forced RLS would hide other tenants' rows, so each batch lifts FORCE from
466
+ * calendar_coverage and outbox under ACCESS EXCLUSIVE (in writer lock order) and restores it before
467
+ * committing. A superuser or BYPASSRLS role instead holds SHARE ROW EXCLUSIVE on the outbox only
468
+ * while reading the head.
496
469
  */
497
470
  async function renameEarlierCoverageChanges(executor, options) {
498
471
  /**
499
- * Run `work` able to read every tenant's outbox and coverage rows: as it is for a superuser or a
500
- * BYPASSRLS role, else with FORCE lifted from the tables that have it, under ACCESS EXCLUSIVE
501
- * (calendar_coverage, then outbox: the order every writer locks them in), and put back after.
472
+ * Run `work` able to read every tenant's rows: as is for a superuser or BYPASSRLS role, else with
473
+ * FORCE lifted under ACCESS EXCLUSIVE (calendar_coverage, then outbox: writer lock order) and put
474
+ * back after.
502
475
  */
503
476
  const everyTenant = async (tx, bypass, work) => {
504
477
  const forced = bypass
@@ -521,8 +494,8 @@ async function renameEarlierCoverageChanges(executor, options) {
521
494
  await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
522
495
  return result;
523
496
  };
524
- // The pass's head, in a short transaction of its own: once the writes in flight have ended (each
525
- // holds ROW EXCLUSIVE from before it took its id), none at or below it can commit later.
497
+ // The head, in its own short transaction: writes in flight hold ROW EXCLUSIVE from before they
498
+ // took their id, so none at or below it commits later.
526
499
  const start = await inMigration(executor, options, async (tx) => {
527
500
  const [state] = await tx `
528
501
  SELECT version.coverage_changes_renamed AS renamed,
@@ -554,7 +527,6 @@ async function renameEarlierCoverageChanges(executor, options) {
554
527
  ]));
555
528
  const read = row?.last ?? null;
556
529
  renamedTotal += row?.renamed ?? 0;
557
- // Saved with the batch, so a pass stopped part way goes on from here.
558
530
  await tx `
559
531
  UPDATE slotlock.schema_version
560
532
  SET coverage_changes_renamed_through = ${read ?? head}::bigint,
@@ -567,7 +539,6 @@ async function renameEarlierCoverageChanges(executor, options) {
567
539
  after = String(last);
568
540
  }
569
541
  }
570
- /** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
571
542
  async function coverageChangesRenamePending(tx) {
572
543
  const [row] = await tx `
573
544
  SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
@@ -586,8 +557,8 @@ async function slotlockConstraintUnvalidated(tx) {
586
557
  return row?.unvalidated === true;
587
558
  }
588
559
  /**
589
- * Whether every tenant table has forced RLS and only its tenant policy, bound to `setting`, whose
590
- * live fingerprint (computed as the RLS DDL computes it) matches the contract the DDL recorded.
560
+ * Whether every tenant table has forced RLS and only its tenant policy bound to `setting`, matching
561
+ * the fingerprint the DDL recorded.
591
562
  */
592
563
  async function tenantRlsUpToDate(tx, setting) {
593
564
  const [present] = await tx `
@@ -599,8 +570,17 @@ async function tenantRlsUpToDate(tx, setting) {
599
570
  const [state] = await tx `
600
571
  SELECT pg_catalog.bool_and(
601
572
  rel.relrowsecurity AND rel.relforcerowsecurity
573
+ -- The tenant policy, and on a table a limited key is held to, the restrictive scope
574
+ -- policy, which can only narrow it; no other.
602
575
  AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
603
- WHERE every.polrelid = rel.oid) = 1
576
+ WHERE every.polrelid = rel.oid
577
+ AND (every.polname = 'tenant_isolation_v1'
578
+ OR NOT (every.polname = 'resource_scope_v1' AND NOT every.polpermissive
579
+ AND wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[])))) = 1
580
+ AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy scoped
581
+ WHERE scoped.polrelid = rel.oid AND scoped.polname = 'resource_scope_v1'
582
+ AND NOT scoped.polpermissive)
583
+ = CASE WHEN wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[]) THEN 1 ELSE 0 END
604
584
  AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
605
585
  AND contract.tenant_context_setting = ${setting}
606
586
  AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
@@ -622,9 +602,8 @@ async function tenantRlsUpToDate(tx, setting) {
622
602
  return state?.current === true && state.matched === tables.length;
623
603
  }
624
604
  /**
625
- * The schema version recorded by the last migration, or `null` when there is none this role can
626
- * read: no schema, a schema from 0.2 or earlier, or a role not yet granted the function (run
627
- * `slotlock migrate`, which grants it).
605
+ * The recorded schema version, or `null` when this role cannot read one: no schema, 0.2 or earlier,
606
+ * or the function not yet granted (`slotlock migrate` grants it).
628
607
  */
629
608
  export async function readSlotlockSchemaVersion(sql) {
630
609
  const [readable] = await sql `
@@ -638,9 +617,8 @@ export async function readSlotlockSchemaVersion(sql) {
638
617
  return row?.version ?? null;
639
618
  }
640
619
  /**
641
- * Refuse to serve a schema another release migrated: `schema_outdated` when it is older than this
642
- * release (or unversioned), so `slotlock migrate` must run first; `schema_newer` when a newer release
643
- * migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
620
+ * Refuse a schema another release migrated: `schema_outdated` (older or unversioned: run `slotlock
621
+ * migrate`) or `schema_newer`. Both carry `installed` and `expected`.
644
622
  */
645
623
  export async function assertSlotlockSchemaCurrent(sql) {
646
624
  const installed = await readSlotlockSchemaVersion(sql);
@@ -747,7 +725,6 @@ function toHold(row) {
747
725
  ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
748
726
  };
749
727
  }
750
- /** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
751
728
  function pageCursor(after) {
752
729
  if (after !== undefined &&
753
730
  (!(after.start instanceof Date) ||
@@ -768,6 +745,9 @@ function pageLimit(limit) {
768
745
  }
769
746
  return value;
770
747
  }
748
+ function sandboxTestOnlyError() {
749
+ return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
750
+ }
771
751
  function invalidIdentityError(message) {
772
752
  return Object.assign(new Error(message), { code: 'invalid_identity' });
773
753
  }
@@ -975,44 +955,57 @@ async function replaceCalendarEventOccurrences(tx, params) {
975
955
  WHERE event_id = ${params.eventId} AND reservation_id IS NOT NULL
976
956
  )`;
977
957
  await tx `DELETE FROM slotlock.calendar_event_occurrences WHERE event_id = ${params.eventId}`;
978
- for (const occurrence of params.occurrences) {
979
- let reservationId = null;
980
- if ((params.event.transparency ?? 'opaque') === 'opaque') {
981
- const occupied = occupiedTime({
982
- ...occurrence,
983
- bufferBeforeMs: params.bufferBeforeMs ?? 0,
984
- bufferAfterMs: params.bufferAfterMs ?? 0,
985
- });
986
- await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
987
- const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
988
- .update(occurrence.recurrenceId)
989
- .digest('hex')}`;
990
- const reservations = await tx `
991
- INSERT INTO slotlock.reservations (
992
- resource_id, tenant_ref, external_ref, starts_at, ends_at,
993
- buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
994
- ) VALUES (
995
- ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
996
- ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
997
- ${params.bufferBeforeMs ?? 0}, ${params.revision},
998
- ${`calendar-event:${params.eventId}`}, ${params.eventId}
999
- )
1000
- RETURNING id`;
1001
- reservationId = reservations[0]?.id ?? null;
1002
- if (!reservationId) {
958
+ if (params.occurrences.length === 0)
959
+ return;
960
+ // Three statements for the whole series, not three per occurrence: a series of 2,000 holds the
961
+ // tenant's locks for as long as its round trips take, and every write queued behind it waits.
962
+ const starts = params.occurrences.map((occurrence) => occurrence.start.toISOString());
963
+ const ends = params.occurrences.map((occurrence) => occurrence.end.toISOString());
964
+ const reservationIds = params.occurrences.map(() => '');
965
+ if ((params.event.transparency ?? 'opaque') === 'opaque') {
966
+ const occupied = params.occurrences.map((occurrence) => occupiedTime({
967
+ ...occurrence,
968
+ bufferBeforeMs: params.bufferBeforeMs ?? 0,
969
+ bufferAfterMs: params.bufferAfterMs ?? 0,
970
+ }));
971
+ await reapExpiredHoldsAcross(tx, params.resourceId, occupied);
972
+ const externalRefs = params.occurrences.map((occurrence) => `calendar:${params.eventId}:${createHash('sha256').update(occurrence.recurrenceId).digest('hex')}`);
973
+ const inserted = await tx `
974
+ INSERT INTO slotlock.reservations (
975
+ resource_id, tenant_ref, external_ref, starts_at, ends_at,
976
+ buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
977
+ )
978
+ SELECT ${params.resourceId}, ${params.tenantRef}, occurrence.external_ref,
979
+ occurrence.starts_at, occurrence.ends_at, ${params.bufferAfterMs ?? 0},
980
+ ${params.bufferBeforeMs ?? 0}, ${params.revision},
981
+ ${`calendar-event:${params.eventId}`}, ${params.eventId}
982
+ FROM ROWS FROM (pg_catalog.unnest(${externalRefs}::text[]),
983
+ pg_catalog.unnest(${starts}::text[]::timestamptz[]),
984
+ pg_catalog.unnest(${ends}::text[]::timestamptz[]))
985
+ WITH ORDINALITY AS occurrence(external_ref, starts_at, ends_at, ordinal)
986
+ ORDER BY occurrence.ordinal
987
+ RETURNING id, external_ref`;
988
+ const byRef = new Map(inserted.map((row) => [row.external_ref, row.id]));
989
+ externalRefs.forEach((externalRef, index) => {
990
+ const id = byRef.get(externalRef);
991
+ if (!id)
1003
992
  throw new Error('slotlock: calendar occurrence reservation insert returned no row');
1004
- }
1005
- }
1006
- await tx `
1007
- INSERT INTO slotlock.calendar_event_occurrences (
1008
- event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
1009
- ) VALUES (
1010
- ${params.eventId}, ${params.tenantRef}, ${occurrence.recurrenceId},
1011
- ${occurrence.start}, ${occurrence.end}, ${reservationId}
1012
- )`;
993
+ reservationIds[index] = id;
994
+ });
1013
995
  }
996
+ await tx `
997
+ INSERT INTO slotlock.calendar_event_occurrences (
998
+ event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
999
+ )
1000
+ SELECT ${params.eventId}, ${params.tenantRef}, occurrence.recurrence_id,
1001
+ occurrence.starts_at, occurrence.ends_at, NULLIF(occurrence.reservation_id, '')::uuid
1002
+ FROM ROWS FROM (pg_catalog.unnest(${params.occurrences.map((occurrence) => occurrence.recurrenceId)}::text[]),
1003
+ pg_catalog.unnest(${starts}::text[]::timestamptz[]),
1004
+ pg_catalog.unnest(${ends}::text[]::timestamptz[]),
1005
+ pg_catalog.unnest(${reservationIds}::text[]))
1006
+ WITH ORDINALITY AS occurrence(recurrence_id, starts_at, ends_at, reservation_id, ordinal)
1007
+ ORDER BY occurrence.ordinal`;
1014
1008
  }
1015
- /** A recurring series' materialization window: finite, forward, and at most 369 days. */
1016
1009
  function seriesWindow(window) {
1017
1010
  if (!isFiniteWindow(window) ||
1018
1011
  window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
@@ -1028,9 +1021,9 @@ function calendarWindow(window) {
1028
1021
  return { start: new Date(window.start), end: new Date(window.end) };
1029
1022
  }
1030
1023
  /**
1031
- * A one-off event's materialization window must contain the event. It defaults to the event's own
1032
- * interval and may exceed the 367-day recurrence cap only by being exactly that interval, which is
1033
- * what the `slotlock_calendar_events_materialization_window_bounded` CHECK admits.
1024
+ * A one-off event's window must contain the event. It may exceed the 367-day cap only by being
1025
+ * exactly the event's interval, as the `slotlock_calendar_events_materialization_window_bounded`
1026
+ * CHECK admits.
1034
1027
  */
1035
1028
  function oneOffEventWindow(event, requested) {
1036
1029
  const duration = event.end.getTime() - event.start.getTime();
@@ -1049,17 +1042,15 @@ function calendarCommandHash(value) {
1049
1042
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
1050
1043
  }
1051
1044
  /**
1052
- * What makes a put the same request: its identity and the event it asks for. Not the window its
1053
- * occurrences are stored over, which for a series is the rolling horizon from the current UTC
1054
- * midnight: with it in the hash, a retry after midnight was refused as a different request.
1045
+ * A put's identity and the event it asks for, not its storage window: a series' window moves with
1046
+ * the UTC day, so a retry after midnight must still match.
1055
1047
  */
1056
1048
  function putCommandHash(params) {
1057
1049
  return calendarCommandHash(putCommandValue(params));
1058
1050
  }
1059
1051
  /**
1060
- * The hash 0.3.0 and earlier stored, with the window in it, so a command stored before the upgrade
1061
- * still replays: always for a one-off event, whose window is its own span, and on the same UTC day
1062
- * for a series. Remove once no stored command can be that old (commands are pruned after 30 days).
1052
+ * The hash 0.3.0 and earlier stored (window included), so an older stored command still replays.
1053
+ * Remove once no stored command can be that old (pruned after 30 days).
1063
1054
  */
1064
1055
  export function legacyPutCommandHash(params) {
1065
1056
  return calendarCommandHash({
@@ -1079,7 +1070,7 @@ function putCommandValue(params) {
1079
1070
  expectedRevision: params.command.expectedRevision,
1080
1071
  resourceId: params.command.resourceId,
1081
1072
  source: params.command.source ?? null,
1082
- // Only when present, so a create without a hold keeps the hash it always had.
1073
+ // Only when present, so a create without a hold keeps its hash.
1083
1074
  ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
1084
1075
  event: {
1085
1076
  ...params.event,
@@ -1138,8 +1129,7 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
1138
1129
  }
1139
1130
  /**
1140
1131
  * The end of every event cancellation, under the event's identity and row locks: remove the event
1141
- * (when there is one) and its occupation, leave the identity's tombstone at `revision`, and record
1142
- * the command in `commandOwnerRef`'s namespace for replay.
1132
+ * and its occupation, leave the tombstone at `revision`, and record the command for replay.
1143
1133
  */
1144
1134
  async function finishCalendarCancel(tx, params) {
1145
1135
  const eventId = params.eventId ?? randomUUID();
@@ -1179,8 +1169,8 @@ async function finishCalendarCancel(tx, params) {
1179
1169
  };
1180
1170
  }
1181
1171
  /**
1182
- * The row an event write lost to. The write was rolled back, so the event's own rows from before
1183
- * it are still there and are left out: they were to be replaced, and never block the event.
1172
+ * The row an event write lost to. The write was rolled back, so the event's own previous rows are
1173
+ * excluded: they were to be replaced.
1184
1174
  */
1185
1175
  async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
1186
1176
  for (const occurrence of occurrences) {
@@ -1191,10 +1181,9 @@ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffer
1191
1181
  return { ok: false, code: 'overlap', conflictingReservationId: null };
1192
1182
  }
1193
1183
  /**
1194
- * What the resource's booking rules (as they apply: its own over its pool's and tenant's defaults)
1195
- * say about a booking of `interval` made now (by the database's clock, at this statement): the rule
1196
- * it breaks, if any, and the buffers it must keep before and after it. A resource without its own
1197
- * hours is judged against `defaultHours`.
1184
+ * What the resource's effective booking rules say about booking `interval` now (database clock):
1185
+ * the rule broken, if any, and the buffers to keep. A resource without its own hours is judged
1186
+ * against `defaultHours`.
1198
1187
  */
1199
1188
  async function bookingRuling(tx, resourceId, interval, defaultHours) {
1200
1189
  const judge = await bookingJudge(tx, resourceId, defaultHours);
@@ -1207,8 +1196,8 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1207
1196
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1208
1197
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1209
1198
  /**
1210
- * The resource's booking rules as they apply, read once, as a check of any interval booked now (by
1211
- * the database's clock at this statement) and the buffers a booking keeps.
1199
+ * The resource's effective booking rules, read once, as a check of any interval booked now
1200
+ * (database clock) and the buffers to keep.
1212
1201
  */
1213
1202
  async function bookingJudge(tx, resourceId, defaultHours) {
1214
1203
  const [row] = await tx `
@@ -1231,7 +1220,6 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1231
1220
  effective_booking_rules: row.effective_booking_rules,
1232
1221
  });
1233
1222
  const hours = availabilityRules ?? defaultHours;
1234
- // Every sync covering the resource, and whether it was read within a day, by the same clock.
1235
1223
  const coverage = rules?.requireCertain === true
1236
1224
  ? await tx `
1237
1225
  SELECT starts_at, ends_at,
@@ -1241,12 +1229,11 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1241
1229
  : [];
1242
1230
  const turnaroundMs = bookingBufferAfterMs(rules);
1243
1231
  const bufferBeforeMs = bookingBufferBeforeMs(rules);
1244
- // The booking and the buffers it keeps, as a search judges the slot.
1245
1232
  const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1246
1233
  source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
1247
1234
  source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
1248
- // A later occurrence of a series is judged on its step and hours only: notice and advance are
1249
- // about when the booking was made, which its first occurrence answers.
1235
+ // Later occurrences are judged on step and hours only: notice and advance concern when the
1236
+ // booking was made.
1250
1237
  const later = rules === undefined
1251
1238
  ? undefined
1252
1239
  : {
@@ -1271,29 +1258,59 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1271
1258
  return (row.resource_id === params.resourceId &&
1272
1259
  row.starts_at.getTime() === params.start.getTime() &&
1273
1260
  row.ends_at.getTime() === params.end.getTime() &&
1274
- // As asked, or raised to the resource's turnaround when it was stored.
1275
1261
  (buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
1276
1262
  Number(row.revision) === params.revision &&
1277
1263
  row.source === (params.source ?? null));
1278
1264
  }
1279
1265
  async function lockExternalIdentity(tx, tenantRef, externalRef) {
1280
- // JSON encoding is unambiguous for this pair and contains no NUL byte (Postgres text rejects
1281
- // U+0000). The database hashes it to one transaction-scoped lock key.
1266
+ // JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
1282
1267
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
1283
1268
  }
1284
1269
  /**
1285
- * Whether the resource is the tenant's (`missing` when not), and whether it is archived. The row is
1286
- * share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
1270
+ * Lock a resource a write is about to occupy time on, with its composite parent, in id order, to
1271
+ * the end of the transaction (RFC 0004 A7): writes to one resource, or one composite's family,
1272
+ * take turns. Without it, writers racing for the same time each insert, then wait at the
1273
+ * exclusion constraint for one another, and PostgreSQL breaks each such deadlock only after its
1274
+ * deadlock_timeout (a second), so a burst of agents for one slot could take many seconds. With
1275
+ * it, each meets the winner's committed row and is refused at once. NO KEY UPDATE, so foreign
1276
+ * keys to the resource are not blocked; an archive or delete waits for the write, as before. The
1277
+ * resource's own row, or null when there is none (for the tenant, when one is given).
1278
+ */
1279
+ async function lockResourceForWrite(tx, resourceId, tenantRef) {
1280
+ const rows = await tx `
1281
+ SELECT id, tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
1282
+ WHERE (id = ${resourceId}
1283
+ OR id = (SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}))
1284
+ AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
1285
+ ORDER BY id
1286
+ FOR NO KEY UPDATE`;
1287
+ const own = rows.find((row) => row.id === resourceId);
1288
+ return own ? { tenant_ref: own.tenant_ref, archived: own.archived } : null;
1289
+ }
1290
+ /**
1291
+ * Whether the resource is the tenant's (`missing` when not), and whether it is archived, locked
1292
+ * for the write that follows (lockResourceForWrite).
1287
1293
  */
1288
1294
  async function tenantResourceState(tx, resourceId, tenantRef) {
1289
- const [row] = await tx `
1290
- SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
1291
- WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
1292
- FOR SHARE`;
1295
+ const row = await readResourceForWrite(tx, resourceId, tenantRef);
1293
1296
  if (!row)
1294
1297
  return 'missing';
1295
1298
  return row.archived ? 'archived' : 'active';
1296
1299
  }
1300
+ /**
1301
+ * A resource a write is about to check its booking against, key-share locked so it is not deleted
1302
+ * meanwhile. It does not make writers wait for each other: lockResourceForWrite does, later, right
1303
+ * before the occupation is written, and checks again that the resource is there and not archived.
1304
+ * (KEY SHARE never conflicts with the NO KEY UPDATE that follows, so two writers cannot deadlock
1305
+ * upgrading.)
1306
+ */
1307
+ async function readResourceForWrite(tx, resourceId, tenantRef) {
1308
+ const [row] = await tx `
1309
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
1310
+ WHERE id = ${resourceId} AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
1311
+ FOR KEY SHARE`;
1312
+ return row ?? null;
1313
+ }
1297
1314
  function pgCode(err) {
1298
1315
  let cur = err;
1299
1316
  while (cur && typeof cur === 'object') {
@@ -1332,10 +1349,19 @@ function invalidPoolError() {
1332
1349
  code: 'invalid_pool',
1333
1350
  });
1334
1351
  }
1352
+ /** A resource's own columns, as toResource reads them. */
1353
+ const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
1354
+ visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
1355
+ parent_id, part_index,
1356
+ (SELECT place.name FROM slotlock.locations place
1357
+ WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
1358
+ ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
1359
+ WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
1360
+ ORDER BY member.group_ref) AS group_refs`;
1335
1361
  /**
1336
- * A resource row's booking rules as they apply (jsonb; NULL for none): the tenant's defaults,
1337
- * overlaid by its pool's, overlaid by its own, field by field, so one set at a narrower level wins.
1338
- * Read from a row of slotlock.resources named `resources` (no alias).
1362
+ * A resource row's effective booking rules (jsonb; NULL for none): tenant defaults, overlaid by its
1363
+ * pool's, overlaid by its own, field by field. Reads from a row of slotlock.resources named
1364
+ * `resources` (no alias).
1339
1365
  */
1340
1366
  const EFFECTIVE_BOOKING_RULES = `NULLIF(
1341
1367
  COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
@@ -1347,7 +1373,7 @@ const EFFECTIVE_BOOKING_RULES = `NULLIF(
1347
1373
  '{}'::jsonb) AS effective_booking_rules`;
1348
1374
  /** What rules Slotlock did not write read as: no booking passes them. */
1349
1375
  const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
1350
- /** Rules read back from storage: written validated, so anything else refuses every booking. */
1376
+ /** Rules written validated, so anything else read back refuses every booking. */
1351
1377
  function storedBookingRules(value) {
1352
1378
  try {
1353
1379
  return parseBookingRules(value);
@@ -1368,8 +1394,37 @@ function toResource(row) {
1368
1394
  resource.archivedAt = row.archived_at;
1369
1395
  if (row.pool_ref !== null && row.pool_ref !== undefined)
1370
1396
  resource.pool = row.pool_ref;
1397
+ // The profile, each part only when it says something.
1398
+ if (typeof row.name === 'string')
1399
+ resource.name = row.name;
1400
+ if (typeof row.description === 'string')
1401
+ resource.description = row.description;
1402
+ if (row.kind !== undefined && row.kind !== 'other' && SLOTLOCK_RESOURCE_KINDS.includes(row.kind)) {
1403
+ resource.kind = row.kind;
1404
+ }
1405
+ if (typeof row.capacity === 'number')
1406
+ resource.capacity = row.capacity;
1407
+ if (Array.isArray(row.labels) && row.labels.length > 0)
1408
+ resource.labels = [...row.labels];
1409
+ if (row.attributes !== null &&
1410
+ typeof row.attributes === 'object' &&
1411
+ !Array.isArray(row.attributes) &&
1412
+ Object.keys(row.attributes).length > 0) {
1413
+ resource.attributes = { ...row.attributes };
1414
+ }
1415
+ if (typeof row.location_ref === 'string') {
1416
+ resource.location = row.location_ref;
1417
+ if (typeof row.location_name === 'string')
1418
+ resource.locationName = row.location_name;
1419
+ }
1420
+ if (Array.isArray(row.group_refs) && row.group_refs.length > 0)
1421
+ resource.groups = [...row.group_refs];
1422
+ if (typeof row.parent_id === 'string' && typeof row.part_index === 'number') {
1423
+ resource.parentId = row.parent_id;
1424
+ resource.partIndex = row.part_index;
1425
+ }
1371
1426
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
1372
- // Written through setResourceAvailability, which validates; anything else reads as closed.
1427
+ // Written validated; anything else reads as closed.
1373
1428
  try {
1374
1429
  resource.availabilityRules = parseAvailabilityRules(row.availability_rules);
1375
1430
  }
@@ -1378,14 +1433,12 @@ function toResource(row) {
1378
1433
  }
1379
1434
  }
1380
1435
  if (row.booking_rules !== null && row.booking_rules !== undefined) {
1381
- // Written through setResourceBookingRules, which validates; anything else reads as refusing
1382
- // every booking, never as no rule.
1436
+ // Written validated; anything else refuses every booking, never as no rule.
1383
1437
  resource.bookingRules = storedBookingRules(row.booking_rules);
1384
1438
  }
1385
1439
  if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
1386
- // Merged from levels each validated alone, so only their combination can disagree (an offset
1387
- // from one level over a shorter step from another): that one is read modulo the step. A field
1388
- // invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
1440
+ // Levels are validated alone, so only their combination can disagree (an offset over a shorter
1441
+ // step): that one is read modulo the step. A field invalid in itself refuses every booking.
1389
1442
  const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
1390
1443
  if (dropped.length > 0) {
1391
1444
  console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
@@ -1397,7 +1450,6 @@ function toResource(row) {
1397
1450
  }
1398
1451
  return resource;
1399
1452
  }
1400
- /** Validated rules as a jsonb parameter, or null for "use the default". */
1401
1453
  function availabilityParameter(sql, rules) {
1402
1454
  // sql.json, not JSON.stringify: a string bound to jsonb is stored as a JSON string.
1403
1455
  if (rules === null)
@@ -1408,23 +1460,137 @@ function availabilityParameter(sql, rules) {
1408
1460
  durationMinutes,
1409
1461
  })));
1410
1462
  }
1463
+ /** What the location and group guards (ddl.ts) refuse with, by their HINT. */
1464
+ const GROUPING_REFUSALS = new Set([
1465
+ 'pool_order_limit_reached',
1466
+ 'composite_too_deep',
1467
+ 'composite_parts_limit',
1468
+ 'composite_busy',
1469
+ 'location_too_deep',
1470
+ 'location_cycle',
1471
+ 'location_limit_reached',
1472
+ 'group_limit_reached',
1473
+ 'resource_group_limit_reached',
1474
+ ]);
1475
+ /**
1476
+ * A location or group write PostgreSQL refused, as the store's code: a guard's HINT, or the code
1477
+ * `foreignKeys` gives a foreign key that failed. Anything else as it was.
1478
+ */
1479
+ function groupingRefusal(error, foreignKeys = {}) {
1480
+ const failure = error;
1481
+ const coded = (code) => Object.assign(new Error(typeof failure?.message === 'string' ? failure.message : code, { cause: error }), { code });
1482
+ if (failure?.code === '23514' && typeof failure.hint === 'string' && GROUPING_REFUSALS.has(failure.hint)) {
1483
+ return coded(failure.hint);
1484
+ }
1485
+ if (failure?.code === '23503' && typeof failure.constraint_name === 'string') {
1486
+ const code = foreignKeys[failure.constraint_name];
1487
+ if (code !== undefined)
1488
+ return coded(code);
1489
+ }
1490
+ return error;
1491
+ }
1492
+ /**
1493
+ * Run a location or group write in a savepoint of its own (a transaction when there is none), and
1494
+ * map what PostgreSQL refuses (groupingRefusal). A failed statement leaves the caller's transaction
1495
+ * usable, and postgres.js reports the mapped error rather than the statement's.
1496
+ */
1497
+ async function guardedWrite(executor, write, foreignKeys = {}) {
1498
+ try {
1499
+ return await inTransaction(executor, write);
1500
+ }
1501
+ catch (error) {
1502
+ throw groupingRefusal(error, foreignKeys);
1503
+ }
1504
+ }
1505
+ /** A resource's location that the tenant does not have. */
1506
+ const RESOURCE_LOCATION_MISSING = Object.freeze({ slotlock_resources_location_fkey: 'location_not_found' });
1507
+ /** The nearest time zone a location, or one above it, has; null when none has one. */
1508
+ async function locationTimezone(executor, tenantRef, location) {
1509
+ const [row] = await executor `
1510
+ WITH RECURSIVE up(ref, parent_ref, timezone, steps) AS (
1511
+ SELECT place.ref, place.parent_ref, place.timezone, 1 FROM slotlock.locations place
1512
+ WHERE place.tenant_ref = ${tenantRef} AND place.ref = ${location}
1513
+ UNION ALL
1514
+ SELECT place.ref, place.parent_ref, place.timezone, up.steps + 1
1515
+ FROM slotlock.locations place JOIN up ON place.tenant_ref = ${tenantRef} AND place.ref = up.parent_ref
1516
+ WHERE up.timezone IS NULL AND up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
1517
+ )
1518
+ SELECT timezone FROM up WHERE timezone IS NOT NULL ORDER BY steps LIMIT 1`;
1519
+ return row?.timezone ?? null;
1520
+ }
1521
+ const toLocation = (row) => ({
1522
+ ref: row.ref,
1523
+ name: row.name,
1524
+ parentRef: row.parent_ref,
1525
+ timezone: row.timezone,
1526
+ resources: Number(row.resources),
1527
+ });
1528
+ const toGroup = (row) => ({
1529
+ ref: row.ref,
1530
+ name: row.name,
1531
+ description: row.description,
1532
+ members: Number(row.members),
1533
+ });
1534
+ /**
1535
+ * A checked profile change as statement parameters: each field's value (null when not given or
1536
+ * cleared) and whether it was given. Labels travel as one comma-joined string, which a label never
1537
+ * contains: postgres.js binds an array as text[] only once the pool knows the server's types.
1538
+ */
1539
+ function profileParameters(change) {
1540
+ return {
1541
+ name: change.name ?? null,
1542
+ setName: change.name !== undefined,
1543
+ description: change.description ?? null,
1544
+ setDescription: change.description !== undefined,
1545
+ kind: change.kind ?? null,
1546
+ setKind: change.kind !== undefined,
1547
+ capacity: change.capacity ?? null,
1548
+ setCapacity: change.capacity !== undefined,
1549
+ labels: change.labels === undefined ? null : change.labels.join(','),
1550
+ setLabels: change.labels !== undefined,
1551
+ attributes: change.attributes === undefined ? null : JSON.stringify(change.attributes),
1552
+ setAttributes: change.attributes !== undefined,
1553
+ };
1554
+ }
1411
1555
  async function upsertResource(executor, params) {
1412
1556
  const visibility = params.visibility ?? 'shared';
1413
1557
  const asked = params.visibility ?? null;
1558
+ const profile = profileParameters(params.profile ?? {});
1414
1559
  let rows;
1415
- // An existing identity keeps the visibility it has: creating it again with none named returns it
1416
- // as it is, and with another one is refused, so a private one is never made shared that way.
1560
+ // An existing identity keeps its visibility: re-creating with another is refused, so a private
1561
+ // one is never made shared. Its profile takes the fields given, and keeps the rest.
1417
1562
  if (params.tenantRef !== undefined && params.externalRef !== undefined) {
1418
1563
  rows = await executor `
1419
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1420
- VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility})
1564
+ INSERT INTO slotlock.resources (
1565
+ external_ref, tenant_ref, timezone, visibility, name, description, kind, capacity, labels, attributes,
1566
+ location_ref
1567
+ )
1568
+ VALUES (
1569
+ ${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility},
1570
+ ${profile.name}::text, ${profile.description}::text, COALESCE(${profile.kind}::text, 'other'),
1571
+ ${profile.capacity}::integer,
1572
+ COALESCE(pg_catalog.string_to_array(${profile.labels}::text, ','), '{}'::text[]),
1573
+ COALESCE(${profile.attributes}::text::jsonb, '{}'::jsonb),
1574
+ ${params.location ?? null}::text
1575
+ )
1421
1576
  ON CONFLICT (tenant_ref, external_ref)
1422
1577
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
1423
- DO UPDATE SET timezone = EXCLUDED.timezone
1578
+ DO UPDATE SET timezone = EXCLUDED.timezone,
1579
+ name = CASE WHEN ${profile.setName}::boolean THEN EXCLUDED.name ELSE slotlock.resources.name END,
1580
+ description = CASE WHEN ${profile.setDescription}::boolean THEN EXCLUDED.description
1581
+ ELSE slotlock.resources.description END,
1582
+ kind = CASE WHEN ${profile.setKind}::boolean THEN EXCLUDED.kind ELSE slotlock.resources.kind END,
1583
+ capacity = CASE WHEN ${profile.setCapacity}::boolean THEN EXCLUDED.capacity ELSE slotlock.resources.capacity END,
1584
+ labels = CASE WHEN ${profile.setLabels}::boolean THEN EXCLUDED.labels ELSE slotlock.resources.labels END,
1585
+ attributes = CASE WHEN ${profile.setAttributes}::boolean THEN EXCLUDED.attributes
1586
+ ELSE slotlock.resources.attributes END,
1587
+ location_ref = CASE WHEN ${params.location !== undefined}::boolean THEN EXCLUDED.location_ref
1588
+ ELSE slotlock.resources.location_ref END
1424
1589
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1425
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1590
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1426
1591
  }
1427
1592
  else if (params.externalRef !== undefined) {
1593
+ // A legacy resource (no tenant) has no profile.
1428
1594
  rows = await executor `
1429
1595
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1430
1596
  VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
@@ -1432,13 +1598,13 @@ async function upsertResource(executor, params) {
1432
1598
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
1433
1599
  DO UPDATE SET external_ref = EXCLUDED.external_ref
1434
1600
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1435
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1601
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1436
1602
  }
1437
1603
  else {
1438
1604
  rows = await executor `
1439
1605
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1440
1606
  VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1441
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1607
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1442
1608
  }
1443
1609
  const row = rows[0];
1444
1610
  if (!row) {
@@ -1449,11 +1615,9 @@ async function upsertResource(executor, params) {
1449
1615
  return toResource(row);
1450
1616
  }
1451
1617
  /**
1452
- * Rematerialize one stored recurring master over the window a roll to `horizon` gives it
1453
- * (rolledWindow): from where its last expansion stopped when that can be trusted, else its start.
1454
- * False when there is no such master or nothing to roll. A collision throws the database's 23P01,
1455
- * and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
1456
- * either rolls back whole.
1618
+ * Rematerialize one stored recurring master over rolledWindow: from its last expansion's cursor
1619
+ * when trustworthy, else its start. False when there is nothing to roll. A collision throws 23P01
1620
+ * and an over-budget rule CalendarExpansionLimitError; run it in a savepoint.
1457
1621
  */
1458
1622
  async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1459
1623
  const [row] = await tx `
@@ -1499,8 +1663,8 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1499
1663
  return true;
1500
1664
  }
1501
1665
  /**
1502
- * Work out and store when a series stored before 0.3 ends (recurrence_ends_by), as its next roll
1503
- * would; its stored occurrences are left as they are.
1666
+ * Work out and store when a series stored before 0.3 ends, as its next roll would; its occurrences
1667
+ * are left as they are.
1504
1668
  */
1505
1669
  async function learnSeriesEnd(tx, tenantRef, eventId) {
1506
1670
  const [row] = await tx `
@@ -1522,7 +1686,6 @@ async function learnSeriesEnd(tx, tenantRef, eventId) {
1522
1686
  SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
1523
1687
  WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
1524
1688
  }
1525
- /** Put a master the roll could not extend behind the others for an hour. */
1526
1689
  async function deferRoll(executor, tenantRef, eventId) {
1527
1690
  await executor `
1528
1691
  UPDATE slotlock.calendar_events
@@ -1531,24 +1694,19 @@ async function deferRoll(executor, tenantRef, eventId) {
1531
1694
  }
1532
1695
  /**
1533
1696
  * Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
1534
- * part of [start, end) that a roll can ever reach: from the later of `start` and the rolling
1535
- * horizon's start. A write there must not land where such a series is not stored, or the series'
1536
- * next roll would collide with it and stop. A series known to end before that part
1537
- * (recurrence_ends_by) cannot meet the write and is left out, as is one stored through its end. A
1538
- * series behind is first rolled to the horizon, in a savepoint of the write's transaction, so the
1539
- * write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
1540
- * past the horizon and so does the write, it collides, or its rule is over the budget). Series are
1541
- * rolled in id order; an event's update holds its own row while it rolls the others, so it and a
1542
- * write rolling the same series in that order can deadlock, which the database breaks (the writer
1543
- * retries once, then answers an overlap).
1697
+ * part of [start, end) a roll can reach. A write must not land where a series is not stored yet, or
1698
+ * the series' next roll would collide with it. A series behind is first rolled to the horizon in a
1699
+ * savepoint, so the write meets its occurrences under the exclusion arbiter; false when one cannot
1700
+ * be. Series roll in id order; a deadlock with a concurrent writer is broken by the database
1701
+ * (retried once, then an overlap).
1544
1702
  */
1545
1703
  async function recurrenceCoversWrite(tx, params) {
1546
1704
  const horizon = calendarEventRollingHorizon();
1547
1705
  if (params.end <= horizon.start)
1548
1706
  return true;
1549
1707
  const from = params.start > horizon.start ? params.start : horizon.start;
1550
- // LEAST and GREATEST ignore a NULL argument (PostgreSQL 16, Conditional Expressions): a series
1551
- // with no known end is behind wherever it is stored short of the write's end.
1708
+ // LEAST and GREATEST ignore NULL arguments (PostgreSQL 16): a series with no known end is behind
1709
+ // wherever it is stored short of the write's end.
1552
1710
  const behind = () => tx `
1553
1711
  SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
1554
1712
  COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
@@ -1578,8 +1736,8 @@ async function recurrenceCoversWrite(tx, params) {
1578
1736
  if (due.length === 0)
1579
1737
  return true;
1580
1738
  if (params.end > horizon.end) {
1581
- // A series stored before 0.3 has not worked out when it ends; it does so now, and is left out
1582
- // if it ends before the write or is stored through its end.
1739
+ // A series stored before 0.3 works out its end now, and is left out if it ends before the write
1740
+ // or is stored through its end.
1583
1741
  if (due.some((row) => row.ends_by_unknown)) {
1584
1742
  for (const { id, ends_by_unknown } of due) {
1585
1743
  if (ends_by_unknown)
@@ -1589,7 +1747,6 @@ async function recurrenceCoversWrite(tx, params) {
1589
1747
  if (due.length === 0)
1590
1748
  return true;
1591
1749
  }
1592
- // No roll stores a series past the horizon: one going on past it stays behind such a write.
1593
1750
  if (due.some((row) => !row.ends_in_horizon))
1594
1751
  return false;
1595
1752
  }
@@ -1607,9 +1764,8 @@ async function recurrenceCoversWrite(tx, params) {
1607
1764
  return (await behind()).length === 0;
1608
1765
  }
1609
1766
  /**
1610
- * Whether an event already occupies, on the resource, all the time `occurrences` would: such a
1611
- * write (a rename, a new description) takes no time a series could later need, so it is not held
1612
- * to where the series on the resource are stored.
1767
+ * Whether an event already occupies all the time `occurrences` would: such a write (a rename) takes
1768
+ * no time a series could later need.
1613
1769
  */
1614
1770
  async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1615
1771
  const held = await tx `
@@ -1620,7 +1776,6 @@ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1620
1776
  AND reservation.resource_id = ${resourceId}`;
1621
1777
  return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
1622
1778
  }
1623
- /** From the earliest start to the latest end of an event's occurrences, or null for none. */
1624
1779
  function occurrenceSpan(occurrences) {
1625
1780
  const [first] = occurrences;
1626
1781
  if (!first)
@@ -1636,10 +1791,9 @@ function occurrenceSpan(occurrences) {
1636
1791
  return { start, end };
1637
1792
  }
1638
1793
  /**
1639
- * After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series on the
1640
- * resource the write's transaction rolled with it (and lost), so they stay extended and the loss
1641
- * can name the occurrence that blocked it. A resource with no tenant has no series. Upkeep only: if
1642
- * it fails, the series are left to maintenance and the write is still answered as a loss.
1794
+ * After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series the
1795
+ * write's transaction rolled and lost, so the loss can name the blocking occurrence. Upkeep only:
1796
+ * on failure, maintenance rolls them later.
1643
1797
  */
1644
1798
  async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
1645
1799
  try {
@@ -1677,35 +1831,30 @@ function tenantLimitsOption(limits) {
1677
1831
  return valid;
1678
1832
  }
1679
1833
  /**
1680
- * Serialize one tenant's writes that add rows of one limited kind, so each counts what every
1681
- * earlier one committed. Taken before any row lock, after the write's identity locks.
1834
+ * Serialize a tenant's writes adding rows of one limited kind, so each counts what earlier ones
1835
+ * committed. Taken after the identity locks, before any row lock.
1682
1836
  */
1683
1837
  async function lockTenantLimit(tx, tenantRef, kind) {
1684
1838
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
1685
1839
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
1686
1840
  }
1687
1841
  /**
1688
- * Rows a tenant added within this many days count against its limits whatever time they hold, so
1689
- * bookings and events dated in the past, and cancellations, add at most the limit in any such
1690
- * window. Ended history is not pruned: over a longer time it can still grow. Maintenance keeps
1691
- * event idempotency records and stray retirements at least as long (the store's `retentionDays`).
1842
+ * Rows added within this many days count whatever time they hold, so back-dated bookings and
1843
+ * cancellations add at most the limit per window. Maintenance keeps idempotency records and stray
1844
+ * retirements at least this long.
1692
1845
  */
1693
1846
  const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1694
1847
  /**
1695
- * The rows a tenant has that its limit of `kind` counts: what is still to come, and what it added
1696
- * in the last TENANT_LIMIT_RECENT_DAYS days; what ended before then is history and does not count.
1697
- * Each part stops at `limit` rows, read through an index, so a count costs no more than the limit
1698
- * however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
1699
- * at it.
1848
+ * The rows a tenant's `kind` limit counts: what is still to come plus what it added in the last
1849
+ * TENANT_LIMIT_RECENT_DAYS days. Each part stops at `limit` rows read through an index, so a count
1850
+ * costs at most the limit.
1700
1851
  */
1701
1852
  async function tenantLimitCount(tx, tenantRef, kind, limit) {
1702
1853
  let rows;
1703
1854
  switch (kind) {
1704
1855
  case 'holds':
1705
- // Every held row, expired or not, until it is deleted: an expired hold no rival writes over
1706
- // stays stored until maintenance or a hold at the limit deletes it. And the answer kept for
1707
- // each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
1708
- // is forgotten. An answer whose hold is still a held row is left out: that row counts.
1856
+ // Every held row until deleted (expired ones too), plus each keyed hold's kept answer once
1857
+ // its row is gone.
1709
1858
  rows = await tx `
1710
1859
  SELECT (
1711
1860
  (SELECT count(*) FROM (
@@ -1725,9 +1874,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1725
1874
  )::int AS count`;
1726
1875
  break;
1727
1876
  case 'reservations':
1728
- // Bookings still to come; bookings that have ended and cancellations, made recently; and
1729
- // references retired with no booking, until maintenance prunes them. A cancelled booking's
1730
- // tombstone is kept for good, and stops counting once it is no longer recent.
1877
+ // Bookings still to come; ended bookings and cancellations made recently; and stray
1878
+ // retirements until pruned.
1731
1879
  rows = await tx `
1732
1880
  WITH recent AS (
1733
1881
  SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
@@ -1755,9 +1903,9 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1755
1903
  )::int AS count`;
1756
1904
  break;
1757
1905
  case 'events':
1758
- // One-off events still to come and series not known to have ended; events that have ended
1759
- // and cancellations of any owner, made recently; and agent cancellations until maintenance
1760
- // prunes them. The owner identity CHECK makes every owner but 'internal' an 'agent:' one.
1906
+ // One-off events still to come and series not known to have ended; recent ended events and
1907
+ // cancellations; and agent cancellations until pruned. The owner CHECK makes every owner but
1908
+ // 'internal' an 'agent:' one.
1761
1909
  rows = await tx `
1762
1910
  WITH recent AS (
1763
1911
  SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
@@ -1802,9 +1950,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1802
1950
  return count;
1803
1951
  }
1804
1952
  /**
1805
- * Delete up to `limit` of a tenant's holds that have expired, oldest first; the number deleted. A
1806
- * writer reaping or confirming a hold locks it: such a row is skipped, and the delete repeats the
1807
- * predicate on the row as it now is.
1953
+ * Delete up to `limit` of a tenant's expired holds, oldest first. Rows a reaper or confirmer has
1954
+ * locked are skipped, and the delete rechecks the predicate.
1808
1955
  */
1809
1956
  async function deleteExpiredHolds(tx, tenantRef, limit) {
1810
1957
  const [row] = await tx `
@@ -1828,8 +1975,8 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
1828
1975
  return Number(row?.deleted ?? 0);
1829
1976
  }
1830
1977
  /**
1831
- * Delete up to `limit` of a tenant's keyed hold answers whose hold has expired, so a retry with one
1832
- * of their keys makes a new hold; answers another transaction holds are skipped. The number deleted.
1978
+ * Delete up to `limit` keyed hold answers whose hold has expired, so a retry makes a new hold;
1979
+ * locked answers are skipped.
1833
1980
  */
1834
1981
  async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1835
1982
  const [row] = await tx `
@@ -1852,8 +1999,8 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1852
1999
  return Number(row?.deleted ?? 0);
1853
2000
  }
1854
2001
  /**
1855
- * The time a booking of [start, end) keeping these buffers occupies, as the exclusion constraint
1856
- * reads it: from its start less its leading buffer to its end plus its trailing one.
2002
+ * A booking's occupied time as the exclusion constraint reads it: start less its leading buffer to
2003
+ * end plus its trailing one.
1857
2004
  */
1858
2005
  function occupiedTime(params) {
1859
2006
  return {
@@ -1862,8 +2009,8 @@ function occupiedTime(params) {
1862
2009
  };
1863
2010
  }
1864
2011
  /**
1865
- * A row's occupied time starts before `end`: a condition on its occupied start that still reads the
1866
- * (resource_id, starts_at, ends_at) index, since no row starts more than a day after it.
2012
+ * Occupied start before `end`, written so the (resource_id, starts_at, ends_at) index still
2013
+ * applies: no row starts more than a day after its occupied start.
1867
2014
  */
1868
2015
  function occupiedStartsBefore(executor, end, alias) {
1869
2016
  const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
@@ -1871,19 +2018,21 @@ function occupiedStartsBefore(executor, end, alias) {
1871
2018
  AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
1872
2019
  }
1873
2020
  /**
1874
- * Delete expired held rows whose occupied time (buffers included, as the EXCLUDE constraint reads
1875
- * it) overlaps the requested window, whose `start` and `end` are its own occupied start and end
1876
- * (`occupiedTime`), in deterministic id
1877
- * order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
1878
- * deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
1879
- * before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
2021
+ * Delete expired held rows whose occupied time overlaps the window, in id order under FOR UPDATE:
2022
+ * concurrent reapers lock in the same order (no deadlock) and the loser finds them gone. Runs right
2023
+ * before the caller's INSERT, so the EXCLUDE arbiter only sees live occupation.
1880
2024
  */
1881
2025
  async function reapExpiredHolds(tx, resourceId, start, end) {
2026
+ // A composite's parent and parts too (RFC 0003 G8): their expired holds' claims are in the way.
1882
2027
  await tx `
1883
2028
  DELETE FROM slotlock.reservations
1884
2029
  WHERE id IN (
1885
2030
  SELECT id FROM slotlock.reservations
1886
- WHERE resource_id = ${resourceId}
2031
+ WHERE resource_id IN (
2032
+ SELECT ${resourceId}::uuid
2033
+ UNION ALL SELECT family.parent_id FROM slotlock.resources family
2034
+ WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
2035
+ UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
1887
2036
  AND status = 'held'
1888
2037
  AND expires_at <= statement_timestamp()
1889
2038
  AND ${occupiedStartsBefore(tx, end)}
@@ -1893,10 +2042,65 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1893
2042
  )`;
1894
2043
  }
1895
2044
  /**
1896
- * Structured answer after losing to the EXCLUDE arbiter: name the (live) winner.
1897
- * conflictingReservationId can be null in one narrow race: the blocker expired between the
1898
- * loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
1899
- * immediate retry reaps it and wins.
2045
+ * reapExpiredHolds for many windows on one resource in one statement (a series' occurrences): the
2046
+ * same rows, in the same id order under FOR UPDATE, as one call per window would delete. The
2047
+ * envelope of the windows bounds the scan so the index applies; each row must still overlap one.
2048
+ */
2049
+ async function reapExpiredHoldsAcross(tx, resourceId, windows) {
2050
+ if (windows.length === 0)
2051
+ return;
2052
+ let first = Number.POSITIVE_INFINITY;
2053
+ let last = Number.NEGATIVE_INFINITY;
2054
+ for (const window of windows) {
2055
+ first = Math.min(first, window.start.getTime());
2056
+ last = Math.max(last, window.end.getTime());
2057
+ }
2058
+ await tx `
2059
+ DELETE FROM slotlock.reservations
2060
+ WHERE id IN (
2061
+ SELECT held.id FROM slotlock.reservations held
2062
+ WHERE held.resource_id IN (
2063
+ SELECT ${resourceId}::uuid
2064
+ UNION ALL SELECT family.parent_id FROM slotlock.resources family
2065
+ WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
2066
+ UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
2067
+ AND held.status = 'held'
2068
+ AND held.expires_at <= statement_timestamp()
2069
+ AND ${occupiedStartsBefore(tx, new Date(last), 'held')}
2070
+ AND COALESCE(held.occupied_ends_at, held.ends_at) > ${new Date(first)}
2071
+ AND EXISTS (
2072
+ SELECT 1
2073
+ FROM ROWS FROM (pg_catalog.unnest(${windows.map((window) => window.start.toISOString())}::text[]::timestamptz[]),
2074
+ pg_catalog.unnest(${windows.map((window) => window.end.toISOString())}::text[]::timestamptz[]))
2075
+ AS occupied(starts_at, ends_at)
2076
+ WHERE COALESCE(held.occupied_starts_at, held.starts_at) < occupied.ends_at
2077
+ AND COALESCE(held.occupied_ends_at, held.ends_at) > occupied.starts_at)
2078
+ ORDER BY held.id
2079
+ FOR UPDATE
2080
+ )`;
2081
+ }
2082
+ /**
2083
+ * What else keeps a resource busy as part of a composite (RFC 0003 G8), as rows of
2084
+ * (occupied_starts_at, occupied_ends_at): a part, the bookings of its parent as a whole; a parent,
2085
+ * the bookings of any of its parts. Its own bookings are read with its own rows. Nothing for a
2086
+ * resource that is neither.
2087
+ */
2088
+ function compositeBusy(executor, resourceId, window) {
2089
+ return executor `
2090
+ SELECT lower(claim.during) AS occupied_starts_at, upper(claim.during) AS occupied_ends_at
2091
+ FROM slotlock.resources me
2092
+ JOIN slotlock.composite_claims claim
2093
+ ON claim.resource_id = COALESCE(me.parent_id, me.id)
2094
+ AND claim.booked_resource_id <> me.id
2095
+ AND (me.parent_id IS NULL OR claim.span @> me.part_index)
2096
+ WHERE me.id = ${resourceId}
2097
+ AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
2098
+ AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
2099
+ }
2100
+ /**
2101
+ * Structured answer after losing to the EXCLUDE arbiter: name the live winner.
2102
+ * conflictingReservationId is null in one narrow race (the blocker expired between the constraint
2103
+ * check and this lookup); an immediate retry reaps it and wins.
1900
2104
  */
1901
2105
  async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
1902
2106
  const winners = await sql `
@@ -1917,7 +2121,27 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
1917
2121
  AND (status <> 'held' OR expires_at > statement_timestamp())
1918
2122
  ORDER BY starts_at
1919
2123
  LIMIT 1`;
1920
- return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
2124
+ if (winners[0])
2125
+ return { ok: false, code: 'overlap', conflictingReservationId: winners[0].id };
2126
+ // Lost to a composite's claim (RFC 0003 G8): the booking on the whole, or on a part, in the way.
2127
+ const claimed = await sql `
2128
+ SELECT claim.reservation_id::text AS id
2129
+ FROM slotlock.resources me
2130
+ JOIN slotlock.composite_claims claim
2131
+ ON claim.resource_id = COALESCE(me.parent_id, me.id)
2132
+ AND claim.booked_resource_id <> me.id
2133
+ AND (me.parent_id IS NULL OR claim.span @> me.part_index)
2134
+ WHERE me.id = ${resourceId}
2135
+ AND (${excludeReservationId ?? null}::uuid IS NULL OR claim.reservation_id <> ${excludeReservationId ?? null}::uuid)
2136
+ AND claim.during && tstzrange(
2137
+ ${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
2138
+ ${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
2139
+ '[)'
2140
+ )
2141
+ AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())
2142
+ ORDER BY lower(claim.during)
2143
+ LIMIT 1`;
2144
+ return { ok: false, code: 'overlap', conflictingReservationId: claimed[0]?.id ?? null };
1921
2145
  }
1922
2146
  /** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
1923
2147
  function holdTenantRef(options) {
@@ -1967,7 +2191,7 @@ export function createTenantSlots(max) {
1967
2191
  if (released)
1968
2192
  return;
1969
2193
  released = true;
1970
- // Handed straight to the next in line, so the count stays as it is.
2194
+ // Handed straight to the next in line, so the count is unchanged.
1971
2195
  const next = slots.waiting.shift();
1972
2196
  if (next) {
1973
2197
  next();
@@ -1991,6 +2215,14 @@ export function createSlotlockStore(sql, options = {}) {
1991
2215
  */
1992
2216
  export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1993
2217
  'createResource',
2218
+ 'updateResourceProfile',
2219
+ 'putLocation',
2220
+ 'updateLocation',
2221
+ 'deleteLocation',
2222
+ 'putGroup',
2223
+ 'deleteGroup',
2224
+ 'addGroupMember',
2225
+ 'removeGroupMember',
1994
2226
  'archiveResource',
1995
2227
  'restoreResource',
1996
2228
  'deleteResource',
@@ -1998,6 +2230,17 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1998
2230
  'setResourceBookingRules',
1999
2231
  'setBookingRuleDefaults',
2000
2232
  'setResourcePool',
2233
+ 'setResourceParent',
2234
+ 'setPoolOrder',
2235
+ 'notePoolBooking',
2236
+ 'putApprovalPolicy',
2237
+ 'deleteApprovalPolicy',
2238
+ 'requestApproval',
2239
+ 'beginApprovalGrant',
2240
+ 'finishApprovalGrant',
2241
+ 'declineApproval',
2242
+ 'resetTestTenant',
2243
+ 'fastForwardTestTenant',
2001
2244
  'setTenantAvailability',
2002
2245
  'createReservation',
2003
2246
  'createExternalReservation',
@@ -2020,6 +2263,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
2020
2263
  'leaveWaitlist',
2021
2264
  'putAssistantSettings',
2022
2265
  'deleteAssistantSettings',
2266
+ 'resealAssistantKey',
2023
2267
  'putAssistantConversation',
2024
2268
  'settleAssistantAction',
2025
2269
  'deleteAssistantConversation',
@@ -2033,9 +2277,8 @@ function staleSuspensionCheckError() {
2033
2277
  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' });
2034
2278
  }
2035
2279
  /**
2036
- * Reading a tenant's suspension needs EXECUTE on slotlock.tenant_suspended(text), which PUBLIC does
2037
- * not hold (SQLSTATE 42501 without it), and a schema this release migrated (42883 before then).
2038
- * Name the remedy for each; any other failure passes through.
2280
+ * Reading a suspension needs EXECUTE on slotlock.tenant_suspended(text) (42501 without it) and a
2281
+ * schema this release migrated (42883 before). Name the remedy for each.
2039
2282
  */
2040
2283
  function suspensionCheckRefusal(error) {
2041
2284
  const code = pgCode(error);
@@ -2048,8 +2291,8 @@ function suspensionCheckRefusal(error) {
2048
2291
  return error;
2049
2292
  }
2050
2293
  /**
2051
- * A store on `sql`. With `refusal`, every method in SLOTLOCK_TENANT_WRITE_METHODS throws what it
2052
- * returns before any query (a suspended tenant, or a suspension check that could be stale).
2294
+ * A store on `sql`. With `refusal`, every SLOTLOCK_TENANT_WRITE_METHODS method throws it before any
2295
+ * query.
2053
2296
  */
2054
2297
  function buildSlotlockStore(sql, options, refusal) {
2055
2298
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
@@ -2096,7 +2339,7 @@ function buildSlotlockStore(sql, options, refusal) {
2096
2339
  ? createTenantSlots(maxTenantTransactions)
2097
2340
  : undefined;
2098
2341
  const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
2099
- createSlotlockTenantRlsDdl(configuredTenantSetting); // Validate once before any scoped query.
2342
+ createSlotlockTenantRlsDdl(configuredTenantSetting);
2100
2343
  const storeOptions = {
2101
2344
  tenantContextSetting: configuredTenantSetting,
2102
2345
  agentOwnerEventQuota: configuredAgentOwnerEventQuota,
@@ -2107,9 +2350,8 @@ function buildSlotlockStore(sql, options, refusal) {
2107
2350
  ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
2108
2351
  };
2109
2352
  /**
2110
- * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. No limit for
2111
- * `kind`, or no tenant, takes no lock. Writes that only lower the count take no lock, so a count
2112
- * read early stays an upper bound for the rest of the transaction.
2353
+ * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
2354
+ * only lower the count take no lock, so an early count stays an upper bound.
2113
2355
  */
2114
2356
  async function tenantLimitReached(tx, tenantRef, kind) {
2115
2357
  const limit = configuredTenantLimits[kind];
@@ -2119,10 +2361,8 @@ function buildSlotlockStore(sql, options, refusal) {
2119
2361
  await lockTenantLimit(tx, tenantRef, kind);
2120
2362
  if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
2121
2363
  return null;
2122
- // A hold, and a keyed hold's answer, keep their place only until the hold expires; at the
2123
- // limit, the expired holds and their answers are deleted, as maintenance would, and the count
2124
- // is read again. Counting expired rows until then keeps what a tenant stores within the limit
2125
- // between maintenance passes, however short its holds.
2364
+ // At the limit, expired holds and their answers are deleted, as maintenance would, and the
2365
+ // count is read again.
2126
2366
  if (kind !== 'holds' ||
2127
2367
  (await deleteExpiredHolds(tx, tenantRef, limit)) +
2128
2368
  (await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
@@ -2133,7 +2373,7 @@ function buildSlotlockStore(sql, options, refusal) {
2133
2373
  }
2134
2374
  /**
2135
2375
  * The tenant whose limit a write on the resource counts against: the caller's, else the
2136
- * resource's (a resource never changes tenant, so it is read before any lock). Null with no limit.
2376
+ * resource's (never changes, so read before any lock). Null with no limit.
2137
2377
  */
2138
2378
  async function limitTenant(tx, kind, resourceId, tenantRef) {
2139
2379
  if (configuredTenantLimits[kind] === undefined)
@@ -2147,12 +2387,9 @@ function buildSlotlockStore(sql, options, refusal) {
2147
2387
  const store = {
2148
2388
  ...slotlockEventFeedMethods(sql, retentionDays),
2149
2389
  async applySchema(migrationOptions) {
2150
- // PostgreSQL's IF NOT EXISTS DDL is idempotent after commit but not race-free: two fresh
2151
- // application instances can still collide on pg_namespace/pg_extension unique indexes.
2152
- // inMigration serializes bootstrap per database and the transaction releases the lock on
2153
- // crashes. A schema this release already left as it is changes nothing and locks no table,
2154
- // unless old rows kept one of its constraints NOT VALID (then only the validation runs again)
2155
- // or the rename of earlier coverage changes is not done (then only a pass of it runs).
2390
+ // IF NOT EXISTS DDL is idempotent but not race-free: two fresh instances can collide on
2391
+ // pg_namespace/pg_extension indexes, so inMigration serializes bootstrap. An unchanged schema
2392
+ // locks no table unless a constraint is still NOT VALID or the coverage rename is unfinished.
2156
2393
  const next = await inMigration(sql, migrationOptions, async (tx) => {
2157
2394
  await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
2158
2395
  throw schemaControlRefusal(error);
@@ -2168,18 +2405,15 @@ function buildSlotlockStore(sql, options, refusal) {
2168
2405
  });
2169
2406
  return { validate: true, rename: await coverageChangesRenamePending(tx) };
2170
2407
  });
2171
- // Changes an earlier release wrote are renamed in short batches once the structure commits,
2172
- // never under its locks, by this migration and the next ones until a pass finds none
2173
- // (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
2174
- // slotlockServedChange).
2408
+ // Renamed in short batches after the structure commits, never under its locks; the feed
2409
+ // redacts any not yet renamed (event-feed.ts, slotlockServedChange).
2175
2410
  if (next.rename)
2176
2411
  await renameEarlierCoverageChanges(sql, migrationOptions);
2177
2412
  if (!next.validate)
2178
2413
  return;
2179
- // Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
2180
- // a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
2181
- // transaction both steps share it, and its locks, until it ends. The digests go last, so a
2182
- // migration stopped before them runs whole again.
2414
+ // After the DDL commits its ACCESS EXCLUSIVE locks are gone, so validation blocks no serving
2415
+ // query (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Digests go last, so a migration stopped
2416
+ // earlier runs whole again.
2183
2417
  await inMigration(sql, migrationOptions, async (tx) => {
2184
2418
  await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
2185
2419
  await tx `
@@ -2348,9 +2582,15 @@ function buildSlotlockStore(sql, options, refusal) {
2348
2582
  if (keyId !== undefined && !isValidResourceId(keyId)) {
2349
2583
  throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
2350
2584
  }
2585
+ const scope = options?.resourceScope;
2586
+ if (scope !== undefined && !isSlotlockResourceScope(scope)) {
2587
+ throw Object.assign(new Error('Slotlock resource scope must name locations or groups by reference'), {
2588
+ code: 'invalid_identity',
2589
+ });
2590
+ }
2351
2591
  const release = await tenantSlots?.(tenantRef, options?.signal);
2352
- // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
2353
- // another error in its place: noted here as it happens.
2592
+ // A lock timeout leaves the transaction unusable, so the reset below would fail with another
2593
+ // error: noted here.
2354
2594
  let timedOut = false;
2355
2595
  return inTransaction(sql, async (tx) => {
2356
2596
  if (lockTimeout !== undefined) {
@@ -2358,9 +2598,8 @@ function buildSlotlockStore(sql, options, refusal) {
2358
2598
  }
2359
2599
  if (isolation !== undefined)
2360
2600
  await assertReadCommitted(tx);
2361
- // Held shared until the transaction ends: suspension and erasure, which take it
2362
- // exclusively, wait for this transaction, and one that starts after them reads them below
2363
- // (READ COMMITTED: a statement after the lock sees what committed before it was granted).
2601
+ // Held shared until the transaction ends: suspension and erasure take it exclusively, so
2602
+ // they wait for this transaction, and one starting after them sees them (READ COMMITTED).
2364
2603
  const previous = await tx `
2365
2604
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2366
2605
  current_setting('slotlock.actor', true) AS actor,
@@ -2376,12 +2615,29 @@ function buildSlotlockStore(sql, options, refusal) {
2376
2615
  set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
2377
2616
  NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
2378
2617
  set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
2618
+ current_setting(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, true) AS scope,
2379
2619
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2380
2620
  throw suspensionCheckRefusal(error);
2381
2621
  });
2382
- // A caller's REPEATABLE READ or SERIALIZABLE snapshot was taken before the lock was
2383
- // granted, so it may not show a suspension that committed while this transaction waited:
2384
- // its writes are refused, and it may read.
2622
+ // A limited key's resources, read in the tenant's context (and within an enclosing
2623
+ // scope, if one is set), then held for the rest of the transaction.
2624
+ if (scope !== undefined) {
2625
+ await tx `
2626
+ SELECT set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, '{' || COALESCE((
2627
+ SELECT string_agg(held.id::text, ',' ORDER BY held.id)
2628
+ FROM slotlock.resources held
2629
+ WHERE held.tenant_ref = ${tenantRef}
2630
+ AND ((held.location_ref IS NOT NULL
2631
+ AND slotlock.location_path(held.tenant_ref, held.location_ref)
2632
+ && pg_catalog.string_to_array(${scope.locations.join(',')}::text, ','))
2633
+ OR EXISTS (
2634
+ SELECT 1 FROM slotlock.resource_group_members member
2635
+ WHERE member.tenant_ref = held.tenant_ref AND member.resource_id = held.id
2636
+ AND member.group_ref = ANY (pg_catalog.string_to_array(${scope.groups.join(',')}::text, ','))))
2637
+ ), '') || '}', true)`;
2638
+ }
2639
+ // A caller's REPEATABLE READ or SERIALIZABLE snapshot predates the lock, so it may miss a
2640
+ // suspension: its writes are refused, reads allowed.
2385
2641
  const refusal = context?.suspended === true
2386
2642
  ? tenantSuspendedError
2387
2643
  : previous[0]?.isolation !== 'read committed'
@@ -2399,7 +2655,8 @@ function buildSlotlockStore(sql, options, refusal) {
2399
2655
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2400
2656
  set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
2401
2657
  set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
2402
- set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
2658
+ set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true),
2659
+ set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, ${context?.scope ?? ''}, true)`;
2403
2660
  }
2404
2661
  }, 'read committed')
2405
2662
  .catch((error) => {
@@ -2412,10 +2669,51 @@ function buildSlotlockStore(sql, options, refusal) {
2412
2669
  },
2413
2670
  async createResource(params) {
2414
2671
  assertValidResourceInput(params);
2672
+ if (params.profile !== undefined && params.tenantRef === undefined) {
2673
+ throw Object.assign(new Error('Slotlock gives a profile only to a resource with a tenant'), {
2674
+ code: 'invalid_resource_profile',
2675
+ });
2676
+ }
2677
+ const { profile: given, maxTenantResources: _limit, location, ...identity } = params;
2678
+ if (location !== undefined) {
2679
+ if (params.tenantRef === undefined) {
2680
+ throw Object.assign(new Error('Slotlock gives a location only to a resource with a tenant'), {
2681
+ code: 'invalid_location',
2682
+ field: 'location',
2683
+ });
2684
+ }
2685
+ slotlockGroupingRef('invalid_location', location);
2686
+ // Without a time zone of its own, it takes its location's.
2687
+ if (identity.timezone === undefined) {
2688
+ const zone = await locationTimezone(sql, params.tenantRef, location);
2689
+ if (zone !== null)
2690
+ identity.timezone = zone;
2691
+ }
2692
+ }
2693
+ const upsert = {
2694
+ ...identity,
2695
+ ...(given !== undefined ? { profile: parseSlotlockResourceProfile(given) } : {}),
2696
+ ...(location !== undefined ? { location } : {}),
2697
+ };
2415
2698
  const requested = params.maxTenantResources;
2416
- const configured = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2417
- if (requested === undefined && configured === undefined)
2418
- return upsertResource(sql, params);
2699
+ // The lower of the store's own limit and the tenant's plan (plans.ts), when it has one.
2700
+ const planned = params.tenantRef === undefined
2701
+ ? undefined
2702
+ : (await sql `
2703
+ SELECT max_resources FROM slotlock.tenant_plan(${params.tenantRef}::text)`.catch((error) => {
2704
+ throw slotlockPlanReadRefusal(error);
2705
+ }))[0]?.max_resources;
2706
+ const storeLimit = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2707
+ const configured = planned === undefined
2708
+ ? storeLimit
2709
+ : storeLimit === undefined
2710
+ ? planned
2711
+ : Math.min(planned, storeLimit);
2712
+ if (requested === undefined && configured === undefined) {
2713
+ if (location === undefined)
2714
+ return upsertResource(sql, upsert);
2715
+ return guardedWrite(sql, (tx) => upsertResource(tx, upsert), RESOURCE_LOCATION_MISSING);
2716
+ }
2419
2717
  if (requested !== undefined &&
2420
2718
  (!Number.isSafeInteger(requested) ||
2421
2719
  requested < 1 ||
@@ -2436,7 +2734,7 @@ function buildSlotlockStore(sql, options, refusal) {
2436
2734
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
2437
2735
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
2438
2736
  // READ COMMITTED: each statement below sees every create committed before the lock.
2439
- // Re-creating a resource the tenant has (by its reference) adds none.
2737
+ // Re-creating a known reference adds none.
2440
2738
  const known = externalRef === null
2441
2739
  ? []
2442
2740
  : await tx `
@@ -2449,7 +2747,9 @@ function buildSlotlockStore(sql, options, refusal) {
2449
2747
  throw Object.assign(new Error(`Slotlock tenant already has ${max} resources, the most allowed here`), { code: 'resource_limit_reached', limit: max });
2450
2748
  }
2451
2749
  }
2452
- return upsertResource(tx, params);
2750
+ if (location === undefined)
2751
+ return upsertResource(tx, upsert);
2752
+ return guardedWrite(tx, (inner) => upsertResource(inner, upsert), RESOURCE_LOCATION_MISSING);
2453
2753
  }, 'read committed');
2454
2754
  },
2455
2755
  async getResource(params) {
@@ -2460,13 +2760,193 @@ function buildSlotlockStore(sql, options, refusal) {
2460
2760
  });
2461
2761
  }
2462
2762
  const rows = await sql `
2463
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2763
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2464
2764
  FROM slotlock.resources
2465
2765
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2466
2766
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
2467
2767
  const row = rows[0];
2468
2768
  return row ? toResource(row) : null;
2469
2769
  },
2770
+ async updateResourceProfile(params) {
2771
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2772
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2773
+ code: 'invalid_identity',
2774
+ });
2775
+ }
2776
+ const profile = profileParameters(parseSlotlockResourceProfile(params.profile));
2777
+ const location = params.location === undefined || params.location === null
2778
+ ? null
2779
+ : slotlockGroupingRef('invalid_location', params.location);
2780
+ const rows = await guardedWrite(sql, (tx) => tx `
2781
+ UPDATE slotlock.resources
2782
+ SET name = CASE WHEN ${profile.setName}::boolean THEN ${profile.name}::text ELSE name END,
2783
+ description = CASE WHEN ${profile.setDescription}::boolean
2784
+ THEN ${profile.description}::text ELSE description END,
2785
+ kind = CASE WHEN ${profile.setKind}::boolean THEN ${profile.kind}::text ELSE kind END,
2786
+ capacity = CASE WHEN ${profile.setCapacity}::boolean
2787
+ THEN ${profile.capacity}::integer ELSE capacity END,
2788
+ labels = CASE WHEN ${profile.setLabels}::boolean
2789
+ THEN pg_catalog.string_to_array(${profile.labels}::text, ',') ELSE labels END,
2790
+ attributes = CASE WHEN ${profile.setAttributes}::boolean
2791
+ THEN ${profile.attributes}::text::jsonb ELSE attributes END,
2792
+ location_ref = CASE WHEN ${params.location !== undefined}::boolean
2793
+ THEN ${location}::text ELSE location_ref END
2794
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2795
+ RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, RESOURCE_LOCATION_MISSING);
2796
+ const row = rows[0];
2797
+ return row ? toResource(row) : null;
2798
+ },
2799
+ async listLocations(params) {
2800
+ if (!isValidIdentity(params.tenantRef)) {
2801
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2802
+ }
2803
+ const rows = await sql `
2804
+ WITH RECURSIVE tree(ref, name, parent_ref, timezone, path) AS (
2805
+ SELECT place.ref, place.name, place.parent_ref, place.timezone, ARRAY[place.ref]
2806
+ FROM slotlock.locations place
2807
+ WHERE place.tenant_ref = ${params.tenantRef} AND place.parent_ref IS NULL
2808
+ UNION ALL
2809
+ SELECT place.ref, place.name, place.parent_ref, place.timezone, tree.path || place.ref
2810
+ FROM slotlock.locations place
2811
+ JOIN tree ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = tree.ref
2812
+ WHERE cardinality(tree.path) <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
2813
+ )
2814
+ SELECT tree.ref, tree.name, tree.parent_ref, tree.timezone,
2815
+ (SELECT count(*) FROM slotlock.resources held
2816
+ WHERE held.tenant_ref = ${params.tenantRef} AND held.location_ref = tree.ref)::int AS resources
2817
+ FROM tree
2818
+ ORDER BY tree.path
2819
+ LIMIT ${SLOTLOCK_MAX_LOCATIONS}`;
2820
+ return rows.map(toLocation);
2821
+ },
2822
+ async putLocation(params) {
2823
+ if (!isValidIdentity(params.tenantRef)) {
2824
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2825
+ }
2826
+ const location = parseSlotlockLocation(params);
2827
+ const [row] = await guardedWrite(sql, (tx) => tx `
2828
+ INSERT INTO slotlock.locations AS kept (tenant_ref, ref, name, parent_ref, timezone)
2829
+ VALUES (${params.tenantRef}, ${location.ref}, ${location.name ?? ''}, ${location.parentRef ?? null},
2830
+ ${location.timezone ?? null})
2831
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
2832
+ SET name = EXCLUDED.name,
2833
+ parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
2834
+ THEN EXCLUDED.parent_ref ELSE kept.parent_ref END,
2835
+ timezone = CASE WHEN ${location.timezone !== undefined}::boolean
2836
+ THEN EXCLUDED.timezone ELSE kept.timezone END
2837
+ RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
2838
+ (SELECT count(*) FROM slotlock.resources held
2839
+ WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
2840
+ return toLocation(row);
2841
+ },
2842
+ async updateLocation(params) {
2843
+ if (!isValidIdentity(params.tenantRef)) {
2844
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2845
+ }
2846
+ const location = parseSlotlockLocation(params);
2847
+ const [row] = await guardedWrite(sql, (tx) => tx `
2848
+ UPDATE slotlock.locations AS kept
2849
+ SET name = COALESCE(${location.name ?? null}::text, kept.name),
2850
+ parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
2851
+ THEN ${location.parentRef ?? null}::text ELSE kept.parent_ref END,
2852
+ timezone = CASE WHEN ${location.timezone !== undefined}::boolean
2853
+ THEN ${location.timezone ?? null}::text ELSE kept.timezone END
2854
+ WHERE kept.tenant_ref = ${params.tenantRef} AND kept.ref = ${location.ref}
2855
+ RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
2856
+ (SELECT count(*) FROM slotlock.resources held
2857
+ WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
2858
+ return row ? toLocation(row) : null;
2859
+ },
2860
+ async deleteLocation(params) {
2861
+ if (!isValidIdentity(params.tenantRef)) {
2862
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2863
+ }
2864
+ const ref = slotlockGroupingRef('invalid_location', params.ref);
2865
+ const rows = await guardedWrite(sql, (tx) => tx `
2866
+ DELETE FROM slotlock.locations
2867
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
2868
+ RETURNING ref`, {
2869
+ slotlock_locations_parent_fkey: 'location_in_use',
2870
+ slotlock_resources_location_fkey: 'location_in_use',
2871
+ });
2872
+ return rows.length > 0;
2873
+ },
2874
+ async listGroups(params) {
2875
+ if (!isValidIdentity(params.tenantRef)) {
2876
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2877
+ }
2878
+ const rows = await sql `
2879
+ SELECT kept.ref, kept.name, kept.description,
2880
+ (SELECT count(*) FROM slotlock.resource_group_members member
2881
+ WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members
2882
+ FROM slotlock.resource_groups kept
2883
+ WHERE kept.tenant_ref = ${params.tenantRef}
2884
+ ORDER BY kept.ref
2885
+ LIMIT ${SLOTLOCK_MAX_RESOURCE_GROUPS}`;
2886
+ return rows.map(toGroup);
2887
+ },
2888
+ async putGroup(params) {
2889
+ if (!isValidIdentity(params.tenantRef)) {
2890
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2891
+ }
2892
+ const group = parseSlotlockResourceGroup(params);
2893
+ const [row] = await guardedWrite(sql, (tx) => tx `
2894
+ INSERT INTO slotlock.resource_groups AS kept (tenant_ref, ref, name, description)
2895
+ VALUES (${params.tenantRef}, ${group.ref}, ${group.name}, ${group.description})
2896
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
2897
+ SET name = EXCLUDED.name,
2898
+ description = CASE WHEN ${params.description !== undefined}::boolean
2899
+ THEN EXCLUDED.description ELSE kept.description END
2900
+ RETURNING kept.ref, kept.name, kept.description,
2901
+ (SELECT count(*) FROM slotlock.resource_group_members member
2902
+ WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members`);
2903
+ return toGroup(row);
2904
+ },
2905
+ async deleteGroup(params) {
2906
+ if (!isValidIdentity(params.tenantRef)) {
2907
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2908
+ }
2909
+ const ref = slotlockGroupingRef('invalid_group', params.ref);
2910
+ const rows = await sql `
2911
+ DELETE FROM slotlock.resource_groups
2912
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
2913
+ RETURNING ref`;
2914
+ return rows.length > 0;
2915
+ },
2916
+ async addGroupMember(params) {
2917
+ if (!isValidIdentity(params.tenantRef)) {
2918
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2919
+ }
2920
+ const group = slotlockGroupingRef('invalid_group', params.group);
2921
+ if (!isValidResourceId(params.resourceId)) {
2922
+ throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
2923
+ code: 'resource_not_found',
2924
+ });
2925
+ }
2926
+ const rows = await guardedWrite(sql, (tx) => tx `
2927
+ INSERT INTO slotlock.resource_group_members (tenant_ref, group_ref, resource_id)
2928
+ VALUES (${params.tenantRef}, ${group}, ${params.resourceId}::uuid)
2929
+ ON CONFLICT DO NOTHING
2930
+ RETURNING true AS added`, {
2931
+ slotlock_resource_group_members_group_fkey: 'group_not_found',
2932
+ slotlock_resource_group_members_resource_fkey: 'resource_not_found',
2933
+ });
2934
+ return rows.length > 0;
2935
+ },
2936
+ async removeGroupMember(params) {
2937
+ if (!isValidIdentity(params.tenantRef)) {
2938
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2939
+ }
2940
+ const group = slotlockGroupingRef('invalid_group', params.group);
2941
+ if (!isValidResourceId(params.resourceId))
2942
+ return false;
2943
+ const rows = await sql `
2944
+ DELETE FROM slotlock.resource_group_members
2945
+ WHERE tenant_ref = ${params.tenantRef} AND group_ref = ${group}
2946
+ AND resource_id = ${params.resourceId}::uuid
2947
+ RETURNING group_ref`;
2948
+ return rows.length > 0;
2949
+ },
2470
2950
  async setResourceAvailability(params) {
2471
2951
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2472
2952
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
@@ -2478,7 +2958,7 @@ function buildSlotlockStore(sql, options, refusal) {
2478
2958
  UPDATE slotlock.resources
2479
2959
  SET availability_rules = ${rules}::jsonb
2480
2960
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2481
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2961
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2482
2962
  const row = rows[0];
2483
2963
  return row ? toResource(row) : null;
2484
2964
  },
@@ -2509,8 +2989,7 @@ function buildSlotlockStore(sql, options, refusal) {
2509
2989
  ELSE statement_timestamp()
2510
2990
  END
2511
2991
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2512
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2513
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2992
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2514
2993
  const row = rows[0];
2515
2994
  return row ? toResource(row) : null;
2516
2995
  },
@@ -2539,8 +3018,8 @@ function buildSlotlockStore(sql, options, refusal) {
2539
3018
  const tenantRef = params.tenantRef;
2540
3019
  return inTransaction(sql, async (tx) => {
2541
3020
  if (pool !== '') {
2542
- // Counted under the tenant's lock, so racing writes stop at the cap.
2543
- // The count after the lock sees every committed row only under READ COMMITTED.
3021
+ // Counted under the tenant's lock, which only READ COMMITTED makes see every committed
3022
+ // row.
2544
3023
  await assertReadCommitted(tx);
2545
3024
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
2546
3025
  const [counted] = await tx `
@@ -2589,8 +3068,25 @@ function buildSlotlockStore(sql, options, refusal) {
2589
3068
  const rows = await sql `
2590
3069
  UPDATE slotlock.resources SET pool_ref = ${params.pool}
2591
3070
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2592
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2593
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3071
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3072
+ const row = rows[0];
3073
+ return row ? toResource(row) : null;
3074
+ },
3075
+ async setResourceParent(params) {
3076
+ if (!isValidIdentity(params.tenantRef) ||
3077
+ !isValidResourceId(params.id) ||
3078
+ (params.parentId !== null && !isValidResourceId(params.parentId))) {
3079
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
3080
+ code: 'invalid_identity',
3081
+ });
3082
+ }
3083
+ if (params.parentId === params.id) {
3084
+ throw Object.assign(new Error('Slotlock resource cannot be a part of itself'), { code: 'composite_too_deep' });
3085
+ }
3086
+ const rows = await guardedWrite(sql, (tx) => tx `
3087
+ UPDATE slotlock.resources SET parent_id = ${params.parentId}::uuid
3088
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
3089
+ RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, { slotlock_resources_parent_fkey: 'parent_not_found' });
2594
3090
  const row = rows[0];
2595
3091
  return row ? toResource(row) : null;
2596
3092
  },
@@ -2600,28 +3096,94 @@ function buildSlotlockStore(sql, options, refusal) {
2600
3096
  if (!isPoolName(params.pool))
2601
3097
  throw invalidPoolError();
2602
3098
  const rows = await sql `
2603
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2604
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
3099
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2605
3100
  FROM slotlock.resources
2606
3101
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2607
3102
  AND archived_at IS NULL
2608
3103
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2609
3104
  ORDER BY id
2610
3105
  LIMIT ${SLOTLOCK_MAX_POOL_MEMBERS}`;
2611
- return rows.map(toResource);
3106
+ const members = rows.map(toResource);
3107
+ if (params.ordered !== true || members.length < 2)
3108
+ return members;
3109
+ let [settings] = await sql `
3110
+ SELECT member_order, last_member::text FROM slotlock.pool_settings
3111
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}`;
3112
+ if (settings?.member_order === 'round_robin' && params.forBooking === true) {
3113
+ // Read again under the row's lock, held until the booking commits with the place it noted.
3114
+ [settings] = await sql `
3115
+ SELECT member_order, last_member::text FROM slotlock.pool_settings
3116
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3117
+ FOR UPDATE`;
3118
+ }
3119
+ if (settings?.member_order === 'round_robin') {
3120
+ // From the first member after the one booked last, by id, round to the start.
3121
+ const after = settings.last_member;
3122
+ const next = after === null ? 0 : members.findIndex((member) => member.id > after);
3123
+ const start = next < 0 ? 0 : next;
3124
+ return [...members.slice(start), ...members.slice(0, start)];
3125
+ }
3126
+ if (settings?.member_order === 'least_booked') {
3127
+ // Bookings made on each in the last 30 days, holds and cancelled ones not counted.
3128
+ const counts = await sql `
3129
+ SELECT booked.resource_id::text, count(*)::int AS booked
3130
+ FROM slotlock.reservations booked
3131
+ WHERE booked.tenant_ref = ${params.tenantRef}
3132
+ AND booked.resource_id = ANY (${members.map((member) => member.id)}::uuid[])
3133
+ AND booked.status = 'confirmed'
3134
+ AND booked.created_at > statement_timestamp() - interval '30 days'
3135
+ GROUP BY booked.resource_id`;
3136
+ const booked = new Map(counts.map((row) => [row.resource_id, Number(row.booked)]));
3137
+ // Stable among equals: by id, as the database orders them.
3138
+ return [...members].sort((a, b) => (booked.get(a.id) ?? 0) - (booked.get(b.id) ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3139
+ }
3140
+ return members;
2612
3141
  },
2613
3142
  async listPools(params) {
2614
3143
  if (!isValidIdentity(params.tenantRef))
2615
3144
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2616
3145
  const rows = await sql `
2617
- SELECT pool_ref AS pool, count(*)::int AS capacity
3146
+ SELECT pool_ref AS pool, count(*)::int AS capacity,
3147
+ COALESCE((SELECT settings.member_order FROM slotlock.pool_settings settings
3148
+ WHERE settings.tenant_ref = ${params.tenantRef}
3149
+ AND settings.pool_ref = resources.pool_ref), 'stable') AS member_order
2618
3150
  FROM slotlock.resources
2619
3151
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref IS NOT NULL AND archived_at IS NULL
2620
3152
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2621
3153
  GROUP BY pool_ref
2622
3154
  ORDER BY pool_ref
2623
3155
  LIMIT 1000`;
2624
- return rows;
3156
+ return rows.map((row) => ({ pool: row.pool, capacity: row.capacity, order: row.member_order }));
3157
+ },
3158
+ async setPoolOrder(params) {
3159
+ if (!isValidIdentity(params.tenantRef))
3160
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3161
+ if (!isPoolName(params.pool))
3162
+ throw invalidPoolError();
3163
+ if (!SLOTLOCK_POOL_ORDERS.includes(params.order)) {
3164
+ throw Object.assign(new Error(`Slotlock pool order must be one of ${SLOTLOCK_POOL_ORDERS.join(', ')}`), {
3165
+ code: 'invalid_pool_order',
3166
+ });
3167
+ }
3168
+ // At most SLOTLOCK_MAX_POOL_SETTINGS pools a tenant (pool_settings_guard).
3169
+ await guardedWrite(sql, (tx) => tx `
3170
+ INSERT INTO slotlock.pool_settings (tenant_ref, pool_ref, member_order)
3171
+ VALUES (${params.tenantRef}, ${params.pool}, ${params.order})
3172
+ ON CONFLICT (tenant_ref, pool_ref)
3173
+ DO UPDATE SET member_order = EXCLUDED.member_order, updated_at = statement_timestamp()`);
3174
+ return params.order;
3175
+ },
3176
+ async notePoolBooking(params) {
3177
+ if (!isValidIdentity(params.tenantRef))
3178
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3179
+ if (!isPoolName(params.pool) || !isValidResourceId(params.resourceId))
3180
+ return;
3181
+ // Only a pool that goes round keeps its place; another has nothing to note.
3182
+ await sql `
3183
+ UPDATE slotlock.pool_settings
3184
+ SET last_member = ${params.resourceId}::uuid, updated_at = statement_timestamp()
3185
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3186
+ AND member_order = 'round_robin'`;
2625
3187
  },
2626
3188
  async setTenantAvailability(params) {
2627
3189
  if (!isValidIdentity(params.tenantRef)) {
@@ -2653,28 +3215,87 @@ function buildSlotlockStore(sql, options, refusal) {
2653
3215
  code: 'invalid_cursor',
2654
3216
  });
2655
3217
  }
3218
+ // The filters are checked as a profile is, so a bad one is refused rather than matching none.
3219
+ const place = params.location === undefined ? null : slotlockGroupingRef('invalid_location', params.location);
3220
+ const group = params.group === undefined ? null : slotlockGroupingRef('invalid_group', params.group);
3221
+ if (params.pool !== undefined && !isPoolName(params.pool))
3222
+ throw invalidPoolError();
3223
+ const pool = params.pool ?? null;
3224
+ const filter = parseSlotlockResourceProfile({
3225
+ ...(params.kind !== undefined ? { kind: params.kind } : {}),
3226
+ ...(params.labels !== undefined ? { labels: params.labels } : {}),
3227
+ });
2656
3228
  const rows = await sql `
2657
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
3229
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2658
3230
  FROM slotlock.resources
2659
3231
  WHERE tenant_ref = ${params.tenantRef}
2660
3232
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2661
3233
  AND (${params.includeArchived === true} OR archived_at IS NULL)
2662
3234
  AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
3235
+ AND (${filter.kind ?? null}::text IS NULL OR kind = ${filter.kind ?? null}::text)
3236
+ AND (${filter.labels === undefined || filter.labels.length === 0 ? null : filter.labels.join(',')}::text IS NULL
3237
+ OR labels @> pg_catalog.string_to_array(${filter.labels?.join(',') ?? ''}::text, ','))
3238
+ AND (${place}::text IS NULL OR location_ref IN (
3239
+ WITH RECURSIVE below(ref, steps) AS (
3240
+ SELECT place.ref, 1 FROM slotlock.locations place
3241
+ WHERE place.tenant_ref = ${params.tenantRef} AND place.ref = ${place}::text
3242
+ UNION ALL
3243
+ SELECT place.ref, below.steps + 1
3244
+ FROM slotlock.locations place
3245
+ JOIN below ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = below.ref
3246
+ WHERE below.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
3247
+ )
3248
+ SELECT ref FROM below))
3249
+ AND (${group}::text IS NULL OR EXISTS (
3250
+ SELECT 1 FROM slotlock.resource_group_members member
3251
+ WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
3252
+ AND member.group_ref = ${group}::text))
3253
+ AND (${pool}::text IS NULL OR pool_ref = ${pool}::text)
2663
3254
  ORDER BY id
2664
3255
  LIMIT ${limit}`;
2665
3256
  return rows.map(toResource);
2666
3257
  },
3258
+ async readUsage(params) {
3259
+ if (!isValidIdentity(params.tenantRef)) {
3260
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3261
+ }
3262
+ return readSlotlockTenantUsage(sql, params.tenantRef, params.from, params.to);
3263
+ },
3264
+ async rollUpUsage(params) {
3265
+ if (!isValidIdentity(params.tenantRef)) {
3266
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3267
+ }
3268
+ const limit = params.limit ?? 5_000;
3269
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
3270
+ throw Object.assign(new Error('Slotlock usage roll-up limit must be 1-100,000'), {
3271
+ code: 'invalid_limit',
3272
+ });
3273
+ }
3274
+ const [result] = await sql `
3275
+ SELECT slotlock.roll_up_usage(${params.tenantRef}::text, ${limit}::integer) AS read`;
3276
+ return result?.read ?? 0;
3277
+ },
3278
+ async countResources(params) {
3279
+ if (!isValidIdentity(params.tenantRef)) {
3280
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3281
+ code: 'invalid_identity',
3282
+ });
3283
+ }
3284
+ const [counted] = await sql `
3285
+ SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef}`;
3286
+ return Number(counted?.count ?? 0);
3287
+ },
2667
3288
  async archiveResource(params) {
2668
3289
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2669
3290
  throw invalidIdentityError('Slotlock resource identity is invalid');
2670
3291
  }
2671
- // Its clock time, not the transaction's start: a booking that committed holding the row's
2672
- // share lock went in before this.
3292
+ // Clock time, not transaction start: a booking that committed holding the row's share lock
3293
+ // went in before this.
2673
3294
  const [row] = await sql `
2674
3295
  UPDATE slotlock.resources
2675
3296
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2676
3297
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2677
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3298
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2678
3299
  return row ? toResource(row) : null;
2679
3300
  },
2680
3301
  async restoreResource(params) {
@@ -2685,7 +3306,7 @@ function buildSlotlockStore(sql, options, refusal) {
2685
3306
  UPDATE slotlock.resources
2686
3307
  SET archived_at = NULL
2687
3308
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2688
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3309
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2689
3310
  return row ? toResource(row) : null;
2690
3311
  },
2691
3312
  async deleteResource(params) {
@@ -2715,8 +3336,8 @@ function buildSlotlockStore(sql, options, refusal) {
2715
3336
  ) AS live`;
2716
3337
  if (live?.live)
2717
3338
  return { ok: false, code: 'resource_in_use' };
2718
- // Its rows go with it: reservations, events and their children, coverage and hold answers
2719
- // all cascade from the resource.
3339
+ // Reservations, events and their children, coverage and hold answers all cascade from the
3340
+ // resource.
2720
3341
  await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
2721
3342
  return { ok: true, deleted: true };
2722
3343
  });
@@ -2730,22 +3351,16 @@ function buildSlotlockStore(sql, options, refusal) {
2730
3351
  if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
2731
3352
  return { ok: false, code: 'resource_not_found' };
2732
3353
  }
2733
- // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2734
- // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
3354
+ // A booking on a tenant's resource takes the tenant and its own id as reference; one on a
3355
+ // tenantless resource stays tenantless.
2735
3356
  const id = randomUUID();
2736
- // The buffers written, for the overlap answer if the write loses.
2737
3357
  let bufferBeforeMs = 0;
2738
3358
  let bufferAfterMs = 0;
2739
3359
  for (let attempt = 1;; attempt++) {
2740
3360
  try {
2741
3361
  return await inTransaction(sql, async (tx) => {
2742
3362
  const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
2743
- const [resource] = await tx `
2744
- SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2745
- WHERE id = ${params.resourceId}
2746
- AND (${params.tenantRef ?? null}::text IS NULL
2747
- OR tenant_ref = ${params.tenantRef ?? null})
2748
- FOR SHARE`;
3363
+ const resource = await readResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
2749
3364
  if (!resource)
2750
3365
  return { ok: false, code: 'resource_not_found' };
2751
3366
  if (resource.archived)
@@ -2767,6 +3382,12 @@ function buildSlotlockStore(sql, options, refusal) {
2767
3382
  }))) {
2768
3383
  return { ok: false, code: 'recurrence_not_materialized' };
2769
3384
  }
3385
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3386
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
3387
+ if (!locked)
3388
+ return { ok: false, code: 'resource_not_found' };
3389
+ if (locked.archived)
3390
+ return { ok: false, code: 'resource_archived' };
2770
3391
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2771
3392
  const [row] = await tx `
2772
3393
  INSERT INTO slotlock.reservations (
@@ -2788,15 +3409,15 @@ function buildSlotlockStore(sql, options, refusal) {
2788
3409
  catch (err) {
2789
3410
  const code = pgCode(err);
2790
3411
  if ((code === DEADLOCK_DETECTED || code === SERIALIZATION_FAILURE) && attempt === 1) {
2791
- continue; // fully rolled back — one retry wins cleanly or loses with a clean 23P01
3412
+ continue; // fully rolled back: one retry wins or loses with a clean 23P01
2792
3413
  }
2793
3414
  if (code !== EXCLUSION_VIOLATION &&
2794
3415
  code !== DEADLOCK_DETECTED &&
2795
3416
  code !== SERIALIZATION_FAILURE) {
2796
3417
  throw err;
2797
3418
  }
2798
- // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2799
- // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
3419
+ // A second contention abort is a structured loss too (possibly with a null winner), never
3420
+ // a raw throw.
2800
3421
  const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2801
3422
  await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2802
3423
  return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
@@ -2821,8 +3442,8 @@ function buildSlotlockStore(sql, options, refusal) {
2821
3442
  if (cancelled[0])
2822
3443
  return { ok: false, code: 'reservation_cancelled' };
2823
3444
  // A calendar event's occupation carries a reference in this namespace too, but it is
2824
- // not a booking: create, reschedule and cancel never read, replay, move or free it,
2825
- // and answer as for a reference no booking holds. Bookings have their own unique index.
3445
+ // not a booking: create, reschedule and cancel treat it as a reference no booking
3446
+ // holds.
2826
3447
  const existing = await tx `
2827
3448
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2828
3449
  buffer_after_ms, revision, source
@@ -2833,7 +3454,7 @@ function buildSlotlockStore(sql, options, refusal) {
2833
3454
  const found = existing[0];
2834
3455
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2835
3456
  if (found) {
2836
- // The booking has moved on since: a delayed create is stale, as a delayed move is.
3457
+ // Moved on since: a delayed create is stale, as a delayed move is.
2837
3458
  if (params.revision < Number(found.revision)) {
2838
3459
  return { ok: false, code: 'stale_revision' };
2839
3460
  }
@@ -2864,6 +3485,12 @@ function buildSlotlockStore(sql, options, refusal) {
2864
3485
  }))) {
2865
3486
  return { ok: false, code: 'recurrence_not_materialized' };
2866
3487
  }
3488
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3489
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3490
+ if (!locked)
3491
+ return { ok: false, code: 'resource_tenant_mismatch' };
3492
+ if (locked.archived)
3493
+ return { ok: false, code: 'resource_archived' };
2867
3494
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2868
3495
  const rows = await tx `
2869
3496
  INSERT INTO slotlock.reservations (
@@ -2956,6 +3583,12 @@ function buildSlotlockStore(sql, options, refusal) {
2956
3583
  }))) {
2957
3584
  return { ok: false, code: 'recurrence_not_materialized' };
2958
3585
  }
3586
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3587
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3588
+ if (!locked)
3589
+ return { ok: false, code: 'resource_tenant_mismatch' };
3590
+ if (locked.archived)
3591
+ return { ok: false, code: 'resource_archived' };
2959
3592
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2960
3593
  const rows = await tx `
2961
3594
  UPDATE slotlock.reservations
@@ -3034,8 +3667,8 @@ function buildSlotlockStore(sql, options, refusal) {
3034
3667
  }
3035
3668
  }
3036
3669
  if (!found) {
3037
- // A calendar event's occupation row carries this reference: it is the event's, not a
3038
- // booking's. Nothing is cancelled, and nothing retired: a tombstone would only grow.
3670
+ // This reference is a calendar event's occupation, not a booking: nothing to cancel or
3671
+ // retire.
3039
3672
  const [occupation] = await tx `
3040
3673
  SELECT id FROM slotlock.reservations
3041
3674
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
@@ -3049,7 +3682,7 @@ function buildSlotlockStore(sql, options, refusal) {
3049
3682
  return { ok: false, code: 'reservation_limit_reached', limit };
3050
3683
  }
3051
3684
  // A reference no booking holds yet is retired too, so a create delivered after its cancel
3052
- // cannot bring the booking back. Only such a stray retirement is pruned past retention.
3685
+ // cannot bring it back.
3053
3686
  await tx `
3054
3687
  INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
3055
3688
  VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
@@ -3064,9 +3697,8 @@ function buildSlotlockStore(sql, options, refusal) {
3064
3697
  if (!isFiniteWindow({ start: params.start, end: params.end })) {
3065
3698
  return { ok: false, code: 'invalid_window' };
3066
3699
  }
3067
- // Finiteness + cap: NaN/Infinity would raise raw 22008 in make_interval, and an absurd
3068
- // ttl mints an effectively-eternal hold whose expiry can even exceed the JS Date range
3069
- // (Invalid Date back to the caller). All refused pre-table.
3700
+ // NaN/Infinity would raise a raw 22008 in make_interval, and a huge ttl could exceed the JS
3701
+ // Date range.
3070
3702
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
3071
3703
  return { ok: false, code: 'invalid_ttl' };
3072
3704
  }
@@ -3106,8 +3738,17 @@ function buildSlotlockStore(sql, options, refusal) {
3106
3738
  ...(holderRef !== undefined ? { holderRef } : {}),
3107
3739
  });
3108
3740
  const ttlSeconds = params.ttlMs / 1000;
3109
- // The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
3110
- // leading buffer.
3741
+ // The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
3742
+ const asked = params.holderCaps;
3743
+ const holdCap = Math.min(maxHoldsPerHolder, asked?.maxHolds ?? Number.POSITIVE_INFINITY);
3744
+ const heldHoursCap = asked?.maxHeldHours !== undefined
3745
+ ? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, asked.maxHeldHours)
3746
+ : maxHeldHoursPerHolder;
3747
+ if ((asked?.maxHolds !== undefined && !(Number.isSafeInteger(asked.maxHolds) && asked.maxHolds >= 1)) ||
3748
+ (asked?.maxHeldHours !== undefined && !(Number.isSafeInteger(asked.maxHeldHours) && asked.maxHeldHours >= 1))) {
3749
+ return { ok: false, code: 'invalid_identity' };
3750
+ }
3751
+ // The buffer asked for, raised to the resource's turnaround, and its rule's leading buffer.
3111
3752
  let written = bufferAfterMs;
3112
3753
  let writtenBefore = 0;
3113
3754
  for (let attempt = 1;; attempt++) {
@@ -3134,22 +3775,17 @@ function buildSlotlockStore(sql, options, refusal) {
3134
3775
  }
3135
3776
  }
3136
3777
  const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
3137
- const [resource] = await tx `
3138
- SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
3139
- WHERE id = ${params.resourceId}
3140
- AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
3141
- FOR SHARE`;
3778
+ const resource = await readResourceForWrite(tx, params.resourceId, tenantRef ?? null);
3142
3779
  if (!resource)
3143
3780
  return { ok: false, code: 'resource_not_found' };
3144
3781
  if (resource.archived)
3145
3782
  return { ok: false, code: 'resource_archived' };
3146
3783
  if (holdLimit !== null)
3147
3784
  return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
3148
- // Seconds the holder already holds, turnaround included, when its time is capped.
3149
3785
  let heldSeconds = null;
3150
3786
  if (holderRef !== undefined) {
3151
- // One holder's live holds, counted under a lock on the holder so racing holds stop
3152
- // at the cap: an agent cannot hold a resource's whole week.
3787
+ // Counted under a lock on the holder, so racing holds stop at the cap: an agent
3788
+ // cannot hold a resource's whole week.
3153
3789
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
3154
3790
  'slotlock-holder',
3155
3791
  tenantRef,
@@ -3165,10 +3801,10 @@ function buildSlotlockStore(sql, options, refusal) {
3165
3801
  WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
3166
3802
  AND expires_at > statement_timestamp()
3167
3803
  AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
3168
- if ((held?.count ?? 0) >= maxHoldsPerHolder) {
3169
- return { ok: false, code: 'hold_limit_reached', limit: maxHoldsPerHolder };
3804
+ if ((held?.count ?? 0) >= holdCap) {
3805
+ return { ok: false, code: 'hold_limit_reached', limit: holdCap };
3170
3806
  }
3171
- if (maxHeldHoursPerHolder !== undefined)
3807
+ if (heldHoursCap !== undefined)
3172
3808
  heldSeconds = held?.seconds ?? 0;
3173
3809
  }
3174
3810
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
@@ -3178,10 +3814,10 @@ function buildSlotlockStore(sql, options, refusal) {
3178
3814
  writtenBefore = ruling.bufferBeforeMs;
3179
3815
  const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3180
3816
  if (heldSeconds !== null &&
3181
- maxHeldHoursPerHolder !== undefined &&
3817
+ heldHoursCap !== undefined &&
3182
3818
  heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
3183
- maxHeldHoursPerHolder * 3_600) {
3184
- return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
3819
+ heldHoursCap * 3_600) {
3820
+ return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
3185
3821
  }
3186
3822
  if (resource.tenant_ref !== null &&
3187
3823
  !(await recurrenceCoversWrite(tx, {
@@ -3191,6 +3827,12 @@ function buildSlotlockStore(sql, options, refusal) {
3191
3827
  }))) {
3192
3828
  return { ok: false, code: 'recurrence_not_materialized' };
3193
3829
  }
3830
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3831
+ const locked = await lockResourceForWrite(tx, params.resourceId, tenantRef ?? null);
3832
+ if (!locked)
3833
+ return { ok: false, code: 'resource_not_found' };
3834
+ if (locked.archived)
3835
+ return { ok: false, code: 'resource_archived' };
3194
3836
  await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
3195
3837
  const [row] = await tx `
3196
3838
  INSERT INTO slotlock.reservations (
@@ -3229,8 +3871,8 @@ function buildSlotlockStore(sql, options, refusal) {
3229
3871
  code !== SERIALIZATION_FAILURE) {
3230
3872
  throw err;
3231
3873
  }
3232
- // A SECOND contention abort is treated as a loss too (structured, possibly with a null
3233
- // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
3874
+ // A second contention abort is a structured loss too (possibly with a null winner), never
3875
+ // a raw throw.
3234
3876
  const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3235
3877
  await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
3236
3878
  return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
@@ -3252,6 +3894,21 @@ function buildSlotlockStore(sql, options, refusal) {
3252
3894
  AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
3253
3895
  return row ? toHold(row) : null;
3254
3896
  },
3897
+ async getHolderHold(params) {
3898
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
3899
+ throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
3900
+ }
3901
+ if (!isValidResourceId(params.holdId))
3902
+ return null;
3903
+ const [row] = await sql `
3904
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3905
+ hold.expires_at, hold.source, hold.expires_at > statement_timestamp() AS live
3906
+ FROM slotlock.reservations hold
3907
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
3908
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
3909
+ AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
3910
+ return row ? { hold: toHold(row), live: row.live } : null;
3911
+ },
3255
3912
  async listHolds(params) {
3256
3913
  if (!isValidIdentity(params.tenantRef)) {
3257
3914
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -3306,6 +3963,276 @@ function buildSlotlockStore(sql, options, refusal) {
3306
3963
  source: row.source,
3307
3964
  }));
3308
3965
  },
3966
+ async describeBooking(params) {
3967
+ if (!isValidIdentity(params.tenantRef)) {
3968
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3969
+ }
3970
+ if (!isValidResourceId(params.id))
3971
+ return null;
3972
+ const [row] = await sql `
3973
+ SELECT booking.resource_id, booking.status, booking.holder_ref,
3974
+ COALESCE(booking.occupied_starts_at, booking.starts_at) AS occupied_start,
3975
+ COALESCE(booking.occupied_ends_at, booking.ends_at) AS occupied_end,
3976
+ event.id AS event_id, event.external_ref AS event_ref, event.owner_ref AS event_owner_ref
3977
+ FROM slotlock.reservations booking
3978
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
3979
+ LEFT JOIN slotlock.calendar_events event
3980
+ ON event.id = booking.calendar_event_id AND event.tenant_ref = ${params.tenantRef}
3981
+ WHERE booking.id = ${params.id} AND resource.tenant_ref = ${params.tenantRef}`;
3982
+ if (!row)
3983
+ return null;
3984
+ return {
3985
+ id: params.id,
3986
+ resourceId: row.resource_id,
3987
+ status: row.status,
3988
+ holderRef: row.holder_ref,
3989
+ eventId: row.event_id,
3990
+ eventRef: row.event_ref,
3991
+ eventOwnerRef: row.event_owner_ref,
3992
+ start: row.occupied_start,
3993
+ end: row.occupied_end,
3994
+ };
3995
+ },
3996
+ async eventOccupancy(params) {
3997
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.ownerRef)) {
3998
+ throw invalidIdentityError('Slotlock tenant and owner must be 1-500 UTF-8 bytes');
3999
+ }
4000
+ const refs = params.externalRefs.filter((ref) => isValidIdentity(ref));
4001
+ if (refs.length === 0)
4002
+ return [];
4003
+ const rows = await sql `
4004
+ SELECT booking.resource_id,
4005
+ COALESCE(booking.occupied_starts_at, booking.starts_at) AS start,
4006
+ COALESCE(booking.occupied_ends_at, booking.ends_at) AS "end",
4007
+ event.recurrence_rule IS NOT NULL AS recurring
4008
+ FROM slotlock.calendar_events event
4009
+ JOIN slotlock.reservations booking ON booking.calendar_event_id = event.id
4010
+ WHERE event.tenant_ref = ${params.tenantRef} AND event.owner_ref = ${params.ownerRef}
4011
+ AND event.external_ref = ANY (${refs}::text[])
4012
+ ORDER BY booking.resource_id, 2, booking.id`;
4013
+ return rows.map((row) => ({ resourceId: row.resource_id, start: row.start, end: row.end, recurring: row.recurring }));
4014
+ },
4015
+ async putApprovalPolicy(params) {
4016
+ if (!isValidIdentity(params.tenantRef)) {
4017
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4018
+ }
4019
+ return putApprovalPolicy(sql, params);
4020
+ },
4021
+ async listApprovalPolicies(params) {
4022
+ if (!isValidIdentity(params.tenantRef)) {
4023
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4024
+ }
4025
+ return listApprovalPolicies(sql, params.tenantRef);
4026
+ },
4027
+ async deleteApprovalPolicy(params) {
4028
+ if (!isValidIdentity(params.tenantRef)) {
4029
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4030
+ }
4031
+ return { deleted: await deleteApprovalPolicy(sql, params.tenantRef, params.ref) };
4032
+ },
4033
+ async approvalPoliciesFor(params) {
4034
+ if (!isValidIdentity(params.tenantRef)) {
4035
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4036
+ }
4037
+ return approvalPoliciesFor(sql, params.tenantRef, params.resourceIds);
4038
+ },
4039
+ async requestApproval(params) {
4040
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.requesterRef)) {
4041
+ throw invalidIdentityError('Slotlock tenant and requester must be 1-500 UTF-8 bytes');
4042
+ }
4043
+ if (!/^[0-9a-f]{64}$/.test(params.requestKey)) {
4044
+ throw invalidIdentityError('Slotlock approval request key must be 64 hex characters');
4045
+ }
4046
+ if (params.holds.length < 1 ||
4047
+ params.holds.length > SLOTLOCK_MAX_APPROVAL_HOLDS ||
4048
+ params.holds.some((hold) => !isValidResourceId(hold.resourceId) || !(hold.end > hold.start))) {
4049
+ throw invalidIdentityError(`Slotlock approval holds must be 1-${SLOTLOCK_MAX_APPROVAL_HOLDS} intervals on resources`);
4050
+ }
4051
+ const { tenantRef } = params;
4052
+ try {
4053
+ return await inTransaction(sql, async (tx) => {
4054
+ // Retries of one request wait for each other, so the second answers the first's approval.
4055
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
4056
+ 'slotlock-approval',
4057
+ tenantRef,
4058
+ params.requestKey,
4059
+ ])}, 0))`;
4060
+ const prior = await approvalByRequestKey(tx, tenantRef, params.requestKey);
4061
+ if (prior) {
4062
+ const [same] = await tx `
4063
+ SELECT request = ${tx.json(params.request)}::jsonb AS same
4064
+ FROM slotlock.approvals WHERE id = ${prior.id}`;
4065
+ return same?.same
4066
+ ? { ok: true, approval: prior, replayed: true }
4067
+ : { ok: false, code: 'idempotency_conflict' };
4068
+ }
4069
+ // Counted under a lock on the tenant's approvals, so racing requests stop at the caps.
4070
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-approvals', tenantRef])}, 0))`;
4071
+ const [pending] = await tx `
4072
+ SELECT count(*)::int AS tenant,
4073
+ (count(*) FILTER (WHERE requester ->> 'subject' = ${params.requesterRef}))::int AS requester
4074
+ FROM slotlock.approvals
4075
+ WHERE tenant_ref = ${tenantRef} AND status = 'pending' AND expires_at > statement_timestamp()`;
4076
+ if ((pending?.requester ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER) {
4077
+ return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER };
4078
+ }
4079
+ if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
4080
+ return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
4081
+ }
4082
+ const id = randomUUID();
4083
+ // The requester's own hold of the time gives way to the approval's.
4084
+ if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
4085
+ await tx `
4086
+ DELETE FROM slotlock.reservations
4087
+ WHERE id = ${params.consumeHold.holdId} AND status = 'held'
4088
+ AND holder_ref = ${params.consumeHold.holderRef}`;
4089
+ }
4090
+ const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
4091
+ // Locked as any booking write locks them, in id order, so a racing booking takes turns.
4092
+ for (const resourceId of [...new Set(holds.map((hold) => hold.resourceId))].sort()) {
4093
+ await lockResourceForWrite(tx, resourceId, tenantRef);
4094
+ }
4095
+ for (const hold of holds)
4096
+ await reapExpiredHolds(tx, hold.resourceId, hold.start, hold.end);
4097
+ const held = await tx `
4098
+ INSERT INTO slotlock.reservations (
4099
+ resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status, expires_at,
4100
+ source, holder_ref, hold_group_id
4101
+ )
4102
+ SELECT wanted.resource_id, wanted.starts_at, wanted.ends_at, 0, 0, 'held', ${params.expiresAt},
4103
+ 'slotlock:approval', ${`approval:${id}`}, ${id}::uuid
4104
+ FROM ROWS FROM (pg_catalog.unnest(${holds.map((hold) => hold.resourceId)}::uuid[]),
4105
+ pg_catalog.unnest(${holds.map((hold) => hold.start.toISOString())}::text[]::timestamptz[]),
4106
+ pg_catalog.unnest(${holds.map((hold) => hold.end.toISOString())}::text[]::timestamptz[]))
4107
+ AS wanted(resource_id, starts_at, ends_at)
4108
+ RETURNING id`;
4109
+ const [row] = await tx `
4110
+ INSERT INTO slotlock.approvals (
4111
+ id, tenant_ref, policy_ref, operation, request, requester, hold_ids, resource_ids,
4112
+ starts_at, ends_at, request_key, expires_at
4113
+ ) VALUES (
4114
+ ${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
4115
+ ${tx.json(params.requester)}, ${held.map((hold) => hold.id)}::uuid[],
4116
+ ${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
4117
+ ${params.requestKey}, ${params.expiresAt}
4118
+ )
4119
+ RETURNING ${tx.unsafe(APPROVAL_COLUMNS)}`;
4120
+ if (!row)
4121
+ throw new Error('slotlock: approval insert returned no row');
4122
+ const approval = toApproval(row);
4123
+ await recordApprovalChange(tx, tenantRef, 'approval.requested', approval);
4124
+ return { ok: true, approval, replayed: false };
4125
+ }, 'read committed');
4126
+ }
4127
+ catch (err) {
4128
+ if (pgCode(err) === EXCLUSION_VIOLATION)
4129
+ return { ok: false, code: 'overlap' };
4130
+ throw err;
4131
+ }
4132
+ },
4133
+ async findApproval(params) {
4134
+ if (!isValidIdentity(params.tenantRef)) {
4135
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4136
+ }
4137
+ if (!/^[0-9a-f]{64}$/.test(params.requestKey))
4138
+ return null;
4139
+ return approvalByRequestKey(sql, params.tenantRef, params.requestKey);
4140
+ },
4141
+ async getApproval(params) {
4142
+ if (!isValidIdentity(params.tenantRef)) {
4143
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4144
+ }
4145
+ return getApproval(sql, params.tenantRef, params.id);
4146
+ },
4147
+ async listApprovals(params) {
4148
+ if (!isValidIdentity(params.tenantRef)) {
4149
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4150
+ }
4151
+ const limit = params.limit ?? 50;
4152
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) {
4153
+ throw invalidIdentityError('Slotlock approval list limit must be 1-200');
4154
+ }
4155
+ return listApprovals(sql, {
4156
+ tenantRef: params.tenantRef,
4157
+ limit,
4158
+ ...(params.status !== undefined ? { status: params.status } : {}),
4159
+ });
4160
+ },
4161
+ async beginApprovalGrant(params) {
4162
+ if (!isValidIdentity(params.tenantRef)) {
4163
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4164
+ }
4165
+ return beginApprovalGrant(sql, params.tenantRef, params.id);
4166
+ },
4167
+ async finishApprovalGrant(params) {
4168
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
4169
+ throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
4170
+ }
4171
+ return finishApprovalGrant(sql, params.tenantRef, params.id, params.decidedBy);
4172
+ },
4173
+ async declineApproval(params) {
4174
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
4175
+ throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
4176
+ }
4177
+ if (params.reason !== undefined && params.reason.length > 500) {
4178
+ throw invalidIdentityError('Slotlock approval reason must be at most 500 characters');
4179
+ }
4180
+ return declineApproval(sql, params);
4181
+ },
4182
+ async resetTestTenant(params) {
4183
+ if (!isValidIdentity(params.tenantRef)) {
4184
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4185
+ }
4186
+ if (!params.tenantRef.endsWith('~test'))
4187
+ throw sandboxTestOnlyError();
4188
+ const rows = await sql `
4189
+ SELECT * FROM slotlock.reset_test_tenant(${params.tenantRef}::text)`.catch((error) => {
4190
+ if (error.hint === 'sandbox_test_only')
4191
+ throw sandboxTestOnlyError();
4192
+ throw error;
4193
+ });
4194
+ return Object.fromEntries(rows.map((row) => [row.reset_table, Number(row.reset_rows)]));
4195
+ },
4196
+ async fastForwardTestTenant(params) {
4197
+ if (!isValidIdentity(params.tenantRef)) {
4198
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4199
+ }
4200
+ if (!params.tenantRef.endsWith('~test'))
4201
+ throw sandboxTestOnlyError();
4202
+ if (!Number.isSafeInteger(params.minutes) || params.minutes < 1 || params.minutes > 525_600) {
4203
+ throw invalidIdentityError('Slotlock fast-forward is 1 to 525,600 minutes');
4204
+ }
4205
+ const [counted] = await sql `
4206
+ WITH lapsed_holds AS (
4207
+ UPDATE slotlock.reservations hold
4208
+ SET expires_at = statement_timestamp() - interval '1 millisecond'
4209
+ WHERE hold.status = 'held'
4210
+ AND hold.expires_at > statement_timestamp()
4211
+ AND hold.expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
4212
+ AND hold.resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef})
4213
+ RETURNING 1
4214
+ ), lapsed_approvals AS (
4215
+ UPDATE slotlock.approvals
4216
+ SET expires_at = statement_timestamp() - interval '1 millisecond'
4217
+ WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
4218
+ AND expires_at > statement_timestamp()
4219
+ AND expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
4220
+ RETURNING 1
4221
+ )
4222
+ SELECT (SELECT count(*) FROM lapsed_holds)::int AS holds,
4223
+ (SELECT count(*) FROM lapsed_approvals)::int AS approvals`;
4224
+ return { holds: counted?.holds ?? 0, approvals: counted?.approvals ?? 0 };
4225
+ },
4226
+ async expireApprovals(params) {
4227
+ if (!isValidIdentity(params.tenantRef)) {
4228
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4229
+ }
4230
+ const limit = params.limit ?? 100;
4231
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
4232
+ throw invalidIdentityError('Slotlock approval expiry limit must be 1-1000');
4233
+ }
4234
+ return { expired: await expireApprovals(sql, params.tenantRef, limit) };
4235
+ },
3309
4236
  async getReservation(params) {
3310
4237
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
3311
4238
  throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
@@ -3344,13 +4271,12 @@ function buildSlotlockStore(sql, options, refusal) {
3344
4271
  },
3345
4272
  async confirmHold(holdId, options = {}) {
3346
4273
  const tenantRef = holdTenantRef(options);
3347
- // Not an id the store issues: it names no hold (and would be a raw 22P02 in the query).
4274
+ // Not an id the store issues (and a raw 22P02 in the query).
3348
4275
  if (!isValidResourceId(holdId))
3349
4276
  return { ok: false, code: 'hold_not_found' };
3350
4277
  return inTransaction(sql, async (tx) => {
3351
- // The tenant whose booking the hold becomes is its resource's; a hold never changes
3352
- // resource, so it is read before the identity lock, which is taken before the row lock as
3353
- // every booking write takes them.
4278
+ // A hold never changes resource, so the owner is read before the identity lock, taken
4279
+ // before the row lock as every booking write does.
3354
4280
  const [owner] = await tx `
3355
4281
  SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
3356
4282
  FROM slotlock.reservations hold
@@ -3363,29 +4289,24 @@ function buildSlotlockStore(sql, options, refusal) {
3363
4289
  if (bookingTenant !== null)
3364
4290
  await lockExternalIdentity(tx, bookingTenant, holdId);
3365
4291
  const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
3366
- // The resource's share lock, as every booking write takes it: an archive waits for this
3367
- // confirmation, and one that committed first is read here.
3368
- const [resource] = await tx `
3369
- SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
3370
- WHERE id = ${owner.resource_id}
3371
- FOR SHARE`;
4292
+ // The resource's lock, as every booking write takes it: an archive waits for this
4293
+ // confirmation.
4294
+ const resource = await lockResourceForWrite(tx, owner.resource_id, null);
3372
4295
  const [row] = await tx `
3373
4296
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3374
- revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live
4297
+ revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live,
4298
+ hold_group_id, holder_ref
3375
4299
  FROM slotlock.reservations
3376
4300
  WHERE id = ${holdId}
3377
4301
  FOR UPDATE`;
3378
4302
  if (!row) {
3379
- // NOTE: a hold reaped mid-flight by a rival (expired while this confirm waited on the
3380
- // reaper's lock) reads as hold_not_found: the row is gone and the space was legitimately
3381
- // taken; both refusal codes are terminal for the caller.
4303
+ // A hold reaped by a rival while this confirm waited reads as hold_not_found; both
4304
+ // refusals are terminal for the caller.
3382
4305
  return { ok: false, code: 'hold_not_found' };
3383
4306
  }
3384
4307
  if (row.status === 'confirmed') {
3385
- // Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
3386
- // SUCCESS, the booking as it now is; reporting failure would make callers double-book
3387
- // elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
3388
- // booking with another reference was never this hold.
4308
+ // A retried confirm of an already-confirmed hold is success: reporting failure would make
4309
+ // callers double-book elsewhere. A booking with another reference was never this hold.
3389
4310
  if (row.external_ref !== null && row.external_ref !== row.id) {
3390
4311
  return { ok: false, code: 'hold_not_found' };
3391
4312
  }
@@ -3393,7 +4314,6 @@ function buildSlotlockStore(sql, options, refusal) {
3393
4314
  }
3394
4315
  if (!row.live)
3395
4316
  return { ok: false, code: 'hold_expired' };
3396
- // Archived since the hold was taken: a confirmation would be a new booking on it.
3397
4317
  if (resource?.archived !== false)
3398
4318
  return { ok: false, code: 'resource_archived' };
3399
4319
  if (bookingTenant !== null) {
@@ -3412,8 +4332,8 @@ function buildSlotlockStore(sql, options, refusal) {
3412
4332
  if (bookingLimit !== null) {
3413
4333
  return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
3414
4334
  }
3415
- // The occupied end is already the end plus the hold's buffer (the trigger set it); the
3416
- // tenant and reference make the hold a booking that cancel and reschedule reach.
4335
+ // The trigger already set the occupied end; the tenant and reference make the hold a
4336
+ // booking cancel and reschedule reach.
3417
4337
  const [confirmed] = await tx `
3418
4338
  UPDATE slotlock.reservations
3419
4339
  SET status = 'confirmed', expires_at = NULL,
@@ -3423,6 +4343,14 @@ function buildSlotlockStore(sql, options, refusal) {
3423
4343
  buffer_after_ms, revision, source`;
3424
4344
  if (!confirmed)
3425
4345
  throw new Error('slotlock: hold confirmation returned no row');
4346
+ // Its linked alternatives (one group, one holder) are released with it, as booking one
4347
+ // from an agent releases the rest.
4348
+ if (row.hold_group_id !== null && row.holder_ref !== null) {
4349
+ await tx `
4350
+ DELETE FROM slotlock.reservations
4351
+ WHERE status = 'held' AND hold_group_id = ${row.hold_group_id}::uuid
4352
+ AND holder_ref = ${row.holder_ref} AND id <> ${holdId}`;
4353
+ }
3426
4354
  return { ok: true, reservation: toReservation(confirmed) };
3427
4355
  });
3428
4356
  },
@@ -3482,17 +4410,21 @@ function buildSlotlockStore(sql, options, refusal) {
3482
4410
  });
3483
4411
  }
3484
4412
  }
3485
- // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
3486
- // space (the acquiring transaction reaps the row itself when it takes the window).
4413
+ // Expired holds are excluded so findNextAvailable can offer their space; the acquiring
4414
+ // transaction reaps the row itself.
3487
4415
  const rows = await sql `
3488
- SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
3489
- COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3490
- FROM slotlock.reservations
3491
- WHERE resource_id = ${resourceId}
3492
- AND ${occupiedStartsBefore(sql, window.end)}
3493
- AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3494
- AND (status <> 'held' OR expires_at > statement_timestamp())
3495
- ORDER BY COALESCE(occupied_starts_at, starts_at)
4416
+ SELECT occupied_starts_at, occupied_ends_at FROM (
4417
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
4418
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
4419
+ FROM slotlock.reservations
4420
+ WHERE resource_id = ${resourceId}
4421
+ AND ${occupiedStartsBefore(sql, window.end)}
4422
+ AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
4423
+ AND (status <> 'held' OR expires_at > statement_timestamp())
4424
+ UNION ALL
4425
+ ${compositeBusy(sql, resourceId, window)}
4426
+ ) busy
4427
+ ORDER BY occupied_starts_at
3496
4428
  LIMIT ${options.limit ?? null}`;
3497
4429
  return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
3498
4430
  },
@@ -3532,8 +4464,8 @@ function buildSlotlockStore(sql, options, refusal) {
3532
4464
  });
3533
4465
  }
3534
4466
  catch (error) {
3535
- // A long-running series can take more than a write searches from its start. If it is
3536
- // stored, it is expanded again below from where its last expansion stopped.
4467
+ // A long-running series can outgrow the write's search budget from its start; a stored
4468
+ // one is expanded below from its last cursor.
3537
4469
  if (!(error instanceof CalendarExpansionLimitError) || !event.recurrence)
3538
4470
  throw error;
3539
4471
  }
@@ -3552,7 +4484,6 @@ function buildSlotlockStore(sql, options, refusal) {
3552
4484
  });
3553
4485
  const recurrenceEndsBy = recurrenceEndsByColumn(event);
3554
4486
  let attempted = expanded?.occurrences ?? [];
3555
- // The buffers the attempt kept, for the overlap answer if it loses.
3556
4487
  let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
3557
4488
  let storedEventId;
3558
4489
  for (let attempt = 1;; attempt++) {
@@ -3580,8 +4511,8 @@ function buildSlotlockStore(sql, options, refusal) {
3580
4511
  if (ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
3581
4512
  await lockCalendarOwnerQuota(tx, params.tenantRef, ownerRef);
3582
4513
  const commandCount = await calendarOwnerCommandCount(tx, params.tenantRef, ownerRef);
3583
- // Preserve one guaranteed cancellation slot for every possible live identity. This
3584
- // prevents update churn from making a principal unable to release calendar space.
4514
+ // Reserve one cancellation slot for every possible live identity, so update churn
4515
+ // cannot leave a principal unable to release calendar space.
3585
4516
  const putCommandLimit = configuredAgentOwnerCommandQuota - configuredAgentOwnerEventQuota;
3586
4517
  if (commandCount >= putCommandLimit) {
3587
4518
  return {
@@ -3668,12 +4599,17 @@ function buildSlotlockStore(sql, options, refusal) {
3668
4599
  return { ok: false, code: 'hold_not_found' };
3669
4600
  }
3670
4601
  const [hold] = await tx `
3671
- SELECT resource_id, starts_at, ends_at, hold_group_id FROM slotlock.reservations
4602
+ SELECT resource_id, starts_at, ends_at, hold_group_id,
4603
+ expires_at > statement_timestamp() AS live
4604
+ FROM slotlock.reservations
3672
4605
  WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
3673
- AND expires_at > statement_timestamp()
3674
4606
  FOR UPDATE`;
3675
4607
  if (!hold)
3676
4608
  return { ok: false, code: 'hold_not_found' };
4609
+ // The holder's own hold that lapsed and is not yet reaped: named as the application
4610
+ // API's confirm names it. Another holder's stays not found.
4611
+ if (!hold.live)
4612
+ return { ok: false, code: 'hold_expired' };
3677
4613
  const [only] = occurrences;
3678
4614
  if (hold.resource_id !== params.resourceId ||
3679
4615
  occurrences.length !== 1 ||
@@ -3682,7 +4618,6 @@ function buildSlotlockStore(sql, options, refusal) {
3682
4618
  only.end > hold.ends_at) {
3683
4619
  return { ok: false, code: 'hold_mismatch' };
3684
4620
  }
3685
- // The hold and its alternatives give way to the event in this transaction.
3686
4621
  await tx `
3687
4622
  DELETE FROM slotlock.reservations
3688
4623
  WHERE status = 'held' AND holder_ref = ${holderRef}
@@ -3773,9 +4708,15 @@ function buildSlotlockStore(sql, options, refusal) {
3773
4708
  ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
3774
4709
  )`;
3775
4710
  }
3776
- // The normalized children are the queryable/canonical representation. The JSON
3777
- // columns above remain a migration-compatibility mirror until older readers retire.
4711
+ // The normalized children are canonical; the JSON columns above are a compatibility
4712
+ // mirror until older readers retire.
3778
4713
  await replaceCalendarEventChildren(tx, params.tenantRef, eventId, event);
4714
+ // Writers to this resource take turns from here to its occupations (lockResourceForWrite).
4715
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
4716
+ if (!locked)
4717
+ return { ok: false, code: 'resource_tenant_mismatch' };
4718
+ if (locked.archived)
4719
+ return { ok: false, code: 'resource_archived' };
3779
4720
  await replaceCalendarEventOccurrences(tx, {
3780
4721
  tenantRef: params.tenantRef,
3781
4722
  eventId,
@@ -3945,11 +4886,8 @@ function buildSlotlockStore(sql, options, refusal) {
3945
4886
  });
3946
4887
  }
3947
4888
  const after = rollCursor(params.after);
3948
- // Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
3949
- // of its start. Those it has never put back come first, then those it has, longest waiting
3950
- // first: however many it cannot extend, they never stand in front of one it has not tried,
3951
- // so every pass that is not capped by such new ones reaches the healthy series, and the ones
3952
- // put back take turns.
4889
+ // Due masters, never-deferred ones first, then deferred ones longest-waiting first: masters
4890
+ // it cannot extend never starve healthy ones, and deferred ones take turns.
3953
4891
  const candidates = await sql `
3954
4892
  SELECT id, materialized_ends_at, roll_retry_at
3955
4893
  FROM slotlock.calendar_events
@@ -3986,10 +4924,8 @@ function buildSlotlockStore(sql, options, refusal) {
3986
4924
  }
3987
4925
  catch (error) {
3988
4926
  if (error instanceof CalendarExpansionLimitError) {
3989
- // It takes more than Slotlock expands: a rule that never occurs again, one with no
3990
- // cursor to resume from whose search from its start has outgrown the budget, or one an
3991
- // earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
3992
- // stays, and the masters after it still roll.
4927
+ // Over the expansion budget (or a rule this release refuses): nothing written, old
4928
+ // coverage kept, later masters still roll.
3993
4929
  refused += 1;
3994
4930
  refusedEventIds.push(candidate.id);
3995
4931
  await deferRoll(sql, params.tenantRef, candidate.id);
@@ -4002,16 +4938,15 @@ function buildSlotlockStore(sql, options, refusal) {
4002
4938
  if (code === EXCLUSION_VIOLATION ||
4003
4939
  code === DEADLOCK_DETECTED ||
4004
4940
  code === SERIALIZATION_FAILURE) {
4005
- // The savepoint rolled every delete/insert back, so the previous materialized
4006
- // coverage remains truthful and callers will fail closed beyond it.
4941
+ // The savepoint rolled everything back, so old coverage stays truthful and callers
4942
+ // fail closed beyond it.
4007
4943
  conflicts += 1;
4008
4944
  conflictEventIds.push(candidate.id);
4009
4945
  await deferRoll(sql, params.tenantRef, candidate.id);
4010
4946
  break;
4011
4947
  }
4012
- // Anything else this one series meets (a row no Slotlock would write, a constraint it
4013
- // trips) was rolled back with its savepoint too: it is put back like one over the
4014
- // budget, so no single row stops the tenant's roll.
4948
+ // Any other failure was rolled back with its savepoint too: defer it like an
4949
+ // over-budget one, so no single row stops the tenant's roll.
4015
4950
  refused += 1;
4016
4951
  refusedEventIds.push(candidate.id);
4017
4952
  await deferRoll(sql, params.tenantRef, candidate.id);
@@ -4178,8 +5113,8 @@ function buildSlotlockStore(sql, options, refusal) {
4178
5113
  idempotent: true,
4179
5114
  };
4180
5115
  }
4181
- // An event's owner and reference never change, so they are read before its identity lock,
4182
- // which is taken before its row lock as every event write takes them.
5116
+ // Owner and reference never change, so they are read before the identity lock, taken before
5117
+ // the row lock as every event write does.
4183
5118
  const [identity] = await tx `
4184
5119
  SELECT owner_ref, external_ref FROM slotlock.calendar_events
4185
5120
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
@@ -4190,7 +5125,6 @@ function buildSlotlockStore(sql, options, refusal) {
4190
5125
  SELECT id, resource_id, revision FROM slotlock.calendar_events
4191
5126
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
4192
5127
  FOR UPDATE`;
4193
- // Cancelled while this waited on its lock.
4194
5128
  if (!found)
4195
5129
  return { ok: false, code: 'event_not_found' };
4196
5130
  if (params.expectedResourceId !== undefined &&
@@ -4290,7 +5224,6 @@ function buildSlotlockStore(sql, options, refusal) {
4290
5224
  catch (error) {
4291
5225
  if (pgCode(error) !== EXCLUSION_VIOLATION)
4292
5226
  throw error;
4293
- // It stays where it was; whoever owns it decides.
4294
5227
  await report(event, 'event.timezone_conflict');
4295
5228
  conflicts++;
4296
5229
  }
@@ -4307,8 +5240,8 @@ function buildSlotlockStore(sql, options, refusal) {
4307
5240
  code: 'invalid_limit',
4308
5241
  });
4309
5242
  }
4310
- // Never under the default: a shorter retention trims the feed, not how long a retried write
4311
- // is answered as the first was.
5243
+ // Never under the default: a shorter retention trims the feed, not how long a retry is
5244
+ // answered as the first was.
4312
5245
  const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4313
5246
  if (!Number.isSafeInteger(keepDays) ||
4314
5247
  keepDays < 1 ||
@@ -4318,11 +5251,10 @@ function buildSlotlockStore(sql, options, refusal) {
4318
5251
  });
4319
5252
  }
4320
5253
  return inTransaction(sql, async (tx) => {
4321
- // A 0.2 server still serving after the migration wrote its confirmed holds (and
4322
- // createReservation bookings) on the tenant's resources without the tenant or a reference,
4323
- // so no cancel or move reaches them; the migration gives those it finds the tenant and
4324
- // their own id, and this gives later ones the same. Each takes its reference's identity lock
4325
- // first, as every booking write does; a tombstone of its id retired nothing and goes.
5254
+ // A 0.2 server still running after the migration wrote confirmed holds and
5255
+ // createReservation bookings without the tenant or a reference; give them both, as the
5256
+ // migration does. Each takes its reference's identity lock first, and a tombstone of its id
5257
+ // goes.
4326
5258
  const legacy = await tx `
4327
5259
  SELECT booking.id
4328
5260
  FROM slotlock.reservations booking
@@ -4402,8 +5334,8 @@ function buildSlotlockStore(sql, options, refusal) {
4402
5334
  code: 'invalid_limit',
4403
5335
  });
4404
5336
  }
4405
- // Never under the default: a shorter retention trims the feed, not how long a retried write
4406
- // is answered as the first was.
5337
+ // Never under the default: a shorter retention trims the feed, not how long a retry is
5338
+ // answered as the first was.
4407
5339
  const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4408
5340
  if (!Number.isSafeInteger(keepDays) ||
4409
5341
  keepDays < 1 ||
@@ -4413,8 +5345,8 @@ function buildSlotlockStore(sql, options, refusal) {
4413
5345
  });
4414
5346
  }
4415
5347
  return inTransaction(sql, async (tx) => {
4416
- // Commands are exact-replay evidence, never resurrection authority: after the window every
4417
- // owner's are eligible, and the active event or tombstone stays canonical where present.
5348
+ // Commands are replay evidence, never resurrection authority: after the window every
5349
+ // owner's go.
4418
5350
  const commands = await tx `
4419
5351
  WITH candidates AS MATERIALIZED (
4420
5352
  SELECT command.owner_ref, command.idempotency_key
@@ -4434,9 +5366,8 @@ function buildSlotlockStore(sql, options, refusal) {
4434
5366
  RETURNING 1
4435
5367
  )
4436
5368
  SELECT count(*)::int AS deleted FROM deleted`;
4437
- // Once every command of an agent-owned cancelled identity is gone its tombstone may follow;
4438
- // internal tombstones are provider/sync authority and stay. Event writers lock the tombstone
4439
- // row, so a row they hold is skipped and the delete repeats the selection predicate.
5369
+ // An agent-owned tombstone may follow once all its commands are gone; internal ones are
5370
+ // provider/sync authority and stay. Locked rows are skipped and the predicate rechecked.
4440
5371
  const tombstones = await tx `
4441
5372
  WITH candidates AS MATERIALIZED (
4442
5373
  SELECT tombstone.owner_ref, tombstone.external_ref
@@ -4532,7 +5463,6 @@ function buildSlotlockStore(sql, options, refusal) {
4532
5463
  if (params.revision !== currentRevision + 1) {
4533
5464
  return { ok: false, code: 'revision_conflict' };
4534
5465
  }
4535
- // A fresh read ends a staleness: the next one is reported again.
4536
5466
  await tx `
4537
5467
  UPDATE slotlock.calendar_coverage
4538
5468
  SET starts_at = ${window.start}, ends_at = ${window.end},
@@ -4686,7 +5616,7 @@ function buildSlotlockStore(sql, options, refusal) {
4686
5616
  if (refusal)
4687
5617
  return 0;
4688
5618
  const tenantRef = params.tenantRef;
4689
- // The same test twice: to find them, then under each reference's lock, as a cancel takes it.
5619
+ // The same test twice: to find them, then under each reference's lock.
4690
5620
  const due = (tx, externalRef) => tx `
4691
5621
  SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
4692
5622
  (resource.booking_rules->>'checkInMinutes')::int AS minutes
@@ -4717,14 +5647,15 @@ function buildSlotlockStore(sql, options, refusal) {
4717
5647
  await tx `
4718
5648
  INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
4719
5649
  VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
5650
+ // The deletion is the change: the feed's trigger names it reservation.no_show (one change,
5651
+ // not a cancellation too), by maintenance, with the check-in window it missed.
5652
+ await tx `
5653
+ SELECT set_config('slotlock.ending', 'no_show', true),
5654
+ set_config('slotlock.ending_detail', ${String(row.minutes)}, true),
5655
+ set_config('slotlock.actor', 'slotlock:maintenance', true)`;
4720
5656
  await tx `DELETE FROM slotlock.reservations WHERE id = ${row.id}`;
4721
5657
  await tx `
4722
- INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4723
- VALUES (${tenantRef}, ${row.resource_id}, 'reservation.no_show', ${row.external_ref},
4724
- jsonb_build_object('id', ${row.id}::uuid, 'reference', ${row.external_ref}::text,
4725
- 'resource_id', ${row.resource_id}::uuid, 'revision', ${revision}::bigint,
4726
- 'check_in_minutes', ${row.minutes}::int),
4727
- 'slotlock:maintenance')`;
5658
+ SELECT set_config('slotlock.ending', '', true), set_config('slotlock.ending_detail', '', true)`;
4728
5659
  return true;
4729
5660
  });
4730
5661
  if (done)
@@ -4753,7 +5684,35 @@ function buildSlotlockStore(sql, options, refusal) {
4753
5684
  if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
4754
5685
  return { ok: false, code: 'invalid_ttl' };
4755
5686
  }
5687
+ const key = params.idempotencyKey;
5688
+ if (key !== undefined && !isValidIdentity(key))
5689
+ return { ok: false, code: 'invalid_identity' };
5690
+ const payloadHash = key === undefined
5691
+ ? null
5692
+ : createHash('sha256')
5693
+ .update(JSON.stringify([
5694
+ params.resourceId ?? null,
5695
+ params.pool ?? null,
5696
+ window.start.toISOString(),
5697
+ window.end.toISOString(),
5698
+ holderRef,
5699
+ offerTtlMs,
5700
+ ]))
5701
+ .digest('hex');
4756
5702
  return inTransaction(sql, async (tx) => {
5703
+ // The tenant's waitlist lock first, which offers and every join take: a retry racing its
5704
+ // first finds the entry the first made.
5705
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5706
+ if (key !== undefined) {
5707
+ const [earlier] = await tx `
5708
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)}, payload_hash FROM slotlock.waitlist_entries
5709
+ WHERE tenant_ref = ${tenantRef} AND idempotency_key = ${key}`;
5710
+ if (earlier) {
5711
+ if (earlier.payload_hash !== payloadHash)
5712
+ return { ok: false, code: 'idempotency_conflict' };
5713
+ return { ok: true, entry: toWaitlistEntry(earlier), replayed: true };
5714
+ }
5715
+ }
4757
5716
  if (params.resourceId !== undefined) {
4758
5717
  const [resource] = await tx `
4759
5718
  SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
@@ -4771,8 +5730,8 @@ function buildSlotlockStore(sql, options, refusal) {
4771
5730
  if (!member)
4772
5731
  return { ok: false, code: 'pool_not_found' };
4773
5732
  }
4774
- // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at the cap.
4775
- await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5733
+ // Counted under the tenant's waitlist lock (taken above), which offers take too, so racing
5734
+ // joins stop at the cap.
4776
5735
  const [counts] = await tx `
4777
5736
  SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
4778
5737
  FROM slotlock.waitlist_entries WHERE tenant_ref = ${tenantRef} AND status = 'waiting'`;
@@ -4782,11 +5741,12 @@ function buildSlotlockStore(sql, options, refusal) {
4782
5741
  }
4783
5742
  const [row] = await tx `
4784
5743
  INSERT INTO slotlock.waitlist_entries
4785
- (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms)
5744
+ (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
5745
+ idempotency_key, payload_hash)
4786
5746
  VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
4787
- ${window.end}, ${holderRef}, ${offerTtlMs})
5747
+ ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
4788
5748
  RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
4789
- return { ok: true, entry: toWaitlistEntry(row) };
5749
+ return { ok: true, entry: toWaitlistEntry(row), replayed: false };
4790
5750
  });
4791
5751
  },
4792
5752
  async listWaitlist(params) {
@@ -4812,7 +5772,6 @@ function buildSlotlockStore(sql, options, refusal) {
4812
5772
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
4813
5773
  return false;
4814
5774
  return inTransaction(sql, async (tx) => {
4815
- // Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
4816
5775
  const [left] = await tx `
4817
5776
  DELETE FROM slotlock.waitlist_entries
4818
5777
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
@@ -4820,8 +5779,7 @@ function buildSlotlockStore(sql, options, refusal) {
4820
5779
  RETURNING hold_id`;
4821
5780
  if (!left)
4822
5781
  return false;
4823
- // An offer not yet taken goes too, freeing the time for the next who waits; a confirmed
4824
- // one is a booking now and stays.
5782
+ // An untaken offer's hold goes too; a confirmed one is a booking now and stays.
4825
5783
  if (left.hold_id) {
4826
5784
  await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
4827
5785
  }
@@ -4878,6 +5836,17 @@ function buildSlotlockStore(sql, options, refusal) {
4878
5836
  DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
4879
5837
  return rows.length > 0;
4880
5838
  },
5839
+ async resealAssistantKey(params) {
5840
+ if (!isValidIdentity(params.tenantRef))
5841
+ return false;
5842
+ if (params.to.length === 0 || params.to === params.from)
5843
+ return false;
5844
+ const rows = await sql `
5845
+ UPDATE slotlock.assistant_settings SET sealed_key = ${params.to}
5846
+ WHERE tenant_ref = ${params.tenantRef} AND sealed_key = ${params.from}
5847
+ RETURNING 1`;
5848
+ return rows.length > 0;
5849
+ },
4881
5850
  async getAssistantConversation(params) {
4882
5851
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4883
5852
  return null;
@@ -5020,7 +5989,6 @@ function buildSlotlockStore(sql, options, refusal) {
5020
5989
  WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
5021
5990
  AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
5022
5991
  }
5023
- // One page in join order; the caller passes `next` back for the rest.
5024
5992
  const waiting = await tx `
5025
5993
  SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
5026
5994
  WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
@@ -5028,19 +5996,16 @@ function buildSlotlockStore(sql, options, refusal) {
5028
5996
  OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
5029
5997
  ORDER BY created_at, id
5030
5998
  LIMIT ${limit}`;
5031
- // Holds made through a store on this transaction, each in a savepoint of its own.
5032
5999
  const inner = buildSlotlockStore(tx, options, null);
5033
6000
  let offered = 0;
5034
6001
  for (const entry of waiting) {
5035
- // Locked so a leave waits for this offer, and skipped if it left first.
5036
6002
  const [still] = await tx `
5037
6003
  SELECT 1 FROM slotlock.waitlist_entries
5038
6004
  WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
5039
6005
  if (!still)
5040
6006
  continue;
5041
- // Only resources whose window looks free are tried: a hold refused keeps its locks
5042
- // until the transaction ends, so trying every member of a full pool would pile them up.
5043
- // A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
6007
+ // Only resources that look free (buffers included) are tried: a refused hold keeps its
6008
+ // locks until the transaction ends.
5044
6009
  const candidates = await tx `
5045
6010
  SELECT resources.id
5046
6011
  FROM slotlock.resources,
@@ -5136,7 +6101,6 @@ function buildSlotlockStore(sql, options, refusal) {
5136
6101
  code: 'resource_tenant_mismatch',
5137
6102
  });
5138
6103
  }
5139
- // Each row's occupied time: its buffers are as busy as the booking.
5140
6104
  const busyRows = await sql `
5141
6105
  SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
5142
6106
  COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
@@ -5145,6 +6109,8 @@ function buildSlotlockStore(sql, options, refusal) {
5145
6109
  AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
5146
6110
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
5147
6111
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
6112
+ UNION ALL
6113
+ ${compositeBusy(sql, params.resourceId, window)}
5148
6114
  ORDER BY 1
5149
6115
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
5150
6116
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
@@ -5187,8 +6153,8 @@ function buildSlotlockStore(sql, options, refusal) {
5187
6153
  FROM recurring_sources
5188
6154
  WHERE earliest_possible_at < ${window.end}`;
5189
6155
  const localRecurrenceRequired = Number(localRecurrenceCoverage?.source_count ?? 0) > 0;
5190
- // A resource that requires certainty requires every sync recording coverage of it, as its
5191
- // writes do (bookingJudge), and judges them by the same day.
6156
+ // Requiring certainty means every sync covering the resource, judged by the same day, as
6157
+ // writes do (bookingJudge).
5192
6158
  const requireCertain = owner[0].require_certain;
5193
6159
  const recordedSources = requireCertain
5194
6160
  ? (await sql `
@@ -5223,8 +6189,8 @@ function buildSlotlockStore(sql, options, refusal) {
5223
6189
  let hasFreshOverlap = coverageRows.some((coverage) => coverage.fresh && coverage.starts_at < window.end && coverage.ends_at > window.start);
5224
6190
  if (localRecurrenceRequired) {
5225
6191
  if (localRecurrenceCoverage?.complete !== true) {
5226
- // Put the intrinsic source first: it is the boundary the caller can repair by rolling
5227
- // the event horizon, independently of any provider snapshot repair.
6192
+ // The intrinsic source first: the caller can repair it by rolling the horizon,
6193
+ // independent of providers.
5228
6194
  missingSources.unshift(SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
5229
6195
  }
5230
6196
  hasFreshOverlap ||= localRecurrenceCoverage?.overlaps === true;
@@ -5263,8 +6229,7 @@ function buildSlotlockStore(sql, options, refusal) {
5263
6229
  if (!isValidResourceId(params.resourceId))
5264
6230
  throw notFound();
5265
6231
  const [row] = await sql `
5266
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
5267
- archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
6232
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
5268
6233
  FROM slotlock.resources
5269
6234
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
5270
6235
  if (!row)
@@ -5273,8 +6238,6 @@ function buildSlotlockStore(sql, options, refusal) {
5273
6238
  return null;
5274
6239
  const resource = toResource(row);
5275
6240
  const rules = params.rules ?? resource.availabilityRules ?? [];
5276
- // Only what a booking made now may take: the resource's booking rules (as they apply) bound
5277
- // the search.
5278
6241
  const search = bookingSearch({
5279
6242
  rules: resource.effectiveBookingRules,
5280
6243
  timezone: resource.timezone,
@@ -5285,11 +6248,11 @@ function buildSlotlockStore(sql, options, refusal) {
5285
6248
  if (!search)
5286
6249
  return null;
5287
6250
  const { window } = search;
5288
- // A slot's buffers may run past the window: the time they can reach must be proven free too.
6251
+ // A slot's buffers may run past the window: that reach must be proven free too.
5289
6252
  const reach = bookingSearchReach(search);
5290
6253
  let busy;
5291
6254
  if (tenantRef === null) {
5292
- // No event, coverage or series can belong to a resource with no tenant: only its rows.
6255
+ // A tenantless resource has no events, coverage or series: only its rows.
5293
6256
  busy = await this.listBusy(params.resourceId, reach);
5294
6257
  }
5295
6258
  else {
@@ -5311,7 +6274,7 @@ function buildSlotlockStore(sql, options, refusal) {
5311
6274
  ? { coverageMaxAgeMs: params.coverageMaxAgeMs }
5312
6275
  : {}),
5313
6276
  });
5314
- // Absence is never treated as free time: what cannot be proven gives no slot.
6277
+ // Absence is never free time.
5315
6278
  if (freeBusy.coverage.state !== 'complete')
5316
6279
  return null;
5317
6280
  busy.push(...freeBusy.busy);
@@ -5376,7 +6339,9 @@ function buildSlotlockStore(sql, options, refusal) {
5376
6339
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5377
6340
  }
5378
6341
  const erased = await sql `
5379
- SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`;
6342
+ SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`.catch((error) => {
6343
+ throw billingActiveRefusal(error);
6344
+ });
5380
6345
  const rows = {};
5381
6346
  let total = 0;
5382
6347
  for (const row of erased) {
@@ -5394,7 +6359,18 @@ function buildSlotlockStore(sql, options, refusal) {
5394
6359
  };
5395
6360
  return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
5396
6361
  }
5397
- /** The deployment role alone may suspend: another role is refused EXECUTE on the functions. */
6362
+ /**
6363
+ * erase_tenant refuses a tenant whose plan names a live subscription (`billing_active`): it is
6364
+ * cancelled first, so it does not renew for an erased account.
6365
+ */
6366
+ function billingActiveRefusal(error) {
6367
+ if (pgCode(error) !== '55006' || error.hint !== 'billing_active')
6368
+ return error;
6369
+ return Object.assign(new Error('Slotlock tenant has a live subscription: cancel it before erasing the tenant', {
6370
+ cause: error,
6371
+ }), { code: 'billing_active' });
6372
+ }
6373
+ /** The deployment role alone may suspend: others are refused EXECUTE on the functions. */
5398
6374
  function suspensionRefusal(error) {
5399
6375
  if (pgCode(error) !== '42501')
5400
6376
  return error;