slotlock 0.6.2 → 0.6.3

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 (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -6
  3. package/agent-server.js +1 -5
  4. package/agent-store-backend.d.ts +5 -6
  5. package/agent-store-backend.js +41 -67
  6. package/api-keys.js +1 -1
  7. package/application-api.d.ts +3 -3
  8. package/application-api.js +16 -24
  9. package/assistant.d.ts +18 -27
  10. package/assistant.js +36 -53
  11. package/booking-rules.d.ts +30 -41
  12. package/booking-rules.js +17 -25
  13. package/change-watch.d.ts +1 -0
  14. package/change-watch.js +1 -1
  15. package/cli.js +3 -4
  16. package/config.d.ts +17 -2
  17. package/config.js +44 -6
  18. package/dashboard.js +13 -0
  19. package/ddl.d.ts +20 -27
  20. package/ddl.js +166 -108
  21. package/engine.d.ts +6 -9
  22. package/engine.js +5 -9
  23. package/event-feed.d.ts +23 -31
  24. package/event-feed.js +30 -45
  25. package/event-schema.js +6 -9
  26. package/hooks.d.ts +2 -4
  27. package/hooks.js +2 -4
  28. package/ical-worker-thread.js +3 -4
  29. package/ical-worker.d.ts +8 -10
  30. package/ical-worker.js +7 -9
  31. package/interop.js +1 -4
  32. package/maintenance.d.ts +14 -10
  33. package/maintenance.js +43 -26
  34. package/mcp-app.d.ts +7 -9
  35. package/mcp-app.js +7 -9
  36. package/mcp-modern.d.ts +14 -16
  37. package/mcp-modern.js +23 -27
  38. package/metrics.js +0 -1
  39. package/node-server.d.ts +9 -9
  40. package/node-server.js +25 -34
  41. package/package-version.js +2 -4
  42. package/package.json +1 -1
  43. package/rate-limit.d.ts +6 -1
  44. package/rate-limit.js +24 -8
  45. package/rest-api.d.ts +13 -14
  46. package/rest-api.js +11 -12
  47. package/rules.d.ts +6 -7
  48. package/rules.js +30 -42
  49. package/self-host.d.ts +16 -2
  50. package/self-host.js +42 -21
  51. package/slotlock.manifest.json +1 -1
  52. package/store.d.ts +109 -161
  53. package/store.js +253 -371
  54. package/sync.d.ts +44 -59
  55. package/sync.js +325 -469
  56. package/timezone.d.ts +25 -33
  57. package/timezone.js +37 -52
  58. package/types.d.ts +35 -56
  59. package/webhooks.d.ts +22 -25
  60. package/webhooks.js +35 -40
  61. package/weekly-hours.d.ts +3 -0
  62. package/weekly-hours.js +1 -1
package/store.js CHANGED
@@ -1,6 +1,4 @@
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.
4
2
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
5
3
  import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
6
4
  import { createHash, randomUUID } from 'node:crypto';
@@ -10,44 +8,36 @@ import { findNextAvailable, mergeIntervals } from './engine.js';
10
8
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
11
9
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
12
10
  import { parseAvailabilityRules } from './weekly-hours.js';
13
- /** Postgres exclusion_violation — the EXCLUDE constraint fired. */
14
11
  const EXCLUSION_VIOLATION = '23P01';
15
12
  /**
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.
13
+ * Concurrent inserts on the same EXCLUDE range can deadlock inside the constraint check itself
14
+ * (intrinsic to Postgres EXCLUDE). The victim is fully rolled back, so one retry either wins or
15
+ * loses with a structured 23P01.
20
16
  */
21
17
  const DEADLOCK_DETECTED = '40P01';
22
18
  const SERIALIZATION_FAILURE = '40001';
23
- /** Holds are negotiation-scoped; a week is already generous. Beyond it is a caller bug. */
19
+ /** Holds are negotiation-scoped; a week is already generous. */
24
20
  const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
25
- /** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
21
+ /** Guards against a caller unit error occupying a resource indefinitely. */
26
22
  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
- */
23
+ /** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
31
24
  const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
32
25
  export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
33
26
  /**
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.
27
+ * Longest one-off event (ten years and some leap days); a recurring series is bounded by its
28
+ * materialization window instead.
37
29
  */
38
30
  export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
39
31
  const DAY_MS = 24 * 60 * 60 * 1000;
40
32
  const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
41
33
  /**
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.
34
+ * The 367-day search window plus two days: the rolling horizon starts a day before the current UTC
35
+ * day and must reach 367 days past its end.
45
36
  */
46
37
  export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
47
38
  const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
48
39
  const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
49
40
  const MAX_RESOURCE_LIST_LIMIT = 1_000;
50
- /** Highest per-tenant resource cap `createResource` accepts. */
51
41
  const MAX_TENANT_RESOURCE_LIMIT = 100_000;
52
42
  const MAX_CALENDAR_LIST_LIMIT = 1_000;
53
43
  const MAX_FREE_BUSY_INTERVALS = 10_000;
@@ -66,7 +56,7 @@ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
66
56
  const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
67
57
  /** How long before its start a booking may be checked in. */
68
58
  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. */
59
+ /** Free-looking resources one entry tries before the next: a race can take one first. */
70
60
  const OFFER_CANDIDATES = 3;
71
61
  /** Default hard ceiling for retained active + tombstoned identities per agent principal. */
72
62
  export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
@@ -86,8 +76,8 @@ const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
86
76
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
87
77
  export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
88
78
  /**
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).
79
+ * From a day before the current UTC day to SLOTLOCK_MATERIALIZATION_WINDOW_DAYS past its end;
80
+ * shared by the writer, maintenance and readiness.
91
81
  */
92
82
  export function calendarEventRollingHorizon(now = new Date()) {
93
83
  if (!Number.isFinite(now.getTime()))
@@ -97,8 +87,8 @@ export function calendarEventRollingHorizon(now = new Date()) {
97
87
  return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
98
88
  }
99
89
  /**
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.
90
+ * A stored series expanded from its last cursor, or `null` when there is no usable cursor or the
91
+ * expansion is refused.
102
92
  */
103
93
  function resumeStoredSeries(event, window, found) {
104
94
  if (!found?.recurrence_cursor)
@@ -116,15 +106,13 @@ function resumeStoredSeries(event, window, found) {
116
106
  throw error;
117
107
  }
118
108
  }
119
- /** A series cursor as the jsonb column takes it. */
120
109
  function seriesCursorJson(tx, cursor) {
121
110
  return cursor === null ? null : tx.json(cursor);
122
111
  }
123
112
  /**
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).
113
+ * From the horizon's start to the later of the two ends, so the end never moves back. `null` keeps
114
+ * the stored window: it already covers the horizon, or no window from the horizon's start fits the
115
+ * 367-day cap yet.
128
116
  */
129
117
  function rolledWindow(stored, horizon) {
130
118
  if (stored.start <= horizon.start && stored.end >= horizon.end)
@@ -134,21 +122,18 @@ function rolledWindow(stored, horizon) {
134
122
  return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
135
123
  }
136
124
  /**
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.
125
+ * How far past a rule's UNTIL, read as UTC, an occurrence can still end. UNTIL bounds starts (RFC
126
+ * 5545 §3.3.10), but a floating or date-only UNTIL is a wall time in any zone (UTC-12 to UTC+14),
127
+ * skipped hours move starts forward, and clock changes stretch ends: three days covers them
128
+ * together.
142
129
  */
143
130
  const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
144
131
  /** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
145
132
  const SERIES_COUNT_SEARCH_WINDOWS = 30;
146
133
  /**
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.
134
+ * A time by which every occurrence of a series ends, or `null` when it may go on: no UNTIL or COUNT
135
+ * as ical.js reads it, or the COUNT occurrences are not all found within
136
+ * SERIES_COUNT_SEARCH_WINDOWS windows or the search budget.
152
137
  */
153
138
  function seriesEndsBy(event) {
154
139
  const recurrence = event.recurrence;
@@ -185,10 +170,8 @@ function seriesEndsBy(event) {
185
170
  return Number.isFinite(ends.getTime()) ? ends : null;
186
171
  }
187
172
  /**
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.
173
+ * The latest end among a COUNT series' occurrences, expanded window by window with exceptions left
174
+ * out (so a moved one is found in its own place), or `null` when fewer than `count` turn up.
192
175
  */
193
176
  function lastCountedOccurrenceEnd(event, count) {
194
177
  const rrule = event.recurrence?.rrule;
@@ -225,16 +208,15 @@ function lastCountedOccurrenceEnd(event, count) {
225
208
  return null;
226
209
  }
227
210
  /**
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'.
211
+ * recurrence_ends_by as text: NULL for a one-off, 'infinity' for no known end. Bind it as text
212
+ * (`::text::timestamptz`): postgres.js serializes timestamptz through Date, which cannot hold
213
+ * 'infinity'.
231
214
  */
232
215
  function recurrenceEndsByColumn(event) {
233
216
  if (!event.recurrence)
234
217
  return null;
235
218
  return seriesEndsBy(event)?.toISOString() ?? 'infinity';
236
219
  }
237
- /** The roll's `after`, checked, or `undefined` for a pass's first batch. */
238
220
  function rollCursor(after) {
239
221
  if (after === undefined)
240
222
  return undefined;
@@ -250,8 +232,8 @@ function rollCursor(after) {
250
232
  return after;
251
233
  }
252
234
  /**
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.
235
+ * A new transaction on a client (READ COMMITTED when asked), or a savepoint inside the caller's,
236
+ * whose isolation cannot change.
255
237
  */
256
238
  async function inTransaction(executor, callback, isolation) {
257
239
  if ('begin' in executor) {
@@ -272,12 +254,9 @@ async function assertReadCommitted(tx) {
272
254
  throw invalidIsolationError();
273
255
  }
274
256
  /**
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.
257
+ * Run deployment SQL with names resolved only in pg_catalog and pg_temp. The deployment role owns
258
+ * every table, so a function found in a schema another role can write to ("$user", public) would
259
+ * run with that ownership. The caller's search_path is restored afterwards.
281
260
  */
282
261
  async function withPinnedSearchPath(tx, operation) {
283
262
  const [caller] = await tx `
@@ -288,10 +267,8 @@ async function withPinnedSearchPath(tx, operation) {
288
267
  return result;
289
268
  }
290
269
  /**
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.
270
+ * Map the DDL's refusals: SQLSTATE 42501 (another role controls the schema; findings as JSON in
271
+ * DETAIL) to `unsafe_slotlock_schema`, and 55000 to `schema_newer`. Anything else passes through.
295
272
  */
296
273
  function schemaControlRefusal(error) {
297
274
  const failure = error;
@@ -322,7 +299,6 @@ function schemaControlRefusal(error) {
322
299
  reasons,
323
300
  });
324
301
  }
325
- /** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
326
302
  const LOCK_NOT_AVAILABLE = '55P03';
327
303
  const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
328
304
  const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
@@ -341,10 +317,10 @@ function migrationSettings(options) {
341
317
  return { lockTimeoutMs, statementTimeoutMs, attempts };
342
318
  }
343
319
  /**
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.
320
+ * One migration step: in a transaction (or savepoint), names pinned to pg_catalog, serialized with
321
+ * every other migration, under the lock and statement timeouts. A lock timeout or deadlock is
322
+ * rolled back and retried up to `attempts` times. The caller's own timeouts are restored
323
+ * afterwards.
348
324
  */
349
325
  async function inMigration(executor, options, step) {
350
326
  const settings = migrationSettings(options);
@@ -367,9 +343,8 @@ async function inMigration(executor, options, step) {
367
343
  }));
368
344
  }
369
345
  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.
346
+ // A deadlock victim (a writer took outbox then coverage, a rename batch the other order) lost
347
+ // its locks, so it retries like a lock timeout.
373
348
  const code = pgCode(error);
374
349
  if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
375
350
  throw error;
@@ -381,12 +356,10 @@ async function inMigration(executor, options, step) {
381
356
  }
382
357
  }
383
358
  /**
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.
359
+ * A digest of what the migration defines in the `slotlock` schema: tables, columns, constraints
360
+ * (and whether validated), indexes, triggers, functions and PUBLIC grants. Grants to other roles,
361
+ * ownership and RLS are left out: applyTenantRls and grantApplicationRole change those later. Runs
362
+ * with search_path pinned, as deparsed definitions depend on it.
390
363
  */
391
364
  async function slotlockCatalogDigest(tx) {
392
365
  const [row] = await tx `
@@ -446,10 +419,9 @@ async function slotlockCatalogDigest(tx) {
446
419
  return row?.digest ?? '';
447
420
  }
448
421
  /**
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.
422
+ * Whether the schema is exactly what this release's applySchema last left: same version, DDL and
423
+ * catalog digest, and no table left unforced by an interrupted migration. Without the right to read
424
+ * it, the full migration runs instead.
453
425
  */
454
426
  async function slotlockSchemaUpToDate(tx) {
455
427
  const [present] = await tx `
@@ -476,29 +448,21 @@ async function slotlockSchemaUpToDate(tx) {
476
448
  }
477
449
  /**
478
450
  * 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.
451
+ * in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, resuming from
452
+ * `schema_version.coverage_changes_renamed_through`. An older server still running can write more,
453
+ * so every migration runs a pass until one finds nothing.
485
454
  *
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.
455
+ * The head is read once every transaction already writing to the outbox has ended, so nothing at or
456
+ * below it commits later. Forced RLS would hide other tenants' rows, so each batch lifts FORCE from
457
+ * calendar_coverage and outbox under ACCESS EXCLUSIVE (in writer lock order) and restores it before
458
+ * committing. A superuser or BYPASSRLS role instead holds SHARE ROW EXCLUSIVE on the outbox only
459
+ * while reading the head.
496
460
  */
497
461
  async function renameEarlierCoverageChanges(executor, options) {
498
462
  /**
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.
463
+ * Run `work` able to read every tenant's rows: as is for a superuser or BYPASSRLS role, else with
464
+ * FORCE lifted under ACCESS EXCLUSIVE (calendar_coverage, then outbox: writer lock order) and put
465
+ * back after.
502
466
  */
503
467
  const everyTenant = async (tx, bypass, work) => {
504
468
  const forced = bypass
@@ -521,8 +485,8 @@ async function renameEarlierCoverageChanges(executor, options) {
521
485
  await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
522
486
  return result;
523
487
  };
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.
488
+ // The head, in its own short transaction: writes in flight hold ROW EXCLUSIVE from before they
489
+ // took their id, so none at or below it commits later.
526
490
  const start = await inMigration(executor, options, async (tx) => {
527
491
  const [state] = await tx `
528
492
  SELECT version.coverage_changes_renamed AS renamed,
@@ -554,7 +518,6 @@ async function renameEarlierCoverageChanges(executor, options) {
554
518
  ]));
555
519
  const read = row?.last ?? null;
556
520
  renamedTotal += row?.renamed ?? 0;
557
- // Saved with the batch, so a pass stopped part way goes on from here.
558
521
  await tx `
559
522
  UPDATE slotlock.schema_version
560
523
  SET coverage_changes_renamed_through = ${read ?? head}::bigint,
@@ -567,7 +530,6 @@ async function renameEarlierCoverageChanges(executor, options) {
567
530
  after = String(last);
568
531
  }
569
532
  }
570
- /** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
571
533
  async function coverageChangesRenamePending(tx) {
572
534
  const [row] = await tx `
573
535
  SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
@@ -586,8 +548,8 @@ async function slotlockConstraintUnvalidated(tx) {
586
548
  return row?.unvalidated === true;
587
549
  }
588
550
  /**
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.
551
+ * Whether every tenant table has forced RLS and only its tenant policy bound to `setting`, matching
552
+ * the fingerprint the DDL recorded.
591
553
  */
592
554
  async function tenantRlsUpToDate(tx, setting) {
593
555
  const [present] = await tx `
@@ -622,9 +584,8 @@ async function tenantRlsUpToDate(tx, setting) {
622
584
  return state?.current === true && state.matched === tables.length;
623
585
  }
624
586
  /**
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).
587
+ * The recorded schema version, or `null` when this role cannot read one: no schema, 0.2 or earlier,
588
+ * or the function not yet granted (`slotlock migrate` grants it).
628
589
  */
629
590
  export async function readSlotlockSchemaVersion(sql) {
630
591
  const [readable] = await sql `
@@ -638,9 +599,8 @@ export async function readSlotlockSchemaVersion(sql) {
638
599
  return row?.version ?? null;
639
600
  }
640
601
  /**
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`.
602
+ * Refuse a schema another release migrated: `schema_outdated` (older or unversioned: run `slotlock
603
+ * migrate`) or `schema_newer`. Both carry `installed` and `expected`.
644
604
  */
645
605
  export async function assertSlotlockSchemaCurrent(sql) {
646
606
  const installed = await readSlotlockSchemaVersion(sql);
@@ -747,7 +707,6 @@ function toHold(row) {
747
707
  ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
748
708
  };
749
709
  }
750
- /** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
751
710
  function pageCursor(after) {
752
711
  if (after !== undefined &&
753
712
  (!(after.start instanceof Date) ||
@@ -1012,7 +971,6 @@ async function replaceCalendarEventOccurrences(tx, params) {
1012
971
  )`;
1013
972
  }
1014
973
  }
1015
- /** A recurring series' materialization window: finite, forward, and at most 369 days. */
1016
974
  function seriesWindow(window) {
1017
975
  if (!isFiniteWindow(window) ||
1018
976
  window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
@@ -1028,9 +986,9 @@ function calendarWindow(window) {
1028
986
  return { start: new Date(window.start), end: new Date(window.end) };
1029
987
  }
1030
988
  /**
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.
989
+ * A one-off event's window must contain the event. It may exceed the 367-day cap only by being
990
+ * exactly the event's interval, as the `slotlock_calendar_events_materialization_window_bounded`
991
+ * CHECK admits.
1034
992
  */
1035
993
  function oneOffEventWindow(event, requested) {
1036
994
  const duration = event.end.getTime() - event.start.getTime();
@@ -1049,17 +1007,15 @@ function calendarCommandHash(value) {
1049
1007
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
1050
1008
  }
1051
1009
  /**
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.
1010
+ * A put's identity and the event it asks for, not its storage window: a series' window moves with
1011
+ * the UTC day, so a retry after midnight must still match.
1055
1012
  */
1056
1013
  function putCommandHash(params) {
1057
1014
  return calendarCommandHash(putCommandValue(params));
1058
1015
  }
1059
1016
  /**
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).
1017
+ * The hash 0.3.0 and earlier stored (window included), so an older stored command still replays.
1018
+ * Remove once no stored command can be that old (pruned after 30 days).
1063
1019
  */
1064
1020
  export function legacyPutCommandHash(params) {
1065
1021
  return calendarCommandHash({
@@ -1079,7 +1035,7 @@ function putCommandValue(params) {
1079
1035
  expectedRevision: params.command.expectedRevision,
1080
1036
  resourceId: params.command.resourceId,
1081
1037
  source: params.command.source ?? null,
1082
- // Only when present, so a create without a hold keeps the hash it always had.
1038
+ // Only when present, so a create without a hold keeps its hash.
1083
1039
  ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
1084
1040
  event: {
1085
1041
  ...params.event,
@@ -1138,8 +1094,7 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
1138
1094
  }
1139
1095
  /**
1140
1096
  * 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.
1097
+ * and its occupation, leave the tombstone at `revision`, and record the command for replay.
1143
1098
  */
1144
1099
  async function finishCalendarCancel(tx, params) {
1145
1100
  const eventId = params.eventId ?? randomUUID();
@@ -1179,8 +1134,8 @@ async function finishCalendarCancel(tx, params) {
1179
1134
  };
1180
1135
  }
1181
1136
  /**
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.
1137
+ * The row an event write lost to. The write was rolled back, so the event's own previous rows are
1138
+ * excluded: they were to be replaced.
1184
1139
  */
1185
1140
  async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
1186
1141
  for (const occurrence of occurrences) {
@@ -1191,10 +1146,9 @@ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffer
1191
1146
  return { ok: false, code: 'overlap', conflictingReservationId: null };
1192
1147
  }
1193
1148
  /**
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`.
1149
+ * What the resource's effective booking rules say about booking `interval` now (database clock):
1150
+ * the rule broken, if any, and the buffers to keep. A resource without its own hours is judged
1151
+ * against `defaultHours`.
1198
1152
  */
1199
1153
  async function bookingRuling(tx, resourceId, interval, defaultHours) {
1200
1154
  const judge = await bookingJudge(tx, resourceId, defaultHours);
@@ -1207,8 +1161,8 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1207
1161
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1208
1162
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1209
1163
  /**
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.
1164
+ * The resource's effective booking rules, read once, as a check of any interval booked now
1165
+ * (database clock) and the buffers to keep.
1212
1166
  */
1213
1167
  async function bookingJudge(tx, resourceId, defaultHours) {
1214
1168
  const [row] = await tx `
@@ -1231,7 +1185,6 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1231
1185
  effective_booking_rules: row.effective_booking_rules,
1232
1186
  });
1233
1187
  const hours = availabilityRules ?? defaultHours;
1234
- // Every sync covering the resource, and whether it was read within a day, by the same clock.
1235
1188
  const coverage = rules?.requireCertain === true
1236
1189
  ? await tx `
1237
1190
  SELECT starts_at, ends_at,
@@ -1241,12 +1194,11 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1241
1194
  : [];
1242
1195
  const turnaroundMs = bookingBufferAfterMs(rules);
1243
1196
  const bufferBeforeMs = bookingBufferBeforeMs(rules);
1244
- // The booking and the buffers it keeps, as a search judges the slot.
1245
1197
  const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1246
1198
  source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
1247
1199
  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.
1200
+ // Later occurrences are judged on step and hours only: notice and advance concern when the
1201
+ // booking was made.
1250
1202
  const later = rules === undefined
1251
1203
  ? undefined
1252
1204
  : {
@@ -1271,19 +1223,17 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1271
1223
  return (row.resource_id === params.resourceId &&
1272
1224
  row.starts_at.getTime() === params.start.getTime() &&
1273
1225
  row.ends_at.getTime() === params.end.getTime() &&
1274
- // As asked, or raised to the resource's turnaround when it was stored.
1275
1226
  (buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
1276
1227
  Number(row.revision) === params.revision &&
1277
1228
  row.source === (params.source ?? null));
1278
1229
  }
1279
1230
  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.
1231
+ // JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
1282
1232
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
1283
1233
  }
1284
1234
  /**
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.
1235
+ * Whether the resource is the tenant's (`missing` when not), and whether it is archived.
1236
+ * Share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
1287
1237
  */
1288
1238
  async function tenantResourceState(tx, resourceId, tenantRef) {
1289
1239
  const [row] = await tx `
@@ -1333,9 +1283,9 @@ function invalidPoolError() {
1333
1283
  });
1334
1284
  }
1335
1285
  /**
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).
1286
+ * A resource row's effective booking rules (jsonb; NULL for none): tenant defaults, overlaid by its
1287
+ * pool's, overlaid by its own, field by field. Reads from a row of slotlock.resources named
1288
+ * `resources` (no alias).
1339
1289
  */
1340
1290
  const EFFECTIVE_BOOKING_RULES = `NULLIF(
1341
1291
  COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
@@ -1347,7 +1297,7 @@ const EFFECTIVE_BOOKING_RULES = `NULLIF(
1347
1297
  '{}'::jsonb) AS effective_booking_rules`;
1348
1298
  /** What rules Slotlock did not write read as: no booking passes them. */
1349
1299
  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. */
1300
+ /** Rules written validated, so anything else read back refuses every booking. */
1351
1301
  function storedBookingRules(value) {
1352
1302
  try {
1353
1303
  return parseBookingRules(value);
@@ -1369,7 +1319,7 @@ function toResource(row) {
1369
1319
  if (row.pool_ref !== null && row.pool_ref !== undefined)
1370
1320
  resource.pool = row.pool_ref;
1371
1321
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
1372
- // Written through setResourceAvailability, which validates; anything else reads as closed.
1322
+ // Written validated; anything else reads as closed.
1373
1323
  try {
1374
1324
  resource.availabilityRules = parseAvailabilityRules(row.availability_rules);
1375
1325
  }
@@ -1378,14 +1328,12 @@ function toResource(row) {
1378
1328
  }
1379
1329
  }
1380
1330
  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.
1331
+ // Written validated; anything else refuses every booking, never as no rule.
1383
1332
  resource.bookingRules = storedBookingRules(row.booking_rules);
1384
1333
  }
1385
1334
  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.
1335
+ // Levels are validated alone, so only their combination can disagree (an offset over a shorter
1336
+ // step): that one is read modulo the step. A field invalid in itself refuses every booking.
1389
1337
  const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
1390
1338
  if (dropped.length > 0) {
1391
1339
  console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
@@ -1397,7 +1345,6 @@ function toResource(row) {
1397
1345
  }
1398
1346
  return resource;
1399
1347
  }
1400
- /** Validated rules as a jsonb parameter, or null for "use the default". */
1401
1348
  function availabilityParameter(sql, rules) {
1402
1349
  // sql.json, not JSON.stringify: a string bound to jsonb is stored as a JSON string.
1403
1350
  if (rules === null)
@@ -1412,8 +1359,8 @@ async function upsertResource(executor, params) {
1412
1359
  const visibility = params.visibility ?? 'shared';
1413
1360
  const asked = params.visibility ?? null;
1414
1361
  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.
1362
+ // An existing identity keeps its visibility: re-creating with another is refused, so a private
1363
+ // one is never made shared.
1417
1364
  if (params.tenantRef !== undefined && params.externalRef !== undefined) {
1418
1365
  rows = await executor `
1419
1366
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
@@ -1449,11 +1396,9 @@ async function upsertResource(executor, params) {
1449
1396
  return toResource(row);
1450
1397
  }
1451
1398
  /**
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.
1399
+ * Rematerialize one stored recurring master over rolledWindow: from its last expansion's cursor
1400
+ * when trustworthy, else its start. False when there is nothing to roll. A collision throws 23P01
1401
+ * and an over-budget rule CalendarExpansionLimitError; run it in a savepoint.
1457
1402
  */
1458
1403
  async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1459
1404
  const [row] = await tx `
@@ -1499,8 +1444,8 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1499
1444
  return true;
1500
1445
  }
1501
1446
  /**
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.
1447
+ * Work out and store when a series stored before 0.3 ends, as its next roll would; its occurrences
1448
+ * are left as they are.
1504
1449
  */
1505
1450
  async function learnSeriesEnd(tx, tenantRef, eventId) {
1506
1451
  const [row] = await tx `
@@ -1522,7 +1467,6 @@ async function learnSeriesEnd(tx, tenantRef, eventId) {
1522
1467
  SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
1523
1468
  WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
1524
1469
  }
1525
- /** Put a master the roll could not extend behind the others for an hour. */
1526
1470
  async function deferRoll(executor, tenantRef, eventId) {
1527
1471
  await executor `
1528
1472
  UPDATE slotlock.calendar_events
@@ -1531,24 +1475,19 @@ async function deferRoll(executor, tenantRef, eventId) {
1531
1475
  }
1532
1476
  /**
1533
1477
  * 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).
1478
+ * part of [start, end) a roll can reach. A write must not land where a series is not stored yet, or
1479
+ * the series' next roll would collide with it. A series behind is first rolled to the horizon in a
1480
+ * savepoint, so the write meets its occurrences under the exclusion arbiter; false when one cannot
1481
+ * be. Series roll in id order; a deadlock with a concurrent writer is broken by the database
1482
+ * (retried once, then an overlap).
1544
1483
  */
1545
1484
  async function recurrenceCoversWrite(tx, params) {
1546
1485
  const horizon = calendarEventRollingHorizon();
1547
1486
  if (params.end <= horizon.start)
1548
1487
  return true;
1549
1488
  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.
1489
+ // LEAST and GREATEST ignore NULL arguments (PostgreSQL 16): a series with no known end is behind
1490
+ // wherever it is stored short of the write's end.
1552
1491
  const behind = () => tx `
1553
1492
  SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
1554
1493
  COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
@@ -1578,8 +1517,8 @@ async function recurrenceCoversWrite(tx, params) {
1578
1517
  if (due.length === 0)
1579
1518
  return true;
1580
1519
  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.
1520
+ // A series stored before 0.3 works out its end now, and is left out if it ends before the write
1521
+ // or is stored through its end.
1583
1522
  if (due.some((row) => row.ends_by_unknown)) {
1584
1523
  for (const { id, ends_by_unknown } of due) {
1585
1524
  if (ends_by_unknown)
@@ -1589,7 +1528,6 @@ async function recurrenceCoversWrite(tx, params) {
1589
1528
  if (due.length === 0)
1590
1529
  return true;
1591
1530
  }
1592
- // No roll stores a series past the horizon: one going on past it stays behind such a write.
1593
1531
  if (due.some((row) => !row.ends_in_horizon))
1594
1532
  return false;
1595
1533
  }
@@ -1607,9 +1545,8 @@ async function recurrenceCoversWrite(tx, params) {
1607
1545
  return (await behind()).length === 0;
1608
1546
  }
1609
1547
  /**
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.
1548
+ * Whether an event already occupies all the time `occurrences` would: such a write (a rename) takes
1549
+ * no time a series could later need.
1613
1550
  */
1614
1551
  async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1615
1552
  const held = await tx `
@@ -1620,7 +1557,6 @@ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
1620
1557
  AND reservation.resource_id = ${resourceId}`;
1621
1558
  return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
1622
1559
  }
1623
- /** From the earliest start to the latest end of an event's occurrences, or null for none. */
1624
1560
  function occurrenceSpan(occurrences) {
1625
1561
  const [first] = occurrences;
1626
1562
  if (!first)
@@ -1636,10 +1572,9 @@ function occurrenceSpan(occurrences) {
1636
1572
  return { start, end };
1637
1573
  }
1638
1574
  /**
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.
1575
+ * After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series the
1576
+ * write's transaction rolled and lost, so the loss can name the blocking occurrence. Upkeep only:
1577
+ * on failure, maintenance rolls them later.
1643
1578
  */
1644
1579
  async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
1645
1580
  try {
@@ -1677,35 +1612,30 @@ function tenantLimitsOption(limits) {
1677
1612
  return valid;
1678
1613
  }
1679
1614
  /**
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.
1615
+ * Serialize a tenant's writes adding rows of one limited kind, so each counts what earlier ones
1616
+ * committed. Taken after the identity locks, before any row lock.
1682
1617
  */
1683
1618
  async function lockTenantLimit(tx, tenantRef, kind) {
1684
1619
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
1685
1620
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
1686
1621
  }
1687
1622
  /**
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`).
1623
+ * Rows added within this many days count whatever time they hold, so back-dated bookings and
1624
+ * cancellations add at most the limit per window. Maintenance keeps idempotency records and stray
1625
+ * retirements at least this long.
1692
1626
  */
1693
1627
  const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1694
1628
  /**
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.
1629
+ * The rows a tenant's `kind` limit counts: what is still to come plus what it added in the last
1630
+ * TENANT_LIMIT_RECENT_DAYS days. Each part stops at `limit` rows read through an index, so a count
1631
+ * costs at most the limit.
1700
1632
  */
1701
1633
  async function tenantLimitCount(tx, tenantRef, kind, limit) {
1702
1634
  let rows;
1703
1635
  switch (kind) {
1704
1636
  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.
1637
+ // Every held row until deleted (expired ones too), plus each keyed hold's kept answer once
1638
+ // its row is gone.
1709
1639
  rows = await tx `
1710
1640
  SELECT (
1711
1641
  (SELECT count(*) FROM (
@@ -1725,9 +1655,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1725
1655
  )::int AS count`;
1726
1656
  break;
1727
1657
  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.
1658
+ // Bookings still to come; ended bookings and cancellations made recently; and stray
1659
+ // retirements until pruned.
1731
1660
  rows = await tx `
1732
1661
  WITH recent AS (
1733
1662
  SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
@@ -1755,9 +1684,9 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1755
1684
  )::int AS count`;
1756
1685
  break;
1757
1686
  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.
1687
+ // One-off events still to come and series not known to have ended; recent ended events and
1688
+ // cancellations; and agent cancellations until pruned. The owner CHECK makes every owner but
1689
+ // 'internal' an 'agent:' one.
1761
1690
  rows = await tx `
1762
1691
  WITH recent AS (
1763
1692
  SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
@@ -1802,9 +1731,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
1802
1731
  return count;
1803
1732
  }
1804
1733
  /**
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.
1734
+ * Delete up to `limit` of a tenant's expired holds, oldest first. Rows a reaper or confirmer has
1735
+ * locked are skipped, and the delete rechecks the predicate.
1808
1736
  */
1809
1737
  async function deleteExpiredHolds(tx, tenantRef, limit) {
1810
1738
  const [row] = await tx `
@@ -1828,8 +1756,8 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
1828
1756
  return Number(row?.deleted ?? 0);
1829
1757
  }
1830
1758
  /**
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.
1759
+ * Delete up to `limit` keyed hold answers whose hold has expired, so a retry makes a new hold;
1760
+ * locked answers are skipped.
1833
1761
  */
1834
1762
  async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1835
1763
  const [row] = await tx `
@@ -1852,8 +1780,8 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1852
1780
  return Number(row?.deleted ?? 0);
1853
1781
  }
1854
1782
  /**
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.
1783
+ * A booking's occupied time as the exclusion constraint reads it: start less its leading buffer to
1784
+ * end plus its trailing one.
1857
1785
  */
1858
1786
  function occupiedTime(params) {
1859
1787
  return {
@@ -1862,8 +1790,8 @@ function occupiedTime(params) {
1862
1790
  };
1863
1791
  }
1864
1792
  /**
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.
1793
+ * Occupied start before `end`, written so the (resource_id, starts_at, ends_at) index still
1794
+ * applies: no row starts more than a day after its occupied start.
1867
1795
  */
1868
1796
  function occupiedStartsBefore(executor, end, alias) {
1869
1797
  const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
@@ -1871,12 +1799,9 @@ function occupiedStartsBefore(executor, end, alias) {
1871
1799
  AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
1872
1800
  }
1873
1801
  /**
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.
1802
+ * Delete expired held rows whose occupied time overlaps the window, in id order under FOR UPDATE:
1803
+ * concurrent reapers lock in the same order (no deadlock) and the loser finds them gone. Runs right
1804
+ * before the caller's INSERT, so the EXCLUDE arbiter only sees live occupation.
1880
1805
  */
1881
1806
  async function reapExpiredHolds(tx, resourceId, start, end) {
1882
1807
  await tx `
@@ -1893,10 +1818,9 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1893
1818
  )`;
1894
1819
  }
1895
1820
  /**
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.
1821
+ * Structured answer after losing to the EXCLUDE arbiter: name the live winner.
1822
+ * conflictingReservationId is null in one narrow race (the blocker expired between the constraint
1823
+ * check and this lookup); an immediate retry reaps it and wins.
1900
1824
  */
1901
1825
  async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
1902
1826
  const winners = await sql `
@@ -1967,7 +1891,7 @@ export function createTenantSlots(max) {
1967
1891
  if (released)
1968
1892
  return;
1969
1893
  released = true;
1970
- // Handed straight to the next in line, so the count stays as it is.
1894
+ // Handed straight to the next in line, so the count is unchanged.
1971
1895
  const next = slots.waiting.shift();
1972
1896
  if (next) {
1973
1897
  next();
@@ -2033,9 +1957,8 @@ function staleSuspensionCheckError() {
2033
1957
  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
1958
  }
2035
1959
  /**
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.
1960
+ * Reading a suspension needs EXECUTE on slotlock.tenant_suspended(text) (42501 without it) and a
1961
+ * schema this release migrated (42883 before). Name the remedy for each.
2039
1962
  */
2040
1963
  function suspensionCheckRefusal(error) {
2041
1964
  const code = pgCode(error);
@@ -2048,8 +1971,8 @@ function suspensionCheckRefusal(error) {
2048
1971
  return error;
2049
1972
  }
2050
1973
  /**
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).
1974
+ * A store on `sql`. With `refusal`, every SLOTLOCK_TENANT_WRITE_METHODS method throws it before any
1975
+ * query.
2053
1976
  */
2054
1977
  function buildSlotlockStore(sql, options, refusal) {
2055
1978
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
@@ -2096,7 +2019,7 @@ function buildSlotlockStore(sql, options, refusal) {
2096
2019
  ? createTenantSlots(maxTenantTransactions)
2097
2020
  : undefined;
2098
2021
  const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
2099
- createSlotlockTenantRlsDdl(configuredTenantSetting); // Validate once before any scoped query.
2022
+ createSlotlockTenantRlsDdl(configuredTenantSetting);
2100
2023
  const storeOptions = {
2101
2024
  tenantContextSetting: configuredTenantSetting,
2102
2025
  agentOwnerEventQuota: configuredAgentOwnerEventQuota,
@@ -2107,9 +2030,8 @@ function buildSlotlockStore(sql, options, refusal) {
2107
2030
  ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
2108
2031
  };
2109
2032
  /**
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.
2033
+ * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
2034
+ * only lower the count take no lock, so an early count stays an upper bound.
2113
2035
  */
2114
2036
  async function tenantLimitReached(tx, tenantRef, kind) {
2115
2037
  const limit = configuredTenantLimits[kind];
@@ -2119,10 +2041,8 @@ function buildSlotlockStore(sql, options, refusal) {
2119
2041
  await lockTenantLimit(tx, tenantRef, kind);
2120
2042
  if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
2121
2043
  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.
2044
+ // At the limit, expired holds and their answers are deleted, as maintenance would, and the
2045
+ // count is read again.
2126
2046
  if (kind !== 'holds' ||
2127
2047
  (await deleteExpiredHolds(tx, tenantRef, limit)) +
2128
2048
  (await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
@@ -2133,7 +2053,7 @@ function buildSlotlockStore(sql, options, refusal) {
2133
2053
  }
2134
2054
  /**
2135
2055
  * 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.
2056
+ * resource's (never changes, so read before any lock). Null with no limit.
2137
2057
  */
2138
2058
  async function limitTenant(tx, kind, resourceId, tenantRef) {
2139
2059
  if (configuredTenantLimits[kind] === undefined)
@@ -2147,12 +2067,9 @@ function buildSlotlockStore(sql, options, refusal) {
2147
2067
  const store = {
2148
2068
  ...slotlockEventFeedMethods(sql, retentionDays),
2149
2069
  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).
2070
+ // IF NOT EXISTS DDL is idempotent but not race-free: two fresh instances can collide on
2071
+ // pg_namespace/pg_extension indexes, so inMigration serializes bootstrap. An unchanged schema
2072
+ // locks no table unless a constraint is still NOT VALID or the coverage rename is unfinished.
2156
2073
  const next = await inMigration(sql, migrationOptions, async (tx) => {
2157
2074
  await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
2158
2075
  throw schemaControlRefusal(error);
@@ -2168,18 +2085,15 @@ function buildSlotlockStore(sql, options, refusal) {
2168
2085
  });
2169
2086
  return { validate: true, rename: await coverageChangesRenamePending(tx) };
2170
2087
  });
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).
2088
+ // Renamed in short batches after the structure commits, never under its locks; the feed
2089
+ // redacts any not yet renamed (event-feed.ts, slotlockServedChange).
2175
2090
  if (next.rename)
2176
2091
  await renameEarlierCoverageChanges(sql, migrationOptions);
2177
2092
  if (!next.validate)
2178
2093
  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.
2094
+ // After the DDL commits its ACCESS EXCLUSIVE locks are gone, so validation blocks no serving
2095
+ // query (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Digests go last, so a migration stopped
2096
+ // earlier runs whole again.
2183
2097
  await inMigration(sql, migrationOptions, async (tx) => {
2184
2098
  await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
2185
2099
  await tx `
@@ -2349,8 +2263,8 @@ function buildSlotlockStore(sql, options, refusal) {
2349
2263
  throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
2350
2264
  }
2351
2265
  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.
2266
+ // A lock timeout leaves the transaction unusable, so the reset below would fail with another
2267
+ // error: noted here.
2354
2268
  let timedOut = false;
2355
2269
  return inTransaction(sql, async (tx) => {
2356
2270
  if (lockTimeout !== undefined) {
@@ -2358,9 +2272,8 @@ function buildSlotlockStore(sql, options, refusal) {
2358
2272
  }
2359
2273
  if (isolation !== undefined)
2360
2274
  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).
2275
+ // Held shared until the transaction ends: suspension and erasure take it exclusively, so
2276
+ // they wait for this transaction, and one starting after them sees them (READ COMMITTED).
2364
2277
  const previous = await tx `
2365
2278
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2366
2279
  current_setting('slotlock.actor', true) AS actor,
@@ -2379,9 +2292,8 @@ function buildSlotlockStore(sql, options, refusal) {
2379
2292
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2380
2293
  throw suspensionCheckRefusal(error);
2381
2294
  });
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.
2295
+ // A caller's REPEATABLE READ or SERIALIZABLE snapshot predates the lock, so it may miss a
2296
+ // suspension: its writes are refused, reads allowed.
2385
2297
  const refusal = context?.suspended === true
2386
2298
  ? tenantSuspendedError
2387
2299
  : previous[0]?.isolation !== 'read committed'
@@ -2436,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
2436
2348
  // An object, so the key never equals a [tenantRef, externalRef] identity lock's.
2437
2349
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
2438
2350
  // 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.
2351
+ // Re-creating a known reference adds none.
2440
2352
  const known = externalRef === null
2441
2353
  ? []
2442
2354
  : await tx `
@@ -2539,8 +2451,8 @@ function buildSlotlockStore(sql, options, refusal) {
2539
2451
  const tenantRef = params.tenantRef;
2540
2452
  return inTransaction(sql, async (tx) => {
2541
2453
  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.
2454
+ // Counted under the tenant's lock, which only READ COMMITTED makes see every committed
2455
+ // row.
2544
2456
  await assertReadCommitted(tx);
2545
2457
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
2546
2458
  const [counted] = await tx `
@@ -2668,8 +2580,8 @@ function buildSlotlockStore(sql, options, refusal) {
2668
2580
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2669
2581
  throw invalidIdentityError('Slotlock resource identity is invalid');
2670
2582
  }
2671
- // Its clock time, not the transaction's start: a booking that committed holding the row's
2672
- // share lock went in before this.
2583
+ // Clock time, not transaction start: a booking that committed holding the row's share lock
2584
+ // went in before this.
2673
2585
  const [row] = await sql `
2674
2586
  UPDATE slotlock.resources
2675
2587
  SET archived_at = COALESCE(archived_at, clock_timestamp())
@@ -2715,8 +2627,8 @@ function buildSlotlockStore(sql, options, refusal) {
2715
2627
  ) AS live`;
2716
2628
  if (live?.live)
2717
2629
  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.
2630
+ // Reservations, events and their children, coverage and hold answers all cascade from the
2631
+ // resource.
2720
2632
  await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
2721
2633
  return { ok: true, deleted: true };
2722
2634
  });
@@ -2730,10 +2642,9 @@ function buildSlotlockStore(sql, options, refusal) {
2730
2642
  if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
2731
2643
  return { ok: false, code: 'resource_not_found' };
2732
2644
  }
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.
2645
+ // A booking on a tenant's resource takes the tenant and its own id as reference; one on a
2646
+ // tenantless resource stays tenantless.
2735
2647
  const id = randomUUID();
2736
- // The buffers written, for the overlap answer if the write loses.
2737
2648
  let bufferBeforeMs = 0;
2738
2649
  let bufferAfterMs = 0;
2739
2650
  for (let attempt = 1;; attempt++) {
@@ -2788,15 +2699,15 @@ function buildSlotlockStore(sql, options, refusal) {
2788
2699
  catch (err) {
2789
2700
  const code = pgCode(err);
2790
2701
  if ((code === DEADLOCK_DETECTED || code === SERIALIZATION_FAILURE) && attempt === 1) {
2791
- continue; // fully rolled back — one retry wins cleanly or loses with a clean 23P01
2702
+ continue; // fully rolled back: one retry wins or loses with a clean 23P01
2792
2703
  }
2793
2704
  if (code !== EXCLUSION_VIOLATION &&
2794
2705
  code !== DEADLOCK_DETECTED &&
2795
2706
  code !== SERIALIZATION_FAILURE) {
2796
2707
  throw err;
2797
2708
  }
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.
2709
+ // A second contention abort is a structured loss too (possibly with a null winner), never
2710
+ // a raw throw.
2800
2711
  const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2801
2712
  await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2802
2713
  return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
@@ -2821,8 +2732,8 @@ function buildSlotlockStore(sql, options, refusal) {
2821
2732
  if (cancelled[0])
2822
2733
  return { ok: false, code: 'reservation_cancelled' };
2823
2734
  // 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.
2735
+ // not a booking: create, reschedule and cancel treat it as a reference no booking
2736
+ // holds.
2826
2737
  const existing = await tx `
2827
2738
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2828
2739
  buffer_after_ms, revision, source
@@ -2833,7 +2744,7 @@ function buildSlotlockStore(sql, options, refusal) {
2833
2744
  const found = existing[0];
2834
2745
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2835
2746
  if (found) {
2836
- // The booking has moved on since: a delayed create is stale, as a delayed move is.
2747
+ // Moved on since: a delayed create is stale, as a delayed move is.
2837
2748
  if (params.revision < Number(found.revision)) {
2838
2749
  return { ok: false, code: 'stale_revision' };
2839
2750
  }
@@ -3034,8 +2945,8 @@ function buildSlotlockStore(sql, options, refusal) {
3034
2945
  }
3035
2946
  }
3036
2947
  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.
2948
+ // This reference is a calendar event's occupation, not a booking: nothing to cancel or
2949
+ // retire.
3039
2950
  const [occupation] = await tx `
3040
2951
  SELECT id FROM slotlock.reservations
3041
2952
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
@@ -3049,7 +2960,7 @@ function buildSlotlockStore(sql, options, refusal) {
3049
2960
  return { ok: false, code: 'reservation_limit_reached', limit };
3050
2961
  }
3051
2962
  // 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.
2963
+ // cannot bring it back.
3053
2964
  await tx `
3054
2965
  INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
3055
2966
  VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
@@ -3064,9 +2975,8 @@ function buildSlotlockStore(sql, options, refusal) {
3064
2975
  if (!isFiniteWindow({ start: params.start, end: params.end })) {
3065
2976
  return { ok: false, code: 'invalid_window' };
3066
2977
  }
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.
2978
+ // NaN/Infinity would raise a raw 22008 in make_interval, and a huge ttl could exceed the JS
2979
+ // Date range.
3070
2980
  if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
3071
2981
  return { ok: false, code: 'invalid_ttl' };
3072
2982
  }
@@ -3106,8 +3016,7 @@ function buildSlotlockStore(sql, options, refusal) {
3106
3016
  ...(holderRef !== undefined ? { holderRef } : {}),
3107
3017
  });
3108
3018
  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.
3019
+ // The buffer asked for, raised to the resource's turnaround, and its rule's leading buffer.
3111
3020
  let written = bufferAfterMs;
3112
3021
  let writtenBefore = 0;
3113
3022
  for (let attempt = 1;; attempt++) {
@@ -3145,11 +3054,10 @@ function buildSlotlockStore(sql, options, refusal) {
3145
3054
  return { ok: false, code: 'resource_archived' };
3146
3055
  if (holdLimit !== null)
3147
3056
  return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
3148
- // Seconds the holder already holds, turnaround included, when its time is capped.
3149
3057
  let heldSeconds = null;
3150
3058
  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.
3059
+ // Counted under a lock on the holder, so racing holds stop at the cap: an agent
3060
+ // cannot hold a resource's whole week.
3153
3061
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
3154
3062
  'slotlock-holder',
3155
3063
  tenantRef,
@@ -3229,8 +3137,8 @@ function buildSlotlockStore(sql, options, refusal) {
3229
3137
  code !== SERIALIZATION_FAILURE) {
3230
3138
  throw err;
3231
3139
  }
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.
3140
+ // A second contention abort is a structured loss too (possibly with a null winner), never
3141
+ // a raw throw.
3234
3142
  const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3235
3143
  await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
3236
3144
  return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
@@ -3344,13 +3252,12 @@ function buildSlotlockStore(sql, options, refusal) {
3344
3252
  },
3345
3253
  async confirmHold(holdId, options = {}) {
3346
3254
  const tenantRef = holdTenantRef(options);
3347
- // Not an id the store issues: it names no hold (and would be a raw 22P02 in the query).
3255
+ // Not an id the store issues (and a raw 22P02 in the query).
3348
3256
  if (!isValidResourceId(holdId))
3349
3257
  return { ok: false, code: 'hold_not_found' };
3350
3258
  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.
3259
+ // A hold never changes resource, so the owner is read before the identity lock, taken
3260
+ // before the row lock as every booking write does.
3354
3261
  const [owner] = await tx `
3355
3262
  SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
3356
3263
  FROM slotlock.reservations hold
@@ -3364,7 +3271,7 @@ function buildSlotlockStore(sql, options, refusal) {
3364
3271
  await lockExternalIdentity(tx, bookingTenant, holdId);
3365
3272
  const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
3366
3273
  // 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.
3274
+ // confirmation.
3368
3275
  const [resource] = await tx `
3369
3276
  SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
3370
3277
  WHERE id = ${owner.resource_id}
@@ -3376,16 +3283,13 @@ function buildSlotlockStore(sql, options, refusal) {
3376
3283
  WHERE id = ${holdId}
3377
3284
  FOR UPDATE`;
3378
3285
  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.
3286
+ // A hold reaped by a rival while this confirm waited reads as hold_not_found; both
3287
+ // refusals are terminal for the caller.
3382
3288
  return { ok: false, code: 'hold_not_found' };
3383
3289
  }
3384
3290
  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.
3291
+ // A retried confirm of an already-confirmed hold is success: reporting failure would make
3292
+ // callers double-book elsewhere. A booking with another reference was never this hold.
3389
3293
  if (row.external_ref !== null && row.external_ref !== row.id) {
3390
3294
  return { ok: false, code: 'hold_not_found' };
3391
3295
  }
@@ -3393,7 +3297,6 @@ function buildSlotlockStore(sql, options, refusal) {
3393
3297
  }
3394
3298
  if (!row.live)
3395
3299
  return { ok: false, code: 'hold_expired' };
3396
- // Archived since the hold was taken: a confirmation would be a new booking on it.
3397
3300
  if (resource?.archived !== false)
3398
3301
  return { ok: false, code: 'resource_archived' };
3399
3302
  if (bookingTenant !== null) {
@@ -3412,8 +3315,8 @@ function buildSlotlockStore(sql, options, refusal) {
3412
3315
  if (bookingLimit !== null) {
3413
3316
  return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
3414
3317
  }
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.
3318
+ // The trigger already set the occupied end; the tenant and reference make the hold a
3319
+ // booking cancel and reschedule reach.
3417
3320
  const [confirmed] = await tx `
3418
3321
  UPDATE slotlock.reservations
3419
3322
  SET status = 'confirmed', expires_at = NULL,
@@ -3482,8 +3385,8 @@ function buildSlotlockStore(sql, options, refusal) {
3482
3385
  });
3483
3386
  }
3484
3387
  }
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).
3388
+ // Expired holds are excluded so findNextAvailable can offer their space; the acquiring
3389
+ // transaction reaps the row itself.
3487
3390
  const rows = await sql `
3488
3391
  SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
3489
3392
  COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
@@ -3532,8 +3435,8 @@ function buildSlotlockStore(sql, options, refusal) {
3532
3435
  });
3533
3436
  }
3534
3437
  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.
3438
+ // A long-running series can outgrow the write's search budget from its start; a stored
3439
+ // one is expanded below from its last cursor.
3537
3440
  if (!(error instanceof CalendarExpansionLimitError) || !event.recurrence)
3538
3441
  throw error;
3539
3442
  }
@@ -3552,7 +3455,6 @@ function buildSlotlockStore(sql, options, refusal) {
3552
3455
  });
3553
3456
  const recurrenceEndsBy = recurrenceEndsByColumn(event);
3554
3457
  let attempted = expanded?.occurrences ?? [];
3555
- // The buffers the attempt kept, for the overlap answer if it loses.
3556
3458
  let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
3557
3459
  let storedEventId;
3558
3460
  for (let attempt = 1;; attempt++) {
@@ -3580,8 +3482,8 @@ function buildSlotlockStore(sql, options, refusal) {
3580
3482
  if (ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
3581
3483
  await lockCalendarOwnerQuota(tx, params.tenantRef, ownerRef);
3582
3484
  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.
3485
+ // Reserve one cancellation slot for every possible live identity, so update churn
3486
+ // cannot leave a principal unable to release calendar space.
3585
3487
  const putCommandLimit = configuredAgentOwnerCommandQuota - configuredAgentOwnerEventQuota;
3586
3488
  if (commandCount >= putCommandLimit) {
3587
3489
  return {
@@ -3682,7 +3584,6 @@ function buildSlotlockStore(sql, options, refusal) {
3682
3584
  only.end > hold.ends_at) {
3683
3585
  return { ok: false, code: 'hold_mismatch' };
3684
3586
  }
3685
- // The hold and its alternatives give way to the event in this transaction.
3686
3587
  await tx `
3687
3588
  DELETE FROM slotlock.reservations
3688
3589
  WHERE status = 'held' AND holder_ref = ${holderRef}
@@ -3773,8 +3674,8 @@ function buildSlotlockStore(sql, options, refusal) {
3773
3674
  ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
3774
3675
  )`;
3775
3676
  }
3776
- // The normalized children are the queryable/canonical representation. The JSON
3777
- // columns above remain a migration-compatibility mirror until older readers retire.
3677
+ // The normalized children are canonical; the JSON columns above are a compatibility
3678
+ // mirror until older readers retire.
3778
3679
  await replaceCalendarEventChildren(tx, params.tenantRef, eventId, event);
3779
3680
  await replaceCalendarEventOccurrences(tx, {
3780
3681
  tenantRef: params.tenantRef,
@@ -3945,11 +3846,8 @@ function buildSlotlockStore(sql, options, refusal) {
3945
3846
  });
3946
3847
  }
3947
3848
  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.
3849
+ // Due masters, never-deferred ones first, then deferred ones longest-waiting first: masters
3850
+ // it cannot extend never starve healthy ones, and deferred ones take turns.
3953
3851
  const candidates = await sql `
3954
3852
  SELECT id, materialized_ends_at, roll_retry_at
3955
3853
  FROM slotlock.calendar_events
@@ -3986,10 +3884,8 @@ function buildSlotlockStore(sql, options, refusal) {
3986
3884
  }
3987
3885
  catch (error) {
3988
3886
  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.
3887
+ // Over the expansion budget (or a rule this release refuses): nothing written, old
3888
+ // coverage kept, later masters still roll.
3993
3889
  refused += 1;
3994
3890
  refusedEventIds.push(candidate.id);
3995
3891
  await deferRoll(sql, params.tenantRef, candidate.id);
@@ -4002,16 +3898,15 @@ function buildSlotlockStore(sql, options, refusal) {
4002
3898
  if (code === EXCLUSION_VIOLATION ||
4003
3899
  code === DEADLOCK_DETECTED ||
4004
3900
  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.
3901
+ // The savepoint rolled everything back, so old coverage stays truthful and callers
3902
+ // fail closed beyond it.
4007
3903
  conflicts += 1;
4008
3904
  conflictEventIds.push(candidate.id);
4009
3905
  await deferRoll(sql, params.tenantRef, candidate.id);
4010
3906
  break;
4011
3907
  }
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.
3908
+ // Any other failure was rolled back with its savepoint too: defer it like an
3909
+ // over-budget one, so no single row stops the tenant's roll.
4015
3910
  refused += 1;
4016
3911
  refusedEventIds.push(candidate.id);
4017
3912
  await deferRoll(sql, params.tenantRef, candidate.id);
@@ -4178,8 +4073,8 @@ function buildSlotlockStore(sql, options, refusal) {
4178
4073
  idempotent: true,
4179
4074
  };
4180
4075
  }
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.
4076
+ // Owner and reference never change, so they are read before the identity lock, taken before
4077
+ // the row lock as every event write does.
4183
4078
  const [identity] = await tx `
4184
4079
  SELECT owner_ref, external_ref FROM slotlock.calendar_events
4185
4080
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
@@ -4190,7 +4085,6 @@ function buildSlotlockStore(sql, options, refusal) {
4190
4085
  SELECT id, resource_id, revision FROM slotlock.calendar_events
4191
4086
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
4192
4087
  FOR UPDATE`;
4193
- // Cancelled while this waited on its lock.
4194
4088
  if (!found)
4195
4089
  return { ok: false, code: 'event_not_found' };
4196
4090
  if (params.expectedResourceId !== undefined &&
@@ -4290,7 +4184,6 @@ function buildSlotlockStore(sql, options, refusal) {
4290
4184
  catch (error) {
4291
4185
  if (pgCode(error) !== EXCLUSION_VIOLATION)
4292
4186
  throw error;
4293
- // It stays where it was; whoever owns it decides.
4294
4187
  await report(event, 'event.timezone_conflict');
4295
4188
  conflicts++;
4296
4189
  }
@@ -4307,8 +4200,8 @@ function buildSlotlockStore(sql, options, refusal) {
4307
4200
  code: 'invalid_limit',
4308
4201
  });
4309
4202
  }
4310
- // Never under the default: a shorter retention trims the feed, not how long a retried write
4311
- // is answered as the first was.
4203
+ // Never under the default: a shorter retention trims the feed, not how long a retry is
4204
+ // answered as the first was.
4312
4205
  const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4313
4206
  if (!Number.isSafeInteger(keepDays) ||
4314
4207
  keepDays < 1 ||
@@ -4318,11 +4211,10 @@ function buildSlotlockStore(sql, options, refusal) {
4318
4211
  });
4319
4212
  }
4320
4213
  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.
4214
+ // A 0.2 server still running after the migration wrote confirmed holds and
4215
+ // createReservation bookings without the tenant or a reference; give them both, as the
4216
+ // migration does. Each takes its reference's identity lock first, and a tombstone of its id
4217
+ // goes.
4326
4218
  const legacy = await tx `
4327
4219
  SELECT booking.id
4328
4220
  FROM slotlock.reservations booking
@@ -4402,8 +4294,8 @@ function buildSlotlockStore(sql, options, refusal) {
4402
4294
  code: 'invalid_limit',
4403
4295
  });
4404
4296
  }
4405
- // Never under the default: a shorter retention trims the feed, not how long a retried write
4406
- // is answered as the first was.
4297
+ // Never under the default: a shorter retention trims the feed, not how long a retry is
4298
+ // answered as the first was.
4407
4299
  const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4408
4300
  if (!Number.isSafeInteger(keepDays) ||
4409
4301
  keepDays < 1 ||
@@ -4413,8 +4305,8 @@ function buildSlotlockStore(sql, options, refusal) {
4413
4305
  });
4414
4306
  }
4415
4307
  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.
4308
+ // Commands are replay evidence, never resurrection authority: after the window every
4309
+ // owner's go.
4418
4310
  const commands = await tx `
4419
4311
  WITH candidates AS MATERIALIZED (
4420
4312
  SELECT command.owner_ref, command.idempotency_key
@@ -4434,9 +4326,8 @@ function buildSlotlockStore(sql, options, refusal) {
4434
4326
  RETURNING 1
4435
4327
  )
4436
4328
  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.
4329
+ // An agent-owned tombstone may follow once all its commands are gone; internal ones are
4330
+ // provider/sync authority and stay. Locked rows are skipped and the predicate rechecked.
4440
4331
  const tombstones = await tx `
4441
4332
  WITH candidates AS MATERIALIZED (
4442
4333
  SELECT tombstone.owner_ref, tombstone.external_ref
@@ -4532,7 +4423,6 @@ function buildSlotlockStore(sql, options, refusal) {
4532
4423
  if (params.revision !== currentRevision + 1) {
4533
4424
  return { ok: false, code: 'revision_conflict' };
4534
4425
  }
4535
- // A fresh read ends a staleness: the next one is reported again.
4536
4426
  await tx `
4537
4427
  UPDATE slotlock.calendar_coverage
4538
4428
  SET starts_at = ${window.start}, ends_at = ${window.end},
@@ -4686,7 +4576,7 @@ function buildSlotlockStore(sql, options, refusal) {
4686
4576
  if (refusal)
4687
4577
  return 0;
4688
4578
  const tenantRef = params.tenantRef;
4689
- // The same test twice: to find them, then under each reference's lock, as a cancel takes it.
4579
+ // The same test twice: to find them, then under each reference's lock.
4690
4580
  const due = (tx, externalRef) => tx `
4691
4581
  SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
4692
4582
  (resource.booking_rules->>'checkInMinutes')::int AS minutes
@@ -4771,7 +4661,8 @@ function buildSlotlockStore(sql, options, refusal) {
4771
4661
  if (!member)
4772
4662
  return { ok: false, code: 'pool_not_found' };
4773
4663
  }
4774
- // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at the cap.
4664
+ // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at
4665
+ // the cap.
4775
4666
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
4776
4667
  const [counts] = await tx `
4777
4668
  SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
@@ -4812,7 +4703,6 @@ function buildSlotlockStore(sql, options, refusal) {
4812
4703
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
4813
4704
  return false;
4814
4705
  return inTransaction(sql, async (tx) => {
4815
- // Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
4816
4706
  const [left] = await tx `
4817
4707
  DELETE FROM slotlock.waitlist_entries
4818
4708
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
@@ -4820,8 +4710,7 @@ function buildSlotlockStore(sql, options, refusal) {
4820
4710
  RETURNING hold_id`;
4821
4711
  if (!left)
4822
4712
  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.
4713
+ // An untaken offer's hold goes too; a confirmed one is a booking now and stays.
4825
4714
  if (left.hold_id) {
4826
4715
  await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
4827
4716
  }
@@ -5020,7 +4909,6 @@ function buildSlotlockStore(sql, options, refusal) {
5020
4909
  WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
5021
4910
  AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
5022
4911
  }
5023
- // One page in join order; the caller passes `next` back for the rest.
5024
4912
  const waiting = await tx `
5025
4913
  SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
5026
4914
  WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
@@ -5028,19 +4916,16 @@ function buildSlotlockStore(sql, options, refusal) {
5028
4916
  OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
5029
4917
  ORDER BY created_at, id
5030
4918
  LIMIT ${limit}`;
5031
- // Holds made through a store on this transaction, each in a savepoint of its own.
5032
4919
  const inner = buildSlotlockStore(tx, options, null);
5033
4920
  let offered = 0;
5034
4921
  for (const entry of waiting) {
5035
- // Locked so a leave waits for this offer, and skipped if it left first.
5036
4922
  const [still] = await tx `
5037
4923
  SELECT 1 FROM slotlock.waitlist_entries
5038
4924
  WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
5039
4925
  if (!still)
5040
4926
  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.
4927
+ // Only resources that look free (buffers included) are tried: a refused hold keeps its
4928
+ // locks until the transaction ends.
5044
4929
  const candidates = await tx `
5045
4930
  SELECT resources.id
5046
4931
  FROM slotlock.resources,
@@ -5136,7 +5021,6 @@ function buildSlotlockStore(sql, options, refusal) {
5136
5021
  code: 'resource_tenant_mismatch',
5137
5022
  });
5138
5023
  }
5139
- // Each row's occupied time: its buffers are as busy as the booking.
5140
5024
  const busyRows = await sql `
5141
5025
  SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
5142
5026
  COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
@@ -5187,8 +5071,8 @@ function buildSlotlockStore(sql, options, refusal) {
5187
5071
  FROM recurring_sources
5188
5072
  WHERE earliest_possible_at < ${window.end}`;
5189
5073
  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.
5074
+ // Requiring certainty means every sync covering the resource, judged by the same day, as
5075
+ // writes do (bookingJudge).
5192
5076
  const requireCertain = owner[0].require_certain;
5193
5077
  const recordedSources = requireCertain
5194
5078
  ? (await sql `
@@ -5223,8 +5107,8 @@ function buildSlotlockStore(sql, options, refusal) {
5223
5107
  let hasFreshOverlap = coverageRows.some((coverage) => coverage.fresh && coverage.starts_at < window.end && coverage.ends_at > window.start);
5224
5108
  if (localRecurrenceRequired) {
5225
5109
  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.
5110
+ // The intrinsic source first: the caller can repair it by rolling the horizon,
5111
+ // independent of providers.
5228
5112
  missingSources.unshift(SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
5229
5113
  }
5230
5114
  hasFreshOverlap ||= localRecurrenceCoverage?.overlaps === true;
@@ -5273,8 +5157,6 @@ function buildSlotlockStore(sql, options, refusal) {
5273
5157
  return null;
5274
5158
  const resource = toResource(row);
5275
5159
  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
5160
  const search = bookingSearch({
5279
5161
  rules: resource.effectiveBookingRules,
5280
5162
  timezone: resource.timezone,
@@ -5285,11 +5167,11 @@ function buildSlotlockStore(sql, options, refusal) {
5285
5167
  if (!search)
5286
5168
  return null;
5287
5169
  const { window } = search;
5288
- // A slot's buffers may run past the window: the time they can reach must be proven free too.
5170
+ // A slot's buffers may run past the window: that reach must be proven free too.
5289
5171
  const reach = bookingSearchReach(search);
5290
5172
  let busy;
5291
5173
  if (tenantRef === null) {
5292
- // No event, coverage or series can belong to a resource with no tenant: only its rows.
5174
+ // A tenantless resource has no events, coverage or series: only its rows.
5293
5175
  busy = await this.listBusy(params.resourceId, reach);
5294
5176
  }
5295
5177
  else {
@@ -5311,7 +5193,7 @@ function buildSlotlockStore(sql, options, refusal) {
5311
5193
  ? { coverageMaxAgeMs: params.coverageMaxAgeMs }
5312
5194
  : {}),
5313
5195
  });
5314
- // Absence is never treated as free time: what cannot be proven gives no slot.
5196
+ // Absence is never free time.
5315
5197
  if (freeBusy.coverage.state !== 'complete')
5316
5198
  return null;
5317
5199
  busy.push(...freeBusy.busy);
@@ -5394,7 +5276,7 @@ function buildSlotlockStore(sql, options, refusal) {
5394
5276
  };
5395
5277
  return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
5396
5278
  }
5397
- /** The deployment role alone may suspend: another role is refused EXECUTE on the functions. */
5279
+ /** The deployment role alone may suspend: others are refused EXECUTE on the functions. */
5398
5280
  function suspensionRefusal(error) {
5399
5281
  if (pgCode(error) !== '42501')
5400
5282
  return error;