slotlock 0.3.0 → 0.5.1

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,6 +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
6
  import { createHash, randomUUID } from 'node:crypto';
5
7
  import ICAL from 'ical.js';
6
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';
@@ -48,6 +50,19 @@ const MAX_COVERAGE_SOURCES = 64;
48
50
  const MAX_COVERAGE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
49
51
  /** Trusted provider/maintenance namespace used by backwards-compatible store callers. */
50
52
  export const SLOTLOCK_INTERNAL_EVENT_OWNER_REF = 'internal';
53
+ /** How many live holds one holder (a keyed hold's principal) may have in a tenant at once. */
54
+ export const SLOTLOCK_MAX_HOLDS_PER_HOLDER = 20;
55
+ /** Windows one holder may wait for at once (joinWaitlist). */
56
+ export const SLOTLOCK_MAX_WAITLIST_PER_HOLDER = 20;
57
+ /** Waiting entries one tenant may have. */
58
+ export const SLOTLOCK_MAX_WAITLIST_PER_TENANT = 10_000;
59
+ /** The longest window one waitlist entry may wait for. */
60
+ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
61
+ const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
62
+ /** How long before its start a booking may be checked in. */
63
+ export const SLOTLOCK_CHECK_IN_OPENS_MINUTES = 60;
64
+ /** Free-looking resources one entry tries before the next entry: a race can take one first. */
65
+ const OFFER_CANDIDATES = 3;
51
66
  /** Default hard ceiling for retained active + tombstoned identities per agent principal. */
52
67
  export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
53
68
  /** Hard retained command ceiling; includes reserved cancellation capacity for every identity. */
@@ -621,6 +636,7 @@ function toHold(row) {
621
636
  bufferAfterMs: Number(row.buffer_after_ms),
622
637
  expiresAt: row.expires_at,
623
638
  source: row.source,
639
+ ...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
624
640
  };
625
641
  }
626
642
  /** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
@@ -854,7 +870,8 @@ async function replaceCalendarEventOccurrences(tx, params) {
854
870
  for (const occurrence of params.occurrences) {
855
871
  let reservationId = null;
856
872
  if ((params.event.transparency ?? 'opaque') === 'opaque') {
857
- await reapExpiredHolds(tx, params.resourceId, occurrence.start, occurrence.end);
873
+ const occupied = new Date(occurrence.end.getTime() + (params.bufferAfterMs ?? 0));
874
+ await reapExpiredHolds(tx, params.resourceId, occurrence.start, occupied);
858
875
  const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
859
876
  .update(occurrence.recurrenceId)
860
877
  .digest('hex')}`;
@@ -864,7 +881,7 @@ async function replaceCalendarEventOccurrences(tx, params) {
864
881
  buffer_after_ms, revision, source, calendar_event_id
865
882
  ) VALUES (
866
883
  ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
867
- ${occurrence.start}, ${occurrence.end}, 0, ${params.revision},
884
+ ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0}, ${params.revision},
868
885
  ${`calendar-event:${params.eventId}`}, ${params.eventId}
869
886
  )
870
887
  RETURNING id`;
@@ -918,8 +935,30 @@ function oneOffEventWindow(event, requested) {
918
935
  function calendarCommandHash(value) {
919
936
  return createHash('sha256').update(JSON.stringify(value)).digest('hex');
920
937
  }
938
+ /**
939
+ * What makes a put the same request: its identity and the event it asks for. Not the window its
940
+ * occurrences are stored over, which for a series is the rolling horizon from the current UTC
941
+ * midnight: with it in the hash, a retry after midnight was refused as a different request.
942
+ */
921
943
  function putCommandHash(params) {
944
+ return calendarCommandHash(putCommandValue(params));
945
+ }
946
+ /**
947
+ * The hash 0.3.0 and earlier stored, with the window in it, so a command stored before the upgrade
948
+ * still replays: always for a one-off event, whose window is its own span, and on the same UTC day
949
+ * for a series. Remove once no stored command can be that old (commands are pruned after 30 days).
950
+ */
951
+ export function legacyPutCommandHash(params) {
922
952
  return calendarCommandHash({
953
+ ...putCommandValue(params),
954
+ materializationWindow: {
955
+ start: params.window.start.toISOString(),
956
+ end: params.window.end.toISOString(),
957
+ },
958
+ });
959
+ }
960
+ function putCommandValue(params) {
961
+ return {
923
962
  operation: 'put',
924
963
  ownerRef: params.ownerRef,
925
964
  externalRef: params.command.externalRef,
@@ -927,6 +966,8 @@ function putCommandHash(params) {
927
966
  expectedRevision: params.command.expectedRevision,
928
967
  resourceId: params.command.resourceId,
929
968
  source: params.command.source ?? null,
969
+ // Only when present, so a create without a hold keeps the hash it always had.
970
+ ...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
930
971
  event: {
931
972
  ...params.event,
932
973
  start: params.event.start.toISOString(),
@@ -938,11 +979,7 @@ function putCommandHash(params) {
938
979
  }
939
980
  : null,
940
981
  },
941
- materializationWindow: {
942
- start: params.window.start.toISOString(),
943
- end: params.window.end.toISOString(),
944
- },
945
- });
982
+ };
946
983
  }
947
984
  function cancelCommandHash(params) {
948
985
  return calendarCommandHash({ operation: 'cancel', ...params });
@@ -1040,11 +1077,79 @@ async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
1040
1077
  }
1041
1078
  return { ok: false, code: 'overlap', conflictingReservationId: null };
1042
1079
  }
1043
- function sameExternalPayload(row, params) {
1080
+ /**
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`.
1084
+ */
1085
+ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1086
+ const judge = await bookingJudge(tx, resourceId, defaultHours);
1087
+ return { rule: judge.broken(interval), turnaroundMs: judge.turnaroundMs };
1088
+ }
1089
+ /** How recently a sync must have read a resource whose booking rules require certainty. */
1090
+ const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1091
+ /**
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.
1094
+ */
1095
+ async function bookingJudge(tx, resourceId, defaultHours) {
1096
+ const [row] = await tx `
1097
+ SELECT booking_rules, availability_rules, timezone, statement_timestamp() AS now
1098
+ FROM slotlock.resources WHERE id = ${resourceId}`;
1099
+ if (!row || row.booking_rules === null || row.booking_rules === undefined) {
1100
+ return { broken: () => null, turnaroundMs: 0 };
1101
+ }
1102
+ const { bookingRules: rules, availabilityRules } = toResource({
1103
+ id: resourceId,
1104
+ external_ref: null,
1105
+ tenant_ref: null,
1106
+ timezone: row.timezone,
1107
+ availability_rules: row.availability_rules,
1108
+ booking_rules: row.booking_rules,
1109
+ pool_ref: null,
1110
+ visibility: 'shared',
1111
+ archived_at: null,
1112
+ });
1113
+ const hours = availabilityRules ?? defaultHours;
1114
+ // Every sync covering the resource, and whether it was read within a day, by the same clock.
1115
+ const coverage = rules?.requireCertain === true
1116
+ ? await tx `
1117
+ SELECT starts_at, ends_at,
1118
+ observed_at >= statement_timestamp()
1119
+ - make_interval(secs => ${CERTAIN_COVERAGE_MAX_AGE_MS / 1000}) AS fresh
1120
+ FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
1121
+ : [];
1122
+ const turnaroundMs = bookingBufferAfterMs(rules);
1123
+ // The booking and the turnaround it keeps, as a search judges the slot.
1124
+ const uncertain = (interval) => coverage.some((source) => !source.fresh ||
1125
+ source.starts_at > interval.start ||
1126
+ source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
1127
+ // A later occurrence of a series is judged on its step and hours only: notice and advance are
1128
+ // about when the booking was made, which its first occurrence answers.
1129
+ const later = rules === undefined
1130
+ ? undefined
1131
+ : {
1132
+ ...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
1133
+ ...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
1134
+ };
1135
+ return {
1136
+ broken: (interval, checks = { first: true }) => bookingRuleBroken({
1137
+ rules: checks.first ? rules : later,
1138
+ timezone: row.timezone,
1139
+ hours,
1140
+ interval,
1141
+ now: row.now,
1142
+ }) ?? (uncertain(interval) ? 'uncertain' : null),
1143
+ turnaroundMs,
1144
+ };
1145
+ }
1146
+ function sameExternalPayload(row, params, turnaroundMs = 0) {
1147
+ const buffer = Number(row.buffer_after_ms);
1044
1148
  return (row.resource_id === params.resourceId &&
1045
1149
  row.starts_at.getTime() === params.start.getTime() &&
1046
1150
  row.ends_at.getTime() === params.end.getTime() &&
1047
- Number(row.buffer_after_ms) === params.bufferAfterMs &&
1151
+ // As asked, or raised to the resource's turnaround when it was stored.
1152
+ (buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
1048
1153
  Number(row.revision) === params.revision &&
1049
1154
  row.source === (params.source ?? null));
1050
1155
  }
@@ -1078,6 +1183,33 @@ function pgCode(err) {
1078
1183
  }
1079
1184
  return undefined;
1080
1185
  }
1186
+ /** Members a pool booking or search considers at most, in id order. */
1187
+ export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
1188
+ 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
+ function toWaitlistEntry(row) {
1190
+ return {
1191
+ id: row.id,
1192
+ resourceId: row.resource_id,
1193
+ pool: row.pool_ref,
1194
+ start: row.starts_at,
1195
+ end: row.ends_at,
1196
+ holderRef: row.holder_ref,
1197
+ offerTtlMs: Number(row.offer_ttl_ms),
1198
+ status: row.status,
1199
+ holdId: row.hold_id,
1200
+ offeredResourceId: row.offered_resource_id,
1201
+ offeredAt: row.offered_at,
1202
+ createdAt: row.created_at,
1203
+ };
1204
+ }
1205
+ export function isPoolName(value) {
1206
+ return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,99}$/.test(value);
1207
+ }
1208
+ function invalidPoolError() {
1209
+ return Object.assign(new Error('Slotlock pool must be 1-100 of a-z, 0-9, ".", "_" and "-"'), {
1210
+ code: 'invalid_pool',
1211
+ });
1212
+ }
1081
1213
  function toResource(row) {
1082
1214
  const resource = {
1083
1215
  id: row.id,
@@ -1088,6 +1220,8 @@ function toResource(row) {
1088
1220
  };
1089
1221
  if (row.archived_at !== null)
1090
1222
  resource.archivedAt = row.archived_at;
1223
+ if (row.pool_ref !== null && row.pool_ref !== undefined)
1224
+ resource.pool = row.pool_ref;
1091
1225
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
1092
1226
  // Written through setResourceAvailability, which validates; anything else reads as closed.
1093
1227
  try {
@@ -1097,6 +1231,16 @@ function toResource(row) {
1097
1231
  resource.availabilityRules = [];
1098
1232
  }
1099
1233
  }
1234
+ if (row.booking_rules !== null && row.booking_rules !== undefined) {
1235
+ // Written through setResourceBookingRules, which validates; anything else reads as refusing
1236
+ // every booking, never as no rule.
1237
+ try {
1238
+ resource.bookingRules = parseBookingRules(row.booking_rules);
1239
+ }
1240
+ catch {
1241
+ resource.bookingRules = { maxAdvanceDays: 1, minNoticeMinutes: 525_600 };
1242
+ }
1243
+ }
1100
1244
  return resource;
1101
1245
  }
1102
1246
  /** Validated rules as a jsonb parameter, or null for "use the default". */
@@ -1124,7 +1268,7 @@ async function upsertResource(executor, params) {
1124
1268
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
1125
1269
  DO UPDATE SET timezone = EXCLUDED.timezone
1126
1270
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1127
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
1271
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1128
1272
  }
1129
1273
  else if (params.externalRef !== undefined) {
1130
1274
  rows = await executor `
@@ -1134,13 +1278,13 @@ async function upsertResource(executor, params) {
1134
1278
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
1135
1279
  DO UPDATE SET external_ref = EXCLUDED.external_ref
1136
1280
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1137
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
1281
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1138
1282
  }
1139
1283
  else {
1140
1284
  rows = await executor `
1141
1285
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1142
1286
  VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1143
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
1287
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
1144
1288
  }
1145
1289
  const row = rows[0];
1146
1290
  if (!row) {
@@ -1163,7 +1307,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1163
1307
  summary, description, location, status, transparency, organizer, attendees,
1164
1308
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
1165
1309
  materialized_ends_at, recurrence_cursor, revision, source, created_at,
1166
- updated_at, recurrence_ends_by IS NULL AS ends_by_missing
1310
+ updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
1167
1311
  FROM slotlock.calendar_events
1168
1312
  WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
1169
1313
  FOR UPDATE`;
@@ -1185,6 +1329,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1185
1329
  event,
1186
1330
  occurrences,
1187
1331
  revision: Number(row.revision),
1332
+ bufferAfterMs: Number(row.buffer_after_ms ?? 0),
1188
1333
  });
