slotlock 0.5.1 → 0.5.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.
package/store.js CHANGED
@@ -1,8 +1,8 @@
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, slotlockCoverageSourceId, slotlockCoverageSourceIdSql, 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
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';
@@ -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
@@ -77,6 +82,8 @@ const MAX_CONFIGURED_AGENT_OWNER_EVENT_QUOTA = 100_000;
77
82
  const MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA = 1_000_000;
78
83
  const SCHEMA_APPLY_LOCK = 'slotlock:schema-apply:v1';
79
84
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
+ // A coverage source as the feed names it (coverage.stale): its digest, never the source itself.
86
+ const COVERAGE_SOURCE_ID = slotlockCoverageSourceIdSql('tenant_ref', 'source');
80
87
  const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
81
88
  /** Store-owned coverage source for bounded, locally materialized recurring events. */
82
89
  export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
@@ -870,18 +877,23 @@ async function replaceCalendarEventOccurrences(tx, params) {
870
877
  for (const occurrence of params.occurrences) {
871
878
  let reservationId = null;
872
879
  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);
880
+ const occupied = occupiedTime({
881
+ ...occurrence,
882
+ bufferBeforeMs: params.bufferBeforeMs ?? 0,
883
+ bufferAfterMs: params.bufferAfterMs ?? 0,
884
+ });
885
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
875
886
  const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
876
887
  .update(occurrence.recurrenceId)
877
888
  .digest('hex')}`;
878
889
  const reservations = await tx `
879
890
  INSERT INTO slotlock.reservations (
880
891
  resource_id, tenant_ref, external_ref, starts_at, ends_at,
881
- buffer_after_ms, revision, source, calendar_event_id
892
+ buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
882
893
  ) VALUES (
883
894
  ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
884
- ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0}, ${params.revision},
895
+ ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
896
+ ${params.bufferBeforeMs ?? 0}, ${params.revision},
885
897
  ${`calendar-event:${params.eventId}`}, ${params.eventId}
886
898
  )
887
899
  RETURNING id`;
@@ -1069,46 +1081,53 @@ async function finishCalendarCancel(tx, params) {
1069
1081
  * The row an event write lost to. The write was rolled back, so the event's own rows from before
1070
1082
  * it are still there and are left out: they were to be replaced, and never block the event.
1071
1083
  */
1072
- async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
1084
+ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
1073
1085
  for (const occurrence of occurrences) {
1074
- const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, 0, undefined, eventId);
1086
+ const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, buffers.bufferAfterMs, undefined, eventId, buffers.bufferBeforeMs);
1075
1087
  if (result.conflictingReservationId !== null)
1076
1088
  return result;
1077
1089
  }
1078
1090
  return { ok: false, code: 'overlap', conflictingReservationId: null };
1079
1091
  }
1080
1092
  /**
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`.
1093
+ * What the resource's booking rules (as they apply: its own over its pool's and tenant's defaults)
1094
+ * say about a booking of `interval` made now (by the database's clock, at this statement): the rule
1095
+ * it breaks, if any, and the buffers it must keep before and after it. A resource without its own
1096
+ * hours is judged against `defaultHours`.
1084
1097
  */
1085
1098
  async function bookingRuling(tx, resourceId, interval, defaultHours) {
1086
1099
  const judge = await bookingJudge(tx, resourceId, defaultHours);
1087
- return { rule: judge.broken(interval), turnaroundMs: judge.turnaroundMs };
1100
+ return {
1101
+ rule: judge.broken(interval),
1102
+ turnaroundMs: judge.turnaroundMs,
1103
+ bufferBeforeMs: judge.bufferBeforeMs,
1104
+ };
1088
1105
  }
1089
1106
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1090
1107
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1091
1108
  /**
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.
1109
+ * The resource's booking rules as they apply, read once, as a check of any interval booked now (by
1110
+ * the database's clock at this statement) and the buffers a booking keeps.
1094
1111
  */
1095
1112
  async function bookingJudge(tx, resourceId, defaultHours) {
1096
1113
  const [row] = await tx `
1097
- SELECT booking_rules, availability_rules, timezone, statement_timestamp() AS now
1114
+ SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
1115
+ statement_timestamp() AS now
1098
1116
  FROM slotlock.resources WHERE id = ${resourceId}`;
1099
- if (!row || row.booking_rules === null || row.booking_rules === undefined) {
1100
- return { broken: () => null, turnaroundMs: 0 };
1117
+ if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
1118
+ return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
1101
1119
  }
1102
- const { bookingRules: rules, availabilityRules } = toResource({
1120
+ const { effectiveBookingRules: rules, availabilityRules } = toResource({
1103
1121
  id: resourceId,
1104
1122
  external_ref: null,
1105
1123
  tenant_ref: null,
1106
1124
  timezone: row.timezone,
1107
1125
  availability_rules: row.availability_rules,
1108
- booking_rules: row.booking_rules,
1126
+ booking_rules: null,
1109
1127
  pool_ref: null,
1110
1128
  visibility: 'shared',
1111
1129
  archived_at: null,
1130
+ effective_booking_rules: row.effective_booking_rules,
1112
1131
  });
1113
1132
  const hours = availabilityRules ?? defaultHours;
1114
1133
  // Every sync covering the resource, and whether it was read within a day, by the same clock.
@@ -1120,9 +1139,10 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1120
1139
  FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
1121
1140
  : [];
1122
1141
  const turnaroundMs = bookingBufferAfterMs(rules);
1123
- // The booking and the turnaround it keeps, as a search judges the slot.
1142
+ const bufferBeforeMs = bookingBufferBeforeMs(rules);
1143
+ // The booking and the buffers it keeps, as a search judges the slot.
1124
1144
  const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1125
- source.starts_at > interval.start ||
1145
+ source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
1126
1146
  source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
1127
1147
  // A later occurrence of a series is judged on its step and hours only: notice and advance are
1128
1148
  // about when the booking was made, which its first occurrence answers.
@@ -1130,6 +1150,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1130
1150
  ? undefined
1131
1151
  : {
1132
1152
  ...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
1153
+ ...(rules.stepOffsetMinutes !== undefined ? { stepOffsetMinutes: rules.stepOffsetMinutes } : {}),
1133
1154
  ...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
1134
1155
  };
1135
1156
  return {
@@ -1141,6 +1162,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1141
1162
  now: row.now,
1142
1163
  }) ?? (uncertain(interval) ? 'uncertain' : null),
1143
1164
  turnaroundMs,
1165
+ bufferBeforeMs,
1144
1166
  };
1145
1167
  }
1146
1168
  function sameExternalPayload(row, params, turnaroundMs = 0) {
@@ -1153,9 +1175,6 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1153
1175
  Number(row.revision) === params.revision &&
1154
1176
  row.source === (params.source ?? null));
1155
1177
  }
