slotlock 0.6.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +511 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +70 -1
  17. package/assistant.js +478 -50
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-assistant-ui.d.ts +21 -0
  23. package/dashboard-assistant-ui.js +438 -0
  24. package/dashboard-icon.d.ts +1 -0
  25. package/dashboard-icon.js +5 -0
  26. package/dashboard-markdown.d.ts +2 -0
  27. package/dashboard-markdown.js +157 -0
  28. package/dashboard-settings-ui.d.ts +1 -0
  29. package/dashboard-settings-ui.js +78 -0
  30. package/dashboard.d.ts +227 -7
  31. package/dashboard.js +2313 -247
  32. package/ddl.d.ts +51 -12
  33. package/ddl.js +2566 -54
  34. package/event-feed.d.ts +30 -4
  35. package/event-feed.js +171 -19
  36. package/index.d.ts +15 -4
  37. package/index.js +8 -2
  38. package/maintenance.d.ts +10 -0
  39. package/maintenance.js +16 -2
  40. package/metrics.d.ts +41 -0
  41. package/metrics.js +22 -0
  42. package/openapi.json +14675 -5957
  43. package/otel.d.ts +86 -0
  44. package/otel.js +273 -0
  45. package/overage.d.ts +33 -0
  46. package/overage.js +115 -0
  47. package/package.json +1 -1
  48. package/partner-billing.d.ts +81 -0
  49. package/partner-billing.js +333 -0
  50. package/partner-names.d.ts +8 -0
  51. package/partner-names.js +10 -0
  52. package/partners.d.ts +147 -0
  53. package/partners.js +375 -0
  54. package/plans.d.ts +92 -0
  55. package/plans.js +115 -0
  56. package/quotas.d.ts +108 -0
  57. package/quotas.js +258 -0
  58. package/rate-limit.d.ts +21 -1
  59. package/rate-limit.js +82 -15
  60. package/resource-profile.d.ts +135 -0
  61. package/resource-profile.js +263 -0
  62. package/rest-api.d.ts +2 -0
  63. package/rest-api.js +68 -3
  64. package/sandbox.d.ts +70 -0
  65. package/sandbox.js +295 -0
  66. package/self-host.d.ts +33 -5
  67. package/self-host.js +950 -22
  68. package/slotlock.manifest.json +1 -1
  69. package/store.d.ts +373 -4
  70. package/store.js +1212 -118
  71. package/teams.d.ts +25 -0
  72. package/teams.js +63 -0
  73. package/timezone.js +13 -1
  74. package/types.d.ts +95 -1
  75. package/usage.d.ts +106 -0
  76. package/usage.js +238 -0
package/store.js CHANGED
@@ -1,14 +1,23 @@
1
1
  // DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
2
+ import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
3
+ import { readSlotlockTenantUsage } from './usage.js';
4
+ import { slotlockPlanReadRefusal } from './plans.js';
2
5
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
3
6
  import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
4
7
  import { createHash, randomUUID } from 'node:crypto';
5
8
  import ICAL from 'ical.js';
6
- import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
9
+ import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
7
10
  import { findNextAvailable, mergeIntervals } from './engine.js';
8
11
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
9
12
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
10
13
  import { parseAvailabilityRules } from './weekly-hours.js';
14
+ import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
11
15
  const EXCLUSION_VIOLATION = '23P01';
16
+ /** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
17
+ const SLOTLOCK_MAX_APPROVAL_HOLDS = 20;
18
+ /** Approvals one requester, and one tenant, may have waiting at once. */
19
+ const SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER = 50;
20
+ const SLOTLOCK_MAX_PENDING_APPROVALS = 500;
12
21
  /**
13
22
  * Concurrent inserts on the same EXCLUDE range can deadlock inside the constraint check itself
14
23
  * (intrinsic to Postgres EXCLUDE). The victim is fully rolled back, so one retry either wins or
@@ -561,8 +570,17 @@ async function tenantRlsUpToDate(tx, setting) {
561
570
  const [state] = await tx `
562
571
  SELECT pg_catalog.bool_and(
563
572
  rel.relrowsecurity AND rel.relforcerowsecurity
573
+ -- The tenant policy, and on a table a limited key is held to, the restrictive scope
574
+ -- policy, which can only narrow it; no other.
564
575
  AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
565
- WHERE every.polrelid = rel.oid) = 1
576
+ WHERE every.polrelid = rel.oid
577
+ AND (every.polname = 'tenant_isolation_v1'
578
+ OR NOT (every.polname = 'resource_scope_v1' AND NOT every.polpermissive
579
+ AND wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[])))) = 1
580
+ AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy scoped
581
+ WHERE scoped.polrelid = rel.oid AND scoped.polname = 'resource_scope_v1'
582
+ AND NOT scoped.polpermissive)
583
+ = CASE WHEN wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[]) THEN 1 ELSE 0 END
566
584
  AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
567
585
  AND contract.tenant_context_setting = ${setting}
568
586
  AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
@@ -727,6 +745,9 @@ function pageLimit(limit) {
727
745
  }
728
746
  return value;
729
747
  }
748
+ function sandboxTestOnlyError() {
749
+ return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
750
+ }
730
751
  function invalidIdentityError(message) {
731
752
  return Object.assign(new Error(message), { code: 'invalid_identity' });
732
753
  }
@@ -934,42 +955,56 @@ async function replaceCalendarEventOccurrences(tx, params) {
934
955
  WHERE event_id = ${params.eventId} AND reservation_id IS NOT NULL
935
956
  )`;
936
957
  await tx `DELETE FROM slotlock.calendar_event_occurrences WHERE event_id = ${params.eventId}`;
937
- for (const occurrence of params.occurrences) {
938
- let reservationId = null;
939
- if ((params.event.transparency ?? 'opaque') === 'opaque') {
940
- const occupied = occupiedTime({
941
- ...occurrence,
942
- bufferBeforeMs: params.bufferBeforeMs ?? 0,
943
- bufferAfterMs: params.bufferAfterMs ?? 0,
944
- });
945
- await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
946
- const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
947
- .update(occurrence.recurrenceId)
948
- .digest('hex')}`;
949
- const reservations = await tx `
950
- INSERT INTO slotlock.reservations (
951
- resource_id, tenant_ref, external_ref, starts_at, ends_at,
952
- buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
953
- ) VALUES (
954
- ${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
955
- ${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
956
- ${params.bufferBeforeMs ?? 0}, ${params.revision},
957
- ${`calendar-event:${params.eventId}`}, ${params.eventId}
958
- )
959
- RETURNING id`;
960
- reservationId = reservations[0]?.id ?? null;
961
- if (!reservationId) {
958
+ if (params.occurrences.length === 0)
959
+ return;
960
+ // Three statements for the whole series, not three per occurrence: a series of 2,000 holds the
961
+ // tenant's locks for as long as its round trips take, and every write queued behind it waits.
962
+ const starts = params.occurrences.map((occurrence) => occurrence.start.toISOString());
963
+ const ends = params.occurrences.map((occurrence) => occurrence.end.toISOString());
964
+ const reservationIds = params.occurrences.map(() => '');
965
+ if ((params.event.transparency ?? 'opaque') === 'opaque') {
966
+ const occupied = params.occurrences.map((occurrence) => occupiedTime({
967
+ ...occurrence,
968
+ bufferBeforeMs: params.bufferBeforeMs ?? 0,
969
+ bufferAfterMs: params.bufferAfterMs ?? 0,
970
+ }));
971
+ await reapExpiredHoldsAcross(tx, params.resourceId, occupied);
972
+ const externalRefs = params.occurrences.map((occurrence) => `calendar:${params.eventId}:${createHash('sha256').update(occurrence.recurrenceId).digest('hex')}`);
973
+ const inserted = await tx `
974
+ INSERT INTO slotlock.reservations (
975
+ resource_id, tenant_ref, external_ref, starts_at, ends_at,
976
+ buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
977
+ )
978
+ SELECT ${params.resourceId}, ${params.tenantRef}, occurrence.external_ref,
979
+ occurrence.starts_at, occurrence.ends_at, ${params.bufferAfterMs ?? 0},
980
+ ${params.bufferBeforeMs ?? 0}, ${params.revision},
981
+ ${`calendar-event:${params.eventId}`}, ${params.eventId}
982
+ FROM ROWS FROM (pg_catalog.unnest(${externalRefs}::text[]),
983
+ pg_catalog.unnest(${starts}::text[]::timestamptz[]),
984
+ pg_catalog.unnest(${ends}::text[]::timestamptz[]))
985
+ WITH ORDINALITY AS occurrence(external_ref, starts_at, ends_at, ordinal)
986
+ ORDER BY occurrence.ordinal
987
+ RETURNING id, external_ref`;
988
+ const byRef = new Map(inserted.map((row) => [row.external_ref, row.id]));
989
+ externalRefs.forEach((externalRef, index) => {
990
+ const id = byRef.get(externalRef);
991
+ if (!id)
962
992
  throw new Error('slotlock: calendar occurrence reservation insert returned no row');
963
- }
964
- }
965
- await tx `
966
- INSERT INTO slotlock.calendar_event_occurrences (
967
- event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
968
- ) VALUES (
969
- ${params.eventId}, ${params.tenantRef}, ${occurrence.recurrenceId},
970
- ${occurrence.start}, ${occurrence.end}, ${reservationId}
971
- )`;
993
+ reservationIds[index] = id;
994
+ });
972
995
  }
996
+ await tx `
997
+ INSERT INTO slotlock.calendar_event_occurrences (
998
+ event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
999
+ )
1000
+ SELECT ${params.eventId}, ${params.tenantRef}, occurrence.recurrence_id,
1001
+ occurrence.starts_at, occurrence.ends_at, NULLIF(occurrence.reservation_id, '')::uuid
1002
+ FROM ROWS FROM (pg_catalog.unnest(${params.occurrences.map((occurrence) => occurrence.recurrenceId)}::text[]),
1003
+ pg_catalog.unnest(${starts}::text[]::timestamptz[]),
1004
+ pg_catalog.unnest(${ends}::text[]::timestamptz[]),
1005
+ pg_catalog.unnest(${reservationIds}::text[]))
1006
+ WITH ORDINALITY AS occurrence(recurrence_id, starts_at, ends_at, reservation_id, ordinal)
1007
+ ORDER BY occurrence.ordinal`;
973
1008
  }
974
1009
  function seriesWindow(window) {
975
1010
  if (!isFiniteWindow(window) ||
@@ -1232,18 +1267,50 @@ async function lockExternalIdentity(tx, tenantRef, externalRef) {
1232
1267
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
1233
1268
  }
1234
1269
  /**
1235
- * Whether the resource is the tenant's (`missing` when not), and whether it is archived.
1236
- * Share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
1270
+ * Lock a resource a write is about to occupy time on, with its composite parent, in id order, to
1271
+ * the end of the transaction (RFC 0004 A7): writes to one resource, or one composite's family,
1272
+ * take turns. Without it, writers racing for the same time each insert, then wait at the
1273
+ * exclusion constraint for one another, and PostgreSQL breaks each such deadlock only after its
1274
+ * deadlock_timeout (a second), so a burst of agents for one slot could take many seconds. With
1275
+ * it, each meets the winner's committed row and is refused at once. NO KEY UPDATE, so foreign
1276
+ * keys to the resource are not blocked; an archive or delete waits for the write, as before. The
1277
+ * resource's own row, or null when there is none (for the tenant, when one is given).
1278
+ */
1279
+ async function lockResourceForWrite(tx, resourceId, tenantRef) {
1280
+ const rows = await tx `
1281
+ SELECT id, tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
1282
+ WHERE (id = ${resourceId}
1283
+ OR id = (SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}))
1284
+ AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
1285
+ ORDER BY id
1286
+ FOR NO KEY UPDATE`;
1287
+ const own = rows.find((row) => row.id === resourceId);
1288
+ return own ? { tenant_ref: own.tenant_ref, archived: own.archived } : null;
1289
+ }
1290
+ /**
1291
+ * Whether the resource is the tenant's (`missing` when not), and whether it is archived, locked
1292
+ * for the write that follows (lockResourceForWrite).
1237
1293
  */