1189
1334
  // A series stored before 0.3 learns when it ends at its first roll.
1190
1335
  const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
@@ -1387,8 +1532,8 @@ async function lockTenantLimit(tx, tenantRef, kind) {
1387
1532
  /**
1388
1533
  * Rows a tenant added within this many days count against its limits whatever time they hold, so
1389
1534
  * bookings and events dated in the past, and cancellations, add at most the limit in any such
1390
- * window. Ended history is not pruned: over a longer time it can still grow. The same window
1391
- * maintenance keeps event idempotency records and stray retirements for.
1535
+ * window. Ended history is not pruned: over a longer time it can still grow. Maintenance keeps
1536
+ * event idempotency records and stray retirements at least as long (the store's `retentionDays`).
1392
1537
  */
1393
1538
  const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
1394
1539
  /**
@@ -1513,7 +1658,7 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
1513
1658
  FROM slotlock.reservations hold
1514
1659
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
1515
1660
  WHERE resource.tenant_ref = ${tenantRef}
1516
- AND hold.status = 'held' AND hold.expires_at <= now()
1661
+ AND hold.status = 'held' AND hold.expires_at <= statement_timestamp()
1517
1662
  ORDER BY hold.expires_at, hold.id
1518
1663
  LIMIT ${limit}
1519
1664
  FOR UPDATE OF hold SKIP LOCKED
@@ -1521,7 +1666,7 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
1521
1666
  DELETE FROM slotlock.reservations hold
1522
1667
  USING candidates candidate
1523
1668
  WHERE hold.id = candidate.id
1524
- AND hold.status = 'held' AND hold.expires_at <= now()
1669
+ AND hold.status = 'held' AND hold.expires_at <= statement_timestamp()
1525
1670
  RETURNING 1
1526
1671
  )
1527
1672
  SELECT count(*)::int AS deleted FROM deleted`;
@@ -1536,7 +1681,7 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
1536
1681
  WITH candidates AS MATERIALIZED (
1537
1682
  SELECT answer.principal_ref, answer.idempotency_key
1538
1683
  FROM slotlock.hold_commands answer
1539
- WHERE answer.tenant_ref = ${tenantRef} AND answer.expires_at <= now()
1684
+ WHERE answer.tenant_ref = ${tenantRef} AND answer.expires_at <= statement_timestamp()
1540
1685
  ORDER BY answer.expires_at, answer.principal_ref, answer.idempotency_key
1541
1686
  LIMIT ${limit}
1542
1687
  FOR UPDATE OF answer SKIP LOCKED
@@ -1565,7 +1710,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1565
1710
  SELECT id FROM slotlock.reservations
1566
1711
  WHERE resource_id = ${resourceId}
1567
1712
  AND status = 'held'
1568
- AND expires_at <= now()
1713
+ AND expires_at <= statement_timestamp()
1569
1714
  AND starts_at < ${end}
1570
1715
  AND COALESCE(occupied_ends_at, ends_at) > ${start}
1571
1716
  ORDER BY id
@@ -1594,7 +1739,7 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
1594
1739
  ${end} + ${bufferAfterMs} * interval '1 millisecond',
1595
1740
  '[)'
1596
1741
  )
1597
- AND (status <> 'held' OR expires_at > now())
1742
+ AND (status <> 'held' OR expires_at > statement_timestamp())
1598
1743
  ORDER BY starts_at
1599
1744
  LIMIT 1`;
1600
1745
  return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
@@ -1675,6 +1820,8 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1675
1820
  'restoreResource',
1676
1821
  'deleteResource',
1677
1822
  'setResourceAvailability',
1823
+ 'setResourceBookingRules',
1824
+ 'setResourcePool',
1678
1825
  'setTenantAvailability',
1679
1826
  'createReservation',
1680
1827
  'createExternalReservation',
@@ -1687,6 +1834,14 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1687
1834
  'cancelCalendarEvent',
1688
1835
  'cancelCalendarEventById',
1689
1836
  'recordCalendarCoverage',
1837
+ 'createWebhookEndpoint',
1838
+ 'deleteWebhookEndpoint',
1839
+ 'enableWebhookEndpoint',
1840
+ 'recordCoverageFailure',
1841
+ 'deleteCoverage',
1842
+ 'checkInReservation',
1843
+ 'joinWaitlist',
1844
+ 'leaveWaitlist',
1690
1845
  ]);
1691
1846
  function tenantSuspendedError() {
1692
1847
  return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
@@ -1717,6 +1872,7 @@ function suspensionCheckRefusal(error) {
1717
1872
  */
1718
1873
  function buildSlotlockStore(sql, options, refusal) {
1719
1874
  const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
1875
+ const defaultHours = parseAvailabilityRules([...(options.defaultAvailabilityRules ?? [])]);
1720
1876
  const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
1721
1877
  const configuredAgentOwnerCommandQuota = options.agentOwnerCommandQuota ?? SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA;
1722
1878
  if (!Number.isSafeInteger(configuredAgentOwnerEventQuota) ||
@@ -1732,7 +1888,23 @@ function buildSlotlockStore(sql, options, refusal) {
1732
1888
  throw Object.assign(new Error('Slotlock agent owner command quota must exceed its event quota and be at most 1000000'), { code: 'invalid_agent_owner_command_quota' });
1733
1889
  }
1734
1890
  const configuredTenantLimits = tenantLimitsOption(options.tenantLimits);
1735
- const { maxTenantTransactions, tenantLockTimeoutMs } = options;
1891
+ const { maxTenantTransactions, tenantLockTimeoutMs, maxHeldHoursPerHolder } = options;
1892
+ const retentionDays = options.retentionDays ?? SLOTLOCK_CHANGE_RETENTION_DAYS;
1893
+ if (!Number.isSafeInteger(retentionDays) || retentionDays < 1 || retentionDays > 3_650) {
1894
+ throw Object.assign(new Error('Slotlock retentionDays must be a whole number from 1 to 3650'), {
1895
+ code: 'invalid_retention',
1896
+ });
1897
+ }
1898
+ const maxHoldsPerHolder = options.maxHoldsPerHolder ?? SLOTLOCK_MAX_HOLDS_PER_HOLDER;
1899
+ if (!Number.isSafeInteger(maxHoldsPerHolder) ||
1900
+ maxHoldsPerHolder < 1 ||
1901
+ maxHoldsPerHolder > 10_000 ||
1902
+ (maxHeldHoursPerHolder !== undefined &&
1903
+ (!Number.isSafeInteger(maxHeldHoursPerHolder) ||
1904
+ maxHeldHoursPerHolder < 1 ||
1905
+ maxHeldHoursPerHolder > 876_000))) {
1906
+ throw Object.assign(new Error('Slotlock maxHoldsPerHolder must be 1-10000 and maxHeldHoursPerHolder 1-876000, whole numbers'), { code: 'invalid_holder_limits' });
1907
+ }
1736
1908
  const whole = (value, max) => value === undefined || (Number.isSafeInteger(value) && value >= 1 && value <= max);
1737
1909
  if (!whole(maxTenantTransactions, 1_000) || !whole(tenantLockTimeoutMs, 600_000)) {
1738
1910
  throw Object.assign(new Error('Slotlock maxTenantTransactions must be 1-1000 and tenantLockTimeoutMs 1-600000, whole numbers'), { code: 'invalid_tenant_transaction_options' });
@@ -1749,6 +1921,9 @@ function buildSlotlockStore(sql, options, refusal) {
1749
1921
  agentOwnerEventQuota: configuredAgentOwnerEventQuota,
1750
1922
  agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
1751
1923
  tenantLimits: configuredTenantLimits,
1924
+ retentionDays,
1925
+ maxHoldsPerHolder,
1926
+ ...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
1752
1927
  };
1753
1928
  /**
1754
1929
  * Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. No limit for
@@ -1789,6 +1964,7 @@ function buildSlotlockStore(sql, options, refusal) {
1789
1964
  return resource?.tenant_ref ?? null;
1790
1965
  }
1791
1966
  const store = {
1967
+ ...slotlockEventFeedMethods(sql, retentionDays),
1792
1968
  async applySchema(migrationOptions) {
1793
1969
  // PostgreSQL's IF NOT EXISTS DDL is idempotent after commit but not race-free: two fresh
1794
1970
  // application instances can still collide on pg_namespace/pg_extension unique indexes.
@@ -1964,6 +2140,12 @@ function buildSlotlockStore(sql, options, refusal) {
1964
2140
  const isolation = options?.isolation;
1965
2141
  if (isolation !== undefined && isolation !== 'read committed')
1966
2142
  throw invalidIsolationError();
2143
+ const actor = options?.actor;
2144
+ if (actor !== undefined && !/^[^\u0000-\u001f\u007f]{1,200}$/u.test(actor)) {
2145
+ throw Object.assign(new Error('Slotlock actor must be 1-200 characters without control characters'), {
2146
+ code: 'invalid_identity',
2147
+ });
2148
+ }
1967
2149
  const release = await tenantSlots?.(tenantRef, options?.signal);
1968
2150
  // A lock wait that timed out leaves the transaction unusable, so the reset below fails with
1969
2151
  // another error in its place: noted here as it happens.
@@ -1979,12 +2161,14 @@ function buildSlotlockStore(sql, options, refusal) {
1979
2161
  // (READ COMMITTED: a statement after the lock sees what committed before it was granted).
1980
2162
  const previous = await tx `
1981
2163
  SELECT current_setting(${configuredTenantSetting}, true) AS value,
2164
+ current_setting('slotlock.actor', true) AS actor,
1982
2165
  current_setting('transaction_isolation') AS isolation,
1983
2166
  pg_advisory_xact_lock_shared(
1984
2167
  hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
1985
2168
  ) AS locked`;
1986
2169
  const [context] = await tx `
1987
2170
  SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
2171
+ set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
1988
2172
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
1989
2173
  throw suspensionCheckRefusal(error);
1990
2174
  });
@@ -2005,7 +2189,8 @@ function buildSlotlockStore(sql, options, refusal) {
2005
2189
  throw error;
2006
2190
  }
2007
2191
  finally {
2008
- await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true)`;
2192
+ await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2193
+ set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)`;
2009
2194
  }
2010
2195
  }, 'read committed')
2011
2196
  .catch((error) => {
@@ -2066,7 +2251,7 @@ function buildSlotlockStore(sql, options, refusal) {
2066
2251
  });
2067
2252
  }
2068
2253
  const rows = await sql `
2069
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
2254
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2070
2255
  FROM slotlock.resources
2071
2256
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2072
2257
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
@@ -2084,10 +2269,87 @@ function buildSlotlockStore(sql, options, refusal) {
2084
2269
  UPDATE slotlock.resources
2085
2270
  SET availability_rules = ${rules}::jsonb
2086
2271
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2087
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
2272
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2273
+ const row = rows[0];
2274
+ return row ? toResource(row) : null;
2275
+ },
2276
+ async setResourceBookingRules(params) {
2277
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2278
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2279
+ code: 'invalid_identity',
2280
+ });
2281
+ }
2282
+ let rules = null;
2283
+ if (params.rules !== null) {
2284
+ try {
2285
+ rules = parseBookingRules(params.rules);
2286
+ }
2287
+ catch (error) {
2288
+ throw Object.assign(new Error(error.message), {
2289
+ code: 'invalid_booking_rules',
2290
+ });
2291
+ }
2292
+ }
2293
+ const rows = await sql `
2294
+ UPDATE slotlock.resources
2295
+ SET booking_rules = ${rules === null ? null : sql.json({ ...rules })}::jsonb,
2296
+ -- Kept while check-in stays asked for, from the moment it first was; cleared without.
2297
+ check_in_since = CASE
2298
+ WHEN ${rules?.checkInMinutes ?? null}::int IS NULL THEN NULL
2299
+ WHEN booking_rules ? 'checkInMinutes' THEN COALESCE(check_in_since, statement_timestamp())
2300
+ ELSE statement_timestamp()
2301
+ END
2302
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2303
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2304
+ visibility, archived_at`;
2088
2305
  const row = rows[0];
2089
2306
  return row ? toResource(row) : null;
2090
2307
  },
2308
+ async setResourcePool(params) {
2309
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2310
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2311
+ code: 'invalid_identity',
2312
+ });
2313
+ }
2314
+ if (params.pool !== null && !isPoolName(params.pool))
2315
+ throw invalidPoolError();
2316
+ const rows = await sql `
2317
+ UPDATE slotlock.resources SET pool_ref = ${params.pool}
2318
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2319
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2320
+ visibility, archived_at`;
2321
+ const row = rows[0];
2322
+ return row ? toResource(row) : null;
2323
+ },
2324
+ async listPoolMembers(params) {
2325
+ if (!isValidIdentity(params.tenantRef))
2326
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2327
+ if (!isPoolName(params.pool))
2328
+ throw invalidPoolError();
2329
+ const rows = await sql `
2330
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2331
+ visibility, archived_at
2332
+ FROM slotlock.resources
2333
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2334
+ AND archived_at IS NULL
2335
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2336
+ ORDER BY id
2337
+ LIMIT ${SLOTLOCK_MAX_POOL_MEMBERS}`;
2338
+ return rows.map(toResource);
2339
+ },
2340
+ async listPools(params) {
2341
+ if (!isValidIdentity(params.tenantRef))
2342
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2343
+ const rows = await sql `
2344
+ SELECT pool_ref AS pool, count(*)::int AS capacity
2345
+ FROM slotlock.resources
2346
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref IS NOT NULL AND archived_at IS NULL
2347
+ AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2348
+ GROUP BY pool_ref
2349
+ ORDER BY pool_ref
2350
+ LIMIT 1000`;
2351
+ return rows;
2352
+ },
2091
2353
  async setTenantAvailability(params) {
2092
2354
  if (!isValidIdentity(params.tenantRef)) {
2093
2355
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -2119,7 +2381,7 @@ function buildSlotlockStore(sql, options, refusal) {
2119
2381
  });
2120
2382
  }
2121
2383
  const rows = await sql `
2122
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
2384
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
2123
2385
  FROM slotlock.resources
2124
2386
  WHERE tenant_ref = ${params.tenantRef}
2125
2387
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
@@ -2139,7 +2401,7 @@ function buildSlotlockStore(sql, options, refusal) {
2139
2401
  UPDATE slotlock.resources
2140
2402
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2141
2403
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2142
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
2404
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2143
2405
  return row ? toResource(row) : null;
2144
2406
  },
2145
2407
  async restoreResource(params) {
@@ -2150,7 +2412,7 @@ function buildSlotlockStore(sql, options, refusal) {
2150
2412
  UPDATE slotlock.resources
2151
2413
  SET archived_at = NULL
2152
2414
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2153
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
2415
+ RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
2154
2416
  return row ? toResource(row) : null;
2155
2417
  },
2156
2418
  async deleteResource(params) {
@@ -2170,7 +2432,7 @@ function buildSlotlockStore(sql, options, refusal) {
2170
2432
  SELECT 1 FROM slotlock.reservations
2171
2433
  WHERE resource_id = ${params.id}
2172
2434
  AND calendar_event_id IS NULL
2173
- AND (CASE WHEN status = 'held' THEN expires_at > now()
2435
+ AND (CASE WHEN status = 'held' THEN expires_at > statement_timestamp()
2174
2436
  ELSE COALESCE(occupied_ends_at, ends_at) > now() END)
2175
2437
  )
2176
2438
  OR EXISTS (
@@ -2215,23 +2477,27 @@ function buildSlotlockStore(sql, options, refusal) {
2215
2477
  if (bookingLimit !== null && resource.tenant_ref !== null) {
2216
2478
  return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2217
2479
  }
2480
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2481
+ if (ruling.rule)
2482
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2483
+ const occupied = new Date(params.end.getTime() + ruling.turnaroundMs);
2218
2484
  if (resource.tenant_ref !== null &&
2219
2485
  !(await recurrenceCoversWrite(tx, {
2220
2486
  tenantRef: resource.tenant_ref,
2221
2487
  resourceId: params.resourceId,
2222
2488
  start: params.start,
2223
- end: params.end,
2489
+ end: occupied,
2224
2490
  }))) {
2225
2491
  return { ok: false, code: 'recurrence_not_materialized' };
2226
2492
  }
2227
- await reapExpiredHolds(tx, params.resourceId, params.start, params.end);
2493
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2228
2494
  const [row] = await tx `
2229
2495
  INSERT INTO slotlock.reservations (
2230
- id, resource_id, tenant_ref, external_ref, starts_at, ends_at, source
2496
+ id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms, source
2231
2497
  ) VALUES (
2232
2498
  ${id}, ${params.resourceId}, ${resource.tenant_ref},
2233
2499
  ${resource.tenant_ref === null ? null : id},
2234
- ${params.start}, ${params.end}, ${params.source ?? null}
2500
+ ${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${params.source ?? null}
2235
2501
  )
2236
2502
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2237
2503
  buffer_after_ms, revision, source`;
@@ -2263,6 +2529,7 @@ function buildSlotlockStore(sql, options, refusal) {
2263
2529
  return invalid;
2264
2530
  if (params.revision !== 1)
2265
2531
  return { ok: false, code: 'invalid_revision' };
2532
+ let written = params.bufferAfterMs;
2266
2533
  for (let attempt = 1;; attempt++) {
2267
2534
  try {
2268
2535
  return await inTransaction(sql, async (tx) => {
@@ -2283,12 +2550,13 @@ function buildSlotlockStore(sql, options, refusal) {
2283
2550
  AND calendar_event_id IS NULL
2284
2551
  FOR UPDATE`;
2285
2552
  const found = existing[0];
2553
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2286
2554
  if (found) {
2287
2555
  // The booking has moved on since: a delayed create is stale, as a delayed move is.
2288
2556
  if (params.revision < Number(found.revision)) {
2289
2557
  return { ok: false, code: 'stale_revision' };
2290
2558
  }
2291
- if (!sameExternalPayload(found, params)) {
2559
+ if (!sameExternalPayload(found, params, ruling.turnaroundMs)) {
2292
2560
  return { ok: false, code: 'idempotency_conflict' };
2293
2561
  }
2294
2562
  return { ok: true, reservation: toReservation(found), idempotent: true };
@@ -2302,22 +2570,27 @@ function buildSlotlockStore(sql, options, refusal) {
2302
2570
  if (bookingLimit !== null) {
2303
2571
  return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
2304
2572
  }
2573
+ if (ruling.rule)
2574
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2575
+ const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2576
+ written = bufferAfterMs;
2577
+ const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2305
2578
  if (!(await recurrenceCoversWrite(tx, {
2306
2579
  tenantRef: params.tenantRef,
2307
2580
  resourceId: params.resourceId,
2308
2581
  start: params.start,
2309
- end: occupiedEnd(params),
2582
+ end: occupied,
2310
2583
  }))) {
2311
2584
  return { ok: false, code: 'recurrence_not_materialized' };
2312
2585
  }
2313
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
2586
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2314
2587
  const rows = await tx `
2315
2588
  INSERT INTO slotlock.reservations (
2316
2589
  resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2317
2590
  revision, source
2318
2591
  ) VALUES (
2319
2592
  ${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
2320
- ${params.end}, ${params.bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2593
+ ${params.end}, ${bufferAfterMs}, ${params.revision}, ${params.source ?? null}
2321
2594
  )
2322
2595
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2323
2596
  buffer_after_ms, revision, source`;
@@ -2337,8 +2610,8 @@ function buildSlotlockStore(sql, options, refusal) {
2337
2610
  code !== SERIALIZATION_FAILURE) {
2338
2611
  throw err;
2339
2612
  }
2340
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
2341
- return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs);
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);
2342
2615
  }
2343
2616
  }
2344
2617
  },
@@ -2347,6 +2620,7 @@ function buildSlotlockStore(sql, options, refusal) {
2347
2620
  if (invalid)
2348
2621
  return invalid;
2349
2622
  let currentReservationId;
2623
+ let bufferAfterMs = params.bufferAfterMs;
2350
2624
  for (let attempt = 1;; attempt++) {
2351
2625
  try {
2352
2626
  return await inTransaction(sql, async (tx) => {
@@ -2359,6 +2633,7 @@ function buildSlotlockStore(sql, options, refusal) {
2359
2633
  AND calendar_event_id IS NULL
2360
2634
  FOR UPDATE`;
2361
2635
  const found = existing[0];
2636
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2362
2637
  if (!found) {
2363
2638
  const cancelled = await tx `
2364
2639
  SELECT revision FROM slotlock.reservation_tombstones
@@ -2371,7 +2646,7 @@ function buildSlotlockStore(sql, options, refusal) {
2371
2646
  const currentRevision = Number(found.revision);
2372
2647
  if (params.revision < currentRevision)
2373
2648
  return { ok: false, code: 'stale_revision' };
2374
- if (sameExternalPayload(found, params)) {
2649
+ if (sameExternalPayload(found, params, ruling.turnaroundMs)) {
2375
2650
  return { ok: true, reservation: toReservation(found), idempotent: true };
2376
2651
  }
2377
2652
  if (params.revision === currentRevision) {
@@ -2385,23 +2660,31 @@ function buildSlotlockStore(sql, options, refusal) {
2385
2660
  return { ok: false, code: 'resource_tenant_mismatch' };
2386
2661
  if (resourceState === 'archived')
2387
2662
  return { ok: false, code: 'resource_archived' };
2663
+ if (ruling.rule)
2664
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2665
+ bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
2388
2666
  if (!(await recurrenceCoversWrite(tx, {
2389
2667
  tenantRef: params.tenantRef,
2390
2668
  resourceId: params.resourceId,
2391
2669
  start: params.start,
2392
- end: occupiedEnd(params),
2670
+ end: occupiedEnd({ end: params.end, bufferAfterMs }),
2393
2671
  }))) {
2394
2672
  return { ok: false, code: 'recurrence_not_materialized' };
2395
2673
  }
2396
- await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
2674
+ await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd({ end: params.end, bufferAfterMs }));
2397
2675
  const rows = await tx `
2398
2676
  UPDATE slotlock.reservations
2399
2677
  SET resource_id = ${params.resourceId},
2400
2678
  starts_at = ${params.start},
2401
2679
  ends_at = ${params.end},
2402
- buffer_after_ms = ${params.bufferAfterMs},
2680
+ buffer_after_ms = ${bufferAfterMs},
2403
2681
  revision = ${params.revision},
2404
- source = ${params.source ?? null}
2682
+ source = ${params.source ?? null},
2683
+ -- A check-in is for the time it was made for: a move to another start clears it.
2684
+ checked_in_at = CASE
2685
+ WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
2686
+ THEN checked_in_at
2687
+ END
2405
2688
  WHERE id = ${found.id}
2406
2689
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
2407
2690
  buffer_after_ms, revision, source`;
@@ -2421,8 +2704,9 @@ function buildSlotlockStore(sql, options, refusal) {
2421
2704
  code !== SERIALIZATION_FAILURE) {
2422
2705
  throw err;
2423
2706
  }
2424
- await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
2425
- return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs, currentReservationId);
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);
2426
2710
  }
2427
2711
  }
2428
2712
  },
@@ -2517,7 +2801,13 @@ function buildSlotlockStore(sql, options, refusal) {
2517
2801
  }
2518
2802
  if (!isValidResourceId(params.resourceId))
2519
2803
  return { ok: false, code: 'resource_not_found' };
2520
- const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
2804
+ if (params.groupId !== undefined && !isValidResourceId(params.groupId)) {
2805
+ return { ok: false, code: 'invalid_identity' };
2806
+ }
2807
+ const { holderRef } = params;
2808
+ if (holderRef !== undefined && (tenantRef === undefined || !isValidIdentity(holderRef))) {
2809
+ return { ok: false, code: 'invalid_identity' };
2810
+ }
2521
2811
  const payloadHash = calendarCommandHash({
2522
2812
  operation: 'hold',
2523
2813
  resourceId: params.resourceId,
@@ -2526,8 +2816,12 @@ function buildSlotlockStore(sql, options, refusal) {
2526
2816
  ttlMs: params.ttlMs,
2527
2817
  bufferAfterMs,
2528
2818
  source: params.source ?? null,
2819
+ ...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
2820
+ ...(holderRef !== undefined ? { holderRef } : {}),
2529
2821
  });
2530
2822
  const ttlSeconds = params.ttlMs / 1000;
2823
+ // The buffer written: the one asked for, raised to the resource's turnaround.
2824
+ let written = bufferAfterMs;
2531
2825
  for (let attempt = 1;; attempt++) {
2532
2826
  try {
2533
2827
  return await inTransaction(sql, async (tx) => {
@@ -2540,7 +2834,8 @@ function buildSlotlockStore(sql, options, refusal) {
2540
2834
  ])}, 0))`;
2541
2835
  const [prior] = await tx `
2542
2836
  SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
2543
- source, payload_hash
2837
+ source, payload_hash,
2838
+ (SELECT hold_group_id FROM slotlock.reservations WHERE id = hold_id) AS hold_group_id
2544
2839
  FROM slotlock.hold_commands
2545
2840
  WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
2546
2841
  AND idempotency_key = ${idempotencyKey ?? null}`;
@@ -2562,24 +2857,63 @@ function buildSlotlockStore(sql, options, refusal) {
2562
2857
  return { ok: false, code: 'resource_archived' };
2563
2858
  if (holdLimit !== null)
2564
2859
  return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
2860
+ // Seconds the holder already holds, turnaround included, when its time is capped.
2861
+ let heldSeconds = null;
2862
+ if (holderRef !== undefined) {
2863
+ // One holder's live holds, counted under a lock on the holder so racing holds stop
2864
+ // at the cap: an agent cannot hold a resource's whole week.
2865
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
2866
+ 'slotlock-holder',
2867
+ tenantRef,
2868
+ holderRef,
2869
+ ])}, 0))`;
2870
+ const [held] = await tx `
2871
+ SELECT count(*)::int AS count,
2872
+ COALESCE(sum(extract(epoch FROM
2873
+ ends_at + buffer_after_ms * interval '1 millisecond' - starts_at)), 0)::float8
2874
+ AS seconds
2875
+ FROM slotlock.reservations
2876
+ WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
2877
+ AND expires_at > statement_timestamp()
2878
+ AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
2879
+ if ((held?.count ?? 0) >= maxHoldsPerHolder) {
2880
+ return { ok: false, code: 'hold_limit_reached', limit: maxHoldsPerHolder };
2881
+ }
2882
+ if (maxHeldHoursPerHolder !== undefined)
2883
+ heldSeconds = held?.seconds ?? 0;
2884
+ }
2885
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
2886
+ if (ruling.rule)
2887
+ return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
2888
+ written = Math.max(bufferAfterMs, ruling.turnaroundMs);
2889
+ const reach = occupiedEnd({ end: params.end, bufferAfterMs: written });
2890
+ if (heldSeconds !== null &&
2891
+ maxHeldHoursPerHolder !== undefined &&
2892
+ heldSeconds + (reach.getTime() - params.start.getTime()) / 1000 >
2893
+ maxHeldHoursPerHolder * 3_600) {
2894
+ return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
2895
+ }
2565
2896
  if (resource.tenant_ref !== null &&
2566
2897
  !(await recurrenceCoversWrite(tx, {
2567
2898
  tenantRef: resource.tenant_ref,
2568
2899
  resourceId: params.resourceId,
2569
2900
  start: params.start,
2570
- end: occupied,
2901
+ end: reach,
2571
2902
  }))) {
2572
2903
  return { ok: false, code: 'recurrence_not_materialized' };
2573
2904
  }
2574
- await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
2905
+ await reapExpiredHolds(tx, params.resourceId, params.start, reach);
2575
2906
  const [row] = await tx `
2576
2907
  INSERT INTO slotlock.reservations (
2577
- resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source
2908
+ resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source,
2909
+ holder_ref, hold_group_id
2578
2910
  ) VALUES (
2579
- ${params.resourceId}, ${params.start}, ${params.end}, ${bufferAfterMs}, 'held',
2580
- now() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null}
2911
+ ${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
2912
+ statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
2913
+ ${holderRef ?? null}, ${params.groupId ?? null}
2581
2914
  )
2582
- RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source`;
2915
+ RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
2916
+ hold_group_id`;
2583
2917
  if (!row)
2584
2918
  throw new Error('slotlock: hold insert returned no row');
2585
2919
  if (keyed) {
@@ -2608,8 +2942,8 @@ function buildSlotlockStore(sql, options, refusal) {
2608
2942
  }
2609
2943
  // A SECOND contention abort is treated as a loss too (structured, possibly with a null
2610
2944
  // winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
2611
- await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
2612
- return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs);
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);
2613
2947
  }
2614
2948
  }
2615
2949
  },
@@ -2625,7 +2959,7 @@ function buildSlotlockStore(sql, options, refusal) {
2625
2959
  FROM slotlock.reservations hold
2626
2960
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
2627
2961
  WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
2628
- AND hold.status = 'held' AND hold.expires_at > now()`;
2962
+ AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
2629
2963
  return row ? toHold(row) : null;
2630
2964
  },
2631
2965
  async listHolds(params) {
@@ -2643,13 +2977,45 @@ function buildSlotlockStore(sql, options, refusal) {
2643
2977
  FROM slotlock.reservations hold
2644
2978
  JOIN slotlock.resources resource ON resource.id = hold.resource_id
2645
2979
  WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
2646
- AND hold.status = 'held' AND hold.expires_at > now()
2980
+ AND hold.status = 'held' AND hold.expires_at > statement_timestamp()
2647
2981
  AND (${after.start}::timestamptz IS NULL
2648
2982
  OR (hold.starts_at, hold.id) > (${after.start}::timestamptz, ${after.id}::uuid))
2649
2983
  ORDER BY hold.starts_at, hold.id
2650
2984
  LIMIT ${limit}`;
2651
2985
  return rows.map(toHold);
2652
2986
  },
2987
+ async listAgenda(params) {
2988
+ if (!isValidIdentity(params.tenantRef)) {
2989
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2990
+ }
2991
+ const window = calendarWindow(params.window);
2992
+ const limit = pageLimit(params.limit);
2993
+ // A confirmed row without a tenant is a parity shadow, not a booking.
2994
+ const rows = await sql `
2995
+ SELECT entry.id, entry.resource_id, entry.starts_at, entry.ends_at, entry.status,
2996
+ entry.external_ref, entry.expires_at, entry.source, entry.calendar_event_id,
2997
+ event.summary
2998
+ FROM slotlock.reservations entry
2999
+ JOIN slotlock.resources resource ON resource.id = entry.resource_id
3000
+ LEFT JOIN slotlock.calendar_events event ON event.id = entry.calendar_event_id
3001
+ WHERE resource.tenant_ref = ${params.tenantRef}
3002
+ AND entry.starts_at < ${window.end} AND entry.ends_at > ${window.start}
3003
+ AND ((entry.status = 'held' AND entry.expires_at > statement_timestamp())
3004
+ OR (entry.status = 'confirmed' AND entry.tenant_ref = ${params.tenantRef}))
3005
+ ORDER BY entry.starts_at, entry.id
3006
+ LIMIT ${limit}`;
3007
+ return rows.map((row) => ({
3008
+ id: row.id,
3009
+ resourceId: row.resource_id,
3010
+ start: row.starts_at,
3011
+ end: row.ends_at,
3012
+ kind: row.status === 'held' ? 'hold' : row.calendar_event_id ? 'event' : 'booking',
3013
+ reference: row.status === 'held' || row.calendar_event_id ? null : row.external_ref,
3014
+ summary: row.summary,
3015
+ expiresAt: row.status === 'held' ? row.expires_at : null,
3016
+ source: row.source,
3017
+ }));
3018
+ },
2653
3019
  async getReservation(params) {
2654
3020
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
2655
3021
  throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
@@ -2715,7 +3081,7 @@ function buildSlotlockStore(sql, options, refusal) {
2715
3081
  FOR SHARE`;
2716
3082
  const [row] = await tx `
2717
3083
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
2718
- revision, source, status, (status = 'held' AND expires_at > now()) AS live
3084
+ revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live
2719
3085
  FROM slotlock.reservations
2720
3086
  WHERE id = ${holdId}
2721
3087
  FOR UPDATE`;
@@ -2774,6 +3140,26 @@ function buildSlotlockStore(sql, options, refusal) {
2774
3140
  const tenantRef = holdTenantRef(options);
2775
3141
  if (!isValidResourceId(holdId))
2776
3142
  return { ok: true, released: false };
3143
+ if (options.holderRef !== undefined) {
3144
+ if (!isValidIdentity(options.holderRef))
3145
+ return { ok: true, released: false };
3146
+ const released = await sql `
3147
+ WITH taken AS (
3148
+ SELECT id, hold_group_id FROM slotlock.reservations
3149
+ WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${options.holderRef}
3150
+ AND (${tenantRef}::text IS NULL
3151
+ OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
3152
+ )
3153
+ DELETE FROM slotlock.reservations hold
3154
+ USING taken
3155
+ WHERE hold.status = 'held' AND hold.holder_ref = ${options.holderRef}
3156
+ AND (${tenantRef}::text IS NULL
3157
+ OR hold.resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
3158
+ AND (hold.id = taken.id
3159
+ OR (taken.hold_group_id IS NOT NULL AND hold.hold_group_id = taken.hold_group_id))
3160
+ RETURNING hold.id`;
3161
+ return { ok: true, released: released.some(({ id }) => id === holdId) };
3162
+ }
2777
3163
  const rows = await sql `
2778
3164
  DELETE FROM slotlock.reservations
2779
3165
  WHERE id = ${holdId} AND status = 'held'
@@ -2814,7 +3200,7 @@ function buildSlotlockStore(sql, options, refusal) {
2814
3200
  WHERE resource_id = ${resourceId}
2815
3201
  AND starts_at < ${window.end}
2816
3202
  AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
2817
- AND (status <> 'held' OR expires_at > now())
3203
+ AND (status <> 'held' OR expires_at > statement_timestamp())
2818
3204
  ORDER BY starts_at
2819
3205
  LIMIT ${options.limit ?? null}`;
2820
3206
  return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
@@ -2866,7 +3252,8 @@ function buildSlotlockStore(sql, options, refusal) {
2866
3252
  return { ok: false, code: 'invalid_event' };
2867
3253
  throw error;
2868
3254
  }
2869
- const payloadHash = putCommandHash({
3255
+ const payloadHash = putCommandHash({ command: params, ownerRef, event });
3256
+ const legacyPayloadHash = legacyPutCommandHash({
2870
3257
  command: params,
2871
3258
  ownerRef,
2872
3259
  event,
@@ -2890,7 +3277,9 @@ function buildSlotlockStore(sql, options, refusal) {
2890
3277
  FOR UPDATE`;
2891
3278
  const priorCommand = commands[0];
2892
3279
  if (priorCommand) {
2893
- if (priorCommand.operation !== 'put' || priorCommand.payload_hash !== payloadHash) {
3280
+ if (priorCommand.operation !== 'put' ||
3281
+ (priorCommand.payload_hash !== payloadHash &&
3282
+ priorCommand.payload_hash !== legacyPayloadHash)) {
2894
3283
  return { ok: false, code: 'idempotency_conflict' };
2895
3284
  }
2896
3285
  return commandResult(priorCommand, true);
@@ -2980,7 +3369,47 @@ function buildSlotlockStore(sql, options, refusal) {
2980
3369
  }
2981
3370
  const eventId = found?.id ?? randomUUID();
2982
3371
  const revision = currentRevision + 1;
2983
- const reach = occurrenceSpan(occurrences);
3372
+ if (params.fromHold) {
3373
+ const { holdId, holderRef } = params.fromHold;
3374
+ if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
3375
+ return { ok: false, code: 'hold_not_found' };
3376
+ }
3377
+ const [hold] = await tx `
3378
+ SELECT resource_id, starts_at, ends_at, hold_group_id FROM slotlock.reservations
3379
+ WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
3380
+ AND expires_at > statement_timestamp()
3381
+ FOR UPDATE`;
3382
+ if (!hold)
3383
+ return { ok: false, code: 'hold_not_found' };
3384
+ const [only] = occurrences;
3385
+ if (hold.resource_id !== params.resourceId ||
3386
+ occurrences.length !== 1 ||
3387
+ !only ||
3388
+ only.start < hold.starts_at ||
3389
+ only.end > hold.ends_at) {
3390
+ return { ok: false, code: 'hold_mismatch' };
3391
+ }
3392
+ // The hold and its alternatives give way to the event in this transaction.
3393
+ await tx `
3394
+ DELETE FROM slotlock.reservations
3395
+ WHERE status = 'held' AND holder_ref = ${holderRef}
3396
+ AND (id = ${holdId}
3397
+ OR (${hold.hold_group_id}::uuid IS NOT NULL
3398
+ AND hold_group_id = ${hold.hold_group_id}::uuid))`;
3399
+ }
3400
+ let turnaroundMs = 0;
3401
+ if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
3402
+ const judge = await bookingJudge(tx, params.resourceId, defaultHours);
3403
+ for (const [index, occurrence] of occurrences.entries()) {
3404
+ // A held time's notice and advance were judged when it was held.
3405
+ const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
3406
+ if (rule)
3407
+ return { ok: false, code: 'booking_rule_violated', rule };
3408
+ }
3409
+ turnaroundMs = judge.turnaroundMs;
3410
+ }
3411
+ const span = occurrenceSpan(occurrences);
3412
+ const reach = span && { start: span.start, end: new Date(span.end.getTime() + turnaroundMs) };
2984
3413
  if ((event.transparency ?? 'opaque') === 'opaque' &&
2985
3414
  reach &&
2986
3415
  !(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
@@ -3017,6 +3446,7 @@ function buildSlotlockStore(sql, options, refusal) {
3017
3446
  roll_retry_at = NULL,
3018
3447
  revision = ${revision},
3019
3448
  source = ${params.source ?? null},
3449
+ buffer_after_ms = ${turnaroundMs},
3020
3450
  updated_at = now()
3021
3451
  WHERE id = ${eventId}
3022
3452
  AND tenant_ref = ${params.tenantRef}
@@ -3028,7 +3458,8 @@ function buildSlotlockStore(sql, options, refusal) {
3028
3458
  id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
3029
3459
  summary, description, location, status, transparency, organizer, attendees,
3030
3460
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
3031
- materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source
3461
+ materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
3462
+ buffer_after_ms
3032
3463
  ) VALUES (
3033
3464
  ${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
3034
3465
  ${params.resourceId},
@@ -3042,7 +3473,7 @@ function buildSlotlockStore(sql, options, refusal) {
3042
3473
  ${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
3043
3474
  ${materializationWindow.start}, ${materializationWindow.end},
3044
3475
  ${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
3045
- ${revision}, ${params.source ?? null}
3476
+ ${revision}, ${params.source ?? null}, ${turnaroundMs}
3046
3477
  )`;
3047
3478
  }
3048
3479
  // The normalized children are the queryable/canonical representation. The JSON
@@ -3055,6 +3486,7 @@ function buildSlotlockStore(sql, options, refusal) {
3055
3486
  event,
3056
3487
  occurrences,
3057
3488
  revision,
3489
+ bufferAfterMs: turnaroundMs,
3058
3490
  });
3059
3491
  await tx `
3060
3492
  INSERT INTO slotlock.calendar_event_commands (
@@ -3482,6 +3914,87 @@ function buildSlotlockStore(sql, options, refusal) {
3482
3914
  });
3483
3915
  });
3484
3916
  },
3917
+ async followZoneRules(params) {
3918
+ if (!isValidIdentity(params.tenantRef)) {
3919
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3920
+ }
3921
+ const limit = params.limit ?? 100;
3922
+ if (!(Number.isSafeInteger(limit) && limit >= 1 && limit <= 1_000)) {
3923
+ throw new RangeError('Slotlock zone-rule batch must be 1-1,000 events');
3924
+ }
3925
+ const shifted = await sql `
3926
+ SELECT event.id, event.resource_id, event.transparency, event.revision, event.starts_at, event.ends_at,
3927
+ slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
3928
+ slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
3929
+ COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
3930
+ WHERE booking.calendar_event_id = event.id), 0) AS buffer
3931
+ FROM slotlock.calendar_events event
3932
+ WHERE event.tenant_ref = ${params.tenantRef}
3933
+ AND event.recurrence_rule IS NULL
3934
+ AND event.starts_at > now()
3935
+ AND event.zone_shift_reported_at IS NULL
3936
+ AND slotlock.zoned_instant(event.wall_starts_at, event.timezone) IS NOT NULL
3937
+ AND slotlock.zoned_instant(event.wall_ends_at, event.timezone) IS NOT NULL
3938
+ AND (slotlock.zoned_instant(event.wall_starts_at, event.timezone) <> event.starts_at
3939
+ OR slotlock.zoned_instant(event.wall_ends_at, event.timezone) <> event.ends_at)
3940
+ ORDER BY event.starts_at, event.id
3941
+ LIMIT ${limit}
3942
+ FOR UPDATE OF event SKIP LOCKED`;
3943
+ let moved = 0;
3944
+ let conflicts = 0;
3945
+ const report = async (event, type) => {
3946
+ await sql `UPDATE slotlock.calendar_events SET zone_shift_reported_at = now() WHERE id = ${event.id}`;
3947
+ await sql `
3948
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
3949
+ VALUES (${params.tenantRef}, ${event.resource_id}, ${type}, ${event.id},
3950
+ ${sql.json({
3951
+ id: event.id,
3952
+ resource_id: event.resource_id,
3953
+ start: event.starts_at.toISOString(),
3954
+ end: event.ends_at.toISOString(),
3955
+ zone_start: event.moved_start.toISOString(),
3956
+ zone_end: event.moved_end.toISOString(),
3957
+ })}, 'slotlock:tzdata')`;
3958
+ };
3959
+ for (const event of shifted) {
3960
+ if (!params.move) {
3961
+ await report(event, 'event.timezone_shifted');
3962
+ continue;
3963
+ }
3964
+ try {
3965
+ await inTransaction(sql, async (tx) => {
3966
+ const revision = Number(event.revision) + 1;
3967
+ await tx `SELECT set_config('slotlock.actor', 'slotlock:tzdata', true)`;
3968
+ await tx `
3969
+ UPDATE slotlock.calendar_events
3970
+ SET starts_at = ${event.moved_start}, ends_at = ${event.moved_end},
3971
+ materialized_starts_at = ${event.moved_start}, materialized_ends_at = ${event.moved_end},
3972
+ revision = ${revision}, updated_at = now()
3973
+ WHERE id = ${event.id}`;
3974
+ await replaceCalendarEventOccurrences(tx, {
3975
+ tenantRef: params.tenantRef,
3976
+ eventId: event.id,
3977
+ resourceId: event.resource_id,
3978
+ event: { transparency: event.transparency },
3979
+ occurrences: [
3980
+ { recurrenceId: event.moved_start.toISOString(), start: event.moved_start, end: event.moved_end },
3981
+ ],
3982
+ revision,
3983
+ bufferAfterMs: Number(event.buffer),
3984
+ });
3985
+ });
3986
+ moved++;
3987
+ }
3988
+ catch (error) {
3989
+ if (pgCode(error) !== EXCLUSION_VIOLATION)
3990
+ throw error;
3991
+ // It stays where it was; whoever owns it decides.
3992
+ await report(event, 'event.timezone_conflict');
3993
+ conflicts++;
3994
+ }
3995
+ }
3996
+ return { shifted: shifted.length, moved, conflicts };
3997
+ },
3485
3998
  async pruneBookingRetention(params) {
3486
3999
  if (!isValidIdentity(params.tenantRef)) {
3487
4000
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -3492,10 +4005,12 @@ function buildSlotlockStore(sql, options, refusal) {
3492
4005
  code: 'invalid_limit',
3493
4006
  });
3494
4007
  }
3495
- const retentionDays = params.retentionDays ?? SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
3496
- if (!Number.isSafeInteger(retentionDays) ||
3497
- retentionDays < 1 ||
3498
- retentionDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
4008
+ // Never under the default: a shorter retention trims the feed, not how long a retried write
4009
+ // is answered as the first was.
4010
+ const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4011
+ if (!Number.isSafeInteger(keepDays) ||
4012
+ keepDays < 1 ||
4013
+ keepDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
3499
4014
  throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
3500
4015
  code: 'invalid_retention_window',
3501
4016
  });
@@ -3548,7 +4063,7 @@ function buildSlotlockStore(sql, options, refusal) {
3548
4063
  WHERE tombstone.tenant_ref = ${params.tenantRef}
3549
4064
  AND tombstone.stray
3550
4065
  AND tombstone.cancelled_at <
3551
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4066
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
3552
4067
  ORDER BY tombstone.cancelled_at, tombstone.external_ref
3553
4068
  LIMIT ${limit}
3554
4069
  FOR UPDATE OF tombstone SKIP LOCKED
@@ -3585,10 +4100,12 @@ function buildSlotlockStore(sql, options, refusal) {
3585
4100
  code: 'invalid_limit',
3586
4101
  });
3587
4102
  }
3588
- const retentionDays = params.retentionDays ?? SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
3589
- if (!Number.isSafeInteger(retentionDays) ||
3590
- retentionDays < 1 ||
3591
- retentionDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
4103
+ // Never under the default: a shorter retention trims the feed, not how long a retried write
4104
+ // is answered as the first was.
4105
+ const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
4106
+ if (!Number.isSafeInteger(keepDays) ||
4107
+ keepDays < 1 ||
4108
+ keepDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
3592
4109
  throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
3593
4110
  code: 'invalid_retention_window',
3594
4111
  });
@@ -3602,7 +4119,7 @@ function buildSlotlockStore(sql, options, refusal) {
3602
4119
  FROM slotlock.calendar_event_commands command
3603
4120
  WHERE command.tenant_ref = ${params.tenantRef}
3604
4121
  AND command.created_at <
3605
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4122
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
3606
4123
  ORDER BY command.created_at, command.owner_ref, command.idempotency_key
3607
4124
  LIMIT ${limit}
3608
4125
  FOR UPDATE OF command SKIP LOCKED
@@ -3625,7 +4142,7 @@ function buildSlotlockStore(sql, options, refusal) {
3625
4142
  WHERE tombstone.tenant_ref = ${params.tenantRef}
3626
4143
  AND tombstone.owner_ref LIKE 'agent:%'
3627
4144
  AND tombstone.cancelled_at <
3628
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4145
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
3629
4146
  AND NOT EXISTS (
3630
4147
  SELECT 1 FROM slotlock.calendar_event_commands command
3631
4148
  WHERE command.tenant_ref = tombstone.tenant_ref
@@ -3644,7 +4161,7 @@ function buildSlotlockStore(sql, options, refusal) {
3644
4161
  AND tombstone.external_ref = candidate.external_ref
3645
4162
  AND tombstone.owner_ref LIKE 'agent:%'
3646
4163
  AND tombstone.cancelled_at <
3647
- clock_timestamp() - (${retentionDays}::int * interval '1 day')
4164
+ clock_timestamp() - (${keepDays}::int * interval '1 day')
3648
4165
  AND NOT EXISTS (
3649
4166
  SELECT 1 FROM slotlock.calendar_event_commands command
3650
4167
  WHERE command.tenant_ref = tombstone.tenant_ref
@@ -3713,10 +4230,11 @@ function buildSlotlockStore(sql, options, refusal) {
3713
4230
  if (params.revision !== currentRevision + 1) {
3714
4231
  return { ok: false, code: 'revision_conflict' };
3715
4232
  }
4233
+ // A fresh read ends a staleness: the next one is reported again.
3716
4234
  await tx `
3717
4235
  UPDATE slotlock.calendar_coverage
3718
4236
  SET starts_at = ${window.start}, ends_at = ${window.end},
3719
- revision = ${params.revision}, observed_at = now()
4237
+ revision = ${params.revision}, observed_at = now(), stale_reported_at = NULL
3720
4238
  WHERE tenant_ref = ${params.tenantRef}
3721
4239
  AND resource_id = ${params.resourceId}
3722
4240
  AND source = ${params.source}`;
@@ -3738,6 +4256,382 @@ function buildSlotlockStore(sql, options, refusal) {
3738
4256
  return { ok: true, revision: params.revision, idempotent: false };
3739
4257
  });
3740
4258
  },
4259
+ async recordCoverageFailure(params) {
4260
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
4261
+ return { ok: false, code: 'invalid_identity' };
4262
+ }
4263
+ const rows = await sql `
4264
+ UPDATE slotlock.calendar_coverage
4265
+ SET last_error = left(${String(params.error).replace(/[\u0000-\u001f\u007f]/g, ' ')}, 500),
4266
+ last_error_at = now()
4267
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4268
+ AND source = ${params.source}
4269
+ RETURNING source`;
4270
+ return rows.length > 0 ? { ok: true } : { ok: false, code: 'coverage_not_found' };
4271
+ },
4272
+ async deleteCoverage(params) {
4273
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.source) || !isValidResourceId(params.resourceId)) {
4274
+ return false;
4275
+ }
4276
+ const rows = await sql `
4277
+ DELETE FROM slotlock.calendar_coverage
4278
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4279
+ AND source = ${params.source}
4280
+ RETURNING source`;
4281
+ return rows.length > 0;
4282
+ },
4283
+ async listCoverage(params) {
4284
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.resourceId)) {
4285
+ throw invalidIdentityError('Slotlock coverage identity is invalid');
4286
+ }
4287
+ const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4288
+ const rows = await sql `
4289
+ SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
4290
+ observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
4291
+ FROM slotlock.calendar_coverage
4292
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
4293
+ ORDER BY source`;
4294
+ return rows.map((row) => ({
4295
+ source: row.source,
4296
+ start: row.starts_at,
4297
+ end: row.ends_at,
4298
+ revision: Number(row.revision),
4299
+ observedAt: row.observed_at,
4300
+ lastError: row.last_error,
4301
+ lastErrorAt: row.last_error_at,
4302
+ stale: row.stale,
4303
+ }));
4304
+ },
4305
+ async reportStaleCoverage(params) {
4306
+ if (!isValidIdentity(params.tenantRef)) {
4307
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4308
+ }
4309
+ const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
4310
+ const rows = await sql `
4311
+ WITH stale AS (
4312
+ UPDATE slotlock.calendar_coverage coverage SET stale_reported_at = now()
4313
+ WHERE (coverage.tenant_ref, coverage.resource_id, coverage.source) IN (
4314
+ SELECT tenant_ref, resource_id, source FROM slotlock.calendar_coverage
4315
+ WHERE tenant_ref = ${params.tenantRef} AND stale_reported_at IS NULL
4316
+ AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
4317
+ ORDER BY observed_at LIMIT ${params.limit ?? 100}
4318
+ FOR UPDATE SKIP LOCKED)
4319
+ RETURNING coverage.resource_id, coverage.source, coverage.observed_at, coverage.last_error
4320
+ ), reported AS (
4321
+ 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)),
4326
+ 'slotlock:maintenance'
4327
+ FROM stale
4328
+ RETURNING 1
4329
+ )
4330
+ SELECT count(*)::int AS count FROM reported`;
4331
+ return rows[0]?.count ?? 0;
4332
+ },
4333
+ async checkInReservation(params) {
4334
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
4335
+ return { ok: false, code: 'invalid_identity' };
4336
+ }
4337
+ return inTransaction(sql, async (tx) => {
4338
+ const [row] = await tx `
4339
+ WITH booking AS (
4340
+ SELECT id, checked_in_at, ends_at <= statement_timestamp() AS ended,
4341
+ starts_at - make_interval(mins => ${SLOTLOCK_CHECK_IN_OPENS_MINUTES}) > statement_timestamp() AS early
4342
+ FROM slotlock.reservations
4343
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4344
+ AND calendar_event_id IS NULL AND status = 'confirmed'
4345
+ FOR UPDATE
4346
+ )
4347
+ UPDATE slotlock.reservations reservation
4348
+ SET checked_in_at = COALESCE(reservation.checked_in_at, statement_timestamp())
4349
+ FROM booking
4350
+ WHERE reservation.id = booking.id AND NOT booking.ended AND NOT booking.early
4351
+ RETURNING reservation.id, reservation.resource_id, reservation.checked_in_at,
4352
+ false AS ended, booking.checked_in_at IS NULL AS first`;
4353
+ if (!row) {
4354
+ const [booking] = await tx `
4355
+ SELECT ends_at <= statement_timestamp() AS ended FROM slotlock.reservations
4356
+ WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4357
+ AND calendar_event_id IS NULL AND status = 'confirmed'`;
4358
+ if (!booking)
4359
+ return { ok: false, code: 'reservation_not_found' };
4360
+ return { ok: false, code: booking.ended ? 'reservation_ended' : 'check_in_not_open' };
4361
+ }
4362
+ if (row.first) {
4363
+ await tx `
4364
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4365
+ VALUES (${params.tenantRef}, ${row.resource_id}, 'reservation.checked_in', ${params.externalRef},
4366
+ jsonb_build_object('id', ${row.id}::uuid, 'reference', ${params.externalRef}::text,
4367
+ 'resource_id', ${row.resource_id}::uuid,
4368
+ 'checked_in_at', ${row.checked_in_at}::timestamptz),
4369
+ NULLIF(current_setting('slotlock.actor', true), ''))`;
4370
+ }
4371
+ return { ok: true, reference: params.externalRef, checkedInAt: row.checked_in_at };
4372
+ });
4373
+ },
4374
+ async releaseNoShows(params) {
4375
+ if (!isValidIdentity(params.tenantRef)) {
4376
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4377
+ }
4378
+ const limit = params.limit ?? 100;
4379
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
4380
+ throw invalidIdentityError('Slotlock no-show limit must be 1-1,000');
4381
+ }
4382
+ // A suspended tenant's bookings stay as they are.
4383
+ if (refusal)
4384
+ return 0;
4385
+ const tenantRef = params.tenantRef;
4386
+ // The same test twice: to find them, then under each reference's lock, as a cancel takes it.
4387
+ const due = (tx, externalRef) => tx `
4388
+ SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
4389
+ (resource.booking_rules->>'checkInMinutes')::int AS minutes
4390
+ FROM slotlock.reservations reservation
4391
+ JOIN slotlock.resources resource ON resource.id = reservation.resource_id
4392
+ WHERE reservation.tenant_ref = ${tenantRef} AND resource.tenant_ref = ${tenantRef}
4393
+ AND (${externalRef}::text IS NULL OR reservation.external_ref = ${externalRef})
4394
+ AND reservation.status = 'confirmed' AND reservation.calendar_event_id IS NULL
4395
+ AND reservation.checked_in_at IS NULL
4396
+ AND resource.check_in_since IS NOT NULL AND resource.booking_rules ? 'checkInMinutes'
4397
+ AND reservation.created_at >= resource.check_in_since
4398
+ -- A booking made after its start (a walk-in) has its window from when it was made.
4399
+ AND GREATEST(reservation.starts_at, reservation.created_at)
4400
+ + make_interval(mins => (resource.booking_rules->>'checkInMinutes')::int)
4401
+ <= statement_timestamp()
4402
+ AND reservation.ends_at > statement_timestamp()
4403
+ ORDER BY reservation.starts_at, reservation.id
4404
+ LIMIT ${externalRef === null ? limit : 1}
4405
+ ${externalRef === null ? tx `` : tx `FOR UPDATE OF reservation`}`;
4406
+ let released = 0;
4407
+ for (const candidate of await due(sql, null)) {
4408
+ const done = await inTransaction(sql, async (tx) => {
4409
+ await lockExternalIdentity(tx, tenantRef, candidate.external_ref);
4410
+ const [row] = await due(tx, candidate.external_ref);
4411
+ if (!row || row.id !== candidate.id)
4412
+ return false;
4413
+ const revision = Number(row.revision) + 1;
4414
+ await tx `
4415
+ INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
4416
+ VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
4417
+ await tx `DELETE FROM slotlock.reservations WHERE id = ${row.id}`;
4418
+ await tx `
4419
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4420
+ VALUES (${tenantRef}, ${row.resource_id}, 'reservation.no_show', ${row.external_ref},
4421
+ jsonb_build_object('id', ${row.id}::uuid, 'reference', ${row.external_ref}::text,
4422
+ 'resource_id', ${row.resource_id}::uuid, 'revision', ${revision}::bigint,
4423
+ 'check_in_minutes', ${row.minutes}::int),
4424
+ 'slotlock:maintenance')`;
4425
+ return true;
4426
+ });
4427
+ if (done)
4428
+ released += 1;
4429
+ }
4430
+ return released;
4431
+ },
4432
+ async joinWaitlist(params) {
4433
+ const { tenantRef, holderRef } = params;
4434
+ if (!isValidIdentity(tenantRef) || !isValidIdentity(holderRef))
4435
+ return { ok: false, code: 'invalid_identity' };
4436
+ if ((params.resourceId === undefined) === (params.pool === undefined)) {
4437
+ return { ok: false, code: 'invalid_identity' };
4438
+ }
4439
+ if (params.resourceId !== undefined && !isValidResourceId(params.resourceId)) {
4440
+ return { ok: false, code: 'resource_not_found' };
4441
+ }
4442
+ if (params.pool !== undefined && !isPoolName(params.pool))
4443
+ return { ok: false, code: 'invalid_pool' };
4444
+ const window = { start: params.start, end: params.end };
4445
+ if (!isFiniteWindow(window) ||
4446
+ window.end.getTime() - window.start.getTime() > MAX_WAITLIST_WINDOW_MS) {
4447
+ return { ok: false, code: 'invalid_window' };
4448
+ }
4449
+ const offerTtlMs = params.offerTtlMs ?? DEFAULT_OFFER_TTL_MS;
4450
+ if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
4451
+ return { ok: false, code: 'invalid_ttl' };
4452
+ }
4453
+ return inTransaction(sql, async (tx) => {
4454
+ if (params.resourceId !== undefined) {
4455
+ const [resource] = await tx `
4456
+ SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
4457
+ WHERE id = ${params.resourceId} AND tenant_ref = ${tenantRef}`;
4458
+ if (!resource)
4459
+ return { ok: false, code: 'resource_not_found' };
4460
+ if (resource.archived)
4461
+ return { ok: false, code: 'resource_archived' };
4462
+ }
4463
+ else {
4464
+ const [member] = await tx `
4465
+ SELECT 1 FROM slotlock.resources
4466
+ WHERE tenant_ref = ${tenantRef} AND pool_ref = ${params.pool ?? null} AND archived_at IS NULL
4467
+ LIMIT 1`;
4468
+ if (!member)
4469
+ return { ok: false, code: 'pool_not_found' };
4470
+ }
4471
+ // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at the cap.
4472
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
4473
+ const [counts] = await tx `
4474
+ SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
4475
+ FROM slotlock.waitlist_entries WHERE tenant_ref = ${tenantRef} AND status = 'waiting'`;
4476
+ if ((counts?.holder ?? 0) >= SLOTLOCK_MAX_WAITLIST_PER_HOLDER ||
4477
+ (counts?.tenant ?? 0) >= SLOTLOCK_MAX_WAITLIST_PER_TENANT) {
4478
+ return { ok: false, code: 'waitlist_limit_reached' };
4479
+ }
4480
+ const [row] = await tx `
4481
+ INSERT INTO slotlock.waitlist_entries
4482
+ (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms)
4483
+ VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
4484
+ ${window.end}, ${holderRef}, ${offerTtlMs})
4485
+ RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
4486
+ return { ok: true, entry: toWaitlistEntry(row) };
4487
+ });
4488
+ },
4489
+ async listWaitlist(params) {
4490
+ if (!isValidIdentity(params.tenantRef))
4491
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4492
+ if (params.resourceId !== undefined && !isValidResourceId(params.resourceId))
4493
+ return [];
4494
+ const limit = params.limit ?? 100;
4495
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1_000) {
4496
+ throw invalidIdentityError('Slotlock waitlist limit must be 1-1,000');
4497
+ }
4498
+ const rows = await sql `
4499
+ SELECT ${sql.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
4500
+ WHERE tenant_ref = ${params.tenantRef}
4501
+ AND (${params.resourceId ?? null}::uuid IS NULL OR resource_id = ${params.resourceId ?? null}::uuid)
4502
+ AND (${params.pool ?? null}::text IS NULL OR pool_ref = ${params.pool ?? null})
4503
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
4504
+ ORDER BY created_at, id
4505
+ LIMIT ${limit}`;
4506
+ return rows.map(toWaitlistEntry);
4507
+ },
4508
+ async leaveWaitlist(params) {
4509
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
4510
+ return false;
4511
+ return inTransaction(sql, async (tx) => {
4512
+ // Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
4513
+ const [left] = await tx `
4514
+ DELETE FROM slotlock.waitlist_entries
4515
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
4516
+ AND (${params.holderRef ?? null}::text IS NULL OR holder_ref = ${params.holderRef ?? null})
4517
+ RETURNING hold_id`;
4518
+ if (!left)
4519
+ return false;
4520
+ // An offer not yet taken goes too, freeing the time for the next who waits; a confirmed
4521
+ // one is a booking now and stays.
4522
+ if (left.hold_id) {
4523
+ await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
4524
+ }
4525
+ return true;
4526
+ });
4527
+ },
4528
+ async offerWaitlist(params) {
4529
+ if (!isValidIdentity(params.tenantRef)) {
4530
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4531
+ }
4532
+ const limit = params.limit ?? 50;
4533
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
4534
+ throw invalidIdentityError('Slotlock waitlist offer limit must be 1-100');
4535
+ }
4536
+ // A suspended tenant gets no new holds.
4537
+ if (refusal)
4538
+ return { offered: 0, expired: 0, next: null };
4539
+ const tenantRef = params.tenantRef;
4540
+ const after = params.after ?? null;
4541
+ return inTransaction(sql, async (tx) => {
4542
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
4543
+ let expired = 0;
4544
+ if (!after) {
4545
+ const [gone] = await tx `
4546
+ WITH gone AS (
4547
+ UPDATE slotlock.waitlist_entries SET status = 'expired'
4548
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting' AND starts_at <= statement_timestamp()
4549
+ RETURNING id, resource_id, pool_ref, starts_at, ends_at, holder_ref
4550
+ ), reported AS (
4551
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4552
+ SELECT ${tenantRef}, gone.resource_id, 'waitlist.expired', gone.id::text,
4553
+ jsonb_strip_nulls(jsonb_build_object(
4554
+ 'id', gone.id, 'resource_id', gone.resource_id, 'pool', gone.pool_ref,
4555
+ 'start', gone.starts_at, 'end', gone.ends_at, 'holder_ref', gone.holder_ref)),
4556
+ 'slotlock:maintenance'
4557
+ FROM gone
4558
+ RETURNING 1
4559
+ )
4560
+ SELECT count(*)::int AS count FROM reported`;
4561
+ expired = gone?.count ?? 0;
4562
+ await tx `
4563
+ DELETE FROM slotlock.waitlist_entries
4564
+ WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
4565
+ AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
4566
+ }
4567
+ // One page in join order; the caller passes `next` back for the rest.
4568
+ const waiting = await tx `
4569
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
4570
+ WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
4571
+ AND (${after?.createdAt ?? null}::timestamptz IS NULL
4572
+ OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
4573
+ ORDER BY created_at, id
4574
+ LIMIT ${limit}`;
4575
+ // Holds made through a store on this transaction, each in a savepoint of its own.
4576
+ const inner = buildSlotlockStore(tx, options, null);
4577
+ let offered = 0;
4578
+ for (const entry of waiting) {
4579
+ // Locked so a leave waits for this offer, and skipped if it left first.
4580
+ const [still] = await tx `
4581
+ SELECT 1 FROM slotlock.waitlist_entries
4582
+ WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
4583
+ if (!still)
4584
+ continue;
4585
+ // Only resources whose window looks free are tried: a hold refused keeps its locks
4586
+ // until the transaction ends, so trying every member of a full pool would pile them up.
4587
+ 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}))
4592
+ AND NOT EXISTS (
4593
+ 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, '[)')
4597
+ AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
4598
+ ORDER BY resource.id
4599
+ LIMIT ${OFFER_CANDIDATES}`;
4600
+ for (const { id: resourceId } of candidates) {
4601
+ const hold = await inner.acquireHold({
4602
+ resourceId,
4603
+ start: entry.starts_at,
4604
+ end: entry.ends_at,
4605
+ ttlMs: Number(entry.offer_ttl_ms),
4606
+ tenantRef,
4607
+ holderRef: entry.holder_ref,
4608
+ source: 'slotlock:waitlist',
4609
+ });
4610
+ if (!hold.ok)
4611
+ continue;
4612
+ await tx `
4613
+ UPDATE slotlock.waitlist_entries
4614
+ SET status = 'offered', hold_id = ${hold.hold.id}, offered_resource_id = ${resourceId},
4615
+ offered_at = statement_timestamp()
4616
+ WHERE id = ${entry.id}`;
4617
+ await tx `
4618
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4619
+ VALUES (${tenantRef}, ${resourceId}, 'waitlist.offered', ${entry.id},
4620
+ jsonb_strip_nulls(jsonb_build_object(
4621
+ 'id', ${entry.id}::uuid, 'hold_id', ${hold.hold.id}::uuid,
4622
+ 'resource_id', ${resourceId}::uuid, 'pool', ${entry.pool_ref}::text,
4623
+ 'start', ${entry.starts_at}::timestamptz, 'end', ${entry.ends_at}::timestamptz,
4624
+ 'expires_at', ${hold.hold.expiresAt}::timestamptz,
4625
+ 'holder_ref', ${entry.holder_ref}::text)),
4626
+ 'slotlock:maintenance')`;
4627
+ offered += 1;
4628
+ break;
4629
+ }
4630
+ }
4631
+ const last = waiting.length === limit ? waiting[waiting.length - 1] : undefined;
4632
+ return { offered, expired, next: last ? { createdAt: last.created_at, id: last.id } : null };
4633
+ });
4634
+ },
3741
4635
  async getFreeBusy(params) {
3742
4636
  if (!isValidIdentity(params.tenantRef)) {
3743
4637
  throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
@@ -3766,7 +4660,8 @@ function buildSlotlockStore(sql, options, refusal) {
3766
4660
  });
3767
4661
  }
3768
4662
  const owner = await sql `
3769
- SELECT id FROM slotlock.resources
4663
+ SELECT id, COALESCE((booking_rules->>'requireCertain')::boolean, false) AS require_certain
4664
+ FROM slotlock.resources
3770
4665
  WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
3771
4666
  if (!owner[0]) {
3772
4667
  throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
@@ -3780,7 +4675,7 @@ function buildSlotlockStore(sql, options, refusal) {
3780
4675
  WHERE reservation.resource_id = ${params.resourceId}
3781
4676
  AND reservation.starts_at < ${window.end}
3782
4677
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
3783
- AND (reservation.status <> 'held' OR reservation.expires_at > now())
4678
+ AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
3784
4679
  ORDER BY reservation.starts_at
3785
4680
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
3786
4681
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
@@ -3823,7 +4718,15 @@ function buildSlotlockStore(sql, options, refusal) {
3823
4718
  FROM recurring_sources
3824
4719
  WHERE earliest_possible_at < ${window.end}`;
3825
4720
  const localRecurrenceRequired = Number(localRecurrenceCoverage?.source_count ?? 0) > 0;
3826
- const providerRequiredSources = callerRequiredSources.filter((source) => source !== SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
4721
+ // A resource that requires certainty requires every sync recording coverage of it, as its
4722
+ // writes do (bookingJudge), and judges them by the same day.
4723
+ const requireCertain = owner[0].require_certain;
4724
+ const recordedSources = requireCertain
4725
+ ? (await sql `
4726
+ SELECT source FROM slotlock.calendar_coverage
4727
+ WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}`).map(({ source }) => source)
4728
+ : [];
4729
+ const providerRequiredSources = [...new Set([...callerRequiredSources, ...recordedSources])].filter((source) => source !== SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
3827
4730
  if (!localRecurrenceRequired && providerRequiredSources.length === 0) {
3828
4731
  return {
3829
4732
  busy: mergeIntervals(busyRows.map((row) => ({
@@ -3833,7 +4736,7 @@ function buildSlotlockStore(sql, options, refusal) {
3833
4736
  coverage: { state: 'complete', missingSources: [] },
3834
4737
  };
3835
4738
  }
3836
- const ageSeconds = coverageMaxAgeMs / 1000;
4739
+ const ageSeconds = (requireCertain ? Math.min(coverageMaxAgeMs, CERTAIN_COVERAGE_MAX_AGE_MS) : coverageMaxAgeMs) / 1000;
3837
4740
  const coverageRows = providerRequiredSources.length === 0
3838
4741
  ? []
3839
4742
  : await sql `
@@ -3869,18 +4772,18 @@ function buildSlotlockStore(sql, options, refusal) {
3869
4772
  };
3870
4773
  },
3871
4774
  async findNextAvailableFor(params) {
3872
- const window = params.searchWindow;
3873
- if (!isFiniteWindow(window))
4775
+ const asked = params.searchWindow;
4776
+ if (!isFiniteWindow(asked))
3874
4777
  throw invalidWindowError();
3875
- assertBoundedSearchWindow(window);
4778
+ assertBoundedSearchWindow(asked);
3876
4779
  const { tenantRef } = params;
3877
4780
  if (tenantRef !== null && !isValidIdentity(tenantRef)) {
3878
4781
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3879
4782
  }
3880
- const bufferAfterMs = params.bufferAfterMs ?? 0;
3881
- if (!Number.isSafeInteger(bufferAfterMs) ||
3882
- bufferAfterMs < 0 ||
3883
- bufferAfterMs > MAX_BUFFER_AFTER_MS) {
4783
+ const askedBufferMs = params.bufferAfterMs ?? 0;
4784
+ if (!Number.isSafeInteger(askedBufferMs) ||
4785
+ askedBufferMs < 0 ||
4786
+ askedBufferMs > MAX_BUFFER_AFTER_MS) {
3884
4787
  throw Object.assign(new Error('Slotlock bufferAfterMs must be a whole 0-30 days'), {
3885
4788
  code: 'invalid_buffer',
3886
4789
  });
@@ -3891,7 +4794,8 @@ function buildSlotlockStore(sql, options, refusal) {
3891
4794
  if (!isValidResourceId(params.resourceId))
3892
4795
  throw notFound();
3893
4796
  const [row] = await sql `
3894
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
4797
+ SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
4798
+ archived_at, statement_timestamp() AS now
3895
4799
  FROM slotlock.resources
3896
4800
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
3897
4801
  if (!row)
@@ -3900,6 +4804,17 @@ function buildSlotlockStore(sql, options, refusal) {
3900
4804
  return null;
3901
4805
  const resource = toResource(row);
3902
4806
  const rules = params.rules ?? resource.availabilityRules ?? [];
4807
+ // Only what a booking made now may take: the resource's booking rules bound the search.
4808
+ const search = bookingSearch({
4809
+ rules: resource.bookingRules,
4810
+ timezone: resource.timezone,
4811
+ window: asked,
4812
+ now: row.now,
4813
+ bufferAfterMs: askedBufferMs,
4814
+ });
4815
+ if (!search)
4816
+ return null;
4817
+ const { window, bufferAfterMs } = search;
3903
4818
  // A slot's buffer may run past the window: the time it can reach must be proven free too.
3904
4819
  const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
3905
4820
  let busy;
@@ -3936,6 +4851,7 @@ function buildSlotlockStore(sql, options, refusal) {
3936
4851
  })),
3937
4852
  windows: expandRules(rules, window, resource.timezone),
3938
4853
  durationMs: params.durationMs,
4854
+ ...(search.align ? { align: search.align } : {}),
3939
4855
  });
3940
4856
  },
3941
4857
  async suspendTenant(params) {