slotlock 0.5.2 → 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',
@@ -2151,6 +2226,16 @@ function buildSlotlockStore(sql, options, refusal) {
2151
2226
  code: 'invalid_identity',
2152
2227
  });
2153
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
+ }
2154
2239
  const release = await tenantSlots?.(tenantRef, options?.signal);
2155
2240
  // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
2156
2241
  // another error in its place: noted here as it happens.
@@ -2167,6 +2252,8 @@ function buildSlotlockStore(sql, options, refusal) {
2167
2252
  const previous = await tx `
2168
2253
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2169
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,
2170
2257
  current_setting('transaction_isolation') AS isolation,
2171
2258
  pg_advisory_xact_lock_shared(
2172
2259
  hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
@@ -2174,6 +2261,9 @@ function buildSlotlockStore(sql, options, refusal) {
2174
2261
  const [context] = await tx `
2175
2262
  SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
2176
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,
2177
2267
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2178
2268
  throw suspensionCheckRefusal(error);
2179
2269
  });
@@ -2195,7 +2285,9 @@ function buildSlotlockStore(sql, options, refusal) {
2195
2285
  }
2196
2286
  finally {
2197
2287
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2198
- 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)`;
2199
2291
  }
2200
2292
  }, 'read committed')
2201
2293
  .catch((error) => {
@@ -2256,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
2256
2348
  });
2257
2349
  }
2258
2350
  const rows = await sql `
2259
- 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)}
2260
2352
  FROM slotlock.resources
2261
2353
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2262
2354
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
@@ -2274,7 +2366,7 @@ function buildSlotlockStore(sql, options, refusal) {
2274
2366
  UPDATE slotlock.resources
2275
2367
  SET availability_rules = ${rules}::jsonb
2276
2368
  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`;
2369
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2278
2370
  const row = rows[0];
2279
2371
  return row ? toResource(row) : null;
2280
2372
  },
@@ -2306,10 +2398,74 @@ function buildSlotlockStore(sql, options, refusal) {
2306
2398
  END
2307
2399
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2308
2400
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2309
- visibility, archived_at`;
2401
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2310
2402
  const row = rows[0];
2311
2403
  return row ? toResource(row) : null;
2312
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
+ },
2313
2469
  async setResourcePool(params) {
2314
2470
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2315
2471
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
@@ -2322,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
2322
2478
  UPDATE slotlock.resources SET pool_ref = ${params.pool}
2323
2479
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2324
2480
  RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2325
- visibility, archived_at`;
2481
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2326
2482
  const row = rows[0];
2327
2483
  return row ? toResource(row) : null;
2328
2484
  },
@@ -2333,7 +2489,7 @@ function buildSlotlockStore(sql, options, refusal) {
2333
2489
  throw invalidPoolError();
2334
2490
  const rows = await sql `
2335
2491
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2336
- visibility, archived_at
2492
+ visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2337
2493
  FROM slotlock.resources
2338
2494
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2339
2495
  AND archived_at IS NULL
@@ -2386,7 +2542,7 @@ function buildSlotlockStore(sql, options, refusal) {
2386
2542
  });
2387
2543
  }
2388
2544
  const rows = await sql `
2389
- 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)}
2390
2546
  FROM slotlock.resources
2391
2547
  WHERE tenant_ref = ${params.tenantRef}
2392
2548
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
@@ -2406,7 +2562,7 @@ function buildSlotlockStore(sql, options, refusal) {
2406
2562
  UPDATE slotlock.resources
2407
2563
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2408
2564
  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`;
2565
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2410
2566
  return row ? toResource(row) : null;
2411
2567
  },