1238
1294
  async function tenantResourceState(tx, resourceId, tenantRef) {
1239
- const [row] = await tx `
1240
- SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
1241
- WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
1242
- FOR SHARE`;
1295
+ const row = await readResourceForWrite(tx, resourceId, tenantRef);
1243
1296
  if (!row)
1244
1297
  return 'missing';
1245
1298
  return row.archived ? 'archived' : 'active';
1246
1299
  }
1300
+ /**
1301
+ * A resource a write is about to check its booking against, key-share locked so it is not deleted
1302
+ * meanwhile. It does not make writers wait for each other: lockResourceForWrite does, later, right
1303
+ * before the occupation is written, and checks again that the resource is there and not archived.
1304
+ * (KEY SHARE never conflicts with the NO KEY UPDATE that follows, so two writers cannot deadlock
1305
+ * upgrading.)
1306
+ */
1307
+ async function readResourceForWrite(tx, resourceId, tenantRef) {
1308
+ const [row] = await tx `
1309
+ SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
1310
+ WHERE id = ${resourceId} AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
1311
+ FOR KEY SHARE`;
1312
+ return row ?? null;
1313
+ }
1247
1314
  function pgCode(err) {
1248
1315
  let cur = err;
1249
1316
  while (cur && typeof cur === 'object') {
@@ -1282,6 +1349,15 @@ function invalidPoolError() {
1282
1349
  code: 'invalid_pool',
1283
1350
  });
1284
1351
  }
1352
+ /** A resource's own columns, as toResource reads them. */
1353
+ const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
1354
+ visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
1355
+ parent_id, part_index,
1356
+ (SELECT place.name FROM slotlock.locations place
1357
+ WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
1358
+ ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
1359
+ WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
1360
+ ORDER BY member.group_ref) AS group_refs`;
1285
1361
  /**
1286
1362
  * A resource row's effective booking rules (jsonb; NULL for none): tenant defaults, overlaid by its
1287
1363
  * pool's, overlaid by its own, field by field. Reads from a row of slotlock.resources named
@@ -1318,6 +1394,35 @@ function toResource(row) {
1318
1394
  resource.archivedAt = row.archived_at;
1319
1395
  if (row.pool_ref !== null && row.pool_ref !== undefined)
1320
1396
  resource.pool = row.pool_ref;
1397
+ // The profile, each part only when it says something.
1398
+ if (typeof row.name === 'string')
1399
+ resource.name = row.name;
1400
+ if (typeof row.description === 'string')
1401
+ resource.description = row.description;
1402
+ if (row.kind !== undefined && row.kind !== 'other' && SLOTLOCK_RESOURCE_KINDS.includes(row.kind)) {
1403
+ resource.kind = row.kind;
1404
+ }
1405
+ if (typeof row.capacity === 'number')
1406
+ resource.capacity = row.capacity;
1407
+ if (Array.isArray(row.labels) && row.labels.length > 0)
1408
+ resource.labels = [...row.labels];
1409
+ if (row.attributes !== null &&
1410
+ typeof row.attributes === 'object' &&
1411
+ !Array.isArray(row.attributes) &&
1412
+ Object.keys(row.attributes).length > 0) {
1413
+ resource.attributes = { ...row.attributes };
1414
+ }
1415
+ if (typeof row.location_ref === 'string') {
1416
+ resource.location = row.location_ref;
1417
+ if (typeof row.location_name === 'string')
1418
+ resource.locationName = row.location_name;
1419
+ }
1420
+ if (Array.isArray(row.group_refs) && row.group_refs.length > 0)
1421
+ resource.groups = [...row.group_refs];
1422
+ if (typeof row.parent_id === 'string' && typeof row.part_index === 'number') {
1423
+ resource.parentId = row.parent_id;
1424
+ resource.partIndex = row.part_index;
1425
+ }
1321
1426
  if (row.availability_rules !== null && row.availability_rules !== undefined) {
1322
1427
  // Written validated; anything else reads as closed.
1323
1428
  try {
@@ -1355,23 +1460,137 @@ function availabilityParameter(sql, rules) {
1355
1460
  durationMinutes,
1356
1461
  })));
1357
1462
  }
1463
+ /** What the location and group guards (ddl.ts) refuse with, by their HINT. */
1464
+ const GROUPING_REFUSALS = new Set([
1465
+ 'pool_order_limit_reached',
1466
+ 'composite_too_deep',
1467
+ 'composite_parts_limit',
1468
+ 'composite_busy',
1469
+ 'location_too_deep',
1470
+ 'location_cycle',
1471
+ 'location_limit_reached',
1472
+ 'group_limit_reached',
1473
+ 'resource_group_limit_reached',
1474
+ ]);
1475
+ /**
1476
+ * A location or group write PostgreSQL refused, as the store's code: a guard's HINT, or the code
1477
+ * `foreignKeys` gives a foreign key that failed. Anything else as it was.
1478
+ */
1479
+ function groupingRefusal(error, foreignKeys = {}) {
1480
+ const failure = error;
1481
+ const coded = (code) => Object.assign(new Error(typeof failure?.message === 'string' ? failure.message : code, { cause: error }), { code });
1482
+ if (failure?.code === '23514' && typeof failure.hint === 'string' && GROUPING_REFUSALS.has(failure.hint)) {
1483
+ return coded(failure.hint);
1484
+ }
1485
+ if (failure?.code === '23503' && typeof failure.constraint_name === 'string') {
1486
+ const code = foreignKeys[failure.constraint_name];
1487
+ if (code !== undefined)
1488
+ return coded(code);
1489
+ }
1490
+ return error;
1491
+ }
1492
+ /**
1493
+ * Run a location or group write in a savepoint of its own (a transaction when there is none), and
1494
+ * map what PostgreSQL refuses (groupingRefusal). A failed statement leaves the caller's transaction
1495
+ * usable, and postgres.js reports the mapped error rather than the statement's.
1496
+ */
1497
+ async function guardedWrite(executor, write, foreignKeys = {}) {
1498
+ try {
1499
+ return await inTransaction(executor, write);
1500
+ }
1501
+ catch (error) {
1502
+ throw groupingRefusal(error, foreignKeys);
1503
+ }
1504
+ }
1505
+ /** A resource's location that the tenant does not have. */
1506
+ const RESOURCE_LOCATION_MISSING = Object.freeze({ slotlock_resources_location_fkey: 'location_not_found' });
1507
+ /** The nearest time zone a location, or one above it, has; null when none has one. */
1508
+ async function locationTimezone(executor, tenantRef, location) {
1509
+ const [row] = await executor `
1510
+ WITH RECURSIVE up(ref, parent_ref, timezone, steps) AS (
1511
+ SELECT place.ref, place.parent_ref, place.timezone, 1 FROM slotlock.locations place
1512
+ WHERE place.tenant_ref = ${tenantRef} AND place.ref = ${location}
1513
+ UNION ALL
1514
+ SELECT place.ref, place.parent_ref, place.timezone, up.steps + 1
1515
+ FROM slotlock.locations place JOIN up ON place.tenant_ref = ${tenantRef} AND place.ref = up.parent_ref
1516
+ WHERE up.timezone IS NULL AND up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
1517
+ )
1518
+ SELECT timezone FROM up WHERE timezone IS NOT NULL ORDER BY steps LIMIT 1`;
1519
+ return row?.timezone ?? null;
1520
+ }
1521
+ const toLocation = (row) => ({
1522
+ ref: row.ref,
1523
+ name: row.name,
1524
+ parentRef: row.parent_ref,
1525
+ timezone: row.timezone,
1526
+ resources: Number(row.resources),
1527
+ });
1528
+ const toGroup = (row) => ({
1529
+ ref: row.ref,
1530
+ name: row.name,
1531
+ description: row.description,
1532
+ members: Number(row.members),
1533
+ });
1534
+ /**
1535
+ * A checked profile change as statement parameters: each field's value (null when not given or
1536
+ * cleared) and whether it was given. Labels travel as one comma-joined string, which a label never
1537
+ * contains: postgres.js binds an array as text[] only once the pool knows the server's types.
1538
+ */
1539
+ function profileParameters(change) {
1540
+ return {
1541
+ name: change.name ?? null,
1542
+ setName: change.name !== undefined,
1543
+ description: change.description ?? null,
1544
+ setDescription: change.description !== undefined,
1545
+ kind: change.kind ?? null,
1546
+ setKind: change.kind !== undefined,
1547
+ capacity: change.capacity ?? null,
1548
+ setCapacity: change.capacity !== undefined,
1549
+ labels: change.labels === undefined ? null : change.labels.join(','),
1550
+ setLabels: change.labels !== undefined,
1551
+ attributes: change.attributes === undefined ? null : JSON.stringify(change.attributes),
1552
+ setAttributes: change.attributes !== undefined,
1553
+ };
1554
+ }
1358
1555
  async function upsertResource(executor, params) {
1359
1556
  const visibility = params.visibility ?? 'shared';
1360
1557
  const asked = params.visibility ?? null;
1558
+ const profile = profileParameters(params.profile ?? {});
1361
1559
  let rows;
1362
1560
  // An existing identity keeps its visibility: re-creating with another is refused, so a private
1363
- // one is never made shared.
1561
+ // one is never made shared. Its profile takes the fields given, and keeps the rest.
1364
1562
  if (params.tenantRef !== undefined && params.externalRef !== undefined) {
1365
1563
  rows = await executor `
1366
- INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1367
- VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility})
1564
+ INSERT INTO slotlock.resources (
1565
+ external_ref, tenant_ref, timezone, visibility, name, description, kind, capacity, labels, attributes,
1566
+ location_ref
1567
+ )
1568
+ VALUES (
1569
+ ${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility},
1570
+ ${profile.name}::text, ${profile.description}::text, COALESCE(${profile.kind}::text, 'other'),
1571
+ ${profile.capacity}::integer,
1572
+ COALESCE(pg_catalog.string_to_array(${profile.labels}::text, ','), '{}'::text[]),
1573
+ COALESCE(${profile.attributes}::text::jsonb, '{}'::jsonb),
1574
+ ${params.location ?? null}::text
1575
+ )
1368
1576
  ON CONFLICT (tenant_ref, external_ref)
1369
1577
  WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
1370
- DO UPDATE SET timezone = EXCLUDED.timezone
1578
+ DO UPDATE SET timezone = EXCLUDED.timezone,
1579
+ name = CASE WHEN ${profile.setName}::boolean THEN EXCLUDED.name ELSE slotlock.resources.name END,
1580
+ description = CASE WHEN ${profile.setDescription}::boolean THEN EXCLUDED.description
1581
+ ELSE slotlock.resources.description END,
1582
+ kind = CASE WHEN ${profile.setKind}::boolean THEN EXCLUDED.kind ELSE slotlock.resources.kind END,
1583
+ capacity = CASE WHEN ${profile.setCapacity}::boolean THEN EXCLUDED.capacity ELSE slotlock.resources.capacity END,
1584
+ labels = CASE WHEN ${profile.setLabels}::boolean THEN EXCLUDED.labels ELSE slotlock.resources.labels END,
1585
+ attributes = CASE WHEN ${profile.setAttributes}::boolean THEN EXCLUDED.attributes
1586
+ ELSE slotlock.resources.attributes END,
1587
+ location_ref = CASE WHEN ${params.location !== undefined}::boolean THEN EXCLUDED.location_ref
1588
+ ELSE slotlock.resources.location_ref END
1371
1589
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1372
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1590
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1373
1591
  }
