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