2412
2568
  async restoreResource(params) {
@@ -2417,7 +2573,7 @@ function buildSlotlockStore(sql, options, refusal) {
2417
2573
  UPDATE slotlock.resources
2418
2574
  SET archived_at = NULL
2419
2575
  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`;
2576
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2421
2577
  return row ? toResource(row) : null;
2422
2578
  },
2423
2579
  async deleteResource(params) {
@@ -2465,6 +2621,9 @@ function buildSlotlockStore(sql, options, refusal) {
2465
2621
  // A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
2466
2622
  // cancelled and moved like any other; one on a resource with no tenant stays tenantless.
2467
2623
  const id = randomUUID();
2624
+ // The buffers written, for the overlap answer if the write loses.
2625
+ let bufferBeforeMs = 0;
2626
+ let bufferAfterMs = 0;
2468
2627
  for (let attempt = 1;; attempt++) {
2469
2628
  try {
2470
2629
  return await inTransaction(sql, async (tx) => {
@@ -2485,24 +2644,27 @@ function buildSlotlockStore(sql, options, refusal) {
2485
2644
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2486
2645
  if (ruling.rule)
2487
2646
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2488
- const occupied = new Date(params.end.getTime() + ruling.turnaroundMs);
2647
+ bufferBeforeMs = ruling.bufferBeforeMs;
2648
+ bufferAfterMs = ruling.turnaroundMs;
2649
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2489
2650
  if (resource.tenant_ref !== null &&
2490
2651
  !(await recurrenceCoversWrite(tx, {
2491
2652
  tenantRef: resource.tenant_ref,
2492
2653
  resourceId: params.resourceId,
2493
- start: params.start,
2494
- end: occupied,
2654
+ ...occupied,
2495
2655
  }))) {
2496
2656
  return { ok: false, code: 'recurrence_not_materialized' };
2497
2657
  }
2498
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2658
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2499
2659
  const [row] = await tx `
2500
2660
  INSERT INTO slotlock.reservations (
2501
- 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
2502
2663
  ) VALUES (
2503
2664
  ${id}, ${params.resourceId}, ${resource.tenant_ref},
2504
2665
  ${resource.tenant_ref === null ? null : id},
2505
- ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${params.source ?? null}
2666
+ ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
2667
+ ${params.source ?? null}
2506
2668
  )
2507
2669
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2508
2670
  buffer_after_ms, revision, source`;
@@ -2523,8 +2685,9 @@ function buildSlotlockStore(sql, options, refusal) {
2523
2685
  }
2524
2686
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2525
2687
  // 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);
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);
2528
2691
  }
2529
2692
  }
2530
2693
  },
@@ -2535,6 +2698,7 @@ function buildSlotlockStore(sql, options, refusal) {
2535
2698
  if (params.revision !== 1)
2536
2699
  return { ok: false, code: 'invalid_revision' };
2537
2700
  let written = params.bufferAfterMs;
2701
+ let writtenBefore = 0;
2538
2702
  for (let attempt = 1;; attempt++) {
2539
2703
  try {
2540
2704
  return await inTransaction(sql, async (tx) => {
@@ -2579,23 +2743,24 @@ function buildSlotlockStore(sql, options, refusal) {
2579
2743
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2580
2744
  const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2581
2745
  written = bufferAfterMs;
2582
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2746
+ writtenBefore = ruling.bufferBeforeMs;
2747
+ const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
2583
2748
  if (!(await recurrenceCoversWrite(tx, {
2584
2749
  tenantRef: params.tenantRef,
2585
2750
  resourceId: params.resourceId,
2586
- start: params.start,
2587
- end: occupied,
2751
+ ...occupied,
2588
2752
  }))) {
2589
2753
  return { ok: false, code: 'recurrence_not_materialized' };
2590
2754
  }
2591
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2755
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2592
2756
  const rows = await tx `
2593
2757
  INSERT INTO slotlock.reservations (
2594
2758
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2595
- revision, source
2759
+ buffer_before_ms, revision, source
2596
2760
  ) VALUES (
2597
2761
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
2598
- ${params.end}, ${bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2762
+ ${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
2763
+ ${params.source ?? null}
2599
2764
  )
2600
2765
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2601
2766
  buffer_after_ms, revision, source`;
@@ -2615,8 +2780,9 @@ function buildSlotlockStore(sql, options, refusal) {
2615
2780
  code !== SERIALIZATION_FAILURE) {
2616
2781
  throw err;
2617
2782
  }
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);
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);
2620
2786
  }
2621
2787
  }
2622
2788
  },
@@ -2626,6 +2792,7 @@ function buildSlotlockStore(sql, options, refusal) {
2626
2792
  return invalid;
2627
2793
  let currentReservationId;
2628
2794
  let bufferAfterMs = params.bufferAfterMs;
2795
+ let bufferBeforeMs = 0;
2629
2796
  for (let attempt = 1;; attempt++) {
2630
2797
  try {
2631
2798
  return await inTransaction(sql, async (tx) => {
@@ -2668,21 +2835,23 @@ function buildSlotlockStore(sql, options, refusal) {
2668
2835
  if (ruling.rule)
2669
2836
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2670
2837
  bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2838
+ bufferBeforeMs = ruling.bufferBeforeMs;
2839
+ const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
2671
2840
  if (!(await recurrenceCoversWrite(tx, {
2672
2841
  tenantRef: params.tenantRef,
2673
2842
  resourceId: params.resourceId,
2674
- start: params.start,
2675
- end: occupiedEnd({ end: params.end, bufferAfterMs }),
2843
+ ...occupied,
2676
2844
  }))) {
2677
2845
  return { ok: false, code: 'recurrence_not_materialized' };
2678
2846
  }
2679
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs }));
2847
+ await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2680
2848
  const rows = await tx `
2681
2849
  UPDATE slotlock.reservations
2682
2850
  SET resource_id = ${params.resourceId},
2683
2851
  starts_at = ${params.start},
2684
2852
  ends_at = ${params.end},
2685
2853
  buffer_after_ms = ${bufferAfterMs},
2854
+ buffer_before_ms = ${bufferBeforeMs},
2686
2855
  revision = ${params.revision},
2687
2856
  source = ${params.source ?? null},
2688
2857
  -- A check-in is for the time it was made for: a move to another start clears it.
@@ -2709,9 +2878,9 @@ function buildSlotlockStore(sql, options, refusal) {
2709
2878
  code !== SERIALIZATION_FAILURE) {
2710
2879
  throw err;
2711
2880
  }
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);
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);
2715
2884
  }
2716
2885
  }