1374
1592
  else if (params.externalRef !== undefined) {
1593
+ // A legacy resource (no tenant) has no profile.
1375
1594
  rows = await executor `
1376
1595
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1377
1596
  VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
@@ -1379,13 +1598,13 @@ async function upsertResource(executor, params) {
1379
1598
  WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
1380
1599
  DO UPDATE SET external_ref = EXCLUDED.external_ref
1381
1600
  WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
1382
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1601
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1383
1602
  }
1384
1603
  else {
1385
1604
  rows = await executor `
1386
1605
  INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
1387
1606
  VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
1388
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1607
+ RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
1389
1608
  }
1390
1609
  const row = rows[0];
1391
1610
  if (!row) {
@@ -1804,11 +2023,16 @@ function occupiedStartsBefore(executor, end, alias) {
1804
2023
  * before the caller's INSERT, so the EXCLUDE arbiter only sees live occupation.
1805
2024
  */
1806
2025
  async function reapExpiredHolds(tx, resourceId, start, end) {
2026
+ // A composite's parent and parts too (RFC 0003 G8): their expired holds' claims are in the way.
1807
2027
  await tx `
1808
2028
  DELETE FROM slotlock.reservations
1809
2029
  WHERE id IN (
1810
2030
  SELECT id FROM slotlock.reservations
1811
- WHERE resource_id = ${resourceId}
2031
+ WHERE resource_id IN (
2032
+ SELECT ${resourceId}::uuid
2033
+ UNION ALL SELECT family.parent_id FROM slotlock.resources family
2034
+ WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
2035
+ UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
1812
2036
  AND status = 'held'
1813
2037
  AND expires_at <= statement_timestamp()
1814
2038
  AND ${occupiedStartsBefore(tx, end)}
@@ -1817,6 +2041,62 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
1817
2041
  FOR UPDATE
1818
2042
  )`;
1819
2043
  }
2044
+ /**
2045
+ * reapExpiredHolds for many windows on one resource in one statement (a series' occurrences): the
2046
+ * same rows, in the same id order under FOR UPDATE, as one call per window would delete. The
2047
+ * envelope of the windows bounds the scan so the index applies; each row must still overlap one.
2048
+ */
2049
+ async function reapExpiredHoldsAcross(tx, resourceId, windows) {
2050
+ if (windows.length === 0)
2051
+ return;
2052
+ let first = Number.POSITIVE_INFINITY;
2053
+ let last = Number.NEGATIVE_INFINITY;
2054
+ for (const window of windows) {
2055
+ first = Math.min(first, window.start.getTime());
2056
+ last = Math.max(last, window.end.getTime());
2057
+ }
2058
+ await tx `
2059
+ DELETE FROM slotlock.reservations
2060
+ WHERE id IN (
2061
+ SELECT held.id FROM slotlock.reservations held
2062
+ WHERE held.resource_id IN (
2063
+ SELECT ${resourceId}::uuid
2064
+ UNION ALL SELECT family.parent_id FROM slotlock.resources family
2065
+ WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
2066
+ UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
2067
+ AND held.status = 'held'
2068
+ AND held.expires_at <= statement_timestamp()
2069
+ AND ${occupiedStartsBefore(tx, new Date(last), 'held')}
2070
+ AND COALESCE(held.occupied_ends_at, held.ends_at) > ${new Date(first)}
2071
+ AND EXISTS (
2072
+ SELECT 1
2073
+ FROM ROWS FROM (pg_catalog.unnest(${windows.map((window) => window.start.toISOString())}::text[]::timestamptz[]),
2074
+ pg_catalog.unnest(${windows.map((window) => window.end.toISOString())}::text[]::timestamptz[]))
2075
+ AS occupied(starts_at, ends_at)
2076
+ WHERE COALESCE(held.occupied_starts_at, held.starts_at) < occupied.ends_at
2077
+ AND COALESCE(held.occupied_ends_at, held.ends_at) > occupied.starts_at)
2078
+ ORDER BY held.id
2079
+ FOR UPDATE
2080
+ )`;
2081
+ }
2082
+ /**
2083
+ * What else keeps a resource busy as part of a composite (RFC 0003 G8), as rows of
2084
+ * (occupied_starts_at, occupied_ends_at): a part, the bookings of its parent as a whole; a parent,
2085
+ * the bookings of any of its parts. Its own bookings are read with its own rows. Nothing for a
2086
+ * resource that is neither.
2087
+ */
2088
+ function compositeBusy(executor, resourceId, window) {
2089
+ return executor `
2090
+ SELECT lower(claim.during) AS occupied_starts_at, upper(claim.during) AS occupied_ends_at
2091
+ FROM slotlock.resources me
2092
+ JOIN slotlock.composite_claims claim
2093
+ ON claim.resource_id = COALESCE(me.parent_id, me.id)
2094
+ AND claim.booked_resource_id <> me.id
2095
+ AND (me.parent_id IS NULL OR claim.span @> me.part_index)
2096
+ WHERE me.id = ${resourceId}
2097
+ AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
2098
+ AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
2099
+ }
1820
2100
  /**
1821
2101
  * Structured answer after losing to the EXCLUDE arbiter: name the live winner.
1822
2102
  * conflictingReservationId is null in one narrow race (the blocker expired between the constraint
@@ -1841,7 +2121,27 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
1841
2121
  AND (status <> 'held' OR expires_at > statement_timestamp())
1842
2122
  ORDER BY starts_at
1843
2123
  LIMIT 1`;
1844
- return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
2124
+ if (winners[0])
2125
+ return { ok: false, code: 'overlap', conflictingReservationId: winners[0].id };
2126
+ // Lost to a composite's claim (RFC 0003 G8): the booking on the whole, or on a part, in the way.
2127
+ const claimed = await sql `
2128
+ SELECT claim.reservation_id::text AS id
2129
+ FROM slotlock.resources me
2130
+ JOIN slotlock.composite_claims claim
2131
+ ON claim.resource_id = COALESCE(me.parent_id, me.id)
2132
+ AND claim.booked_resource_id <> me.id
2133
+ AND (me.parent_id IS NULL OR claim.span @> me.part_index)
2134
+ WHERE me.id = ${resourceId}
2135
+ AND (${excludeReservationId ?? null}::uuid IS NULL OR claim.reservation_id <> ${excludeReservationId ?? null}::uuid)
2136
+ AND claim.during && tstzrange(
2137
+ ${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
2138
+ ${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
2139
+ '[)'
2140
+ )
2141
+ AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())
2142
+ ORDER BY lower(claim.during)
2143
+ LIMIT 1`;
2144
+ return { ok: false, code: 'overlap', conflictingReservationId: claimed[0]?.id ?? null };
1845
2145
  }
1846
2146
  /** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
1847
2147
  function holdTenantRef(options) {
@@ -1915,6 +2215,14 @@ export function createSlotlockStore(sql, options = {}) {
1915
2215
  */
1916
2216
  export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1917
2217
  'createResource',
2218
+ 'updateResourceProfile',
2219
+ 'putLocation',
2220
+ 'updateLocation',
2221
+ 'deleteLocation',
2222
+ 'putGroup',
2223
+ 'deleteGroup',
2224
+ 'addGroupMember',
2225
+ 'removeGroupMember',
1918
2226
  'archiveResource',
1919
2227
  'restoreResource',
1920
2228
  'deleteResource',
@@ -1922,6 +2230,17 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1922
2230
  'setResourceBookingRules',
1923
2231
  'setBookingRuleDefaults',
1924
2232
  'setResourcePool',
2233
+ 'setResourceParent',
2234
+ 'setPoolOrder',
2235
+ 'notePoolBooking',
2236
+ 'putApprovalPolicy',
2237
+ 'deleteApprovalPolicy',
2238
+ 'requestApproval',
2239
+ 'beginApprovalGrant',
2240
+ 'finishApprovalGrant',
2241
+ 'declineApproval',
2242
+ 'resetTestTenant',
2243
+ 'fastForwardTestTenant',
1925
2244
  'setTenantAvailability',
1926
2245
  'createReservation',
1927
2246
  'createExternalReservation',
@@ -1944,6 +2263,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
1944
2263
  'leaveWaitlist',
1945
2264
  'putAssistantSettings',
1946
2265
  'deleteAssistantSettings',
2266
+ 'resealAssistantKey',
1947
2267
  'putAssistantConversation',
1948
2268
  'settleAssistantAction',
1949
2269
  'deleteAssistantConversation',
@@ -2262,6 +2582,12 @@ function buildSlotlockStore(sql, options, refusal) {
2262
2582
  if (keyId !== undefined && !isValidResourceId(keyId)) {
2263
2583
  throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
2264
2584
  }
2585
+ const scope = options?.resourceScope;
2586
+ if (scope !== undefined && !isSlotlockResourceScope(scope)) {
2587
+ throw Object.assign(new Error('Slotlock resource scope must name locations or groups by reference'), {
2588
+ code: 'invalid_identity',
2589
+ });
2590
+ }
2265
2591
  const release = await tenantSlots?.(tenantRef, options?.signal);
2266
2592
  // A lock timeout leaves the transaction unusable, so the reset below would fail with another
2267
2593
  // error: noted here.
@@ -2289,9 +2615,27 @@ function buildSlotlockStore(sql, options, refusal) {
2289
2615
  set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
2290
2616
  NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
2291
2617
  set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
2618
+ current_setting(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, true) AS scope,
2292
2619
  slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
2293
2620
  throw suspensionCheckRefusal(error);
2294
2621
  });
2622
+ // A limited key's resources, read in the tenant's context (and within an enclosing
2623
+ // scope, if one is set), then held for the rest of the transaction.
2624
+ if (scope !== undefined) {
2625
+ await tx `
2626
+ SELECT set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, '{' || COALESCE((
2627
+ SELECT string_agg(held.id::text, ',' ORDER BY held.id)
2628
+ FROM slotlock.resources held
2629
+ WHERE held.tenant_ref = ${tenantRef}
2630
+ AND ((held.location_ref IS NOT NULL
2631
+ AND slotlock.location_path(held.tenant_ref, held.location_ref)
2632
+ && pg_catalog.string_to_array(${scope.locations.join(',')}::text, ','))
2633
+ OR EXISTS (
2634
+ SELECT 1 FROM slotlock.resource_group_members member
2635
+ WHERE member.tenant_ref = held.tenant_ref AND member.resource_id = held.id
2636
+ AND member.group_ref = ANY (pg_catalog.string_to_array(${scope.groups.join(',')}::text, ','))))
2637
+ ), '') || '}', true)`;
2638
+ }
2295
2639
  // A caller's REPEATABLE READ or SERIALIZABLE snapshot predates the lock, so it may miss a
2296
2640
  // suspension: its writes are refused, reads allowed.
2297
2641
  const refusal = context?.suspended === true
@@ -2311,7 +2655,8 @@ function buildSlotlockStore(sql, options, refusal) {
2311
2655
  await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
2312
2656
  set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
2313
2657
  set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
2314
- set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
2658
+ set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true),
2659
+ set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, ${context?.scope ?? ''}, true)`;
2315
2660
  }
2316
2661
  }, 'read committed')
2317
2662
  .catch((error) => {
@@ -2324,10 +2669,51 @@ function buildSlotlockStore(sql, options, refusal) {
2324
2669
  },
2325
2670
  async createResource(params) {
2326
2671
  assertValidResourceInput(params);
2672
+ if (params.profile !== undefined && params.tenantRef === undefined) {
2673
+ throw Object.assign(new Error('Slotlock gives a profile only to a resource with a tenant'), {
2674
+ code: 'invalid_resource_profile',
2675
+ });
2676
+ }
2677
+ const { profile: given, maxTenantResources: _limit, location, ...identity } = params;
2678
+ if (location !== undefined) {
2679
+ if (params.tenantRef === undefined) {
2680
+ throw Object.assign(new Error('Slotlock gives a location only to a resource with a tenant'), {
2681
+ code: 'invalid_location',
2682
+ field: 'location',
2683
+ });
2684
+ }
2685
+ slotlockGroupingRef('invalid_location', location);
2686
+ // Without a time zone of its own, it takes its location's.
2687
+ if (identity.timezone === undefined) {
2688
+ const zone = await locationTimezone(sql, params.tenantRef, location);
2689
+ if (zone !== null)
2690
+ identity.timezone = zone;
2691
+ }
2692
+ }
2693
+ const upsert = {
2694
+ ...identity,
2695
+ ...(given !== undefined ? { profile: parseSlotlockResourceProfile(given) } : {}),
2696
+ ...(location !== undefined ? { location } : {}),
2697
+ };
2327
2698
  const requested = params.maxTenantResources;
2328
- const configured = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2329
- if (requested === undefined && configured === undefined)
2330
- return upsertResource(sql, params);
2699
+ // The lower of the store's own limit and the tenant's plan (plans.ts), when it has one.
2700
+ const planned = params.tenantRef === undefined
2701
+ ? undefined
2702
+ : (await sql `
2703
+ SELECT max_resources FROM slotlock.tenant_plan(${params.tenantRef}::text)`.catch((error) => {
2704
+ throw slotlockPlanReadRefusal(error);
2705
+ }))[0]?.max_resources;
2706
+ const storeLimit = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
2707
+ const configured = planned === undefined
2708
+ ? storeLimit
2709
+ : storeLimit === undefined
2710
+ ? planned
2711
+ : Math.min(planned, storeLimit);
2712
+ if (requested === undefined && configured === undefined) {
2713
+ if (location === undefined)
2714
+ return upsertResource(sql, upsert);
2715
+ return guardedWrite(sql, (tx) => upsertResource(tx, upsert), RESOURCE_LOCATION_MISSING);
2716
+ }
2331
2717
  if (requested !== undefined &&
2332
2718
  (!Number.isSafeInteger(requested) ||
2333
2719
  requested < 1 ||
@@ -2361,7 +2747,9 @@ function buildSlotlockStore(sql, options, refusal) {
2361
2747
  throw Object.assign(new Error(`Slotlock tenant already has ${max} resources, the most allowed here`), { code: 'resource_limit_reached', limit: max });
2362
2748
  }
2363
2749
  }
2364
- return upsertResource(tx, params);
2750
+ if (location === undefined)
2751
+ return upsertResource(tx, upsert);
2752
+ return guardedWrite(tx, (inner) => upsertResource(inner, upsert), RESOURCE_LOCATION_MISSING);
2365
2753
  }, 'read committed');
2366
2754
  },
2367
2755
  async getResource(params) {
@@ -2372,13 +2760,193 @@ function buildSlotlockStore(sql, options, refusal) {
2372
2760
  });
2373
2761
  }
2374
2762
  const rows = await sql `
2375
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2763
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2376
2764
  FROM slotlock.resources
2377
2765
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2378
2766
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
2379
2767
  const row = rows[0];
2380
2768
  return row ? toResource(row) : null;
2381
2769
  },