1156
- function occupiedEnd(params) {
1157
- return new Date(params.end.getTime() + params.bufferAfterMs);
1158
- }
1159
1178
  async function lockExternalIdentity(tx, tenantRef, externalRef) {
1160
1179
  // JSON encoding is unambiguous for this pair and contains no NUL byte (Postgres text rejects
1161
1180
  // U+0000). The database hashes it to one transaction-scoped lock key.
@@ -1185,6 +1204,8 @@ function pgCode(err) {
1185
1204
  }
1186
1205
  /** Members a pool booking or search considers at most, in id order. */
1187
1206
  export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1207
+ /** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
1208
+ export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
1188
1209
  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
1210
  function toWaitlistEntry(row) {
1190
1211
  return {
@@ -1210,6 +1231,30 @@ function invalidPoolError() {
1210
1231
  code: 'invalid_pool',
1211
1232
  });
1212
1233
  }
1234
+ /**
1235
+ * A resource row's booking rules as they apply (jsonb; NULL for none): the tenant's defaults,
1236
+ * overlaid by its pool's, overlaid by its own, field by field, so one set at a narrower level wins.
1237
+ * Read from a row of slotlock.resources named `resources` (no alias).
1238
+ */
1239
+ const EFFECTIVE_BOOKING_RULES = `NULLIF(
1240
+ COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
1241
+ WHERE defaults.tenant_ref = resources.tenant_ref AND defaults.pool_ref = ''), '{}'::jsonb)
1242
+ || COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
1243
+ WHERE defaults.tenant_ref = resources.tenant_ref
1244
+ AND defaults.pool_ref = resources.pool_ref), '{}'::jsonb)
1245
+ || COALESCE(resources.booking_rules, '{}'::jsonb),
1246
+ '{}'::jsonb) AS effective_booking_rules`;
1247
+ /** What rules Slotlock did not write read as: no booking passes them. */
1248
+ const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
1249
+ /** Rules read back from storage: written validated, so anything else refuses every booking. */
1250
+ function storedBookingRules(value) {
1251
+ try {
1252
+ return parseBookingRules(value);
1253
+ }
1254
+ catch {
1255
+ return { ...REFUSE_EVERY_BOOKING };
1256
+ }
1257
+ }
1213
1258
  function toResource(row) {
1214
1259
  const resource = {
1215
1260
  id: row.id,
@@ -1234,11 +1279,19 @@ function toResource(row) {
1234
1279
  if (row.booking_rules !== null && row.booking_rules !== undefined) {
1235
1280
  // Written through setResourceBookingRules, which validates; anything else reads as refusing
1236
1281
  // every booking, never as no rule.
1237
- try {
1238
- resource.bookingRules = parseBookingRules(row.booking_rules);
1282
+ resource.bookingRules = storedBookingRules(row.booking_rules);
1283
+ }
1284
+ if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
1285
+ // Merged from levels each validated alone, so only their combination can disagree (an offset
1286
+ // from one level over a shorter step from another): that one is read modulo the step. A field
1287
+ // invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
1288
+ const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
1289
+ if (dropped.length > 0) {
1290
+ console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
1291
+ resource.effectiveBookingRules = { ...REFUSE_EVERY_BOOKING };
1239
1292
  }
1240
- catch {
1241
- resource.bookingRules = { maxAdvanceDays: 1, minNoticeMinutes: 525_600 };
1293
+ else if (Object.keys(rules).length > 0) {
1294
+ resource.effectiveBookingRules = rules;
1242
1295
  }
1243
1296
  }
1244
1297
  return resource;
@@ -1268,7 +1321,7 @@ async function upsertResource(executor, params) {
1268
1321
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
1269
1322
  DO UPDATE SET timezone = EXCLUDED.timezone
1270
1323
  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`;
1324
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1272
1325
  }
1273
1326
  else if (params.externalRef !== undefined) {
1274
1327
  rows = await executor `
@@ -1278,13 +1331,13 @@ async function upsertResource(executor, params) {
1278
1331
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
1279
1332
  DO UPDATE SET external_ref = EXCLUDED.external_ref
1280
1333
  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`;
1334
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1282
1335
  }
1283
1336
  else {
1284
1337
  rows = await executor `
1285
1338
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1286
1339
  VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1287
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1340
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1288
1341
  }
1289
1342
  const row = rows[0];
1290
1343
  if (!row) {
@@ -1307,7 +1360,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1307
1360
  summary, description, location, status, transparency, organizer, attendees,
1308
1361
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1309
1362
  materialized_ends_at, recurrence_cursor, revision, source, created_at,
1310
- updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
1363
+ updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms, buffer_before_ms
1311
1364
  FROM slotlock.calendar_events
1312
1365
  WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1313
1366
  FOR UPDATE`;
@@ -1330,6 +1383,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1330
1383
  occurrences,
1331
1384
  revision: Number(row.revision),
1332
1385
  bufferAfterMs: Number(row.buffer_after_ms ?? 0),
1386
+ bufferBeforeMs: Number(row.buffer_before_ms ?? 0),
1333
1387
  });
1334
1388
  // A series stored before 0.3 learns when it ends at its first roll.
1335
1389
  const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
@@ -1697,8 +1751,28 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1697
1751
  return Number(row?.deleted ?? 0);
1698
1752
  }
1699
1753
  /**
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
1754
+ * The time a booking of [start, end) keeping these buffers occupies, as the exclusion constraint
1755
+ * reads it: from its start less its leading buffer to its end plus its trailing one.
1756
+ */
1757
+ function occupiedTime(params) {
1758
+ return {
1759
+ start: new Date(params.start.getTime() - (params.bufferBeforeMs ?? 0)),
1760
+ end: new Date(params.end.getTime() + (params.bufferAfterMs ?? 0)),
1761
+ };
1762
+ }
1763
+ /**
1764
+ * A row's occupied time starts before `end`: a condition on its occupied start that still reads the
1765
+ * (resource_id, starts_at, ends_at) index, since no row starts more than a day after it.
1766
+ */
1767
+ function occupiedStartsBefore(executor, end, alias) {
1768
+ const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
1769
+ return executor `(${column('starts_at')} < ${new Date(end.getTime() + MAX_BUFFER_BEFORE_MS)}
1770
+ AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
1771
+ }
1772
+ /**
1773
+ * Delete expired held rows whose occupied time (buffers included, as the EXCLUDE constraint reads
1774
+ * it) overlaps the requested window, whose `start` and `end` are its own occupied start and end
1775
+ * (`occupiedTime`), in deterministic id
1702
1776
  * order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
1703
1777
  * deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
1704
1778
  * before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
@@ -1711,7 +1785,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1711
1785
  WHERE resource_id = ${resourceId}
1712
1786
  AND status = 'held'
1713
1787
  AND expires_at <= statement_timestamp()
1714
- AND starts_at < ${end}
1788
+ AND ${occupiedStartsBefore(tx, end)}
1715
1789
  AND COALESCE(occupied_ends_at, ends_at) > ${start}
1716
1790
  ORDER BY id
1717
1791
  FOR UPDATE
@@ -1723,7 +1797,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1723
1797
  * loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
1724
1798
  * immediate retry reaps it and wins.
1725
1799
  */
1726
- async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
1800
+ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
1727
1801
  const winners = await sql `
1728
1802
  SELECT id FROM slotlock.reservations
1729
1803
  WHERE resource_id = ${resourceId}
@@ -1731,12 +1805,12 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
1731
1805
  AND (${excludeCalendarEventId ?? null}::uuid IS NULL
1732
1806
  OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
1733
1807
  AND tstzrange(
1734
- starts_at,
1808
+ COALESCE(occupied_starts_at, starts_at),
1735
1809
  COALESCE(occupied_ends_at, ends_at),
1736
1810
  '[)'
1737
1811
  ) && tstzrange(
1738
- ${start},
1739
- ${end} + ${bufferAfterMs} * interval '1 millisecond',
1812
+ ${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
1813
+ ${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
1740
1814
  '[)'
1741
1815
  )
1742
1816
  AND (status <> 'held' OR expires_at > statement_timestamp())
@@ -1821,6 +1895,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1821
1895
  'deleteResource',
1822
1896
  'setResourceAvailability',
1823
1897
  'setResourceBookingRules',
1898
+ 'setBookingRuleDefaults',
1824
1899
  'setResourcePool',
1825
1900
  'setTenantAvailability',
1826
1901
  'createReservation',
@@ -1842,6 +1917,11 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1842
1917
  'checkInReservation',
1843
1918
  'joinWaitlist',
1844
1919
  'leaveWaitlist',
1920
+ 'putAssistantSettings',
1921
+ 'deleteAssistantSettings',
1922
+ 'putAssistantConversation',
1923
+ 'settleAssistantAction',
1924
+ 'deleteAssistantConversation',
1845
1925
  ]);
1846
1926
  function tenantSuspendedError() {
1847
1927
  return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
@@ -2146,6 +2226,16 @@ function buildSlotlockStore(sql, options, refusal) {
2146
2226
  code: 'invalid_identity',
2147
2227
  });
2148
2228
  }
2229
+ const origin = options?.origin;
2230
+ if (origin !== undefined && !isSlotlockChangeOrigin(origin)) {
2231
+ throw Object.assign(new Error(`Slotlock origin must be one of ${SLOTLOCK_CHANGE_ORIGINS.join(', ')}`), {
2232
+ code: 'invalid_identity',
2233
+ });
2234
+ }
2235
+ const keyId = options?.keyId;
2236
+ if (keyId !== undefined && !isValidResourceId(keyId)) {
2237
+ throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
2238
+ }
2149
2239
  const release = await tenantSlots?.(tenantRef, options?.signal);
2150
2240
  // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
2151
2241
  // another error in its place: noted here as it happens.
@@ -2162,6 +2252,8 @@ function buildSlotlockStore(sql, options, refusal) {
2162
2252
  const previous = await tx `
2163
2253
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2164
2254
  current_setting('slotlock.actor', true) AS actor,
2255
+ current_setting('slotlock.origin', true) AS origin,
2256
+ current_setting('slotlock.key_id', true) AS key,
2165
2257
  current_setting('transaction_isolation') AS isolation,
2166
2258
  pg_advisory_xact_lock_shared(
2167
2259
  hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
@@ -2169,6 +2261,9 @@ function buildSlotlockStore(sql, options, refusal) {
2169
2261
  const [context] = await tx `
2170
2262
  SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
2171
2263
  set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
2264
+ set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
2265
+ NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
2266
+ set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
2172
2267
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2173
2268
  throw suspensionCheckRefusal(error);
2174
2269
  });
@@ -2190,7 +2285,9 @@ function buildSlotlockStore(sql, options, refusal) {
2190
2285
  }
2191
2286
  finally {
2192
2287
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2193
- set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)`;
2288
+ set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
2289
+ set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
2290
+ set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
2194
2291
  }
2195
2292
  }, 'read committed')
2196
2293
  .catch((error) => {
@@ -2251,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
2251
2348
  });
2252
2349
  }
2253
2350
  const rows = await sql `
2254
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2351
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2255
2352
  FROM slotlock.resources
2256
2353
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2257
2354
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
@@ -2269,7 +2366,7 @@ function buildSlotlockStore(sql, options, refusal) {
2269
2366
  UPDATE slotlock.resources
2270
2367
  SET availability_rules = ${rules}::jsonb
2271
2368
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2272
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2369
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2273
2370
  const row = rows[0];
2274
2371
  return row ? toResource(row) : null;
2275
2372
  },
@@ -2301,10 +2398,74 @@ function buildSlotlockStore(sql, options, refusal) {
2301
2398
  END
2302
2399
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2303
2400
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2304
- visibility, archived_at`;
2401
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2305
2402
  const row = rows[0];
2306
2403
  return row ? toResource(row) : null;
2307
2404
  },
2405
+ async setBookingRuleDefaults(params) {
2406
+ if (!isValidIdentity(params.tenantRef)) {
2407
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2408
+ }
2409
+ if (params.pool !== null && !isPoolName(params.pool))
2410
+ throw invalidPoolError();
2411
+ const pool = params.pool ?? '';
2412
+ if (params.rules === null) {
2413
+ await sql `
2414
+ DELETE FROM slotlock.booking_rule_defaults
2415
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${pool}`;
2416
+ return null;
2417
+ }
2418
+ let rules;
2419
+ try {
2420
+ rules = parseBookingRuleDefaults(params.rules);
2421
+ }
2422
+ catch (error) {
2423
+ throw Object.assign(new Error(error.message), {
2424
+ code: 'invalid_booking_rules',
2425
+ });
2426
+ }
2427
+ const tenantRef = params.tenantRef;
2428
+ return inTransaction(sql, async (tx) => {
2429
+ if (pool !== '') {
2430
+ // Counted under the tenant's lock, so racing writes stop at the cap.
2431
+ // The count after the lock sees every committed row only under READ COMMITTED.
2432
+ await assertReadCommitted(tx);
2433
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
2434
+ const [counted] = await tx `
2435
+ SELECT count(*)::int AS pools, bool_or(pool_ref = ${pool}) IS TRUE AS exists
2436
+ FROM slotlock.booking_rule_defaults
2437
+ WHERE tenant_ref = ${tenantRef} AND pool_ref <> ''`;
2438
+ if (!counted?.exists && (counted?.pools ?? 0) >= SLOTLOCK_MAX_POOL_RULE_DEFAULTS) {
2439
+ 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 });
2440
+ }
2441
+ }
2442
+ await tx `
2443
+ INSERT INTO slotlock.booking_rule_defaults (tenant_ref, pool_ref, rules)
2444
+ VALUES (${tenantRef}, ${pool}, ${tx.json({ ...rules })}::jsonb)
2445
+ ON CONFLICT (tenant_ref, pool_ref)
2446
+ DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
2447
+ return rules;
2448
+ });
2449
+ },
2450
+ async getBookingRuleDefaults(params) {
2451
+ if (!isValidIdentity(params.tenantRef)) {
2452
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2453
+ }
2454
+ const rows = await sql `
2455
+ SELECT pool_ref, rules FROM slotlock.booking_rule_defaults
2456
+ WHERE tenant_ref = ${params.tenantRef}
2457
+ ORDER BY pool_ref
2458
+ LIMIT ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS + 1}`;
2459
+ const defaults = { tenant: null, pools: {} };
2460
+ for (const row of rows) {
2461
+ const rules = storedBookingRules(row.rules);
2462
+ if (row.pool_ref === '')
2463
+ defaults.tenant = rules;
2464
+ else
2465
+ defaults.pools[row.pool_ref] = rules;
2466
+ }
2467
+ return defaults;
2468
+ },
2308
2469
  async setResourcePool(params) {
2309
2470
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2310
2471
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
@@ -2317,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
2317
2478
  UPDATE slotlock.resources SET pool_ref = ${params.pool}
2318
2479
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2319
2480
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2320
- visibility, archived_at`;
2481
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2321
2482
  const row = rows[0];
2322
2483
  return row ? toResource(row) : null;
2323
2484
  },
@@ -2328,7 +2489,7 @@ function buildSlotlockStore(sql, options, refusal) {
2328
2489
  throw invalidPoolError();
2329
2490
  const rows = await sql `
2330
2491
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2331
- visibility, archived_at
2492
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2332
2493
  FROM slotlock.resources
2333
2494
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2334
2495
  AND archived_at IS NULL
@@ -2381,7 +2542,7 @@ function buildSlotlockStore(sql, options, refusal) {
2381
2542
  });
2382
2543
  }
2383
2544
  const rows = await sql `
2384
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2545
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2385
2546
  FROM slotlock.resources
2386
2547
  WHERE tenant_ref = ${params.tenantRef}
2387
2548
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
@@ -2401,7 +2562,7 @@ function buildSlotlockStore(sql, options, refusal) {
2401
2562
  UPDATE slotlock.resources
2402
2563
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2403
2564
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2404
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2565
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2405
2566
  return row ? toResource(row) : null;
2406
2567
  },
2407
2568
  async restoreResource(params) {
@@ -2412,7 +2573,7 @@ function buildSlotlockStore(sql, options, refusal) {
2412
2573
  UPDATE slotlock.resources
2413
2574
  SET archived_at = NULL
2414
2575
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2415
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2576
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2416
2577
  return row ? toResource(row) : null;
2417
2578
  },
2418
2579
  async deleteResource(params) {
@@ -2460,6 +2621,9 @@ function buildSlotlockStore(sql, options, refusal) {
2460
2621
  // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2461
2622
  // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
2462
2623
  const id = randomUUID();
2624
+ // The buffers written, for the overlap answer if the write loses.
2625
+ let bufferBeforeMs = 0;
2626
+ let bufferAfterMs = 0;
2463
2627
  for (let attempt = 1;; attempt++) {
2464
2628
  try {
2465
2629
  return await inTransaction(sql, async (tx) => {
@@ -2480,24 +2644,27 @@ function buildSlotlockStore(sql, options, refusal) {
2480
2644
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2481
2645
  if (ruling.rule)
2482
2646
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2483
- const occupied = new Date(params.end.getTime() + ruling.turnaroundMs);
2647
+ bufferBeforeMs = ruling.bufferBeforeMs;
2648
+ bufferAfterMs = ruling.turnaroundMs;
2649
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2484
2650
  if (resource.tenant_ref !== null &&
2485
2651
  !(await recurrenceCoversWrite(tx, {
2486
2652
  tenantRef: resource.tenant_ref,
2487
2653
  resourceId: params.resourceId,
2488
- start: params.start,
2489
- end: occupied,
2654
+ ...occupied,
2490
2655
  }))) {
2491
2656
  return { ok: false, code: 'recurrence_not_materialized' };
2492
2657
  }
2493
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2658
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2494
2659
  const [row] = await tx `
2495
2660
  INSERT INTO slotlock.reservations (
2496
- id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms, source
2661
+ id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2662
+ buffer_before_ms, source
2497
2663
  ) VALUES (
2498
2664
  ${id}, ${params.resourceId}, ${resource.tenant_ref},
2499
2665
  ${resource.tenant_ref === null ? null : id},
2500
- ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${params.source ?? null}
2666
+ ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
2667
+ ${params.source ?? null}
2501
2668
  )
2502
2669
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2503
2670
  buffer_after_ms, revision, source`;
@@ -2518,8 +2685,9 @@ function buildSlotlockStore(sql, options, refusal) {
2518
2685
  }
2519
2686
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2520
2687
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2521
- await rollSeriesBehind(sql, params.resourceId, params.start, params.end);
2522
- return overlapLoss(sql, params.resourceId, params.start, params.end);
2688
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2689
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2690
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
2523
2691
  }
2524
2692
  }
2525
2693
  },
@@ -2530,6 +2698,7 @@ function buildSlotlockStore(sql, options, refusal) {
2530
2698
  if (params.revision !== 1)
2531
2699
  return { ok: false, code: 'invalid_revision' };
2532
2700
  let written = params.bufferAfterMs;
2701
+ let writtenBefore = 0;
2533
2702
  for (let attempt = 1;; attempt++) {
2534
2703
  try {
2535
2704
  return await inTransaction(sql, async (tx) => {
@@ -2574,23 +2743,24 @@ function buildSlotlockStore(sql, options, refusal) {
2574
2743
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2575
2744
  const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2576
2745
  written = bufferAfterMs;
2577
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2746
+ writtenBefore = ruling.bufferBeforeMs;
2747
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
2578
2748
  if (!(await recurrenceCoversWrite(tx, {
2579
2749
  tenantRef: params.tenantRef,
2580
2750
  resourceId: params.resourceId,
2581
- start: params.start,
2582
- end: occupied,
2751
+ ...occupied,
2583
2752
  }))) {
2584
2753
  return { ok: false, code: 'recurrence_not_materialized' };
2585
2754
  }
2586
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2755
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2587
2756
  const rows = await tx `
2588
2757
  INSERT INTO slotlock.reservations (
2589
2758
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2590
- revision, source
2759
+ buffer_before_ms, revision, source
2591
2760
  ) VALUES (
2592
2761
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
2593
- ${params.end}, ${bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2762
+ ${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
2763
+ ${params.source ?? null}
2594
2764
  )
2595
2765
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2596
2766
  buffer_after_ms, revision, source`;
@@ -2610,8 +2780,9 @@ function buildSlotlockStore(sql, options, refusal) {
2610
2780
  code !== SERIALIZATION_FAILURE) {
2611
2781
  throw err;
2612
2782
  }
2613
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2614
- return overlapLoss(sql, params.resourceId, params.start, params.end, written);
2783
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
2784
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2785
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
2615
2786
  }
2616
2787
  }
2617
2788
  },
@@ -2621,6 +2792,7 @@ function buildSlotlockStore(sql, options, refusal) {
2621
2792
  return invalid;
2622
2793
  let currentReservationId;
2623
2794
  let bufferAfterMs = params.bufferAfterMs;
2795
+ let bufferBeforeMs = 0;
2624
2796
  for (let attempt = 1;; attempt++) {
2625
2797
  try {
2626
2798
  return await inTransaction(sql, async (tx) => {
@@ -2663,21 +2835,23 @@ function buildSlotlockStore(sql, options, refusal) {
2663
2835
  if (ruling.rule)
2664
2836
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2665
2837
  bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2838
+ bufferBeforeMs = ruling.bufferBeforeMs;
2839
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2666
2840
  if (!(await recurrenceCoversWrite(tx, {
2667
2841
  tenantRef: params.tenantRef,
2668
2842
  resourceId: params.resourceId,
2669
- start: params.start,
2670
- end: occupiedEnd({ end: params.end, bufferAfterMs }),
2843
+ ...occupied,
2671
2844
  }))) {
2672
2845
  return { ok: false, code: 'recurrence_not_materialized' };
2673
2846
  }
2674
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs }));
2847
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2675
2848
  const rows = await tx `
2676
2849
  UPDATE slotlock.reservations
2677
2850
  SET resource_id = ${params.resourceId},
2678
2851
  starts_at = ${params.start},
2679
2852
  ends_at = ${params.end},
2680
2853
  buffer_after_ms = ${bufferAfterMs},
2854
+ buffer_before_ms = ${bufferBeforeMs},
2681
2855
  revision = ${params.revision},
2682
2856
  source = ${params.source ?? null},
2683
2857
  -- A check-in is for the time it was made for: a move to another start clears it.
@@ -2704,9 +2878,9 @@ function buildSlotlockStore(sql, options, refusal) {
2704
2878
  code !== SERIALIZATION_FAILURE) {
2705
2879
  throw err;
2706
2880
  }
2707
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2708
- await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
2709
- return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId);
2881
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2882
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
2883
+ return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId, undefined, bufferBeforeMs);
2710
2884
  }
2711
2885
  }
2712
2886
  },
@@ -2820,8 +2994,10 @@ function buildSlotlockStore(sql, options, refusal) {
2820
2994
  ...(holderRef !== undefined ? { holderRef } : {}),
2821
2995
  });
2822
2996
  const ttlSeconds = params.ttlMs / 1000;
2823
- // The buffer written: the one asked for, raised to the resource's turnaround.
2997
+ // The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
2998
+ // leading buffer.
2824
2999
  let written = bufferAfterMs;
3000
+ let writtenBefore = 0;
2825
3001
  for (let attempt = 1;; attempt++) {
2826
3002
  try {
2827
3003
  return await inTransaction(sql, async (tx) => {
@@ -2870,7 +3046,8 @@ function buildSlotlockStore(sql, options, refusal) {
2870
3046
  const [held] = await tx `
2871
3047
  SELECT count(*)::int AS count,
2872
3048
  COALESCE(sum(extract(epoch FROM
2873
- ends_at + buffer_after_ms * interval '1 millisecond' - starts_at)), 0)::float8
3049
+ ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
3050
+ - starts_at)), 0)::float8
2874
3051
  AS seconds
2875
3052
  FROM slotlock.reservations
2876
3053
  WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
@@ -2886,10 +3063,11 @@ function buildSlotlockStore(sql, options, refusal) {
2886
3063
  if (ruling.rule)
2887
3064
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2888
3065
  written = Math.max(bufferAfterMs, ruling.turnaroundMs);
2889
- const reach = occupiedEnd({ end: params.end, bufferAfterMs: written });
3066
+ writtenBefore = ruling.bufferBeforeMs;
3067
+ const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
2890
3068
  if (heldSeconds !== null &&
2891
3069
  maxHeldHoursPerHolder !== undefined &&
2892
- heldSeconds + (reach.getTime() - params.start.getTime()) / 1000 >
3070
+ heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
2893
3071
  maxHeldHoursPerHolder * 3_600) {
2894
3072
  return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
2895
3073
  }
@@ -2897,18 +3075,17 @@ function buildSlotlockStore(sql, options, refusal) {
2897
3075
  !(await recurrenceCoversWrite(tx, {
2898
3076
  tenantRef: resource.tenant_ref,
2899
3077
  resourceId: params.resourceId,
2900
- start: params.start,
2901
- end: reach,
3078
+ ...reach,
2902
3079
  }))) {
2903
3080
  return { ok: false, code: 'recurrence_not_materialized' };
2904
3081
  }
2905
- await reapExpiredHolds(tx, params.resourceId, params.start, reach);
3082
+ await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
2906
3083
  const [row] = await tx `
2907
3084
  INSERT INTO slotlock.reservations (
2908
- resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source,
2909
- holder_ref, hold_group_id
3085
+ resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
3086
+ expires_at, source, holder_ref, hold_group_id
2910
3087
  ) VALUES (
2911
- ${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
3088
+ ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
2912
3089
  statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
2913
3090
  ${holderRef ?? null}, ${params.groupId ?? null}
2914
3091
  )
@@ -2942,8 +3119,9 @@ function buildSlotlockStore(sql, options, refusal) {
2942
3119
  }
2943
3120
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2944
3121
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2945
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs: written }));
2946
- return overlapLoss(sql, params.resourceId, params.start, params.end, written);
3122
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3123
+ await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
3124
+ return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
2947
3125
  }
2948
3126
  }
2949
3127
  },
@@ -3195,15 +3373,16 @@ function buildSlotlockStore(sql, options, refusal) {
3195
3373
  // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
3196
3374
  // space (the acquiring transaction reaps the row itself when it takes the window).
3197
3375
  const rows = await sql `
3198
- SELECT starts_at, COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3376
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
3377
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3199
3378
  FROM slotlock.reservations
3200
3379
  WHERE resource_id = ${resourceId}
3201
- AND starts_at < ${window.end}
3380
+ AND ${occupiedStartsBefore(sql, window.end)}
3202
3381
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3203
3382
  AND (status <> 'held' OR expires_at > statement_timestamp())
3204
- ORDER BY starts_at
3383
+ ORDER BY COALESCE(occupied_starts_at, starts_at)
3205
3384
  LIMIT ${options.limit ?? null}`;
3206
- return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
3385
+ return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
3207
3386
  },
3208
3387
  async putCalendarEvent(params) {
3209
3388
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
@@ -3261,6 +3440,8 @@ function buildSlotlockStore(sql, options, refusal) {
3261
3440
  });
3262
3441
  const recurrenceEndsBy = recurrenceEndsByColumn(event);
3263
3442
  let attempted = expanded?.occurrences ?? [];
3443
+ // The buffers the attempt kept, for the overlap answer if it loses.
3444
+ let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
3264
3445
  let storedEventId;
3265
3446
  for (let attempt = 1;; attempt++) {
3266
3447
  try {
@@ -3398,6 +3579,7 @@ function buildSlotlockStore(sql, options, refusal) {
3398
3579
  AND hold_group_id = ${hold.hold_group_id}::uuid))`;
3399
3580
  }
3400
3581
  let turnaroundMs = 0;
3582
+ let bufferBeforeMs = 0;
3401
3583
  if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
3402
3584
  const judge = await bookingJudge(tx, params.resourceId, defaultHours);
3403
3585
  for (const [index, occurrence] of occurrences.entries()) {
@@ -3407,9 +3589,11 @@ function buildSlotlockStore(sql, options, refusal) {
3407
3589
  return { ok: false, code: 'booking_rule_violated', rule };
3408
3590
  }
3409
3591
  turnaroundMs = judge.turnaroundMs;
3592
+ bufferBeforeMs = judge.bufferBeforeMs;
3410
3593
  }
3594
+ attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
3411
3595
  const span = occurrenceSpan(occurrences);
3412
- const reach = span && { start: span.start, end: new Date(span.end.getTime() + turnaroundMs) };
3596
+ const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
3413
3597
  if ((event.transparency ?? 'opaque') === 'opaque' &&
3414
3598
  reach &&
3415
3599
  !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
@@ -3447,6 +3631,7 @@ function buildSlotlockStore(sql, options, refusal) {
3447
3631
  revision = ${revision},
3448
3632
  source = ${params.source ?? null},
3449
3633
  buffer_after_ms = ${turnaroundMs},
3634
+ buffer_before_ms = ${bufferBeforeMs},
3450
3635
  updated_at = now()
3451
3636
  WHERE id = ${eventId}
3452
3637
  AND tenant_ref = ${params.tenantRef}
@@ -3459,7 +3644,7 @@ function buildSlotlockStore(sql, options, refusal) {
3459
3644
  summary, description, location, status, transparency, organizer, attendees,
3460
3645
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3461
3646
  materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
3462
- buffer_after_ms
3647
+ buffer_after_ms, buffer_before_ms
3463
3648
  ) VALUES (
3464
3649
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
3465
3650
  ${params.resourceId},
@@ -3473,7 +3658,7 @@ function buildSlotlockStore(sql, options, refusal) {
3473
3658
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
3474
3659
  ${materializationWindow.start}, ${materializationWindow.end},
3475
3660
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3476
- ${revision}, ${params.source ?? null}, ${turnaroundMs}
3661
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
3477
3662
  )`;
3478
3663
  }
3479
3664
  // The normalized children are the queryable/canonical representation. The JSON
@@ -3487,6 +3672,7 @@ function buildSlotlockStore(sql, options, refusal) {
3487
3672
  occurrences,
3488
3673
  revision,
3489
3674
  bufferAfterMs: turnaroundMs,
3675
+ bufferBeforeMs,
3490
3676
  });
3491
3677
  await tx `
3492
3678
  INSERT INTO slotlock.calendar_event_commands (
@@ -3517,11 +3703,12 @@ function buildSlotlockStore(sql, options, refusal) {
3517
3703
  code !== SERIALIZATION_FAILURE) {
3518
3704
  throw error;
3519
3705
  }
3520
- const reach = occurrenceSpan(attempted);
3521
- if (reach) {
3706
+ const span = occurrenceSpan(attempted);
3707
+ if (span) {
3708
+ const reach = occupiedTime({ ...span, ...attemptedBuffers });
3522
3709
  await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
3523
3710
  }
3524
- return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
3711
+ return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
3525
3712
  }
3526
3713
  }
3527
3714
  },
@@ -3927,7 +4114,9 @@ function buildSlotlockStore(sql, options, refusal) {
3927
4114
  slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
3928
4115
  slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
3929
4116
  COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
3930
- WHERE booking.calendar_event_id = event.id), 0) AS buffer
4117
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer,
4118
+ COALESCE((SELECT max(booking.buffer_before_ms) FROM slotlock.reservations booking
4119
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer_before
3931
4120
  FROM slotlock.calendar_events event
3932
4121
  WHERE event.tenant_ref = ${params.tenantRef}
3933
4122
  AND event.recurrence_rule IS NULL
@@ -3981,6 +4170,7 @@ function buildSlotlockStore(sql, options, refusal) {
3981
4170
  ],
3982
4171
  revision,
3983
4172
  bufferAfterMs: Number(event.buffer),
4173
+ bufferBeforeMs: Number(event.buffer_before),
3984
4174
  });
3985
4175
  });
3986
4176
  moved++;
@@ -4293,6 +4483,7 @@ function buildSlotlockStore(sql, options, refusal) {
4293
4483
  ORDER BY source`;
4294
4484
  return rows.map((row) => ({
4295
4485
  source: row.source,
4486
+ sourceId: slotlockCoverageSourceId(params.tenantRef, row.source),
4296
4487
  start: row.starts_at,
4297
4488
  end: row.ends_at,
4298
4489
  revision: Number(row.revision),
@@ -4316,15 +4507,15 @@ function buildSlotlockStore(sql, options, refusal) {
4316
4507
  AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4317
4508
  ORDER BY observed_at LIMIT ${params.limit ?? 100}
4318
4509
  FOR UPDATE SKIP LOCKED)
4319
- RETURNING coverage.resource_id, coverage.source, coverage.observed_at, coverage.last_error
4510
+ RETURNING coverage.tenant_ref, coverage.resource_id, coverage.source, coverage.observed_at
4320
4511
  ), reported AS (
4321
4512
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4322
- SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source,
4323
- jsonb_strip_nulls(jsonb_build_object(
4324
- 'resource_id', stale.resource_id, 'source', stale.source,
4325
- 'observed_at', stale.observed_at, 'last_error', stale.last_error)),
4513
+ SELECT ${params.tenantRef}, named.resource_id, 'coverage.stale', named.source_id,
4514
+ jsonb_build_object(
4515
+ 'resource_id', named.resource_id, 'source_id', named.source_id,
4516
+ 'observed_at', named.observed_at),
4326
4517
  'slotlock:maintenance'
4327
- FROM stale
4518
+ FROM (SELECT resource_id, observed_at, ${sql.unsafe(COVERAGE_SOURCE_ID)} AS source_id FROM stale) named
4328
4519
  RETURNING 1
4329
4520
  )
4330
4521
  SELECT count(*)::int AS count FROM reported`;
@@ -4525,6 +4716,159 @@ function buildSlotlockStore(sql, options, refusal) {
4525
4716
  return true;
4526
4717
  });
4527
4718
  },
4719
+ async getAssistantSettings(params) {
4720
+ if (!isValidIdentity(params.tenantRef))
4721
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4722
+ const [row] = await sql `
4723
+ SELECT provider, base_url, model, sealed_key, key_hint, updated_by, updated_at
4724
+ FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef}`;
4725
+ if (!row)
4726
+ return null;
4727
+ return {
4728
+ provider: row.provider,
4729
+ baseUrl: row.base_url,
4730
+ model: row.model,
4731
+ sealedKey: row.sealed_key,
4732
+ keyHint: row.key_hint,
4733
+ updatedBy: row.updated_by,
4734
+ updatedAt: row.updated_at,
4735
+ };
4736
+ },
4737
+ async putAssistantSettings(params) {
4738
+ if (!isValidIdentity(params.tenantRef))
4739
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4740
+ const keep = params.key === 'keep';
4741
+ const key = params.key === 'keep' ? null : params.key;
4742
+ const saved = await sql `
4743
+ INSERT INTO slotlock.assistant_settings AS stored
4744
+ (tenant_ref, provider, base_url, model, sealed_key, key_hint, updated_by, updated_at)
4745
+ VALUES (${params.tenantRef}, ${params.provider}, ${params.baseUrl}, ${params.model},
4746
+ ${key?.sealed ?? null}, ${key?.hint ?? null}, ${params.updatedBy ?? null},
4747
+ statement_timestamp())
4748
+ ON CONFLICT (tenant_ref) DO UPDATE SET
4749
+ provider = EXCLUDED.provider,
4750
+ base_url = EXCLUDED.base_url,
4751
+ model = EXCLUDED.model,
4752
+ sealed_key = CASE WHEN ${keep}::boolean THEN stored.sealed_key ELSE EXCLUDED.sealed_key END,
4753
+ key_hint = CASE WHEN ${keep}::boolean THEN stored.key_hint ELSE EXCLUDED.key_hint END,
4754
+ updated_by = EXCLUDED.updated_by,
4755
+ updated_at = EXCLUDED.updated_at
4756
+ WHERE NOT ${keep}::boolean
4757
+ OR stored.sealed_key IS NULL
4758
+ OR (stored.provider = EXCLUDED.provider AND stored.base_url = EXCLUDED.base_url)
4759
+ RETURNING 1`;
4760
+ return { saved: saved.length > 0 };
4761
+ },
4762
+ async deleteAssistantSettings(params) {
4763
+ if (!isValidIdentity(params.tenantRef))
4764
+ return false;
4765
+ const rows = await sql `
4766
+ DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
4767
+ return rows.length > 0;
4768
+ },
4769
+ async getAssistantConversation(params) {
4770
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4771
+ return null;
4772
+ const [row] = await sql `
4773
+ SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
4774
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
4775
+ AND expires_at > statement_timestamp()`;
4776
+ if (!row)
4777
+ return null;
4778
+ return {
4779
+ messages: row.messages,
4780
+ actions: row.actions,
4781
+ updatedAt: row.updated_at,
4782
+ expiresAt: row.expires_at,
4783
+ };
4784
+ },
4785
+ async putAssistantConversation(params) {
4786
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
4787
+ throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
4788
+ }
4789
+ if (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 86_400_000) {
4790
+ throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to a day');
4791
+ }
4792
+ const messages = sql.json(params.messages);
4793
+ const added = sql.json(params.added);
4794
+ const keep = [...params.keep];
4795
+ await sql `
4796
+ DELETE FROM slotlock.assistant_conversations
4797
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
4798
+ await sql `
4799
+ INSERT INTO slotlock.assistant_conversations AS stored
4800
+ (tenant_ref, member_ref, messages, actions, updated_at, expires_at)
4801
+ VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
4802
+ statement_timestamp(),
4803
+ statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))
4804
+ ON CONFLICT (tenant_ref, member_ref) DO UPDATE SET
4805
+ messages = EXCLUDED.messages,
4806
+ -- The stored ones kept, as they stand, then the new ones: never a settled one again.
4807
+ actions = (
4808
+ SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
4809
+ FROM jsonb_each(stored.actions) AS existing
4810
+ WHERE existing.key = ANY(${keep}::text[])
4811
+ ) || (
4812
+ SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
4813
+ FROM jsonb_each(EXCLUDED.actions) AS fresh
4814
+ WHERE NOT stored.actions ? fresh.key
4815
+ ),
4816
+ updated_at = EXCLUDED.updated_at,
4817
+ expires_at = EXCLUDED.expires_at`;
4818
+ await sql `
4819
+ SELECT slotlock.register_assistant_tenant(
4820
+ ${params.tenantRef}::text,
4821
+ statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
4822
+ },
4823
+ async settleAssistantAction(params) {
4824
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4825
+ return null;
4826
+ const patch = sql.json(params.patch);
4827
+ const [row] = await sql `
4828
+ UPDATE slotlock.assistant_conversations
4829
+ SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
4830
+ (actions -> ${params.actionId}::text) || ${patch}::jsonb)
4831
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
4832
+ AND expires_at > statement_timestamp()
4833
+ AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
4834
+ RETURNING actions -> ${params.actionId}::text AS action`;
4835
+ return row?.action ?? null;
4836
+ },
4837
+ async pruneAssistantConversations(params) {
4838
+ if (!isValidIdentity(params.tenantRef))
4839
+ return 0;
4840
+ const limit = params.limit ?? 1_000;
4841
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
4842
+ throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
4843
+ }
4844
+ const rows = await sql `
4845
+ DELETE FROM slotlock.assistant_conversations
4846
+ WHERE tenant_ref = ${params.tenantRef}
4847
+ AND member_ref IN (
4848
+ SELECT member_ref FROM slotlock.assistant_conversations
4849
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
4850
+ LIMIT ${limit})
4851
+ AND expires_at <= statement_timestamp()
4852
+ RETURNING 1`;
4853
+ return rows.length;
4854
+ },
4855
+ async sweepAssistantConversations(params) {
4856
+ const limit = params?.limit ?? 10_000;
4857
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
4858
+ throw invalidIdentityError('Slotlock assistant sweep limit must be 1-100,000');
4859
+ }
4860
+ const [row] = await sql `
4861
+ SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
4862
+ return Number(row?.deleted ?? 0);
4863
+ },
4864
+ async deleteAssistantConversation(params) {
4865
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4866
+ return false;
4867
+ const rows = await sql `
4868
+ DELETE FROM slotlock.assistant_conversations
4869
+ WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
4870
+ return rows.length > 0;
4871
+ },
4528
4872
  async offerWaitlist(params) {
4529
4873
  if (!isValidIdentity(params.tenantRef)) {
4530
4874
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -4584,18 +4928,28 @@ function buildSlotlockStore(sql, options, refusal) {
4584
4928
  continue;
4585
4929
  // Only resources whose window looks free are tried: a hold refused keeps its locks
4586
4930
  // until the transaction ends, so trying every member of a full pool would pile them up.
4931
+ // A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
4587
4932
  const candidates = await tx `
4588
- SELECT resource.id FROM slotlock.resources resource
4589
- WHERE resource.tenant_ref = ${tenantRef} AND resource.archived_at IS NULL
4590
- AND (resource.id = ${entry.resource_id}::uuid
4591
- OR (${entry.resource_id}::uuid IS NULL AND resource.pool_ref = ${entry.pool_ref}))
4933
+ SELECT resources.id
4934
+ FROM slotlock.resources,
4935
+ LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective,
4936
+ LATERAL (SELECT
4937
+ COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0)
4938
+ AS before_minutes,
4939
+ COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 1440)::int, 0)
4940
+ AS after_minutes) buffers
4941
+ WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
4942
+ AND (resources.id = ${entry.resource_id}::uuid
4943
+ OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
4592
4944
  AND NOT EXISTS (
4593
4945
  SELECT 1 FROM slotlock.reservations taken
4594
- WHERE taken.resource_id = resource.id
4595
- AND tstzrange(taken.starts_at, COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
4596
- && tstzrange(${entry.starts_at}::timestamptz, ${entry.ends_at}::timestamptz, '[)')
4946
+ WHERE taken.resource_id = resources.id
4947
+ AND tstzrange(COALESCE(taken.occupied_starts_at, taken.starts_at),
4948
+ COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
4949
+ && tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
4950
+ ${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
4597
4951
  AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
4598
- ORDER BY resource.id
4952
+ ORDER BY resources.id
4599
4953
  LIMIT ${OFFER_CANDIDATES}`;
4600
4954
  for (const { id: resourceId } of candidates) {
4601
4955
  const hold = await inner.acquireHold({
@@ -4660,23 +5014,26 @@ function buildSlotlockStore(sql, options, refusal) {
4660
5014
  });
4661
5015
  }
4662
5016
  const owner = await sql `
4663
- SELECT id, COALESCE((booking_rules->>'requireCertain')::boolean, false) AS require_certain
4664
- FROM slotlock.resources
5017
+ SELECT id, COALESCE((effective.effective_booking_rules->>'requireCertain')::boolean, false)
5018
+ AS require_certain
5019
+ FROM slotlock.resources,
5020
+ LATERAL (SELECT ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
4665
5021
  WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
4666
5022
  if (!owner[0]) {
4667
5023
  throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
4668
5024
  code: 'resource_tenant_mismatch',
4669
5025
  });
4670
5026
  }
5027
+ // Each row's occupied time: its buffers are as busy as the booking.
4671
5028
  const busyRows = await sql `
4672
- SELECT reservation.starts_at,
5029
+ SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
4673
5030
  COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
4674
5031
  FROM slotlock.reservations reservation
4675
5032
  WHERE reservation.resource_id = ${params.resourceId}
4676
- AND reservation.starts_at < ${window.end}
5033
+ AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
4677
5034
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
4678
5035
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
4679
- ORDER BY reservation.starts_at
5036
+ ORDER BY 1
4680
5037
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
4681
5038
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
4682
5039
  throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
@@ -4795,7 +5152,7 @@ function buildSlotlockStore(sql, options, refusal) {
4795
5152
  throw notFound();
4796
5153
  const [row] = await sql `
4797
5154
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
4798
- archived_at, statement_timestamp() AS now
5155
+ archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
4799
5156
  FROM slotlock.resources
4800
5157
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
4801
5158
  if (!row)
@@ -4804,9 +5161,10 @@ function buildSlotlockStore(sql, options, refusal) {
4804
5161
  return null;
4805
5162
  const resource = toResource(row);
4806
5163
  const rules = params.rules ?? resource.availabilityRules ?? [];
4807
- // Only what a booking made now may take: the resource's booking rules bound the search.
5164
+ // Only what a booking made now may take: the resource's booking rules (as they apply) bound
5165
+ // the search.
4808
5166
  const search = bookingSearch({
4809
- rules: resource.bookingRules,
5167
+ rules: resource.effectiveBookingRules,
4810
5168
  timezone: resource.timezone,
4811
5169
  window: asked,
4812
5170
  now: row.now,
@@ -4814,9 +5172,9 @@ function buildSlotlockStore(sql, options, refusal) {
4814
5172
  });
4815
5173
  if (!search)
4816
5174
  return null;
4817
- const { window, bufferAfterMs } = search;
4818
- // A slot's buffer may run past the window: the time it can reach must be proven free too.
4819
- const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
5175
+ const { window } = search;
5176
+ // A slot's buffers may run past the window: the time they can reach must be proven free too.
5177
+ const reach = bookingSearchReach(search);
4820
5178
  let busy;
4821
5179
  if (tenantRef === null) {
4822
5180
  // No event, coverage or series can belong to a resource with no tenant: only its rows.
@@ -4824,7 +5182,11 @@ function buildSlotlockStore(sql, options, refusal) {
4824
5182
  }
4825
5183
  else {
4826
5184
  busy = [];
4827
- const parts = bufferAfterMs > 0 ? [window, { start: window.end, end: reach.end }] : [window];
5185
+ const parts = [
5186
+ ...(reach.start < window.start ? [{ start: reach.start, end: window.start }] : []),
5187
+ window,
5188
+ ...(reach.end > window.end ? [{ start: window.end, end: reach.end }] : []),
5189
+ ];
4828
5190
  for (const part of parts) {
4829
5191
  const freeBusy = await this.getFreeBusy({
4830
5192
  tenantRef,
@@ -4843,12 +5205,8 @@ function buildSlotlockStore(sql, options, refusal) {
4843
5205
  busy.push(...freeBusy.busy);
4844
5206
  }
4845
5207
  }
4846
- // A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
4847
5208
  return findNextAvailable({
4848
- busy: busy.map((interval) => ({
4849
- start: new Date(interval.start.getTime() - bufferAfterMs),
4850
- end: interval.end,
4851
- })),
5209
+ busy: bookingSearchBlocked(busy, search),
4852
5210
  windows: expandRules(rules, window, resource.timezone),
4853
5211
  durationMs: params.durationMs,
4854
5212
  ...(search.align ? { align: search.align } : {}),