2717
2886
  },
@@ -2825,8 +2994,10 @@ function buildSlotlockStore(sql, options, refusal) {
2825
2994
  ...(holderRef !== undefined ? { holderRef } : {}),
2826
2995
  });
2827
2996
  const ttlSeconds = params.ttlMs / 1000;
2828
- // 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.
2829
2999
  let written = bufferAfterMs;
3000
+ let writtenBefore = 0;
2830
3001
  for (let attempt = 1;; attempt++) {
2831
3002
  try {
2832
3003
  return await inTransaction(sql, async (tx) => {
@@ -2875,7 +3046,8 @@ function buildSlotlockStore(sql, options, refusal) {
2875
3046
  const [held] = await tx `
2876
3047
  SELECT count(*)::int AS count,
2877
3048
  COALESCE(sum(extract(epoch FROM
2878
- 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
2879
3051
  AS seconds
2880
3052
  FROM slotlock.reservations
2881
3053
  WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
@@ -2891,10 +3063,11 @@ function buildSlotlockStore(sql, options, refusal) {
2891
3063
  if (ruling.rule)
2892
3064
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2893
3065
  written = Math.max(bufferAfterMs, ruling.turnaroundMs);
2894
- const reach = occupiedEnd({ end: params.end, bufferAfterMs: written });
3066
+ writtenBefore = ruling.bufferBeforeMs;
3067
+ const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
2895
3068
  if (heldSeconds !== null &&
2896
3069
  maxHeldHoursPerHolder !== undefined &&
2897
- heldSeconds + (reach.getTime() - params.start.getTime()) / 1000 >
3070
+ heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
2898
3071
  maxHeldHoursPerHolder * 3_600) {
2899
3072
  return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
2900
3073
  }
@@ -2902,18 +3075,17 @@ function buildSlotlockStore(sql, options, refusal) {
2902
3075
  !(await recurrenceCoversWrite(tx, {
2903
3076
  tenantRef: resource.tenant_ref,
2904
3077
  resourceId: params.resourceId,
2905
- start: params.start,
2906
- end: reach,
3078
+ ...reach,
2907
3079
  }))) {
2908
3080
  return { ok: false, code: 'recurrence_not_materialized' };
2909
3081
  }
2910
- await reapExpiredHolds(tx, params.resourceId, params.start, reach);
3082
+ await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
2911
3083
  const [row] = await tx `
2912
3084
  INSERT INTO slotlock.reservations (
2913
- resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source,
2914
- 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
2915
3087
  ) VALUES (
2916
- ${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
3088
+ ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
2917
3089
  statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
2918
3090
  ${holderRef ?? null}, ${params.groupId ?? null}
2919
3091
  )
@@ -2947,8 +3119,9 @@ function buildSlotlockStore(sql, options, refusal) {
2947
3119
  }
2948
3120
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2949
3121
  // 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);
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);
2952
3125
  }
2953
3126
  }
2954
3127
  },
@@ -3200,15 +3373,16 @@ function buildSlotlockStore(sql, options, refusal) {
3200
3373
  // Expired holds are dead occupation: excluded here so findNextAvailable can offer their
3201
3374
  // space (the acquiring transaction reaps the row itself when it takes the window).
3202
3375
  const rows = await sql `
3203
- 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
3204
3378
  FROM slotlock.reservations
3205
3379
  WHERE resource_id = ${resourceId}
3206
- AND starts_at < ${window.end}
3380
+ AND ${occupiedStartsBefore(sql, window.end)}
3207
3381
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3208
3382
  AND (status <> 'held' OR expires_at > statement_timestamp())
3209
- ORDER BY starts_at
3383
+ ORDER BY COALESCE(occupied_starts_at, starts_at)
3210
3384
  LIMIT ${options.limit ?? null}`;
3211
- 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 }));
3212
3386
  },