2770
+ async updateResourceProfile(params) {
2771
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2772
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
2773
+ code: 'invalid_identity',
2774
+ });
2775
+ }
2776
+ const profile = profileParameters(parseSlotlockResourceProfile(params.profile));
2777
+ const location = params.location === undefined || params.location === null
2778
+ ? null
2779
+ : slotlockGroupingRef('invalid_location', params.location);
2780
+ const rows = await guardedWrite(sql, (tx) => tx `
2781
+ UPDATE slotlock.resources
2782
+ SET name = CASE WHEN ${profile.setName}::boolean THEN ${profile.name}::text ELSE name END,
2783
+ description = CASE WHEN ${profile.setDescription}::boolean
2784
+ THEN ${profile.description}::text ELSE description END,
2785
+ kind = CASE WHEN ${profile.setKind}::boolean THEN ${profile.kind}::text ELSE kind END,
2786
+ capacity = CASE WHEN ${profile.setCapacity}::boolean
2787
+ THEN ${profile.capacity}::integer ELSE capacity END,
2788
+ labels = CASE WHEN ${profile.setLabels}::boolean
2789
+ THEN pg_catalog.string_to_array(${profile.labels}::text, ',') ELSE labels END,
2790
+ attributes = CASE WHEN ${profile.setAttributes}::boolean
2791
+ THEN ${profile.attributes}::text::jsonb ELSE attributes END,
2792
+ location_ref = CASE WHEN ${params.location !== undefined}::boolean
2793
+ THEN ${location}::text ELSE location_ref END
2794
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2795
+ RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, RESOURCE_LOCATION_MISSING);
2796
+ const row = rows[0];
2797
+ return row ? toResource(row) : null;
2798
+ },
2799
+ async listLocations(params) {
2800
+ if (!isValidIdentity(params.tenantRef)) {
2801
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2802
+ }
2803
+ const rows = await sql `
2804
+ WITH RECURSIVE tree(ref, name, parent_ref, timezone, path) AS (
2805
+ SELECT place.ref, place.name, place.parent_ref, place.timezone, ARRAY[place.ref]
2806
+ FROM slotlock.locations place
2807
+ WHERE place.tenant_ref = ${params.tenantRef} AND place.parent_ref IS NULL
2808
+ UNION ALL
2809
+ SELECT place.ref, place.name, place.parent_ref, place.timezone, tree.path || place.ref
2810
+ FROM slotlock.locations place
2811
+ JOIN tree ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = tree.ref
2812
+ WHERE cardinality(tree.path) <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
2813
+ )
2814
+ SELECT tree.ref, tree.name, tree.parent_ref, tree.timezone,
2815
+ (SELECT count(*) FROM slotlock.resources held
2816
+ WHERE held.tenant_ref = ${params.tenantRef} AND held.location_ref = tree.ref)::int AS resources
2817
+ FROM tree
2818
+ ORDER BY tree.path
2819
+ LIMIT ${SLOTLOCK_MAX_LOCATIONS}`;
2820
+ return rows.map(toLocation);
2821
+ },
2822
+ async putLocation(params) {
2823
+ if (!isValidIdentity(params.tenantRef)) {
2824
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2825
+ }
2826
+ const location = parseSlotlockLocation(params);
2827
+ const [row] = await guardedWrite(sql, (tx) => tx `
2828
+ INSERT INTO slotlock.locations AS kept (tenant_ref, ref, name, parent_ref, timezone)
2829
+ VALUES (${params.tenantRef}, ${location.ref}, ${location.name ?? ''}, ${location.parentRef ?? null},
2830
+ ${location.timezone ?? null})
2831
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
2832
+ SET name = EXCLUDED.name,
2833
+ parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
2834
+ THEN EXCLUDED.parent_ref ELSE kept.parent_ref END,
2835
+ timezone = CASE WHEN ${location.timezone !== undefined}::boolean
2836
+ THEN EXCLUDED.timezone ELSE kept.timezone END
2837
+ RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
2838
+ (SELECT count(*) FROM slotlock.resources held
2839
+ WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
2840
+ return toLocation(row);
2841
+ },
2842
+ async updateLocation(params) {
2843
+ if (!isValidIdentity(params.tenantRef)) {
2844
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2845
+ }
2846
+ const location = parseSlotlockLocation(params);
2847
+ const [row] = await guardedWrite(sql, (tx) => tx `
2848
+ UPDATE slotlock.locations AS kept
2849
+ SET name = COALESCE(${location.name ?? null}::text, kept.name),
2850
+ parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
2851
+ THEN ${location.parentRef ?? null}::text ELSE kept.parent_ref END,
2852
+ timezone = CASE WHEN ${location.timezone !== undefined}::boolean
2853
+ THEN ${location.timezone ?? null}::text ELSE kept.timezone END
2854
+ WHERE kept.tenant_ref = ${params.tenantRef} AND kept.ref = ${location.ref}
2855
+ RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
2856
+ (SELECT count(*) FROM slotlock.resources held
2857
+ WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
2858
+ return row ? toLocation(row) : null;
2859
+ },
2860
+ async deleteLocation(params) {
2861
+ if (!isValidIdentity(params.tenantRef)) {
2862
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2863
+ }
2864
+ const ref = slotlockGroupingRef('invalid_location', params.ref);
2865
+ const rows = await guardedWrite(sql, (tx) => tx `
2866
+ DELETE FROM slotlock.locations
2867
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
2868
+ RETURNING ref`, {
2869
+ slotlock_locations_parent_fkey: 'location_in_use',
2870
+ slotlock_resources_location_fkey: 'location_in_use',
2871
+ });
2872
+ return rows.length > 0;
2873
+ },
2874
+ async listGroups(params) {
2875
+ if (!isValidIdentity(params.tenantRef)) {
2876
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2877
+ }
2878
+ const rows = await sql `
2879
+ SELECT kept.ref, kept.name, kept.description,
2880
+ (SELECT count(*) FROM slotlock.resource_group_members member
2881
+ WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members
2882
+ FROM slotlock.resource_groups kept
2883
+ WHERE kept.tenant_ref = ${params.tenantRef}
2884
+ ORDER BY kept.ref
2885
+ LIMIT ${SLOTLOCK_MAX_RESOURCE_GROUPS}`;
2886
+ return rows.map(toGroup);
2887
+ },
2888
+ async putGroup(params) {
2889
+ if (!isValidIdentity(params.tenantRef)) {
2890
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2891
+ }
2892
+ const group = parseSlotlockResourceGroup(params);
2893
+ const [row] = await guardedWrite(sql, (tx) => tx `
2894
+ INSERT INTO slotlock.resource_groups AS kept (tenant_ref, ref, name, description)
2895
+ VALUES (${params.tenantRef}, ${group.ref}, ${group.name}, ${group.description})
2896
+ ON CONFLICT (tenant_ref, ref) DO UPDATE
2897
+ SET name = EXCLUDED.name,
2898
+ description = CASE WHEN ${params.description !== undefined}::boolean
2899
+ THEN EXCLUDED.description ELSE kept.description END
2900
+ RETURNING kept.ref, kept.name, kept.description,
2901
+ (SELECT count(*) FROM slotlock.resource_group_members member
2902
+ WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members`);
2903
+ return toGroup(row);
2904
+ },
2905
+ async deleteGroup(params) {
2906
+ if (!isValidIdentity(params.tenantRef)) {
2907
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2908
+ }
2909
+ const ref = slotlockGroupingRef('invalid_group', params.ref);
2910
+ const rows = await sql `
2911
+ DELETE FROM slotlock.resource_groups
2912
+ WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
2913
+ RETURNING ref`;
2914
+ return rows.length > 0;
2915
+ },
2916
+ async addGroupMember(params) {
2917
+ if (!isValidIdentity(params.tenantRef)) {
2918
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2919
+ }
2920
+ const group = slotlockGroupingRef('invalid_group', params.group);
2921
+ if (!isValidResourceId(params.resourceId)) {
2922
+ throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
2923
+ code: 'resource_not_found',
2924
+ });
2925
+ }
2926
+ const rows = await guardedWrite(sql, (tx) => tx `
2927
+ INSERT INTO slotlock.resource_group_members (tenant_ref, group_ref, resource_id)
2928
+ VALUES (${params.tenantRef}, ${group}, ${params.resourceId}::uuid)
2929
+ ON CONFLICT DO NOTHING
2930
+ RETURNING true AS added`, {
2931
+ slotlock_resource_group_members_group_fkey: 'group_not_found',
2932
+ slotlock_resource_group_members_resource_fkey: 'resource_not_found',
2933
+ });
2934
+ return rows.length > 0;
2935
+ },
2936
+ async removeGroupMember(params) {
2937
+ if (!isValidIdentity(params.tenantRef)) {
2938
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2939
+ }
2940
+ const group = slotlockGroupingRef('invalid_group', params.group);
2941
+ if (!isValidResourceId(params.resourceId))
2942
+ return false;
2943
+ const rows = await sql `
2944
+ DELETE FROM slotlock.resource_group_members
2945
+ WHERE tenant_ref = ${params.tenantRef} AND group_ref = ${group}
2946
+ AND resource_id = ${params.resourceId}::uuid
2947
+ RETURNING group_ref`;
2948
+ return rows.length > 0;
2949
+ },
2382
2950
  async setResourceAvailability(params) {
2383
2951
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2384
2952
  throw Object.assign(new Error('Slotlock resource identity is invalid'), {
@@ -2390,7 +2958,7 @@ function buildSlotlockStore(sql, options, refusal) {
2390
2958
  UPDATE slotlock.resources
2391
2959
  SET availability_rules = ${rules}::jsonb
2392
2960
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2393
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2961
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2394
2962
  const row = rows[0];
2395
2963
  return row ? toResource(row) : null;
2396
2964
  },
@@ -2421,8 +2989,7 @@ function buildSlotlockStore(sql, options, refusal) {
2421
2989
  ELSE statement_timestamp()
2422
2990
  END
2423
2991
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2424
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2425
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2992
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2426
2993
  const row = rows[0];
2427
2994
  return row ? toResource(row) : null;
2428
2995
  },
@@ -2501,8 +3068,25 @@ function buildSlotlockStore(sql, options, refusal) {
2501
3068
  const rows = await sql `
2502
3069
  UPDATE slotlock.resources SET pool_ref = ${params.pool}
2503
3070
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2504
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2505
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3071
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3072
+ const row = rows[0];
3073
+ return row ? toResource(row) : null;
3074
+ },
3075
+ async setResourceParent(params) {
3076
+ if (!isValidIdentity(params.tenantRef) ||
3077
+ !isValidResourceId(params.id) ||
3078
+ (params.parentId !== null && !isValidResourceId(params.parentId))) {
3079
+ throw Object.assign(new Error('Slotlock resource identity is invalid'), {
3080
+ code: 'invalid_identity',
3081
+ });
3082
+ }
3083
+ if (params.parentId === params.id) {
3084
+ throw Object.assign(new Error('Slotlock resource cannot be a part of itself'), { code: 'composite_too_deep' });
3085
+ }
3086
+ const rows = await guardedWrite(sql, (tx) => tx `
3087
+ UPDATE slotlock.resources SET parent_id = ${params.parentId}::uuid
3088
+ WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
3089
+ RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, { slotlock_resources_parent_fkey: 'parent_not_found' });
2506
3090
  const row = rows[0];
2507
3091
  return row ? toResource(row) : null;
2508
3092
  },
@@ -2512,28 +3096,94 @@ function buildSlotlockStore(sql, options, refusal) {
2512
3096
  if (!isPoolName(params.pool))
2513
3097
  throw invalidPoolError();
2514
3098
  const rows = await sql `
2515
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
2516
- visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
3099
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2517
3100
  FROM slotlock.resources
2518
3101
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
2519
3102
  AND archived_at IS NULL
2520
3103
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2521
3104
  ORDER BY id
2522
3105
  LIMIT ${SLOTLOCK_MAX_POOL_MEMBERS}`;
2523
- return rows.map(toResource);
3106
+ const members = rows.map(toResource);
3107
+ if (params.ordered !== true || members.length < 2)
3108
+ return members;
3109
+ let [settings] = await sql `
3110
+ SELECT member_order, last_member::text FROM slotlock.pool_settings
3111
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}`;
3112
+ if (settings?.member_order === 'round_robin' && params.forBooking === true) {
3113
+ // Read again under the row's lock, held until the booking commits with the place it noted.
3114
+ [settings] = await sql `
3115
+ SELECT member_order, last_member::text FROM slotlock.pool_settings
3116
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3117
+ FOR UPDATE`;
3118
+ }
3119
+ if (settings?.member_order === 'round_robin') {
3120
+ // From the first member after the one booked last, by id, round to the start.
3121
+ const after = settings.last_member;
3122
+ const next = after === null ? 0 : members.findIndex((member) => member.id > after);
3123
+ const start = next < 0 ? 0 : next;
3124
+ return [...members.slice(start), ...members.slice(0, start)];
3125
+ }
3126
+ if (settings?.member_order === 'least_booked') {
3127
+ // Bookings made on each in the last 30 days, holds and cancelled ones not counted.
3128
+ const counts = await sql `
3129
+ SELECT booked.resource_id::text, count(*)::int AS booked
3130
+ FROM slotlock.reservations booked
3131
+ WHERE booked.tenant_ref = ${params.tenantRef}
3132
+ AND booked.resource_id = ANY (${members.map((member) => member.id)}::uuid[])
3133
+ AND booked.status = 'confirmed'
3134
+ AND booked.created_at > statement_timestamp() - interval '30 days'
3135
+ GROUP BY booked.resource_id`;
3136
+ const booked = new Map(counts.map((row) => [row.resource_id, Number(row.booked)]));
3137
+ // Stable among equals: by id, as the database orders them.
3138
+ return [...members].sort((a, b) => (booked.get(a.id) ?? 0) - (booked.get(b.id) ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
3139
+ }
3140
+ return members;
2524
3141
  },
2525
3142
  async listPools(params) {
2526
3143
  if (!isValidIdentity(params.tenantRef))
2527
3144
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
2528
3145
  const rows = await sql `
2529
- SELECT pool_ref AS pool, count(*)::int AS capacity
3146
+ SELECT pool_ref AS pool, count(*)::int AS capacity,
3147
+ COALESCE((SELECT settings.member_order FROM slotlock.pool_settings settings
3148
+ WHERE settings.tenant_ref = ${params.tenantRef}
3149
+ AND settings.pool_ref = resources.pool_ref), 'stable') AS member_order
2530
3150
  FROM slotlock.resources
2531
3151
  WHERE tenant_ref = ${params.tenantRef} AND pool_ref IS NOT NULL AND archived_at IS NULL
2532
3152
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2533
3153
  GROUP BY pool_ref
2534
3154
  ORDER BY pool_ref
2535
3155
  LIMIT 1000`;
2536
- return rows;
3156
+ return rows.map((row) => ({ pool: row.pool, capacity: row.capacity, order: row.member_order }));
3157
+ },
3158
+ async setPoolOrder(params) {
3159
+ if (!isValidIdentity(params.tenantRef))
3160
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3161
+ if (!isPoolName(params.pool))
3162
+ throw invalidPoolError();
3163
+ if (!SLOTLOCK_POOL_ORDERS.includes(params.order)) {
3164
+ throw Object.assign(new Error(`Slotlock pool order must be one of ${SLOTLOCK_POOL_ORDERS.join(', ')}`), {
3165
+ code: 'invalid_pool_order',
3166
+ });
3167
+ }
3168
+ // At most SLOTLOCK_MAX_POOL_SETTINGS pools a tenant (pool_settings_guard).
3169
+ await guardedWrite(sql, (tx) => tx `
3170
+ INSERT INTO slotlock.pool_settings (tenant_ref, pool_ref, member_order)
3171
+ VALUES (${params.tenantRef}, ${params.pool}, ${params.order})
3172
+ ON CONFLICT (tenant_ref, pool_ref)
3173
+ DO UPDATE SET member_order = EXCLUDED.member_order, updated_at = statement_timestamp()`);
3174
+ return params.order;
3175
+ },
3176
+ async notePoolBooking(params) {
3177
+ if (!isValidIdentity(params.tenantRef))
3178
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3179
+ if (!isPoolName(params.pool) || !isValidResourceId(params.resourceId))
3180
+ return;
3181
+ // Only a pool that goes round keeps its place; another has nothing to note.
3182
+ await sql `
3183
+ UPDATE slotlock.pool_settings
3184
+ SET last_member = ${params.resourceId}::uuid, updated_at = statement_timestamp()
3185
+ WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
3186
+ AND member_order = 'round_robin'`;
2537
3187
  },
2538
3188
  async setTenantAvailability(params) {
2539
3189
  if (!isValidIdentity(params.tenantRef)) {
@@ -2565,17 +3215,76 @@ function buildSlotlockStore(sql, options, refusal) {
2565
3215
  code: 'invalid_cursor',
2566
3216
  });
2567
3217
  }
3218
+ // The filters are checked as a profile is, so a bad one is refused rather than matching none.
3219
+ const place = params.location === undefined ? null : slotlockGroupingRef('invalid_location', params.location);
3220
+ const group = params.group === undefined ? null : slotlockGroupingRef('invalid_group', params.group);
3221
+ if (params.pool !== undefined && !isPoolName(params.pool))
3222
+ throw invalidPoolError();
3223
+ const pool = params.pool ?? null;
3224
+ const filter = parseSlotlockResourceProfile({
3225
+ ...(params.kind !== undefined ? { kind: params.kind } : {}),
3226
+ ...(params.labels !== undefined ? { labels: params.labels } : {}),
3227
+ });
2568
3228
  const rows = await sql `
2569
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
3229
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
2570
3230
  FROM slotlock.resources
2571
3231
  WHERE tenant_ref = ${params.tenantRef}
2572
3232
  AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
2573
3233
  AND (${params.includeArchived === true} OR archived_at IS NULL)
2574
3234
  AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
3235
+ AND (${filter.kind ?? null}::text IS NULL OR kind = ${filter.kind ?? null}::text)
3236
+ AND (${filter.labels === undefined || filter.labels.length === 0 ? null : filter.labels.join(',')}::text IS NULL
3237
+ OR labels @> pg_catalog.string_to_array(${filter.labels?.join(',') ?? ''}::text, ','))
3238
+ AND (${place}::text IS NULL OR location_ref IN (
3239
+ WITH RECURSIVE below(ref, steps) AS (
3240
+ SELECT place.ref, 1 FROM slotlock.locations place
3241
+ WHERE place.tenant_ref = ${params.tenantRef} AND place.ref = ${place}::text
3242
+ UNION ALL
3243
+ SELECT place.ref, below.steps + 1
3244
+ FROM slotlock.locations place
3245
+ JOIN below ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = below.ref
3246
+ WHERE below.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
3247
+ )
3248
+ SELECT ref FROM below))
3249
+ AND (${group}::text IS NULL OR EXISTS (
3250
+ SELECT 1 FROM slotlock.resource_group_members member
3251
+ WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
3252
+ AND member.group_ref = ${group}::text))
3253
+ AND (${pool}::text IS NULL OR pool_ref = ${pool}::text)
2575
3254
  ORDER BY id
2576
3255
  LIMIT ${limit}`;
2577
3256
  return rows.map(toResource);
2578
3257
  },
3258
+ async readUsage(params) {
3259
+ if (!isValidIdentity(params.tenantRef)) {
3260
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3261
+ }
3262
+ return readSlotlockTenantUsage(sql, params.tenantRef, params.from, params.to);
3263
+ },
3264
+ async rollUpUsage(params) {
3265
+ if (!isValidIdentity(params.tenantRef)) {
3266
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3267
+ }
3268
+ const limit = params.limit ?? 5_000;
3269
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
3270
+ throw Object.assign(new Error('Slotlock usage roll-up limit must be 1-100,000'), {
3271
+ code: 'invalid_limit',
3272
+ });
3273
+ }
3274
+ const [result] = await sql `
3275
+ SELECT slotlock.roll_up_usage(${params.tenantRef}::text, ${limit}::integer) AS read`;
3276
+ return result?.read ?? 0;
3277
+ },
3278
+ async countResources(params) {
3279
+ if (!isValidIdentity(params.tenantRef)) {
3280
+ throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
3281
+ code: 'invalid_identity',
3282
+ });
3283
+ }
3284
+ const [counted] = await sql `
3285
+ SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef}`;
3286
+ return Number(counted?.count ?? 0);
3287
+ },
2579
3288
  async archiveResource(params) {
2580
3289
  if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
2581
3290
  throw invalidIdentityError('Slotlock resource identity is invalid');
@@ -2586,7 +3295,7 @@ function buildSlotlockStore(sql, options, refusal) {
2586
3295
  UPDATE slotlock.resources
2587
3296
  SET archived_at = COALESCE(archived_at, clock_timestamp())
2588
3297
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2589
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3298
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2590
3299
  return row ? toResource(row) : null;
2591
3300
  },
2592
3301
  async restoreResource(params) {
@@ -2597,7 +3306,7 @@ function buildSlotlockStore(sql, options, refusal) {
2597
3306
  UPDATE slotlock.resources
2598
3307
  SET archived_at = NULL
2599
3308
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
2600
- RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
3309
+ RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
2601
3310
  return row ? toResource(row) : null;
2602
3311
  },
2603
3312
  async deleteResource(params) {
@@ -2651,12 +3360,7 @@ function buildSlotlockStore(sql, options, refusal) {
2651
3360
  try {
2652
3361
  return await inTransaction(sql, async (tx) => {
2653
3362
  const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
2654
- const [resource] = await tx `
2655
- SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
2656
- WHERE id = ${params.resourceId}
2657
- AND (${params.tenantRef ?? null}::text IS NULL
2658
- OR tenant_ref = ${params.tenantRef ?? null})
2659
- FOR SHARE`;
3363
+ const resource = await readResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
2660
3364
  if (!resource)
2661
3365
  return { ok: false, code: 'resource_not_found' };
2662
3366
  if (resource.archived)
@@ -2678,6 +3382,12 @@ function buildSlotlockStore(sql, options, refusal) {
2678
3382
  }))) {
2679
3383
  return { ok: false, code: 'recurrence_not_materialized' };
2680
3384
  }
3385
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3386
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
3387
+ if (!locked)
3388
+ return { ok: false, code: 'resource_not_found' };
3389
+ if (locked.archived)
3390
+ return { ok: false, code: 'resource_archived' };
2681
3391
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2682
3392
  const [row] = await tx `
2683
3393
  INSERT INTO slotlock.reservations (
@@ -2775,6 +3485,12 @@ function buildSlotlockStore(sql, options, refusal) {
2775
3485
  }))) {
2776
3486
  return { ok: false, code: 'recurrence_not_materialized' };
2777
3487
  }
3488
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3489
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3490
+ if (!locked)
3491
+ return { ok: false, code: 'resource_tenant_mismatch' };
3492
+ if (locked.archived)
3493
+ return { ok: false, code: 'resource_archived' };
2778
3494
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2779
3495
  const rows = await tx `
2780
3496
  INSERT INTO slotlock.reservations (
@@ -2867,6 +3583,12 @@ function buildSlotlockStore(sql, options, refusal) {
2867
3583
  }))) {
2868
3584
  return { ok: false, code: 'recurrence_not_materialized' };
2869
3585
  }
3586
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3587
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
3588
+ if (!locked)
3589
+ return { ok: false, code: 'resource_tenant_mismatch' };
3590
+ if (locked.archived)
3591
+ return { ok: false, code: 'resource_archived' };
2870
3592
  await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
2871
3593
  const rows = await tx `
2872
3594
  UPDATE slotlock.reservations
@@ -3016,6 +3738,16 @@ function buildSlotlockStore(sql, options, refusal) {
3016
3738
  ...(holderRef !== undefined ? { holderRef } : {}),
3017
3739
  });
3018
3740
  const ttlSeconds = params.ttlMs / 1000;
3741
+ // The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
3742
+ const asked = params.holderCaps;
3743
+ const holdCap = Math.min(maxHoldsPerHolder, asked?.maxHolds ?? Number.POSITIVE_INFINITY);
3744
+ const heldHoursCap = asked?.maxHeldHours !== undefined
3745
+ ? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, asked.maxHeldHours)
3746
+ : maxHeldHoursPerHolder;
3747
+ if ((asked?.maxHolds !== undefined && !(Number.isSafeInteger(asked.maxHolds) && asked.maxHolds >= 1)) ||
3748
+ (asked?.maxHeldHours !== undefined && !(Number.isSafeInteger(asked.maxHeldHours) && asked.maxHeldHours >= 1))) {
3749
+ return { ok: false, code: 'invalid_identity' };
3750
+ }
3019
3751
  // The buffer asked for, raised to the resource's turnaround, and its rule's leading buffer.
