slotlock 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/store.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
2
2
  // Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
3
3
  // reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
4
- import { SLOTLOCK_CHANGE_RETENTION_DAYS, slotlockEventFeedMethods } from './event-feed.js';
5
- import { bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
4
+ import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
5
+ import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import ICAL from 'ical.js';
8
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
8
+ import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
9
9
  import { findNextAvailable, mergeIntervals } from './engine.js';
10
10
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
11
11
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
@@ -24,6 +24,11 @@ const SERIALIZATION_FAILURE = '40001';
24
24
  const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
25
25
  /** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
26
26
  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
+ */
31
+ const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
27
32
  export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
28
33
  /**
29
34
  * Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
@@ -338,8 +343,8 @@ function migrationSettings(options) {
338
343
  /**
339
344
  * Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
340
345
  * in pg_catalog only, serialized with every other migration of this database, and with the lock and
341
- * statement timeouts set. A step that waits past its lock timeout is rolled back and tried again
342
- * after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
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.
343
348
  */
344
349
  async function inMigration(executor, options, step) {
345
350
  const settings = migrationSettings(options);
@@ -362,10 +367,14 @@ async function inMigration(executor, options, step) {
362
367
  }));
363
368
  }
364
369
  catch (error) {
365
- if (pgCode(error) !== LOCK_NOT_AVAILABLE)
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.
373
+ const code = pgCode(error);
374
+ if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
366
375
  throw error;
367
376
  if (attempt >= settings.attempts) {
368
- throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
377
+ throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table, or took them in another order. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
369
378
  }
370
379
  await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
371
380
  }
@@ -465,6 +474,105 @@ async function slotlockSchemaUpToDate(tx) {
465
474
  }
466
475
  return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
467
476
  }
477
+ /**
478
+ * 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.
485
+ *
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.
496
+ */
497
+ async function renameEarlierCoverageChanges(executor, options) {
498
+ /**
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.
502
+ */
503
+ const everyTenant = async (tx, bypass, work) => {
504
+ const forced = bypass
505
+ ? []
506
+ : await tx `
507
+ SELECT rel.relname::text AS name
508
+ FROM pg_catalog.pg_class rel
509
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
510
+ WHERE ns.nspname = 'slotlock' AND rel.relname IN ('calendar_coverage', 'outbox')
511
+ AND rel.relforcerowsecurity
512
+ ORDER BY rel.relname`;
513
+ const lifted = forced.map(({ name }) => `slotlock.${name}`);
514
+ if (lifted.length > 0) {
515
+ await tx.unsafe(`LOCK TABLE ${lifted.join(', ')} IN ACCESS EXCLUSIVE MODE`);
516
+ for (const table of lifted)
517
+ await tx.unsafe(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY`);
518
+ }
519
+ const result = await work();
520
+ for (const table of lifted)
521
+ await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
522
+ return result;
523
+ };
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.
526
+ const start = await inMigration(executor, options, async (tx) => {
527
+ const [state] = await tx `
528
+ SELECT version.coverage_changes_renamed AS renamed,
529
+ version.coverage_changes_renamed_through::text AS through,
530
+ EXISTS (SELECT 1 FROM pg_catalog.pg_roles
531
+ WHERE rolname = current_user AND (rolsuper OR rolbypassrls)) AS bypass
532
+ FROM slotlock.schema_version version
533
+ WHERE version.singleton`;
534
+ if (state === undefined || state.renamed)
535
+ return null;
536
+ if (state.bypass)
537
+ await tx.unsafe('LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE');
538
+ const [top] = await everyTenant(tx, state.bypass, () => tx `SELECT COALESCE(max(id), 0)::text AS head FROM slotlock.outbox`);
539
+ return { head: top?.head ?? '0', through: state.through, bypass: state.bypass };
540
+ });
541
+ if (start === null)
542
+ return;
543
+ const { head, bypass } = start;
544
+ const firstPass = start.through === null;
545
+ const rename = slotlockCoverageChangeRenameSql('$1::bigint', '$2::bigint', '$3::int');
546
+ let after = start.through ?? '0';
547
+ let renamedTotal = 0;
548
+ for (;;) {
549
+ const last = await inMigration(executor, options, async (tx) => {
550
+ const [row] = await everyTenant(tx, bypass, () => tx.unsafe(rename, [
551
+ after,
552
+ head,
553
+ SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH,
554
+ ]));
555
+ const read = row?.last ?? null;
556
+ renamedTotal += row?.renamed ?? 0;
557
+ // Saved with the batch, so a pass stopped part way goes on from here.
558
+ await tx `
559
+ UPDATE slotlock.schema_version
560
+ SET coverage_changes_renamed_through = ${read ?? head}::bigint,
561
+ coverage_changes_renamed = ${read === null && !firstPass && renamedTotal === 0}
562
+ WHERE singleton`;
563
+ return read;
564
+ });
565
+ if (last === null)
566
+ return;
567
+ after = String(last);
568
+ }
569
+ }
570
+ /** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
571
+ async function coverageChangesRenamePending(tx) {
572
+ const [row] = await tx `
573
+ SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
574
+ return row !== undefined && !row.renamed;
575
+ }
468
576
  /** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
469
577
  async function slotlockConstraintUnvalidated(tx) {
470
578
  const [row] = await tx `
@@ -870,18 +978,23 @@ async function replaceCalendarEventOccurrences(tx, params) {
870
978
  for (const occurrence of params.occurrences) {
871
979
  let reservationId = null;
872
980
  if ((params.event.transparency ?? 'opaque') === 'opaque') {
873
- const occupied = new Date(occurrence.end.getTime() + (params.bufferAfterMs ?? 0));
874
- await reapExpiredHolds(tx, params.resourceId, occurrence.start, occupied);
981
+ const occupied = occupiedTime({
982
+ ...occurrence,
983
+ bufferBeforeMs: params.bufferBeforeMs ?? 0,
984
+ bufferAfterMs: params.bufferAfterMs ?? 0,
985
+ });
986
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
875
987
  const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
876
988
  .update(occurrence.recurrenceId)
877
989
  .digest('hex')}`;
878
990
  const reservations = await tx `
879
991
  INSERT INTO slotlock.reservations (
880
992
  resource_id, tenant_ref, external_ref, starts_at, ends_at,
881
- buffer_after_ms, revision, source, calendar_event_id
993
+ buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
882
994
  ) VALUES (
883
995
  ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
884
- ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0}, ${params.revision},
996
+ ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
997
+ ${params.bufferBeforeMs ?? 0}, ${params.revision},
885
998
  ${`calendar-event:${params.eventId}`}, ${params.eventId}
886
999
  )
887
1000
  RETURNING id`;
@@ -1069,46 +1182,53 @@ async function finishCalendarCancel(tx, params) {
1069
1182
  * The row an event write lost to. The write was rolled back, so the event's own rows from before
1070
1183
  * it are still there and are left out: they were to be replaced, and never block the event.
1071
1184
  */
1072
- async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
1185
+ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
1073
1186
  for (const occurrence of occurrences) {
1074
- const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, 0, undefined, eventId);
1187
+ const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, buffers.bufferAfterMs, undefined, eventId, buffers.bufferBeforeMs);
1075
1188
  if (result.conflictingReservationId !== null)
1076
1189
  return result;
1077
1190
  }
1078
1191
  return { ok: false, code: 'overlap', conflictingReservationId: null };
1079
1192
  }