3213
3387
  async putCalendarEvent(params) {
3214
3388
  const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
@@ -3266,6 +3440,8 @@ function buildSlotlockStore(sql, options, refusal) {
3266
3440
  });
3267
3441
  const recurrenceEndsBy = recurrenceEndsByColumn(event);
3268
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 };
3269
3445
  let storedEventId;
3270
3446
  for (let attempt = 1;; attempt++) {
3271
3447
  try {
@@ -3403,6 +3579,7 @@ function buildSlotlockStore(sql, options, refusal) {
3403
3579
  AND hold_group_id = ${hold.hold_group_id}::uuid))`;
3404
3580
  }
3405
3581
  let turnaroundMs = 0;
3582
+ let bufferBeforeMs = 0;
3406
3583
  if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
3407
3584
  const judge = await bookingJudge(tx, params.resourceId, defaultHours);
3408
3585
  for (const [index, occurrence] of occurrences.entries()) {
@@ -3412,9 +3589,11 @@ function buildSlotlockStore(sql, options, refusal) {
3412
3589
  return { ok: false, code: 'booking_rule_violated', rule };
3413
3590
  }
3414
3591
  turnaroundMs = judge.turnaroundMs;
3592
+ bufferBeforeMs = judge.bufferBeforeMs;
3415
3593
  }
3594
+ attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
3416
3595
  const span = occurrenceSpan(occurrences);
3417
- const reach = span && { start: span.start, end: new Date(span.end.getTime() + turnaroundMs) };
3596
+ const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
3418
3597
  if ((event.transparency ?? 'opaque') === 'opaque' &&
3419
3598
  reach &&
3420
3599
  !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
@@ -3452,6 +3631,7 @@ function buildSlotlockStore(sql, options, refusal) {
3452
3631
  revision = ${revision},
3453
3632
  source = ${params.source ?? null},
3454
3633
  buffer_after_ms = ${turnaroundMs},
3634
+ buffer_before_ms = ${bufferBeforeMs},
3455
3635
  updated_at = now()
3456
3636
  WHERE id = ${eventId}
3457
3637
  AND tenant_ref = ${params.tenantRef}
@@ -3464,7 +3644,7 @@ function buildSlotlockStore(sql, options, refusal) {
3464
3644
  summary, description, location, status, transparency, organizer, attendees,
3465
3645
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3466
3646
  materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
3467
- buffer_after_ms
3647
+ buffer_after_ms, buffer_before_ms
3468
3648
  ) VALUES (
3469
3649
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
3470
3650
  ${params.resourceId},
@@ -3478,7 +3658,7 @@ function buildSlotlockStore(sql, options, refusal) {
3478
3658
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
3479
3659
  ${materializationWindow.start}, ${materializationWindow.end},
3480
3660
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3481
- ${revision}, ${params.source ?? null}, ${turnaroundMs}
3661
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
3482
3662
  )`;
3483
3663
  }
