slotlock 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +467 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1551
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +559 -44
- package/agent-store-backend.d.ts +34 -1
- package/agent-store-backend.js +1399 -38
- package/api-keys.d.ts +69 -3
- package/api-keys.js +217 -28
- package/application-api.d.ts +56 -3
- package/application-api.js +826 -15
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +40 -0
- package/assistant.js +115 -0
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/config.d.ts +25 -4
- package/config.js +72 -3
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1545 -64
- package/ddl.d.ts +51 -12
- package/ddl.js +2566 -54
- package/event-feed.d.ts +30 -4
- package/event-feed.js +171 -19
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/maintenance.d.ts +10 -0
- package/maintenance.js +16 -2
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -0
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +21 -1
- package/rate-limit.js +82 -15
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +2 -0
- package/rest-api.js +68 -3
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +33 -5
- package/self-host.js +950 -22
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +373 -4
- package/store.js +1212 -118
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.js +13 -1
- package/types.d.ts +95 -1
- package/usage.d.ts +106 -0
- 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
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
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
|
-
*
|
|
1236
|
-
*
|
|
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
|
|
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 (
|
|
1367
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2505
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) >=
|
|
3077
|
-
return { ok: false, code: 'hold_limit_reached', limit:
|
|
3804
|
+
if ((held?.count ?? 0) >= holdCap) {
|
|
3805
|
+
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
3078
3806
|
}
|
|
3079
|
-
if (
|
|
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
|
-
|
|
3817
|
+
heldHoursCap !== undefined &&
|
|
3090
3818
|
heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
|
|
3091
|
-
|
|
3092
|
-
return { ok: false, code: 'held_time_limit_reached', limit:
|
|
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
|
|
4292
|
+
// The resource's lock, as every booking write takes it: an archive waits for this
|
|
3274
4293
|
// confirmation.
|
|
3275
|
-
const
|
|
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
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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')
|