3020
3752
  let written = bufferAfterMs;
3021
3753
  let writtenBefore = 0;
@@ -3043,11 +3775,7 @@ function buildSlotlockStore(sql, options, refusal) {
3043
3775
  }
3044
3776
  }
3045
3777
  const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
3046
- const [resource] = await tx `
3047
- SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
3048
- WHERE id = ${params.resourceId}
3049
- AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
3050
- FOR SHARE`;
3778
+ const resource = await readResourceForWrite(tx, params.resourceId, tenantRef ?? null);
3051
3779
  if (!resource)
3052
3780
  return { ok: false, code: 'resource_not_found' };
3053
3781
  if (resource.archived)
@@ -3073,10 +3801,10 @@ function buildSlotlockStore(sql, options, refusal) {
3073
3801
  WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
3074
3802
  AND expires_at > statement_timestamp()
3075
3803
  AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
3076
- if ((held?.count ?? 0) >= maxHoldsPerHolder) {
3077
- return { ok: false, code: 'hold_limit_reached', limit: maxHoldsPerHolder };
3804
+ if ((held?.count ?? 0) >= holdCap) {
3805
+ return { ok: false, code: 'hold_limit_reached', limit: holdCap };
3078
3806
  }
3079
- if (maxHeldHoursPerHolder !== undefined)
3807
+ if (heldHoursCap !== undefined)
3080
3808
  heldSeconds = held?.seconds ?? 0;
3081
3809
  }
3082
3810
  const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
@@ -3086,10 +3814,10 @@ function buildSlotlockStore(sql, options, refusal) {
3086
3814
  writtenBefore = ruling.bufferBeforeMs;
3087
3815
  const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
3088
3816
  if (heldSeconds !== null &&
3089
- maxHeldHoursPerHolder !== undefined &&
3817
+ heldHoursCap !== undefined &&
3090
3818
  heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
3091
- maxHeldHoursPerHolder * 3_600) {
3092
- return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
3819
+ heldHoursCap * 3_600) {
3820
+ return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
3093
3821
  }
3094
3822
  if (resource.tenant_ref !== null &&
3095
3823
  !(await recurrenceCoversWrite(tx, {
@@ -3099,6 +3827,12 @@ function buildSlotlockStore(sql, options, refusal) {
3099
3827
  }))) {
3100
3828
  return { ok: false, code: 'recurrence_not_materialized' };
3101
3829
  }
3830
+ // Writers to this resource take turns from here to the insert (lockResourceForWrite).
3831
+ const locked = await lockResourceForWrite(tx, params.resourceId, tenantRef ?? null);
3832
+ if (!locked)
3833
+ return { ok: false, code: 'resource_not_found' };
3834
+ if (locked.archived)
3835
+ return { ok: false, code: 'resource_archived' };
3102
3836
  await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
3103
3837
  const [row] = await tx `
3104
3838
  INSERT INTO slotlock.reservations (
@@ -3160,6 +3894,21 @@ function buildSlotlockStore(sql, options, refusal) {
3160
3894
  AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
3161
3895
  return row ? toHold(row) : null;
3162
3896
  },
3897
+ async getHolderHold(params) {
3898
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
3899
+ throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
3900
+ }
3901
+ if (!isValidResourceId(params.holdId))
3902
+ return null;
3903
+ const [row] = await sql `
3904
+ SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
3905
+ hold.expires_at, hold.source, hold.expires_at > statement_timestamp() AS live
3906
+ FROM slotlock.reservations hold
3907
+ JOIN slotlock.resources resource ON resource.id = hold.resource_id
3908
+ WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
3909
+ AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
3910
+ return row ? { hold: toHold(row), live: row.live } : null;
3911
+ },
3163
3912
  async listHolds(params) {
3164
3913
  if (!isValidIdentity(params.tenantRef)) {
3165
3914
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
@@ -3214,6 +3963,276 @@ function buildSlotlockStore(sql, options, refusal) {
3214
3963
  source: row.source,
3215
3964
  }));
3216
3965
  },
3966
+ async describeBooking(params) {
3967
+ if (!isValidIdentity(params.tenantRef)) {
3968
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
3969
+ }
3970
+ if (!isValidResourceId(params.id))
3971
+ return null;
3972
+ const [row] = await sql `
3973
+ SELECT booking.resource_id, booking.status, booking.holder_ref,
3974
+ COALESCE(booking.occupied_starts_at, booking.starts_at) AS occupied_start,
3975
+ COALESCE(booking.occupied_ends_at, booking.ends_at) AS occupied_end,
3976
+ event.id AS event_id, event.external_ref AS event_ref, event.owner_ref AS event_owner_ref
3977
+ FROM slotlock.reservations booking
3978
+ JOIN slotlock.resources resource ON resource.id = booking.resource_id
3979
+ LEFT JOIN slotlock.calendar_events event
3980
+ ON event.id = booking.calendar_event_id AND event.tenant_ref = ${params.tenantRef}
3981
+ WHERE booking.id = ${params.id} AND resource.tenant_ref = ${params.tenantRef}`;
3982
+ if (!row)
3983
+ return null;
3984
+ return {
3985
+ id: params.id,
3986
+ resourceId: row.resource_id,
3987
+ status: row.status,
3988
+ holderRef: row.holder_ref,
3989
+ eventId: row.event_id,
3990
+ eventRef: row.event_ref,
3991
+ eventOwnerRef: row.event_owner_ref,
3992
+ start: row.occupied_start,
3993
+ end: row.occupied_end,
3994
+ };
3995
+ },
3996
+ async eventOccupancy(params) {
3997
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.ownerRef)) {
3998
+ throw invalidIdentityError('Slotlock tenant and owner must be 1-500 UTF-8 bytes');
3999
+ }
4000
+ const refs = params.externalRefs.filter((ref) => isValidIdentity(ref));
4001
+ if (refs.length === 0)
4002
+ return [];
4003
+ const rows = await sql `
4004
+ SELECT booking.resource_id,
4005
+ COALESCE(booking.occupied_starts_at, booking.starts_at) AS start,
4006
+ COALESCE(booking.occupied_ends_at, booking.ends_at) AS "end",
4007
+ event.recurrence_rule IS NOT NULL AS recurring
4008
+ FROM slotlock.calendar_events event
4009
+ JOIN slotlock.reservations booking ON booking.calendar_event_id = event.id
4010
+ WHERE event.tenant_ref = ${params.tenantRef} AND event.owner_ref = ${params.ownerRef}
4011
+ AND event.external_ref = ANY (${refs}::text[])
4012
+ ORDER BY booking.resource_id, 2, booking.id`;
4013
+ return rows.map((row) => ({ resourceId: row.resource_id, start: row.start, end: row.end, recurring: row.recurring }));
4014
+ },
4015
+ async putApprovalPolicy(params) {
4016
+ if (!isValidIdentity(params.tenantRef)) {
4017
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4018
+ }
4019
+ return putApprovalPolicy(sql, params);
4020
+ },
4021
+ async listApprovalPolicies(params) {
4022
+ if (!isValidIdentity(params.tenantRef)) {
4023
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4024
+ }
4025
+ return listApprovalPolicies(sql, params.tenantRef);
4026
+ },
4027
+ async deleteApprovalPolicy(params) {
4028
+ if (!isValidIdentity(params.tenantRef)) {
4029
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4030
+ }
4031
+ return { deleted: await deleteApprovalPolicy(sql, params.tenantRef, params.ref) };
4032
+ },
4033
+ async approvalPoliciesFor(params) {
4034
+ if (!isValidIdentity(params.tenantRef)) {
4035
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4036
+ }
4037
+ return approvalPoliciesFor(sql, params.tenantRef, params.resourceIds);
4038
+ },
4039
+ async requestApproval(params) {
4040
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.requesterRef)) {
4041
+ throw invalidIdentityError('Slotlock tenant and requester must be 1-500 UTF-8 bytes');
4042
+ }
4043
+ if (!/^[0-9a-f]{64}$/.test(params.requestKey)) {
4044
+ throw invalidIdentityError('Slotlock approval request key must be 64 hex characters');
4045
+ }
4046
+ if (params.holds.length < 1 ||
4047
+ params.holds.length > SLOTLOCK_MAX_APPROVAL_HOLDS ||
4048
+ params.holds.some((hold) => !isValidResourceId(hold.resourceId) || !(hold.end > hold.start))) {
4049
+ throw invalidIdentityError(`Slotlock approval holds must be 1-${SLOTLOCK_MAX_APPROVAL_HOLDS} intervals on resources`);
4050
+ }
4051
+ const { tenantRef } = params;
4052
+ try {
4053
+ return await inTransaction(sql, async (tx) => {
4054
+ // Retries of one request wait for each other, so the second answers the first's approval.
4055
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
4056
+ 'slotlock-approval',
4057
+ tenantRef,
4058
+ params.requestKey,
4059
+ ])}, 0))`;
4060
+ const prior = await approvalByRequestKey(tx, tenantRef, params.requestKey);
4061
+ if (prior) {
4062
+ const [same] = await tx `
4063
+ SELECT request = ${tx.json(params.request)}::jsonb AS same
4064
+ FROM slotlock.approvals WHERE id = ${prior.id}`;
4065
+ return same?.same
4066
+ ? { ok: true, approval: prior, replayed: true }
4067
+ : { ok: false, code: 'idempotency_conflict' };
4068
+ }
4069
+ // Counted under a lock on the tenant's approvals, so racing requests stop at the caps.
4070
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-approvals', tenantRef])}, 0))`;
4071
+ const [pending] = await tx `
4072
+ SELECT count(*)::int AS tenant,
4073
+ (count(*) FILTER (WHERE requester ->> 'subject' = ${params.requesterRef}))::int AS requester
4074
+ FROM slotlock.approvals
4075
+ WHERE tenant_ref = ${tenantRef} AND status = 'pending' AND expires_at > statement_timestamp()`;
4076
+ if ((pending?.requester ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER) {
4077
+ return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER };
4078
+ }
4079
+ if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
4080
+ return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
4081
+ }
4082
+ const id = randomUUID();
4083
+ // The requester's own hold of the time gives way to the approval's.
4084
+ if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
4085
+ await tx `
4086
+ DELETE FROM slotlock.reservations
4087
+ WHERE id = ${params.consumeHold.holdId} AND status = 'held'
4088
+ AND holder_ref = ${params.consumeHold.holderRef}`;
4089
+ }
4090
+ const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
4091
+ // Locked as any booking write locks them, in id order, so a racing booking takes turns.
4092
+ for (const resourceId of [...new Set(holds.map((hold) => hold.resourceId))].sort()) {
4093
+ await lockResourceForWrite(tx, resourceId, tenantRef);
4094
+ }
4095
+ for (const hold of holds)
4096
+ await reapExpiredHolds(tx, hold.resourceId, hold.start, hold.end);
4097
+ const held = await tx `
4098
+ INSERT INTO slotlock.reservations (
4099
+ resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status, expires_at,
4100
+ source, holder_ref, hold_group_id
4101
+ )
4102
+ SELECT wanted.resource_id, wanted.starts_at, wanted.ends_at, 0, 0, 'held', ${params.expiresAt},
4103
+ 'slotlock:approval', ${`approval:${id}`}, ${id}::uuid
4104
+ FROM ROWS FROM (pg_catalog.unnest(${holds.map((hold) => hold.resourceId)}::uuid[]),
4105
+ pg_catalog.unnest(${holds.map((hold) => hold.start.toISOString())}::text[]::timestamptz[]),
4106
+ pg_catalog.unnest(${holds.map((hold) => hold.end.toISOString())}::text[]::timestamptz[]))
4107
+ AS wanted(resource_id, starts_at, ends_at)
4108
+ RETURNING id`;
4109
+ const [row] = await tx `
4110
+ INSERT INTO slotlock.approvals (
4111
+ id, tenant_ref, policy_ref, operation, request, requester, hold_ids, resource_ids,
4112
+ starts_at, ends_at, request_key, expires_at
4113
+ ) VALUES (
4114
+ ${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
4115
+ ${tx.json(params.requester)}, ${held.map((hold) => hold.id)}::uuid[],
4116
+ ${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
4117
+ ${params.requestKey}, ${params.expiresAt}
4118
+ )
4119
+ RETURNING ${tx.unsafe(APPROVAL_COLUMNS)}`;
4120
+ if (!row)
4121
+ throw new Error('slotlock: approval insert returned no row');
4122
+ const approval = toApproval(row);
4123
+ await recordApprovalChange(tx, tenantRef, 'approval.requested', approval);
4124
+ return { ok: true, approval, replayed: false };
4125
+ }, 'read committed');
4126
+ }
4127
+ catch (err) {
4128
+ if (pgCode(err) === EXCLUSION_VIOLATION)
4129
+ return { ok: false, code: 'overlap' };
4130
+ throw err;
4131
+ }
4132
+ },
4133
+ async findApproval(params) {
4134
+ if (!isValidIdentity(params.tenantRef)) {
4135
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4136
+ }
4137
+ if (!/^[0-9a-f]{64}$/.test(params.requestKey))
4138
+ return null;
4139
+ return approvalByRequestKey(sql, params.tenantRef, params.requestKey);
4140
+ },
4141
+ async getApproval(params) {
4142
+ if (!isValidIdentity(params.tenantRef)) {
4143
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4144
+ }
4145
+ return getApproval(sql, params.tenantRef, params.id);
4146
+ },
4147
+ async listApprovals(params) {
4148
+ if (!isValidIdentity(params.tenantRef)) {
4149
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4150
+ }
4151
+ const limit = params.limit ?? 50;
4152
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) {
4153
+ throw invalidIdentityError('Slotlock approval list limit must be 1-200');
4154
+ }
4155
+ return listApprovals(sql, {
4156
+ tenantRef: params.tenantRef,
4157
+ limit,
4158
+ ...(params.status !== undefined ? { status: params.status } : {}),
4159
+ });
4160
+ },
4161
+ async beginApprovalGrant(params) {
4162
+ if (!isValidIdentity(params.tenantRef)) {
4163
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4164
+ }
4165
+ return beginApprovalGrant(sql, params.tenantRef, params.id);
4166
+ },
4167
+ async finishApprovalGrant(params) {
4168
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
4169
+ throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
4170
+ }
4171
+ return finishApprovalGrant(sql, params.tenantRef, params.id, params.decidedBy);
4172
+ },
4173
+ async declineApproval(params) {
4174
+ if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
4175
+ throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
4176
+ }
4177
+ if (params.reason !== undefined && params.reason.length > 500) {
4178
+ throw invalidIdentityError('Slotlock approval reason must be at most 500 characters');
4179
+ }
4180
+ return declineApproval(sql, params);
4181
+ },
4182
+ async resetTestTenant(params) {
4183
+ if (!isValidIdentity(params.tenantRef)) {
4184
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4185
+ }
4186
+ if (!params.tenantRef.endsWith('~test'))
4187
+ throw sandboxTestOnlyError();
4188
+ const rows = await sql `
4189
+ SELECT * FROM slotlock.reset_test_tenant(${params.tenantRef}::text)`.catch((error) => {
4190
+ if (error.hint === 'sandbox_test_only')
4191
+ throw sandboxTestOnlyError();
4192
+ throw error;
4193
+ });
4194
+ return Object.fromEntries(rows.map((row) => [row.reset_table, Number(row.reset_rows)]));
4195
+ },
4196
+ async fastForwardTestTenant(params) {
4197
+ if (!isValidIdentity(params.tenantRef)) {
4198
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4199
+ }
4200
+ if (!params.tenantRef.endsWith('~test'))
4201
+ throw sandboxTestOnlyError();
4202
+ if (!Number.isSafeInteger(params.minutes) || params.minutes < 1 || params.minutes > 525_600) {
4203
+ throw invalidIdentityError('Slotlock fast-forward is 1 to 525,600 minutes');
4204
+ }
4205
+ const [counted] = await sql `
4206
+ WITH lapsed_holds AS (
4207
+ UPDATE slotlock.reservations hold
4208
+ SET expires_at = statement_timestamp() - interval '1 millisecond'
4209
+ WHERE hold.status = 'held'
4210
+ AND hold.expires_at > statement_timestamp()
4211
+ AND hold.expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
4212
+ AND hold.resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef})
4213
+ RETURNING 1
4214
+ ), lapsed_approvals AS (
4215
+ UPDATE slotlock.approvals
4216
+ SET expires_at = statement_timestamp() - interval '1 millisecond'
4217
+ WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
4218
+ AND expires_at > statement_timestamp()
4219
+ AND expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
4220
+ RETURNING 1
4221
+ )
4222
+ SELECT (SELECT count(*) FROM lapsed_holds)::int AS holds,
4223
+ (SELECT count(*) FROM lapsed_approvals)::int AS approvals`;
4224
+ return { holds: counted?.holds ?? 0, approvals: counted?.approvals ?? 0 };
4225
+ },
4226
+ async expireApprovals(params) {
4227
+ if (!isValidIdentity(params.tenantRef)) {
4228
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4229
+ }
4230
+ const limit = params.limit ?? 100;
4231
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
4232
+ throw invalidIdentityError('Slotlock approval expiry limit must be 1-1000');
4233
+ }
4234
+ return { expired: await expireApprovals(sql, params.tenantRef, limit) };
4235
+ },
3217
4236
  async getReservation(params) {
3218
4237
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
3219
4238
  throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
@@ -3270,15 +4289,13 @@ function buildSlotlockStore(sql, options, refusal) {
3270
4289
  if (bookingTenant !== null)
3271
4290
  await lockExternalIdentity(tx, bookingTenant, holdId);
3272
4291
  const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
3273
- // The resource's share lock, as every booking write takes it: an archive waits for this
4292
+ // The resource's lock, as every booking write takes it: an archive waits for this
3274
4293
  // confirmation.
3275
- const [resource] = await tx `
3276
- SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
3277
- WHERE id = ${owner.resource_id}
3278
- FOR SHARE`;
4294
+ const resource = await lockResourceForWrite(tx, owner.resource_id, null);
3279
4295
  const [row] = await tx `
3280
4296
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
3281
- revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live
4297
+ revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live,
4298
+ hold_group_id, holder_ref
3282
4299
  FROM slotlock.reservations
3283
4300
  WHERE id = ${holdId}
3284
4301
  FOR UPDATE`;
@@ -3326,6 +4343,14 @@ function buildSlotlockStore(sql, options, refusal) {
3326
4343
  buffer_after_ms, revision, source`;
3327
4344
  if (!confirmed)
3328
4345
  throw new Error('slotlock: hold confirmation returned no row');
4346
+ // Its linked alternatives (one group, one holder) are released with it, as booking one
4347
+ // from an agent releases the rest.
4348
+ if (row.hold_group_id !== null && row.holder_ref !== null) {
4349
+ await tx `
4350
+ DELETE FROM slotlock.reservations
4351
+ WHERE status = 'held' AND hold_group_id = ${row.hold_group_id}::uuid
4352
+ AND holder_ref = ${row.holder_ref} AND id <> ${holdId}`;
4353
+ }
3329
4354
  return { ok: true, reservation: toReservation(confirmed) };
3330
4355
  });
3331
4356
  },
@@ -3388,14 +4413,18 @@ function buildSlotlockStore(sql, options, refusal) {
3388
4413
  // Expired holds are excluded so findNextAvailable can offer their space; the acquiring
3389
4414
  // transaction reaps the row itself.
3390
4415
  const rows = await sql `
3391
- SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
3392
- COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
3393
- FROM slotlock.reservations
3394
- WHERE resource_id = ${resourceId}
3395
- AND ${occupiedStartsBefore(sql, window.end)}
3396
- AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
3397
- AND (status <> 'held' OR expires_at > statement_timestamp())
3398
- ORDER BY COALESCE(occupied_starts_at, starts_at)
4416
+ SELECT occupied_starts_at, occupied_ends_at FROM (
4417
+ SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
4418
+ COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
4419
+ FROM slotlock.reservations
4420
+ WHERE resource_id = ${resourceId}
4421
+ AND ${occupiedStartsBefore(sql, window.end)}
4422
+ AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
4423
+ AND (status <> 'held' OR expires_at > statement_timestamp())
4424
+ UNION ALL
4425
+ ${compositeBusy(sql, resourceId, window)}
4426
+ ) busy
4427
+ ORDER BY occupied_starts_at
3399
4428
  LIMIT ${options.limit ?? null}`;
3400
4429
  return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
3401
4430
  },
@@ -3570,12 +4599,17 @@ function buildSlotlockStore(sql, options, refusal) {
3570
4599
  return { ok: false, code: 'hold_not_found' };
3571
4600
  }
3572
4601
  const [hold] = await tx `
3573
- SELECT resource_id, starts_at, ends_at, hold_group_id FROM slotlock.reservations
4602
+ SELECT resource_id, starts_at, ends_at, hold_group_id,
4603
+ expires_at > statement_timestamp() AS live
4604
+ FROM slotlock.reservations
3574
4605
  WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
3575
- AND expires_at > statement_timestamp()
3576
4606
  FOR UPDATE`;
3577
4607
  if (!hold)
3578
4608
  return { ok: false, code: 'hold_not_found' };
4609
+ // The holder's own hold that lapsed and is not yet reaped: named as the application
4610
+ // API's confirm names it. Another holder's stays not found.
4611
+ if (!hold.live)
4612
+ return { ok: false, code: 'hold_expired' };
3579
4613
  const [only] = occurrences;
3580
4614
  if (hold.resource_id !== params.resourceId ||
3581
4615
  occurrences.length !== 1 ||
@@ -3677,6 +4711,12 @@ function buildSlotlockStore(sql, options, refusal) {
3677
4711
  // The normalized children are canonical; the JSON columns above are a compatibility
3678
4712
  // mirror until older readers retire.
3679
4713
  await replaceCalendarEventChildren(tx, params.tenantRef, eventId, event);
4714
+ // Writers to this resource take turns from here to its occupations (lockResourceForWrite).
4715
+ const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
4716
+ if (!locked)
4717
+ return { ok: false, code: 'resource_tenant_mismatch' };
4718
+ if (locked.archived)
4719
+ return { ok: false, code: 'resource_archived' };
3680
4720
  await replaceCalendarEventOccurrences(tx, {
3681
4721
  tenantRef: params.tenantRef,
3682
4722
  eventId,
@@ -4607,14 +5647,15 @@ function buildSlotlockStore(sql, options, refusal) {
4607
5647
  await tx `
4608
5648
  INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
4609
5649
  VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
5650
+ // The deletion is the change: the feed's trigger names it reservation.no_show (one change,
5651
+ // not a cancellation too), by maintenance, with the check-in window it missed.
5652
+ await tx `
5653
+ SELECT set_config('slotlock.ending', 'no_show', true),
5654
+ set_config('slotlock.ending_detail', ${String(row.minutes)}, true),
5655
+ set_config('slotlock.actor', 'slotlock:maintenance', true)`;
4610
5656
  await tx `DELETE FROM slotlock.reservations WHERE id = ${row.id}`;
4611
5657
  await tx `
4612
- INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4613
- VALUES (${tenantRef}, ${row.resource_id}, 'reservation.no_show', ${row.external_ref},
4614
- jsonb_build_object('id', ${row.id}::uuid, 'reference', ${row.external_ref}::text,
4615
- 'resource_id', ${row.resource_id}::uuid, 'revision', ${revision}::bigint,
4616
- 'check_in_minutes', ${row.minutes}::int),
4617
- 'slotlock:maintenance')`;
5658
+ SELECT set_config('slotlock.ending', '', true), set_config('slotlock.ending_detail', '', true)`;
4618
5659
  return true;
4619
5660
  });
4620
5661
  if (done)
@@ -4643,7 +5684,35 @@ function buildSlotlockStore(sql, options, refusal) {
4643
5684
  if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
4644
5685
  return { ok: false, code: 'invalid_ttl' };
4645
5686
  }
5687
+ const key = params.idempotencyKey;
5688
+ if (key !== undefined && !isValidIdentity(key))
5689
+ return { ok: false, code: 'invalid_identity' };
5690
+ const payloadHash = key === undefined
5691
+ ? null
5692
+ : createHash('sha256')
5693
+ .update(JSON.stringify([
5694
+ params.resourceId ?? null,
5695
+ params.pool ?? null,
5696
+ window.start.toISOString(),
5697
+ window.end.toISOString(),
5698
+ holderRef,
5699
+ offerTtlMs,
5700
+ ]))
5701
+ .digest('hex');
4646
5702
  return inTransaction(sql, async (tx) => {
5703
+ // The tenant's waitlist lock first, which offers and every join take: a retry racing its
5704
+ // first finds the entry the first made.
5705
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5706
+ if (key !== undefined) {
5707
+ const [earlier] = await tx `
5708
+ SELECT ${tx.unsafe(WAITLIST_COLUMNS)}, payload_hash FROM slotlock.waitlist_entries
5709
+ WHERE tenant_ref = ${tenantRef} AND idempotency_key = ${key}`;
5710
+ if (earlier) {
5711
+ if (earlier.payload_hash !== payloadHash)
5712
+ return { ok: false, code: 'idempotency_conflict' };
5713
+ return { ok: true, entry: toWaitlistEntry(earlier), replayed: true };
5714
+ }
5715
+ }
4647
5716
  if (params.resourceId !== undefined) {
4648
5717
  const [resource] = await tx `
4649
5718
  SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
@@ -4661,9 +5730,8 @@ function buildSlotlockStore(sql, options, refusal) {
4661
5730
  if (!member)
4662
5731
  return { ok: false, code: 'pool_not_found' };
4663
5732
  }
4664
- // Counted under the tenant's waitlist lock, which offers take too, so racing joins stop at
4665
- // the cap.
4666
- await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
5733
+ // Counted under the tenant's waitlist lock (taken above), which offers take too, so racing
5734
+ // joins stop at the cap.
4667
5735
  const [counts] = await tx `
4668
5736
  SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
4669
5737
  FROM slotlock.waitlist_entries WHERE tenant_ref = ${tenantRef} AND status = 'waiting'`;
@@ -4673,11 +5741,12 @@ function buildSlotlockStore(sql, options, refusal) {
4673
5741
  }
4674
5742
  const [row] = await tx `
4675
5743
  INSERT INTO slotlock.waitlist_entries
4676
- (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms)
5744
+ (tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
5745
+ idempotency_key, payload_hash)
4677
5746
  VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
4678
- ${window.end}, ${holderRef}, ${offerTtlMs})
5747
+ ${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
4679
5748
  RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
4680
- return { ok: true, entry: toWaitlistEntry(row) };
5749
+ return { ok: true, entry: toWaitlistEntry(row), replayed: false };
4681
5750
  });
4682
5751
  },
4683
5752
  async listWaitlist(params) {
@@ -4767,6 +5836,17 @@ function buildSlotlockStore(sql, options, refusal) {
4767
5836
  DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
4768
5837
  return rows.length > 0;
4769
5838
  },
5839
+ async resealAssistantKey(params) {
5840
+ if (!isValidIdentity(params.tenantRef))
5841
+ return false;
5842
+ if (params.to.length === 0 || params.to === params.from)
5843
+ return false;
5844
+ const rows = await sql `
5845
+ UPDATE slotlock.assistant_settings SET sealed_key = ${params.to}
5846
+ WHERE tenant_ref = ${params.tenantRef} AND sealed_key = ${params.from}
5847
+ RETURNING 1`;
5848
+ return rows.length > 0;
5849
+ },
4770
5850
  async getAssistantConversation(params) {
4771
5851
  if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
4772
5852
  return null;
@@ -5029,6 +6109,8 @@ function buildSlotlockStore(sql, options, refusal) {
5029
6109
  AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
5030
6110
  AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
5031
6111
  AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
6112
+ UNION ALL
6113
+ ${compositeBusy(sql, params.resourceId, window)}
5032
6114
  ORDER BY 1
5033
6115
  LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
5034
6116
  if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
@@ -5147,8 +6229,7 @@ function buildSlotlockStore(sql, options, refusal) {
5147
6229
  if (!isValidResourceId(params.resourceId))
5148
6230
  throw notFound();
5149
6231
  const [row] = await sql `
5150
- SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
5151
- archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
6232
+ SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
5152
6233
  FROM slotlock.resources
5153
6234
  WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
5154
6235
  if (!row)
@@ -5258,7 +6339,9 @@ function buildSlotlockStore(sql, options, refusal) {
5258
6339
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5259
6340
  }
5260
6341
  const erased = await sql `
5261
- SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`;
6342
+ SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`.catch((error) => {
6343
+ throw billingActiveRefusal(error);
6344
+ });
5262
6345
  const rows = {};
5263
6346
  let total = 0;
5264
6347
  for (const row of erased) {
@@ -5276,6 +6359,17 @@ function buildSlotlockStore(sql, options, refusal) {
5276
6359
  };
5277
6360
  return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
5278
6361
  }
6362
+ /**
6363
+ * erase_tenant refuses a tenant whose plan names a live subscription (`billing_active`): it is
6364
+ * cancelled first, so it does not renew for an erased account.
6365
+ */
6366
+ function billingActiveRefusal(error) {
6367
+ if (pgCode(error) !== '55006' || error.hint !== 'billing_active')
6368
+ return error;
6369
+ return Object.assign(new Error('Slotlock tenant has a live subscription: cancel it before erasing the tenant', {
6370
+ cause: error,
6371
+ }), { code: 'billing_active' });
6372
+ }
5279
6373
  /** The deployment role alone may suspend: others are refused EXECUTE on the functions. */
5280
6374
  function suspensionRefusal(error) {
5281
6375
  if (pgCode(error) !== '42501')