3484
3664
  // The normalized children are the queryable/canonical representation. The JSON
@@ -3492,6 +3672,7 @@ function buildSlotlockStore(sql, options, refusal) {
3492
3672
  occurrences,
3493
3673
  revision,
3494
3674
  bufferAfterMs: turnaroundMs,
3675
+ bufferBeforeMs,
3495
3676
  });
3496
3677
  await tx `
3497
3678
  INSERT INTO slotlock.calendar_event_commands (
@@ -3522,11 +3703,12 @@ function buildSlotlockStore(sql, options, refusal) {
3522
3703
  code !== SERIALIZATION_FAILURE) {
3523
3704
  throw error;
3524
3705
  }
3525
- const reach = occurrenceSpan(attempted);
3526
- if (reach) {
3706
+ const span = occurrenceSpan(attempted);
3707
+ if (span) {
3708
+ const reach = occupiedTime({ ...span, ...attemptedBuffers });
3527
3709
  await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
3528
3710
  }
3529
- return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
3711
+ return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
3530
3712
  }
3531
3713
  }
3532
3714
  },
@@ -3932,7 +4114,9 @@ function buildSlotlockStore(sql, options, refusal) {
3932
4114
  slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
3933
4115
  slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
3934
4116
  COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
3935
- 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
3936
4120
  FROM slotlock.calendar_events event
3937
4121
  WHERE event.tenant_ref = ${params.tenantRef}
3938
4122
  AND event.recurrence_rule IS NULL
@@ -3986,6 +4170,7 @@ function buildSlotlockStore(sql, options, refusal) {
3986
4170
  ],
3987
4171
  revision,
3988
4172
  bufferAfterMs: Number(event.buffer),
4173
+ bufferBeforeMs: Number(event.buffer_before),
3989
4174
  });
3990
4175
  });
3991
4176
  moved++;
@@ -4298,6 +4483,7 @@ function buildSlotlockStore(sql, options, refusal) {
4298
4483
  ORDER BY source`;
4299
4484
  return rows.map((row) => ({
4300
4485
  source: row.source,
4486
+ sourceId: slotlockCoverageSourceId(params.tenantRef, row.source),
4301
4487
  start: row.starts_at,
4302
4488
  end: row.ends_at,
4303
4489
  revision: Number(row.revision),
@@ -4321,15 +4507,15 @@ function buildSlotlockStore(sql, options, refusal) {
4321
4507
  AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4322
4508
  ORDER BY observed_at LIMIT ${params.limit ?? 100}
4323
4509
  FOR UPDATE SKIP LOCKED)
4324
- 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
4325
4511
  ), reported AS (
4326
4512
  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)),
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),
4331
4517
  'slotlock:maintenance'
4332
- FROM stale
4518
+ FROM (SELECT resource_id, observed_at, ${sql.unsafe(COVERAGE_SOURCE_ID)} AS source_id FROM stale) named
4333
4519
  RETURNING 1
4334
4520
  )