1080
1193
  /**
1081
- * What the resource's booking rules say about a booking of `interval` made now (by the database's
1082
- * clock, at this statement): the rule it breaks, if any, and the turnaround it must keep after it.
1083
- * A resource without its own hours is judged against `defaultHours`.
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`.
1084
1198
  */
1085
1199
  async function bookingRuling(tx, resourceId, interval, defaultHours) {
1086
1200
  const judge = await bookingJudge(tx, resourceId, defaultHours);
1087
- return { rule: judge.broken(interval), turnaroundMs: judge.turnaroundMs };
1201
+ return {
1202
+ rule: judge.broken(interval),
1203
+ turnaroundMs: judge.turnaroundMs,
1204
+ bufferBeforeMs: judge.bufferBeforeMs,
1205
+ };
1088
1206
  }
1089
1207
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1090
1208
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1091
1209
  /**
1092
- * The resource's booking rules, read once, as a check of any interval booked now (by the database's
1093
- * clock at this statement) and the turnaround a booking keeps.
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.
1094
1212
  */
1095
1213
  async function bookingJudge(tx, resourceId, defaultHours) {
1096
1214
  const [row] = await tx `
1097
- SELECT booking_rules, availability_rules, timezone, statement_timestamp() AS now
1215
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
1216
+ statement_timestamp() AS now
1098
1217
  FROM slotlock.resources WHERE id = ${resourceId}`;
1099
- if (!row || row.booking_rules === null || row.booking_rules === undefined) {
1100
- return { broken: () => null, turnaroundMs: 0 };
1218
+ if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
1219
+ return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
1101
1220
  }
1102
- const { bookingRules: rules, availabilityRules } = toResource({
1221
+ const { effectiveBookingRules: rules, availabilityRules } = toResource({
1103
1222
  id: resourceId,
1104
1223
  external_ref: null,
1105
1224
  tenant_ref: null,
1106
1225
  timezone: row.timezone,
1107
1226
  availability_rules: row.availability_rules,
1108
- booking_rules: row.booking_rules,
1227
+ booking_rules: null,
1109
1228
  pool_ref: null,
1110
1229
  visibility: 'shared',
1111
1230
  archived_at: null,
1231
+ effective_booking_rules: row.effective_booking_rules,
1112
1232
  });
1113
1233
  const hours = availabilityRules ?? defaultHours;
1114
1234
  // Every sync covering the resource, and whether it was read within a day, by the same clock.
@@ -1120,9 +1240,10 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1120
1240
  FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
1121
1241
  : [];
1122
1242
  const turnaroundMs = bookingBufferAfterMs(rules);
1123
- // The booking and the turnaround it keeps, as a search judges the slot.
1243
+ const bufferBeforeMs = bookingBufferBeforeMs(rules);
1244
+ // The booking and the buffers it keeps, as a search judges the slot.
1124
1245
  const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1125
- source.starts_at > interval.start ||
1246
+ source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
1126
1247
  source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
1127
1248
  // A later occurrence of a series is judged on its step and hours only: notice and advance are
1128
1249
  // about when the booking was made, which its first occurrence answers.
@@ -1130,6 +1251,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1130
1251
  ? undefined
1131
1252
  : {
1132
1253
  ...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
1254
+ ...(rules.stepOffsetMinutes !== undefined ? { stepOffsetMinutes: rules.stepOffsetMinutes } : {}),
1133
1255
  ...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
1134
1256
  };
1135
1257
  return {
@@ -1141,6 +1263,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1141
1263
  now: row.now,
1142
1264
  }) ?? (uncertain(interval) ? 'uncertain' : null),
1143
1265
  turnaroundMs,
1266
+ bufferBeforeMs,
1144
1267
  };
1145
1268
  }
1146
1269
  function sameExternalPayload(row, params, turnaroundMs = 0) {
@@ -1153,9 +1276,6 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1153
1276
  Number(row.revision) === params.revision &&
1154
1277
  row.source === (params.source ?? null));
1155
1278
  }