4335
4521
  SELECT count(*)::int AS count FROM reported`;
@@ -4742,18 +4928,28 @@ function buildSlotlockStore(sql, options, refusal) {
4742
4928
  continue;
4743
4929
  // Only resources whose window looks free are tried: a hold refused keeps its locks
4744
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.
4745
4932
  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}))
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}))
4750
4944
  AND NOT EXISTS (
4751
4945
  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, '[)')
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), '[)')
4755
4951
  AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
4756
- ORDER BY resource.id
4952
+ ORDER BY resources.id
4757
4953
  LIMIT ${OFFER_CANDIDATES}`;
4758
4954
  for (const { id: resourceId } of candidates) {
4759
4955
  const hold = await inner.acquireHold({
@@ -4818,23 +5014,26 @@ function buildSlotlockStore(sql, options, refusal) {
4818
5014
  });
4819
5015
  }
4820
5016
  const owner = await sql `
4821
- SELECT id, COALESCE((booking_rules->>'requireCertain')::boolean, false) AS require_certain
4822
- 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
4823
5021
  WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
4824
5022
  if (!owner[0]) {
4825
5023
  throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
4826
5024
  code: 'resource_tenant_mismatch',
4827
5025
  });
4828
5026
  }
5027
+ // Each row's occupied time: its buffers are as busy as the booking.
4829
5028
  const busyRows = await sql `
4830
- SELECT reservation.starts_at,
5029
+ SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
4831
5030
  COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
4832
5031
  FROM slotlock.reservations reservation
4833
5032
  WHERE reservation.resource_id = ${params.resourceId}
4834
- AND reservation.starts_at < ${window.end}
5033
+ AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
4835
5034
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
4836
5035
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
4837
- ORDER BY reservation.starts_at
5036
+ ORDER BY 1
4838
5037
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
4839
5038
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
4840
5039
  throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
@@ -4953,7 +5152,7 @@ function buildSlotlockStore(sql, options, refusal) {
4953
5152
  throw notFound();
4954
5153
  const [row] = await sql `
4955
5154
  SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
4956
- archived_at, statement_timestamp() AS now
5155
+ archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
4957
5156
  FROM slotlock.resources
4958
5157
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
4959
5158
  if (!row)
@@ -4962,9 +5161,10 @@ function buildSlotlockStore(sql, options, refusal) {
4962
5161
  return null;
4963
5162
  const resource = toResource(row);
4964
5163
  const rules = params.rules ?? resource.availabilityRules ?? [];
4965
- // 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.
4966
5166
  const search = bookingSearch({
4967
- rules: resource.bookingRules,
5167
+ rules: resource.effectiveBookingRules,
4968
5168
  timezone: resource.timezone,
4969
5169
  window: asked,
4970
5170
  now: row.now,
@@ -4972,9 +5172,9 @@ function buildSlotlockStore(sql, options, refusal) {
4972
5172
  });
4973
5173
  if (!search)
4974
5174
  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) };
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);
4978
5178
  let busy;
4979
5179
  if (tenantRef === null) {
4980
5180
  // No event, coverage or series can belong to a resource with no tenant: only its rows.
@@ -4982,7 +5182,11 @@ function buildSlotlockStore(sql, options, refusal) {
4982
5182
  }
4983
5183
  else {
4984
5184
  busy = [];
4985
- 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
+ ];
4986
5190
  for (const part of parts) {
4987
5191
  const freeBusy = await this.getFreeBusy({
4988
5192
  tenantRef,
@@ -5001,12 +5205,8 @@ function buildSlotlockStore(sql, options, refusal) {
5001
5205
  busy.push(...freeBusy.busy);
5002
5206
  }
5003
5207
  }
5004
- // A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
5005
5208
  return findNextAvailable({
5006
- busy: busy.map((interval) => ({
5007
- start: new Date(interval.start.getTime() - bufferAfterMs),
5008
- end: interval.end,
5009
- })),
5209
+ busy: bookingSearchBlocked(busy, search),
5010
5210
  windows: expandRules(rules, window, resource.timezone),
5011
5211
  durationMs: params.durationMs,
5012
5212
  ...(search.align ? { align: search.align } : {}),