1156
- function occupiedEnd(params) {
1157
- return new Date(params.end.getTime() + params.bufferAfterMs);
1158
- }
1159
1279
  async function lockExternalIdentity(tx, tenantRef, externalRef) {
1160
1280
  // JSON encoding is unambiguous for this pair and contains no NUL byte (Postgres text rejects
1161
1281
  // U+0000). The database hashes it to one transaction-scoped lock key.
@@ -1185,6 +1305,8 @@ function pgCode(err) {
1185
1305
  }
1186
1306
  /** Members a pool booking or search considers at most, in id order. */
1187
1307
  export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1308
+ /** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
1309
+ export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
1188
1310
  const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at';
1189
1311
  function toWaitlistEntry(row) {
1190
1312
  return {
@@ -1210,6 +1332,30 @@ function invalidPoolError() {
1210
1332
  code: 'invalid_pool',
1211
1333
  });
1212
1334
  }
1335
+ /**
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).
1339
+ */
1340
+ const EFFECTIVE_BOOKING_RULES = `NULLIF(
1341
+ COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
1342
+ WHERE defaults.tenant_ref = resources.tenant_ref AND defaults.pool_ref = ''), '{}'::jsonb)
1343
+ || COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
1344
+ WHERE defaults.tenant_ref = resources.tenant_ref
1345
+ AND defaults.pool_ref = resources.pool_ref), '{}'::jsonb)
1346
+ || COALESCE(resources.booking_rules, '{}'::jsonb),
1347
+ '{}'::jsonb) AS effective_booking_rules`;
1348
+ /** What rules Slotlock did not write read as: no booking passes them. */
1349
+ 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. */
1351
+ function storedBookingRules(value) {
1352
+ try {
1353
+ return parseBookingRules(value);
1354
+ }
1355
+ catch {
1356
+ return { ...REFUSE_EVERY_BOOKING };
1357
+ }
1358
+ }
1213
1359
  function toResource(row) {
1214
1360
  const resource = {
1215
1361
  id: row.id,
@@ -1234,11 +1380,19 @@ function toResource(row) {
1234
1380
  if (row.booking_rules !== null && row.booking_rules !== undefined) {
1235
1381
  // Written through setResourceBookingRules, which validates; anything else reads as refusing
1236
1382
  // every booking, never as no rule.
1237
- try {
1238
- resource.bookingRules = parseBookingRules(row.booking_rules);
1383
+ resource.bookingRules = storedBookingRules(row.booking_rules);
1384
+ }
1385
+ 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.
1389
+ const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
1390
+ if (dropped.length > 0) {
1391
+ console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
1392
+ resource.effectiveBookingRules = { ...REFUSE_EVERY_BOOKING };
1239
1393
  }
1240
- catch {
1241
- resource.bookingRules = { maxAdvanceDays: 1, minNoticeMinutes: 525_600 };
1394
+ else if (Object.keys(rules).length > 0) {
1395
+ resource.effectiveBookingRules = rules;
1242
1396
  }
1243
1397
  }
1244
1398
  return resource;
@@ -1268,7 +1422,7 @@ async function upsertResource(executor, params) {
1268
1422
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
1269
1423
  DO UPDATE SET timezone = EXCLUDED.timezone
1270
1424
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1271
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1425
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1272
1426
  }
1273
1427
  else if (params.externalRef !== undefined) {
1274
1428
  rows = await executor `
@@ -1278,13 +1432,13 @@ async function upsertResource(executor, params) {
1278
1432
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
1279
1433
  DO UPDATE SET external_ref = EXCLUDED.external_ref
1280
1434
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1281
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1435
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1282
1436
  }
1283
1437
  else {
1284
1438
  rows = await executor `
1285
1439
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1286
1440
  VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1287
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1441
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1288
1442
  }
1289
1443
  const row = rows[0];
1290
1444
  if (!row) {
@@ -1307,7 +1461,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1307
1461
  summary, description, location, status, transparency, organizer, attendees,
1308
1462
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1309
1463
  materialized_ends_at, recurrence_cursor, revision, source, created_at,
1310
- updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
1464
+ updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms, buffer_before_ms
1311
1465
  FROM slotlock.calendar_events
1312
1466
  WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1313
1467
  FOR UPDATE`;
@@ -1330,6 +1484,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1330
1484
  occurrences,
1331
1485
  revision: Number(row.revision),
1332
1486
  bufferAfterMs: Number(row.buffer_after_ms ?? 0),
1487
+ bufferBeforeMs: Number(row.buffer_before_ms ?? 0),
1333
1488
  });
1334
1489
  // A series stored before 0.3 learns when it ends at its first roll.
1335
1490
  const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
@@ -1697,8 +1852,28 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1697
1852
  return Number(row?.deleted ?? 0);
1698
1853
  }
1699
1854
  /**
1700
- * Delete expired held rows whose occupied time (buffer included, as the EXCLUDE constraint reads
1701
- * it) overlaps the requested window, whose `end` is its own occupied end, in deterministic id
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.
1857
+ */
1858
+ function occupiedTime(params) {
1859
+ return {
1860
+ start: new Date(params.start.getTime() - (params.bufferBeforeMs ?? 0)),
1861
+ end: new Date(params.end.getTime() + (params.bufferAfterMs ?? 0)),
1862
+ };
1863
+ }
1864
+ /**
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.
1867
+ */
1868
+ function occupiedStartsBefore(executor, end, alias) {
1869
+ const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
1870
+ return executor `(${column('starts_at')} < ${new Date(end.getTime() + MAX_BUFFER_BEFORE_MS)}
1871
+ AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
1872
+ }
1873
+ /**
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
1702
1877
  * order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
1703
1878
  * deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
1704
1879
  * before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
@@ -1711,7 +1886,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1711
1886
  WHERE resource_id = ${resourceId}
1712
1887
  AND status = 'held'
1713
1888
  AND expires_at <= statement_timestamp()
1714
- AND starts_at < ${end}
1889
+ AND ${occupiedStartsBefore(tx, end)}
1715
1890
  AND COALESCE(occupied_ends_at, ends_at) > ${start}
1716
1891
  ORDER BY id
1717
1892
  FOR UPDATE
@@ -1723,7 +1898,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1723
1898
  * loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
1724
1899
  * immediate retry reaps it and wins.
1725
1900
  */
1726
- async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
1901
+ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
1727
1902
  const winners = await sql `
1728
1903
  SELECT id FROM slotlock.reservations
1729
1904
  WHERE resource_id = ${resourceId}
@@ -1731,12 +1906,12 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
1731
1906
  AND (${excludeCalendarEventId ?? null}::uuid IS NULL
1732
1907
  OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
1733
1908
  AND tstzrange(
1734
- starts_at,
1909
+ COALESCE(occupied_starts_at, starts_at),
1735
1910
  COALESCE(occupied_ends_at, ends_at),
1736
1911
  '[)'
1737
1912
  ) && tstzrange(
1738
- ${start},
1739
- ${end} + ${bufferAfterMs} * interval '1 millisecond',
1913
+ ${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
1914
+ ${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
1740
1915
  '[)'
1741
1916
  )
1742
1917
  AND (status <> 'held' OR expires_at > statement_timestamp())
@@ -1821,6 +1996,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1821
1996
  'deleteResource',
1822
1997
  'setResourceAvailability',
1823
1998
  'setResourceBookingRules',
1999
+ 'setBookingRuleDefaults',
1824
2000
  'setResourcePool',
1825
2001
  'setTenantAvailability',
1826
2002
  'createReservation',
@@ -1975,19 +2151,30 @@ function buildSlotlockStore(sql, options, refusal) {
1975
2151
  // application instances can still collide on pg_namespace/pg_extension unique indexes.
1976
2152
  // inMigration serializes bootstrap per database and the transaction releases the lock on
1977
2153
  // crashes. A schema this release already left as it is changes nothing and locks no table,
1978
- // unless old rows kept one of its constraints NOT VALID: then only the validation runs again.
1979
- const validate = await inMigration(sql, migrationOptions, async (tx) => {
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).
2156
+ const next = await inMigration(sql, migrationOptions, async (tx) => {
1980
2157
  await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
1981
2158
  throw schemaControlRefusal(error);
1982
2159
  });
1983
- if (await slotlockSchemaUpToDate(tx))
1984
- return slotlockConstraintUnvalidated(tx);
2160
+ if (await slotlockSchemaUpToDate(tx)) {
2161
+ return {
2162
+ validate: await slotlockConstraintUnvalidated(tx),
2163
+ rename: await coverageChangesRenamePending(tx),
2164
+ };
2165
+ }
1985
2166
  await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
1986
2167
  throw schemaControlRefusal(error);
1987
2168
  });
1988
- return true;
2169
+ return { validate: true, rename: await coverageChangesRenamePending(tx) };
1989
2170
  });
1990
- if (!validate)
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).
2175
+ if (next.rename)
2176
+ await renameEarlierCoverageChanges(sql, migrationOptions);
2177
+ if (!next.validate)
1991
2178
  return;
1992
2179
  // Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
1993
2180
  // a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
@@ -2151,6 +2338,16 @@ function buildSlotlockStore(sql, options, refusal) {
2151
2338
  code: 'invalid_identity',
2152
2339
  });
2153
2340
  }
2341
+ const origin = options?.origin;
2342
+ if (origin !== undefined && !isSlotlockChangeOrigin(origin)) {
2343
+ throw Object.assign(new Error(`Slotlock origin must be one of ${SLOTLOCK_CHANGE_ORIGINS.join(', ')}`), {
2344
+ code: 'invalid_identity',
2345
+ });
2346
+ }
2347
+ const keyId = options?.keyId;
2348
+ if (keyId !== undefined && !isValidResourceId(keyId)) {
2349
+ throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
2350
+ }
2154
2351
  const release = await tenantSlots?.(tenantRef, options?.signal);
2155
2352
  // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
2156
2353
  // another error in its place: noted here as it happens.
@@ -2167,6 +2364,8 @@ function buildSlotlockStore(sql, options, refusal) {
2167
2364
  const previous = await tx `
2168
2365
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2169
2366
  current_setting('slotlock.actor', true) AS actor,
2367
+ current_setting('slotlock.origin', true) AS origin,
2368
+ current_setting('slotlock.key_id', true) AS key,
2170
2369
  current_setting('transaction_isolation') AS isolation,
2171
2370
  pg_advisory_xact_lock_shared(
2172
2371
  hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
@@ -2174,6 +2373,9 @@ function buildSlotlockStore(sql, options, refusal) {
2174
2373
  const [context] = await tx `
2175
2374
  SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
2176
2375
  set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
2376
+ set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
2377
+ NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
2378
+ set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
2177
2379
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2178
2380
  throw suspensionCheckRefusal(error);
2179
2381
  });
@@ -2195,7 +2397,9 @@ function buildSlotlockStore(sql, options, refusal) {
2195
2397
  }
2196
2398
  finally {
2197
2399
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2198
- set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)`;
2400
+ set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
2401
+ set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
2402
+ set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
2199
2403
  }
2200
2404
  }, 'read committed')
2201
2405
  .catch((error) => {
@@ -2256,7 +2460,7 @@ function buildSlotlockStore(sql, options, refusal) {
2256
2460
  });
2257
2461
  }
2258
2462
  const rows = await sql `
2259
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2463
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2260
2464
  FROM slotlock.resources
2261
2465
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2262
2466
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
@@ -2274,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
2274
2478
  UPDATE slotlock.resources
2275
2479
  SET availability_rules = ${rules}::jsonb
2276
2480
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2277
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2481
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2278
2482
  const row = rows[0];
2279
2483
  return row ? toResource(row) : null;
2280
2484
  },
@@ -2306,10 +2510,74 @@ function buildSlotlockStore(sql, options, refusal) {
2306
2510
  END
2307
2511
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2308
2512
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2309
- visibility, archived_at`;
2513
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2310
2514
  const row = rows[0];
2311
2515
  return row ? toResource(row) : null;
2312
2516
  },
2517
+ async setBookingRuleDefaults(params) {
2518
+ if (!isValidIdentity(params.tenantRef)) {
2519
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2520
+ }
2521
+ if (params.pool !== null && !isPoolName(params.pool))
2522
+ throw invalidPoolError();
2523
+ const pool = params.pool ?? '';
2524
+ if (params.rules === null) {
2525
+ await sql `
2526
+ DELETE FROM slotlock.booking_rule_defaults
2527
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${pool}`;
2528
+ return null;
2529
+ }
2530
+ let rules;
2531
+ try {
2532
+ rules = parseBookingRuleDefaults(params.rules);
2533
+ }
2534
+ catch (error) {
2535
+ throw Object.assign(new Error(error.message), {
2536
+ code: 'invalid_booking_rules',
2537
+ });
2538
+ }
2539
+ const tenantRef = params.tenantRef;
2540
+ return inTransaction(sql, async (tx) => {
2541
+ 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.
2544
+ await assertReadCommitted(tx);
2545
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
2546
+ const [counted] = await tx `
2547
+ SELECT count(*)::int AS pools, bool_or(pool_ref = ${pool}) IS TRUE AS exists
2548
+ FROM slotlock.booking_rule_defaults
2549
+ WHERE tenant_ref = ${tenantRef} AND pool_ref <> ''`;
2550
+ if (!counted?.exists && (counted?.pools ?? 0) >= SLOTLOCK_MAX_POOL_RULE_DEFAULTS) {
2551
+ throw Object.assign(new Error(`Slotlock keeps default booking rules for at most ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS} pools a tenant`), { code: 'pool_rules_limit_reached', limit: SLOTLOCK_MAX_POOL_RULE_DEFAULTS });
2552
+ }
2553
+ }
2554
+ await tx `
2555
+ INSERT INTO slotlock.booking_rule_defaults (tenant_ref, pool_ref, rules)
2556
+ VALUES (${tenantRef}, ${pool}, ${tx.json({ ...rules })}::jsonb)
2557
+ ON CONFLICT (tenant_ref, pool_ref)
2558
+ DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
2559
+ return rules;
2560
+ });
2561
+ },
2562
+ async getBookingRuleDefaults(params) {
2563
+ if (!isValidIdentity(params.tenantRef)) {
2564
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2565
+ }
2566
+ const rows = await sql `
2567
+ SELECT pool_ref, rules FROM slotlock.booking_rule_defaults
2568
+ WHERE tenant_ref = ${params.tenantRef}
2569
+ ORDER BY pool_ref
2570
+ LIMIT ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS + 1}`;
2571
+ const defaults = { tenant: null, pools: {} };
2572
+ for (const row of rows) {
2573
+ const rules = storedBookingRules(row.rules);
2574
+ if (row.pool_ref === '')
2575
+ defaults.tenant = rules;
2576
+ else
2577
+ defaults.pools[row.pool_ref] = rules;
2578
+ }
2579
+ return defaults;
2580
+ },
2313
2581
  async setResourcePool(params) {
2314
2582
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2315
2583
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
@@ -2322,7 +2590,7 @@ function buildSlotlockStore(sql, options, refusal) {
2322
2590
  UPDATE slotlock.resources SET pool_ref = ${params.pool}
2323
2591
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2324
2592
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2325
- visibility, archived_at`;
2593
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2326
2594
  const row = rows[0];
2327
2595
  return row ? toResource(row) : null;
2328
2596
  },
@@ -2333,7 +2601,7 @@ function buildSlotlockStore(sql, options, refusal) {
2333
2601
  throw invalidPoolError();
2334
2602
  const rows = await sql `
2335
2603
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2336
- visibility, archived_at
2604
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2337
2605
  FROM slotlock.resources
2338
2606
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2339
2607
  AND archived_at IS NULL
@@ -2386,7 +2654,7 @@ function buildSlotlockStore(sql, options, refusal) {
2386
2654
  });
2387
2655
  }
2388
2656
  const rows = await sql `
2389
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2657
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2390
2658
  FROM slotlock.resources
2391
2659
  WHERE tenant_ref = ${params.tenantRef}
2392
2660
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
@@ -2406,7 +2674,7 @@ function buildSlotlockStore(sql, options, refusal) {
2406
2674
  UPDATE slotlock.resources
2407
2675
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2408
2676
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2409
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2677
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2410
2678
  return row ? toResource(row) : null;
2411
2679
  },
2412
2680
  async restoreResource(params) {
@@ -2417,7 +2685,7 @@ function buildSlotlockStore(sql, options, refusal) {
2417
2685
  UPDATE slotlock.resources
2418
2686
  SET archived_at = NULL
2419
2687
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2420
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2688
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2421
2689
  return row ? toResource(row) : null;
2422
2690
  },
2423
2691
  async deleteResource(params) {
@@ -2465,6 +2733,9 @@ function buildSlotlockStore(sql, options, refusal) {
2465
2733
  // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2466
2734
  // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
2467
2735
  const id = randomUUID();
2736
+ // The buffers written, for the overlap answer if the write loses.
2737
+ let bufferBeforeMs = 0;
2738
+ let bufferAfterMs = 0;
2468
2739
  for (let attempt = 1;; attempt++) {
2469
2740
  try {
2470
2741
  return await inTransaction(sql, async (tx) => {
@@ -2485,24 +2756,27 @@ function buildSlotlockStore(sql, options, refusal) {
2485
2756
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2486
2757
  if (ruling.rule)
2487
2758
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2488
- const occupied = new Date(params.end.getTime() + ruling.turnaroundMs);
2759
+ bufferBeforeMs = ruling.bufferBeforeMs;
2760
+ bufferAfterMs = ruling.turnaroundMs;
2761
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2489
2762
  if (resource.tenant_ref !== null &&
2490
2763
  !(await recurrenceCoversWrite(tx, {
2491
2764
  tenantRef: resource.tenant_ref,
2492
2765
  resourceId: params.resourceId,
2493
- start: params.start,
2494
- end: occupied,
2766
+ ...occupied,
2495
2767
  }))) {
2496
2768
  return { ok: false, code: 'recurrence_not_materialized' };
2497
2769
  }
2498
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2770
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2499
2771
  const [row] = await tx `
2500
2772
  INSERT INTO slotlock.reservations (
2501
- id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms, source
2773
+ id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2774
+ buffer_before_ms, source
2502
2775
  ) VALUES (
2503
2776
  ${id}, ${params.resourceId}, ${resource.tenant_ref},
2504
2777
  ${resource.tenant_ref === null ? null : id},
2505
- ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${params.source ?? null}
2778
+ ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
2779
+ ${params.source ?? null}
2506
2780
  )
2507
2781
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2508
2782
  buffer_after_ms, revision, source`;
@@ -2523,8 +2797,9 @@ function buildSlotlockStore(sql, options, refusal) {
2523
2797
  }
2524
2798
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2525
2799
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2526
- await rollSeriesBehind(sql, params.resourceId, params.start, params.end);
2527
- return overlapLoss(sql, params.resourceId, params.start, params.end);
2800
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2801
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2802
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
2528
2803
  }
2529
2804
  }
2530
2805
  },
@@ -2535,6 +2810,7 @@ function buildSlotlockStore(sql, options, refusal) {
2535
2810
  if (params.revision !== 1)
2536
2811
  return { ok: false, code: 'invalid_revision' };
2537
2812
  let written = params.bufferAfterMs;
2813
+ let writtenBefore = 0;
2538
2814
  for (let attempt = 1;; attempt++) {
2539
2815
  try {
2540
2816
  return await inTransaction(sql, async (tx) => {
@@ -2579,23 +2855,24 @@ function buildSlotlockStore(sql, options, refusal) {
2579
2855
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2580
2856
  const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2581
2857
  written = bufferAfterMs;
2582
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2858
+ writtenBefore = ruling.bufferBeforeMs;
2859
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
2583
2860
  if (!(await recurrenceCoversWrite(tx, {
2584
2861
  tenantRef: params.tenantRef,
2585
2862
  resourceId: params.resourceId,
2586
- start: params.start,
2587
- end: occupied,
2863
+ ...occupied,
2588
2864
  }))) {
2589
2865
  return { ok: false, code: 'recurrence_not_materialized' };
2590
2866
  }
2591
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2867
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2592
2868
  const rows = await tx `
2593
2869
  INSERT INTO slotlock.reservations (
2594
2870
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2595
- revision, source
2871
+ buffer_before_ms, revision, source
2596
2872
  ) VALUES (
2597
2873
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
2598
- ${params.end}, ${bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2874
+ ${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
2875
+ ${params.source ?? null}
2599
2876
  )
2600
2877
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2601
2878
  buffer_after_ms, revision, source`;
@@ -2615,8 +2892,9 @@ function buildSlotlockStore(sql, options, refusal) {
2615
2892
  code !== SERIALIZATION_FAILURE) {
2616
2893
  throw err;
2617
2894
  }
2618
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2619
- return overlapLoss(sql, params.resourceId, params.start, params.end, written);
2895
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
2896
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2897
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
2620
2898
  }
2621
2899
  }
2622
2900
  },
@@ -2626,6 +2904,7 @@ function buildSlotlockStore(sql, options, refusal) {
2626
2904
  return invalid;
2627
2905
  let currentReservationId;
2628
2906
  let bufferAfterMs = params.bufferAfterMs;
2907
+ let bufferBeforeMs = 0;
2629
2908
  for (let attempt = 1;; attempt++) {
2630
2909
  try {
2631
2910
  return await inTransaction(sql, async (tx) => {
@@ -2668,21 +2947,23 @@ function buildSlotlockStore(sql, options, refusal) {
2668
2947
  if (ruling.rule)
2669
2948
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2670
2949
  bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2950
+ bufferBeforeMs = ruling.bufferBeforeMs;
2951
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2671
2952
  if (!(await recurrenceCoversWrite(tx, {
2672
2953
  tenantRef: params.tenantRef,
2673
2954
  resourceId: params.resourceId,
2674
- start: params.start,
2675
- end: occupiedEnd({ end: params.end, bufferAfterMs }),
2955
+ ...occupied,
2676
2956
  }))) {
2677
2957
  return { ok: false, code: 'recurrence_not_materialized' };
2678
2958
  }
2679
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs }));
2959
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2680
2960
  const rows = await tx `
2681
2961
  UPDATE slotlock.reservations
2682
2962
  SET resource_id = ${params.resourceId},
2683
2963
  starts_at = ${params.start},
2684
2964
  ends_at = ${params.end},
2685
2965
  buffer_after_ms = ${bufferAfterMs},
2966
+ buffer_before_ms = ${bufferBeforeMs},
2686
2967
  revision = ${params.revision},
2687
2968
  source = ${params.source ?? null},
2688
2969
  -- A check-in is for the time it was made for: a move to another start clears it.
@@ -2709,9 +2990,9 @@ function buildSlotlockStore(sql, options, refusal) {
2709
2990
  code !== SERIALIZATION_FAILURE) {
2710
2991
  throw err;
2711
2992
  }
2712
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2713
- await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
2714
- return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId);
2993
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2994
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2995
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId, undefined, bufferBeforeMs);
2715
2996
  }
2716
2997
  }
2717
2998
  },
@@ -2825,8 +3106,10 @@ function buildSlotlockStore(sql, options, refusal) {
2825
3106
  ...(holderRef !== undefined ? { holderRef } : {}),
2826
3107
  });
2827
3108
  const ttlSeconds = params.ttlMs / 1000;
2828
- // The buffer written: the one asked for, raised to the resource's turnaround.
3109
+ // The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
3110
+ // leading buffer.
2829
3111
  let written = bufferAfterMs;
3112
+ let writtenBefore = 0;
2830
3113
  for (let attempt = 1;; attempt++) {
2831
3114
  try {
2832
3115
  return await inTransaction(sql, async (tx) => {
@@ -2875,7 +3158,8 @@ function buildSlotlockStore(sql, options, refusal) {
2875
3158
  const [held] = await tx `
2876
3159
  SELECT count(*)::int AS count,
2877
3160
  COALESCE(sum(extract(epoch FROM
2878
- ends_at + buffer_after_ms * interval '1 millisecond' - starts_at)), 0)::float8
3161
+ ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
3162
+ - starts_at)), 0)::float8
2879
3163
  AS seconds
2880
3164
  FROM slotlock.reservations
2881
3165
  WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
@@ -2891,10 +3175,11 @@ function buildSlotlockStore(sql, options, refusal) {
2891
3175
  if (ruling.rule)
2892
3176
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2893
3177
  written = Math.max(bufferAfterMs, ruling.turnaroundMs);
2894
- const reach = occupiedEnd({ end: params.end, bufferAfterMs: written });
3178
+ writtenBefore = ruling.bufferBeforeMs;
3179
+ const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
2895
3180
  if (heldSeconds !== null &&
2896
3181
  maxHeldHoursPerHolder !== undefined &&
2897
- heldSeconds + (reach.getTime() - params.start.getTime()) / 1000 >
3182
+ heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
2898
3183
  maxHeldHoursPerHolder * 3_600) {
2899
3184
  return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
2900
3185
  }
@@ -2902,18 +3187,17 @@ function buildSlotlockStore(sql, options, refusal) {
2902
3187
  !(await recurrenceCoversWrite(tx, {
2903
3188
  tenantRef: resource.tenant_ref,
2904
3189
  resourceId: params.resourceId,
2905
- start: params.start,
2906
- end: reach,
3190
+ ...reach,
2907
3191
  }))) {
2908
3192
  return { ok: false, code: 'recurrence_not_materialized' };
2909
3193
  }
2910
- await reapExpiredHolds(tx, params.resourceId, params.start, reach);
3194
+ await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
2911
3195
  const [row] = await tx `
2912
3196
  INSERT INTO slotlock.reservations (
2913
- resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source,
2914
- holder_ref, hold_group_id
3197
+ resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
3198
+ expires_at, source, holder_ref, hold_group_id
2915
3199
  ) VALUES (
2916
- ${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
3200
+ ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
2917
3201
  statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
2918
3202
  ${holderRef ?? null}, ${params.groupId ?? null}
2919
3203
  )
@@ -2947,8 +3231,9 @@ function buildSlotlockStore(sql, options, refusal) {
2947
3231
  }
2948
3232
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2949
3233
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2950
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2951
- return overlapLoss(sql, params.resourceId, params.start, params.end, written);
3234
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3235
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
3236
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
2952
3237
  }
2953
3238
  }
2954
3239
  },
@@ -3200,15 +3485,16 @@ function buildSlotlockStore(sql, options, refusal) {
3200
3485
  // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
3201
3486
  // space (the acquiring transaction reaps the row itself when it takes the window).
3202
3487
  const rows = await sql `
3203
- SELECT starts_at, COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3488
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
3489
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3204
3490
  FROM slotlock.reservations
3205
3491
  WHERE resource_id = ${resourceId}
3206
- AND starts_at < ${window.end}
3492
+ AND ${occupiedStartsBefore(sql, window.end)}
3207
3493
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3208
3494
  AND (status <> 'held' OR expires_at > statement_timestamp())
3209
- ORDER BY starts_at
3495
+ ORDER BY COALESCE(occupied_starts_at, starts_at)
3210
3496
  LIMIT ${options.limit ?? null}`;
3211
- return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
3497
+ return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
3212
3498
  },
3213
3499
  async putCalendarEvent(params) {
3214
3500
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
@@ -3266,6 +3552,8 @@ function buildSlotlockStore(sql, options, refusal) {
3266
3552
  });
3267
3553
  const recurrenceEndsBy = recurrenceEndsByColumn(event);
3268
3554
  let attempted = expanded?.occurrences ?? [];
3555
+ // The buffers the attempt kept, for the overlap answer if it loses.
3556
+ let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
3269
3557
  let storedEventId;
3270
3558
  for (let attempt = 1;; attempt++) {
3271
3559
  try {
@@ -3403,6 +3691,7 @@ function buildSlotlockStore(sql, options, refusal) {
3403
3691
  AND hold_group_id = ${hold.hold_group_id}::uuid))`;
3404
3692
  }
3405
3693
  let turnaroundMs = 0;
3694
+ let bufferBeforeMs = 0;
3406
3695
  if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
3407
3696
  const judge = await bookingJudge(tx, params.resourceId, defaultHours);
3408
3697
  for (const [index, occurrence] of occurrences.entries()) {
@@ -3412,9 +3701,11 @@ function buildSlotlockStore(sql, options, refusal) {
3412
3701
  return { ok: false, code: 'booking_rule_violated', rule };
3413
3702
  }
3414
3703
  turnaroundMs = judge.turnaroundMs;
3704
+ bufferBeforeMs = judge.bufferBeforeMs;
3415
3705
  }
3706
+ attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
3416
3707
  const span = occurrenceSpan(occurrences);
3417
- const reach = span && { start: span.start, end: new Date(span.end.getTime() + turnaroundMs) };
3708
+ const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
3418
3709
  if ((event.transparency ?? 'opaque') === 'opaque' &&
3419
3710
  reach &&
3420
3711
  !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
@@ -3452,6 +3743,7 @@ function buildSlotlockStore(sql, options, refusal) {
3452
3743
  revision = ${revision},
3453
3744
  source = ${params.source ?? null},
3454
3745
  buffer_after_ms = ${turnaroundMs},
3746
+ buffer_before_ms = ${bufferBeforeMs},
3455
3747
  updated_at = now()
3456
3748
  WHERE id = ${eventId}
3457
3749
  AND tenant_ref = ${params.tenantRef}
@@ -3464,7 +3756,7 @@ function buildSlotlockStore(sql, options, refusal) {
3464
3756
  summary, description, location, status, transparency, organizer, attendees,
3465
3757
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3466
3758
  materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
3467
- buffer_after_ms
3759
+ buffer_after_ms, buffer_before_ms
3468
3760
  ) VALUES (
3469
3761
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
3470
3762
  ${params.resourceId},
@@ -3478,7 +3770,7 @@ function buildSlotlockStore(sql, options, refusal) {
3478
3770
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
3479
3771
  ${materializationWindow.start}, ${materializationWindow.end},
3480
3772
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3481
- ${revision}, ${params.source ?? null}, ${turnaroundMs}
3773
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
3482
3774
  )`;
3483
3775
  }
3484
3776
  // The normalized children are the queryable/canonical representation. The JSON
@@ -3492,6 +3784,7 @@ function buildSlotlockStore(sql, options, refusal) {
3492
3784
  occurrences,
3493
3785
  revision,
3494
3786
  bufferAfterMs: turnaroundMs,
3787
+ bufferBeforeMs,
3495
3788
  });
3496
3789
  await tx `
3497
3790
  INSERT INTO slotlock.calendar_event_commands (
@@ -3522,11 +3815,12 @@ function buildSlotlockStore(sql, options, refusal) {
3522
3815
  code !== SERIALIZATION_FAILURE) {
3523
3816
  throw error;
3524
3817
  }
3525
- const reach = occurrenceSpan(attempted);
3526
- if (reach) {
3818
+ const span = occurrenceSpan(attempted);
3819
+ if (span) {
3820
+ const reach = occupiedTime({ ...span, ...attemptedBuffers });
3527
3821
  await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
3528
3822
  }
3529
- return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
3823
+ return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
3530
3824
  }
3531
3825
  }
3532
3826
  },
@@ -3932,7 +4226,9 @@ function buildSlotlockStore(sql, options, refusal) {
3932
4226
  slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
3933
4227
  slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
3934
4228
  COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
3935
- WHERE booking.calendar_event_id = event.id), 0) AS buffer
4229
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer,
4230
+ COALESCE((SELECT max(booking.buffer_before_ms) FROM slotlock.reservations booking
4231
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer_before
3936
4232
  FROM slotlock.calendar_events event
3937
4233
  WHERE event.tenant_ref = ${params.tenantRef}
3938
4234
  AND event.recurrence_rule IS NULL
@@ -3986,6 +4282,7 @@ function buildSlotlockStore(sql, options, refusal) {
3986
4282
  ],
3987
4283
  revision,
3988
4284
  bufferAfterMs: Number(event.buffer),
4285
+ bufferBeforeMs: Number(event.buffer_before),
3989
4286
  });
3990
4287
  });
3991
4288
  moved++;
@@ -4291,13 +4588,14 @@ function buildSlotlockStore(sql, options, refusal) {
4291
4588
  }
4292
4589
  const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4293
4590
  const rows = await sql `
4294
- SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4591
+ SELECT source, source_id, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4295
4592
  observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
4296
4593
  FROM slotlock.calendar_coverage
4297
4594
  WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4298
4595
  ORDER BY source`;
4299
4596
  return rows.map((row) => ({
4300
4597
  source: row.source,
4598
+ sourceId: row.source_id,
4301
4599
  start: row.starts_at,
4302
4600
  end: row.ends_at,
4303
4601
  revision: Number(row.revision),
@@ -4321,13 +4619,13 @@ function buildSlotlockStore(sql, options, refusal) {
4321
4619
  AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4322
4620
  ORDER BY observed_at LIMIT ${params.limit ?? 100}
4323
4621
  FOR UPDATE SKIP LOCKED)
4324
- RETURNING coverage.resource_id, coverage.source, coverage.observed_at, coverage.last_error
4622
+ RETURNING coverage.resource_id, coverage.source_id, coverage.observed_at
4325
4623
  ), reported AS (
4326
4624
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4327
- SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source,
4328
- jsonb_strip_nulls(jsonb_build_object(
4329
- 'resource_id', stale.resource_id, 'source', stale.source,
4330
- 'observed_at', stale.observed_at, 'last_error', stale.last_error)),
4625
+ SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source_id,
4626
+ jsonb_build_object(
4627
+ 'resource_id', stale.resource_id, 'source_id', stale.source_id,
4628
+ 'observed_at', stale.observed_at),
4331
4629
  'slotlock:maintenance'
4332
4630
  FROM stale
4333
4631
  RETURNING 1
@@ -4742,18 +5040,28 @@ function buildSlotlockStore(sql, options, refusal) {
4742
5040
  continue;
4743
5041
  // Only resources whose window looks free are tried: a hold refused keeps its locks
4744
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.
4745
5044
  const candidates = await tx `
4746
- SELECT resource.id FROM slotlock.resources resource
4747
- WHERE resource.tenant_ref = ${tenantRef} AND resource.archived_at IS NULL
4748
- AND (resource.id = ${entry.resource_id}::uuid
4749
- OR (${entry.resource_id}::uuid IS NULL AND resource.pool_ref = ${entry.pool_ref}))
5045
+ SELECT resources.id
5046
+ FROM slotlock.resources,
5047
+ LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective,
5048
+ LATERAL (SELECT
5049
+ COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0)
5050
+ AS before_minutes,
5051
+ COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 1440)::int, 0)
5052
+ AS after_minutes) buffers
5053
+ WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
5054
+ AND (resources.id = ${entry.resource_id}::uuid
5055
+ OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
4750
5056
  AND NOT EXISTS (
4751
5057
  SELECT 1 FROM slotlock.reservations taken
4752
- WHERE taken.resource_id = resource.id
4753
- AND tstzrange(taken.starts_at, COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
4754
- && tstzrange(${entry.starts_at}::timestamptz, ${entry.ends_at}::timestamptz, '[)')
5058
+ WHERE taken.resource_id = resources.id
5059
+ AND tstzrange(COALESCE(taken.occupied_starts_at, taken.starts_at),
5060
+ COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
5061
+ && tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
5062
+ ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
4755
5063
  AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
4756
- ORDER BY resource.id
5064
+ ORDER BY resources.id
4757
5065
  LIMIT ${OFFER_CANDIDATES}`;
4758
5066
  for (const { id: resourceId } of candidates) {
4759
5067
  const hold = await inner.acquireHold({
@@ -4818,23 +5126,26 @@ function buildSlotlockStore(sql, options, refusal) {
4818
5126
  });
4819
5127
  }
4820
5128
  const owner = await sql `
4821
- SELECT id, COALESCE((booking_rules->>'requireCertain')::boolean, false) AS require_certain
4822
- FROM slotlock.resources
5129
+ SELECT id, COALESCE((effective.effective_booking_rules->>'requireCertain')::boolean, false)
5130
+ AS require_certain
5131
+ FROM slotlock.resources,
5132
+ LATERAL (SELECT ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
4823
5133
  WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
4824
5134
  if (!owner[0]) {
4825
5135
  throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
4826
5136
  code: 'resource_tenant_mismatch',
4827
5137
  });
4828
5138
  }
5139
+ // Each row's occupied time: its buffers are as busy as the booking.
4829
5140
  const busyRows = await sql `
4830
- SELECT reservation.starts_at,
5141
+ SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
4831
5142
  COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
4832
5143
  FROM slotlock.reservations reservation
4833
5144
  WHERE reservation.resource_id = ${params.resourceId}
4834
- AND reservation.starts_at < ${window.end}
5145
+ AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
4835
5146
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
4836
5147
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
4837
- ORDER BY reservation.starts_at
5148
+ ORDER BY 1
4838
5149
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
4839
5150
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
4840
5151
  throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
@@ -4953,7 +5264,7 @@ function buildSlotlockStore(sql, options, refusal) {
4953
5264
  throw notFound();
4954
5265
  const [row] = await sql `
4955
5266
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
4956
- archived_at, statement_timestamp() AS now
5267
+ archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
4957
5268
  FROM slotlock.resources
4958
5269
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
4959
5270
  if (!row)
@@ -4962,9 +5273,10 @@ function buildSlotlockStore(sql, options, refusal) {
4962
5273
  return null;
4963
5274
  const resource = toResource(row);
4964
5275
  const rules = params.rules ?? resource.availabilityRules ?? [];
4965
- // Only what a booking made now may take: the resource's booking rules bound the search.
5276
+ // Only what a booking made now may take: the resource's booking rules (as they apply) bound
5277
+ // the search.
4966
5278
  const search = bookingSearch({
4967
- rules: resource.bookingRules,
5279
+ rules: resource.effectiveBookingRules,
4968
5280
  timezone: resource.timezone,
4969
5281
  window: asked,
4970
5282
  now: row.now,
@@ -4972,9 +5284,9 @@ function buildSlotlockStore(sql, options, refusal) {
4972
5284
  });
4973
5285
  if (!search)
4974
5286
  return null;
4975
- const { window, bufferAfterMs } = search;
4976
- // A slot's buffer may run past the window: the time it can reach must be proven free too.
4977
- const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
5287
+ const { window } = search;
5288
+ // A slot's buffers may run past the window: the time they can reach must be proven free too.
5289
+ const reach = bookingSearchReach(search);
4978
5290
  let busy;
4979
5291
  if (tenantRef === null) {
4980
5292
  // No event, coverage or series can belong to a resource with no tenant: only its rows.
@@ -4982,7 +5294,11 @@ function buildSlotlockStore(sql, options, refusal) {
4982
5294
  }
4983
5295
  else {
4984
5296
  busy = [];
4985
- const parts = bufferAfterMs > 0 ? [window, { start: window.end, end: reach.end }] : [window];
5297
+ const parts = [
5298
+ ...(reach.start < window.start ? [{ start: reach.start, end: window.start }] : []),
5299
+ window,
5300
+ ...(reach.end > window.end ? [{ start: window.end, end: reach.end }] : []),
5301
+ ];
4986
5302
  for (const part of parts) {
4987
5303
  const freeBusy = await this.getFreeBusy({
4988
5304
  tenantRef,
@@ -5001,12 +5317,8 @@ function buildSlotlockStore(sql, options, refusal) {
5001
5317
  busy.push(...freeBusy.busy);
5002
5318
  }
5003
5319
  }
5004
- // A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
5005
5320
  return findNextAvailable({
5006
- busy: busy.map((interval) => ({
5007
- start: new Date(interval.start.getTime() - bufferAfterMs),
5008
- end: interval.end,
5009
- })),
5321
+ busy: bookingSearchBlocked(busy, search),
5010
5322
  windows: expandRules(rules, window, resource.timezone),
5011
5323
  durationMs: params.durationMs,
5012
5324
  ...(search.align ? { align: search.align } : {}),