slotlock 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/README.md +97 -18
- package/SPEC.md +10 -7
- package/agent-server.d.ts +38 -4
- package/agent-server.js +464 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +1 -1
- package/ddl.js +395 -4
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +7 -6
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/self-host.js +8 -4
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +32 -3
- package/store.js +324 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +9 -2
- package/webhooks.js +6 -1
package/store.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// DB mechanics for the Slotlock core — explicit `postgres` client in, structured results out.
|
|
2
2
|
// Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
|
|
3
3
|
// reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
|
|
4
|
-
import { SLOTLOCK_CHANGE_RETENTION_DAYS, slotlockEventFeedMethods } from './event-feed.js';
|
|
5
|
-
import { bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
4
|
+
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockCoverageSourceId, slotlockCoverageSourceIdSql, slotlockEventFeedMethods, } from './event-feed.js';
|
|
5
|
+
import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
7
|
import ICAL from 'ical.js';
|
|
8
8
|
import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
|
|
@@ -24,6 +24,11 @@ const SERIALIZATION_FAILURE = '40001';
|
|
|
24
24
|
const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
25
|
/** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
|
|
26
26
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
27
|
+
/**
|
|
28
|
+
* The longest leading buffer a row keeps (a day, as bufferBeforeMinutes allows): a row's occupied
|
|
29
|
+
* time starts at most this long before its start, so a read by start can still use the index.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
|
|
27
32
|
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
28
33
|
/**
|
|
29
34
|
* Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
|
|
@@ -77,6 +82,8 @@ const MAX_CONFIGURED_AGENT_OWNER_EVENT_QUOTA = 100_000;
|
|
|
77
82
|
const MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA = 1_000_000;
|
|
78
83
|
const SCHEMA_APPLY_LOCK = 'slotlock:schema-apply:v1';
|
|
79
84
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
85
|
+
// A coverage source as the feed names it (coverage.stale): its digest, never the source itself.
|
|
86
|
+
const COVERAGE_SOURCE_ID = slotlockCoverageSourceIdSql('tenant_ref', 'source');
|
|
80
87
|
const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
|
|
81
88
|
/** Store-owned coverage source for bounded, locally materialized recurring events. */
|
|
82
89
|
export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
|
|
@@ -870,18 +877,23 @@ async function replaceCalendarEventOccurrences(tx, params) {
|
|
|
870
877
|
for (const occurrence of params.occurrences) {
|
|
871
878
|
let reservationId = null;
|
|
872
879
|
if ((params.event.transparency ?? 'opaque') === 'opaque') {
|
|
873
|
-
const occupied =
|
|
874
|
-
|
|
880
|
+
const occupied = occupiedTime({
|
|
881
|
+
...occurrence,
|
|
882
|
+
bufferBeforeMs: params.bufferBeforeMs ?? 0,
|
|
883
|
+
bufferAfterMs: params.bufferAfterMs ?? 0,
|
|
884
|
+
});
|
|
885
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
875
886
|
const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
|
|
876
887
|
.update(occurrence.recurrenceId)
|
|
877
888
|
.digest('hex')}`;
|
|
878
889
|
const reservations = await tx `
|
|
879
890
|
INSERT INTO slotlock.reservations (
|
|
880
891
|
resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
881
|
-
buffer_after_ms, revision, source, calendar_event_id
|
|
892
|
+
buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
|
|
882
893
|
) VALUES (
|
|
883
894
|
${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
|
|
884
|
-
${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
|
|
895
|
+
${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
|
|
896
|
+
${params.bufferBeforeMs ?? 0}, ${params.revision},
|
|
885
897
|
${`calendar-event:${params.eventId}`}, ${params.eventId}
|
|
886
898
|
)
|
|
887
899
|
RETURNING id`;
|
|
@@ -1069,46 +1081,53 @@ async function finishCalendarCancel(tx, params) {
|
|
|
1069
1081
|
* The row an event write lost to. The write was rolled back, so the event's own rows from before
|
|
1070
1082
|
* it are still there and are left out: they were to be replaced, and never block the event.
|
|
1071
1083
|
*/
|
|
1072
|
-
async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
|
|
1084
|
+
async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
|
|
1073
1085
|
for (const occurrence of occurrences) {
|
|
1074
|
-
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end,
|
|
1086
|
+
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, buffers.bufferAfterMs, undefined, eventId, buffers.bufferBeforeMs);
|
|
1075
1087
|
if (result.conflictingReservationId !== null)
|
|
1076
1088
|
return result;
|
|
1077
1089
|
}
|
|
1078
1090
|
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
1079
1091
|
}
|
|
1080
1092
|
/**
|
|
1081
|
-
* What the resource's booking rules
|
|
1082
|
-
*
|
|
1083
|
-
* A resource without its own
|
|
1093
|
+
* What the resource's booking rules (as they apply: its own over its pool's and tenant's defaults)
|
|
1094
|
+
* say about a booking of `interval` made now (by the database's clock, at this statement): the rule
|
|
1095
|
+
* it breaks, if any, and the buffers it must keep before and after it. A resource without its own
|
|
1096
|
+
* hours is judged against `defaultHours`.
|
|
1084
1097
|
*/
|
|
1085
1098
|
async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
1086
1099
|
const judge = await bookingJudge(tx, resourceId, defaultHours);
|
|
1087
|
-
return {
|
|
1100
|
+
return {
|
|
1101
|
+
rule: judge.broken(interval),
|
|
1102
|
+
turnaroundMs: judge.turnaroundMs,
|
|
1103
|
+
bufferBeforeMs: judge.bufferBeforeMs,
|
|
1104
|
+
};
|
|
1088
1105
|
}
|
|
1089
1106
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1090
1107
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1091
1108
|
/**
|
|
1092
|
-
* The resource's booking rules, read once, as a check of any interval booked now (by
|
|
1093
|
-
* clock at this statement) and the
|
|
1109
|
+
* The resource's booking rules as they apply, read once, as a check of any interval booked now (by
|
|
1110
|
+
* the database's clock at this statement) and the buffers a booking keeps.
|
|
1094
1111
|
*/
|
|
1095
1112
|
async function bookingJudge(tx, resourceId, defaultHours) {
|
|
1096
1113
|
const [row] = await tx `
|
|
1097
|
-
SELECT
|
|
1114
|
+
SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
|
|
1115
|
+
statement_timestamp() AS now
|
|
1098
1116
|
FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1099
|
-
if (!row || row.
|
|
1100
|
-
return { broken: () => null, turnaroundMs: 0 };
|
|
1117
|
+
if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
|
|
1118
|
+
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
|
|
1101
1119
|
}
|
|
1102
|
-
const {
|
|
1120
|
+
const { effectiveBookingRules: rules, availabilityRules } = toResource({
|
|
1103
1121
|
id: resourceId,
|
|
1104
1122
|
external_ref: null,
|
|
1105
1123
|
tenant_ref: null,
|
|
1106
1124
|
timezone: row.timezone,
|
|
1107
1125
|
availability_rules: row.availability_rules,
|
|
1108
|
-
booking_rules:
|
|
1126
|
+
booking_rules: null,
|
|
1109
1127
|
pool_ref: null,
|
|
1110
1128
|
visibility: 'shared',
|
|
1111
1129
|
archived_at: null,
|
|
1130
|
+
effective_booking_rules: row.effective_booking_rules,
|
|
1112
1131
|
});
|
|
1113
1132
|
const hours = availabilityRules ?? defaultHours;
|
|
1114
1133
|
// Every sync covering the resource, and whether it was read within a day, by the same clock.
|
|
@@ -1120,9 +1139,10 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1120
1139
|
FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
|
|
1121
1140
|
: [];
|
|
1122
1141
|
const turnaroundMs = bookingBufferAfterMs(rules);
|
|
1123
|
-
|
|
1142
|
+
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
1143
|
+
// The booking and the buffers it keeps, as a search judges the slot.
|
|
1124
1144
|
const uncertain = (interval) => coverage.some((source) => !source.fresh ||
|
|
1125
|
-
source.starts_at > interval.start ||
|
|
1145
|
+
source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
|
|
1126
1146
|
source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
|
|
1127
1147
|
// A later occurrence of a series is judged on its step and hours only: notice and advance are
|
|
1128
1148
|
// about when the booking was made, which its first occurrence answers.
|
|
@@ -1130,6 +1150,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1130
1150
|
? undefined
|
|
1131
1151
|
: {
|
|
1132
1152
|
...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
|
|
1153
|
+
...(rules.stepOffsetMinutes !== undefined ? { stepOffsetMinutes: rules.stepOffsetMinutes } : {}),
|
|
1133
1154
|
...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
|
|
1134
1155
|
};
|
|
1135
1156
|
return {
|
|
@@ -1141,6 +1162,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1141
1162
|
now: row.now,
|
|
1142
1163
|
}) ?? (uncertain(interval) ? 'uncertain' : null),
|
|
1143
1164
|
turnaroundMs,
|
|
1165
|
+
bufferBeforeMs,
|
|
1144
1166
|
};
|
|
1145
1167
|
}
|
|
1146
1168
|
function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
@@ -1153,9 +1175,6 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1153
1175
|
Number(row.revision) === params.revision &&
|
|
1154
1176
|
row.source === (params.source ?? null));
|
|
1155
1177
|
}
|
|
1156
|
-
function occupiedEnd(params) {
|
|
1157
|
-
return new Date(params.end.getTime() + params.bufferAfterMs);
|
|
1158
|
-
}
|
|
1159
1178
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1160
1179
|
// JSON encoding is unambiguous for this pair and contains no NUL byte (Postgres text rejects
|
|
1161
1180
|
// U+0000). The database hashes it to one transaction-scoped lock key.
|
|
@@ -1185,6 +1204,8 @@ function pgCode(err) {
|
|
|
1185
1204
|
}
|
|
1186
1205
|
/** Members a pool booking or search considers at most, in id order. */
|
|
1187
1206
|
export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
|
|
1207
|
+
/** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
|
|
1208
|
+
export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
|
|
1188
1209
|
const WAITLIST_COLUMNS = 'id, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms, status, hold_id, offered_resource_id, offered_at, created_at';
|
|
1189
1210
|
function toWaitlistEntry(row) {
|
|
1190
1211
|
return {
|
|
@@ -1210,6 +1231,30 @@ function invalidPoolError() {
|
|
|
1210
1231
|
code: 'invalid_pool',
|
|
1211
1232
|
});
|
|
1212
1233
|
}
|
|
1234
|
+
/**
|
|
1235
|
+
* A resource row's booking rules as they apply (jsonb; NULL for none): the tenant's defaults,
|
|
1236
|
+
* overlaid by its pool's, overlaid by its own, field by field, so one set at a narrower level wins.
|
|
1237
|
+
* Read from a row of slotlock.resources named `resources` (no alias).
|
|
1238
|
+
*/
|
|
1239
|
+
const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
1240
|
+
COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
1241
|
+
WHERE defaults.tenant_ref = resources.tenant_ref AND defaults.pool_ref = ''), '{}'::jsonb)
|
|
1242
|
+
|| COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
1243
|
+
WHERE defaults.tenant_ref = resources.tenant_ref
|
|
1244
|
+
AND defaults.pool_ref = resources.pool_ref), '{}'::jsonb)
|
|
1245
|
+
|| COALESCE(resources.booking_rules, '{}'::jsonb),
|
|
1246
|
+
'{}'::jsonb) AS effective_booking_rules`;
|
|
1247
|
+
/** What rules Slotlock did not write read as: no booking passes them. */
|
|
1248
|
+
const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
|
|
1249
|
+
/** Rules read back from storage: written validated, so anything else refuses every booking. */
|
|
1250
|
+
function storedBookingRules(value) {
|
|
1251
|
+
try {
|
|
1252
|
+
return parseBookingRules(value);
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
return { ...REFUSE_EVERY_BOOKING };
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1213
1258
|
function toResource(row) {
|
|
1214
1259
|
const resource = {
|
|
1215
1260
|
id: row.id,
|
|
@@ -1234,11 +1279,19 @@ function toResource(row) {
|
|
|
1234
1279
|
if (row.booking_rules !== null && row.booking_rules !== undefined) {
|
|
1235
1280
|
// Written through setResourceBookingRules, which validates; anything else reads as refusing
|
|
1236
1281
|
// every booking, never as no rule.
|
|
1237
|
-
|
|
1238
|
-
|
|
1282
|
+
resource.bookingRules = storedBookingRules(row.booking_rules);
|
|
1283
|
+
}
|
|
1284
|
+
if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
|
|
1285
|
+
// Merged from levels each validated alone, so only their combination can disagree (an offset
|
|
1286
|
+
// from one level over a shorter step from another): that one is read modulo the step. A field
|
|
1287
|
+
// invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
|
|
1288
|
+
const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
|
|
1289
|
+
if (dropped.length > 0) {
|
|
1290
|
+
console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
|
|
1291
|
+
resource.effectiveBookingRules = { ...REFUSE_EVERY_BOOKING };
|
|
1239
1292
|
}
|
|
1240
|
-
|
|
1241
|
-
resource.
|
|
1293
|
+
else if (Object.keys(rules).length > 0) {
|
|
1294
|
+
resource.effectiveBookingRules = rules;
|
|
1242
1295
|
}
|
|
1243
1296
|
}
|
|
1244
1297
|
return resource;
|
|
@@ -1268,7 +1321,7 @@ async function upsertResource(executor, params) {
|
|
|
1268
1321
|
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
|
|
1269
1322
|
DO UPDATE SET timezone = EXCLUDED.timezone
|
|
1270
1323
|
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1271
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
1324
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1272
1325
|
}
|
|
1273
1326
|
else if (params.externalRef !== undefined) {
|
|
1274
1327
|
rows = await executor `
|
|
@@ -1278,13 +1331,13 @@ async function upsertResource(executor, params) {
|
|
|
1278
1331
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
|
|
1279
1332
|
DO UPDATE SET external_ref = EXCLUDED.external_ref
|
|
1280
1333
|
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1281
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
1334
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1282
1335
|
}
|
|
1283
1336
|
else {
|
|
1284
1337
|
rows = await executor `
|
|
1285
1338
|
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1286
1339
|
VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
1287
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
1340
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1288
1341
|
}
|
|
1289
1342
|
const row = rows[0];
|
|
1290
1343
|
if (!row) {
|
|
@@ -1307,7 +1360,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1307
1360
|
summary, description, location, status, transparency, organizer, attendees,
|
|
1308
1361
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1309
1362
|
materialized_ends_at, recurrence_cursor, revision, source, created_at,
|
|
1310
|
-
updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
|
|
1363
|
+
updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms, buffer_before_ms
|
|
1311
1364
|
FROM slotlock.calendar_events
|
|
1312
1365
|
WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
|
|
1313
1366
|
FOR UPDATE`;
|
|
@@ -1330,6 +1383,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1330
1383
|
occurrences,
|
|
1331
1384
|
revision: Number(row.revision),
|
|
1332
1385
|
bufferAfterMs: Number(row.buffer_after_ms ?? 0),
|
|
1386
|
+
bufferBeforeMs: Number(row.buffer_before_ms ?? 0),
|
|
1333
1387
|
});
|
|
1334
1388
|
// A series stored before 0.3 learns when it ends at its first roll.
|
|
1335
1389
|
const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
|
|
@@ -1697,8 +1751,28 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
|
1697
1751
|
return Number(row?.deleted ?? 0);
|
|
1698
1752
|
}
|
|
1699
1753
|
/**
|
|
1700
|
-
*
|
|
1701
|
-
* it
|
|
1754
|
+
* The time a booking of [start, end) keeping these buffers occupies, as the exclusion constraint
|
|
1755
|
+
* reads it: from its start less its leading buffer to its end plus its trailing one.
|
|
1756
|
+
*/
|
|
1757
|
+
function occupiedTime(params) {
|
|
1758
|
+
return {
|
|
1759
|
+
start: new Date(params.start.getTime() - (params.bufferBeforeMs ?? 0)),
|
|
1760
|
+
end: new Date(params.end.getTime() + (params.bufferAfterMs ?? 0)),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* A row's occupied time starts before `end`: a condition on its occupied start that still reads the
|
|
1765
|
+
* (resource_id, starts_at, ends_at) index, since no row starts more than a day after it.
|
|
1766
|
+
*/
|
|
1767
|
+
function occupiedStartsBefore(executor, end, alias) {
|
|
1768
|
+
const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
|
|
1769
|
+
return executor `(${column('starts_at')} < ${new Date(end.getTime() + MAX_BUFFER_BEFORE_MS)}
|
|
1770
|
+
AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Delete expired held rows whose occupied time (buffers included, as the EXCLUDE constraint reads
|
|
1774
|
+
* it) overlaps the requested window, whose `start` and `end` are its own occupied start and end
|
|
1775
|
+
* (`occupiedTime`), in deterministic id
|
|
1702
1776
|
* order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
|
|
1703
1777
|
* deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
|
|
1704
1778
|
* before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
|
|
@@ -1711,7 +1785,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1711
1785
|
WHERE resource_id = ${resourceId}
|
|
1712
1786
|
AND status = 'held'
|
|
1713
1787
|
AND expires_at <= statement_timestamp()
|
|
1714
|
-
AND
|
|
1788
|
+
AND ${occupiedStartsBefore(tx, end)}
|
|
1715
1789
|
AND COALESCE(occupied_ends_at, ends_at) > ${start}
|
|
1716
1790
|
ORDER BY id
|
|
1717
1791
|
FOR UPDATE
|
|
@@ -1723,7 +1797,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1723
1797
|
* loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
|
|
1724
1798
|
* immediate retry reaps it and wins.
|
|
1725
1799
|
*/
|
|
1726
|
-
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
|
|
1800
|
+
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
|
|
1727
1801
|
const winners = await sql `
|
|
1728
1802
|
SELECT id FROM slotlock.reservations
|
|
1729
1803
|
WHERE resource_id = ${resourceId}
|
|
@@ -1731,12 +1805,12 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
|
|
|
1731
1805
|
AND (${excludeCalendarEventId ?? null}::uuid IS NULL
|
|
1732
1806
|
OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
|
|
1733
1807
|
AND tstzrange(
|
|
1734
|
-
starts_at,
|
|
1808
|
+
COALESCE(occupied_starts_at, starts_at),
|
|
1735
1809
|
COALESCE(occupied_ends_at, ends_at),
|
|
1736
1810
|
'[)'
|
|
1737
1811
|
) && tstzrange(
|
|
1738
|
-
${start},
|
|
1739
|
-
${end} + ${bufferAfterMs} * interval '1 millisecond',
|
|
1812
|
+
${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
|
|
1813
|
+
${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
|
|
1740
1814
|
'[)'
|
|
1741
1815
|
)
|
|
1742
1816
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
@@ -1821,6 +1895,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
1821
1895
|
'deleteResource',
|
|
1822
1896
|
'setResourceAvailability',
|
|
1823
1897
|
'setResourceBookingRules',
|
|
1898
|
+
'setBookingRuleDefaults',
|
|
1824
1899
|
'setResourcePool',
|
|
1825
1900
|
'setTenantAvailability',
|
|
1826
1901
|
'createReservation',
|
|
@@ -2151,6 +2226,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2151
2226
|
code: 'invalid_identity',
|
|
2152
2227
|
});
|
|
2153
2228
|
}
|
|
2229
|
+
const origin = options?.origin;
|
|
2230
|
+
if (origin !== undefined && !isSlotlockChangeOrigin(origin)) {
|
|
2231
|
+
throw Object.assign(new Error(`Slotlock origin must be one of ${SLOTLOCK_CHANGE_ORIGINS.join(', ')}`), {
|
|
2232
|
+
code: 'invalid_identity',
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
const keyId = options?.keyId;
|
|
2236
|
+
if (keyId !== undefined && !isValidResourceId(keyId)) {
|
|
2237
|
+
throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
|
|
2238
|
+
}
|
|
2154
2239
|
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
2155
2240
|
// A lock wait that timed out leaves the transaction unusable, so the reset below fails with
|
|
2156
2241
|
// another error in its place: noted here as it happens.
|
|
@@ -2167,6 +2252,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2167
2252
|
const previous = await tx `
|
|
2168
2253
|
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
2169
2254
|
current_setting('slotlock.actor', true) AS actor,
|
|
2255
|
+
current_setting('slotlock.origin', true) AS origin,
|
|
2256
|
+
current_setting('slotlock.key_id', true) AS key,
|
|
2170
2257
|
current_setting('transaction_isolation') AS isolation,
|
|
2171
2258
|
pg_advisory_xact_lock_shared(
|
|
2172
2259
|
hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
|
|
@@ -2174,6 +2261,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2174
2261
|
const [context] = await tx `
|
|
2175
2262
|
SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
|
|
2176
2263
|
set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
|
|
2264
|
+
set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
|
|
2265
|
+
NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
|
|
2266
|
+
set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
|
|
2177
2267
|
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
2178
2268
|
throw suspensionCheckRefusal(error);
|
|
2179
2269
|
});
|
|
@@ -2195,7 +2285,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2195
2285
|
}
|
|
2196
2286
|
finally {
|
|
2197
2287
|
await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
|
|
2198
|
-
set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)
|
|
2288
|
+
set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
|
|
2289
|
+
set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
|
|
2290
|
+
set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
|
|
2199
2291
|
}
|
|
2200
2292
|
}, 'read committed')
|
|
2201
2293
|
.catch((error) => {
|
|
@@ -2256,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2256
2348
|
});
|
|
2257
2349
|
}
|
|
2258
2350
|
const rows = await sql `
|
|
2259
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
|
|
2351
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2260
2352
|
FROM slotlock.resources
|
|
2261
2353
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2262
2354
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
|
|
@@ -2274,7 +2366,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2274
2366
|
UPDATE slotlock.resources
|
|
2275
2367
|
SET availability_rules = ${rules}::jsonb
|
|
2276
2368
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2277
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
2369
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2278
2370
|
const row = rows[0];
|
|
2279
2371
|
return row ? toResource(row) : null;
|
|
2280
2372
|
},
|
|
@@ -2306,10 +2398,74 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2306
2398
|
END
|
|
2307
2399
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2308
2400
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2309
|
-
visibility, archived_at`;
|
|
2401
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2310
2402
|
const row = rows[0];
|
|
2311
2403
|
return row ? toResource(row) : null;
|
|
2312
2404
|
},
|
|
2405
|
+
async setBookingRuleDefaults(params) {
|
|
2406
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2407
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2408
|
+
}
|
|
2409
|
+
if (params.pool !== null && !isPoolName(params.pool))
|
|
2410
|
+
throw invalidPoolError();
|
|
2411
|
+
const pool = params.pool ?? '';
|
|
2412
|
+
if (params.rules === null) {
|
|
2413
|
+
await sql `
|
|
2414
|
+
DELETE FROM slotlock.booking_rule_defaults
|
|
2415
|
+
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${pool}`;
|
|
2416
|
+
return null;
|
|
2417
|
+
}
|
|
2418
|
+
let rules;
|
|
2419
|
+
try {
|
|
2420
|
+
rules = parseBookingRuleDefaults(params.rules);
|
|
2421
|
+
}
|
|
2422
|
+
catch (error) {
|
|
2423
|
+
throw Object.assign(new Error(error.message), {
|
|
2424
|
+
code: 'invalid_booking_rules',
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
const tenantRef = params.tenantRef;
|
|
2428
|
+
return inTransaction(sql, async (tx) => {
|
|
2429
|
+
if (pool !== '') {
|
|
2430
|
+
// Counted under the tenant's lock, so racing writes stop at the cap.
|
|
2431
|
+
// The count after the lock sees every committed row only under READ COMMITTED.
|
|
2432
|
+
await assertReadCommitted(tx);
|
|
2433
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
|
|
2434
|
+
const [counted] = await tx `
|
|
2435
|
+
SELECT count(*)::int AS pools, bool_or(pool_ref = ${pool}) IS TRUE AS exists
|
|
2436
|
+
FROM slotlock.booking_rule_defaults
|
|
2437
|
+
WHERE tenant_ref = ${tenantRef} AND pool_ref <> ''`;
|
|
2438
|
+
if (!counted?.exists && (counted?.pools ?? 0) >= SLOTLOCK_MAX_POOL_RULE_DEFAULTS) {
|
|
2439
|
+
throw Object.assign(new Error(`Slotlock keeps default booking rules for at most ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS} pools a tenant`), { code: 'pool_rules_limit_reached', limit: SLOTLOCK_MAX_POOL_RULE_DEFAULTS });
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
await tx `
|
|
2443
|
+
INSERT INTO slotlock.booking_rule_defaults (tenant_ref, pool_ref, rules)
|
|
2444
|
+
VALUES (${tenantRef}, ${pool}, ${tx.json({ ...rules })}::jsonb)
|
|
2445
|
+
ON CONFLICT (tenant_ref, pool_ref)
|
|
2446
|
+
DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
|
|
2447
|
+
return rules;
|
|
2448
|
+
});
|
|
2449
|
+
},
|
|
2450
|
+
async getBookingRuleDefaults(params) {
|
|
2451
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2452
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2453
|
+
}
|
|
2454
|
+
const rows = await sql `
|
|
2455
|
+
SELECT pool_ref, rules FROM slotlock.booking_rule_defaults
|
|
2456
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
2457
|
+
ORDER BY pool_ref
|
|
2458
|
+
LIMIT ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS + 1}`;
|
|
2459
|
+
const defaults = { tenant: null, pools: {} };
|
|
2460
|
+
for (const row of rows) {
|
|
2461
|
+
const rules = storedBookingRules(row.rules);
|
|
2462
|
+
if (row.pool_ref === '')
|
|
2463
|
+
defaults.tenant = rules;
|
|
2464
|
+
else
|
|
2465
|
+
defaults.pools[row.pool_ref] = rules;
|
|
2466
|
+
}
|
|
2467
|
+
return defaults;
|
|
2468
|
+
},
|
|
2313
2469
|
async setResourcePool(params) {
|
|
2314
2470
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2315
2471
|
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
@@ -2322,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2322
2478
|
UPDATE slotlock.resources SET pool_ref = ${params.pool}
|
|
2323
2479
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2324
2480
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2325
|
-
visibility, archived_at`;
|
|
2481
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2326
2482
|
const row = rows[0];
|
|
2327
2483
|
return row ? toResource(row) : null;
|
|
2328
2484
|
},
|
|
@@ -2333,7 +2489,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2333
2489
|
throw invalidPoolError();
|
|
2334
2490
|
const rows = await sql `
|
|
2335
2491
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2336
|
-
visibility, archived_at
|
|
2492
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2337
2493
|
FROM slotlock.resources
|
|
2338
2494
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
2339
2495
|
AND archived_at IS NULL
|
|
@@ -2386,7 +2542,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2386
2542
|
});
|
|
2387
2543
|
}
|
|
2388
2544
|
const rows = await sql `
|
|
2389
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
|
|
2545
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2390
2546
|
FROM slotlock.resources
|
|
2391
2547
|
WHERE tenant_ref = ${params.tenantRef}
|
|
2392
2548
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
@@ -2406,7 +2562,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2406
2562
|
UPDATE slotlock.resources
|
|
2407
2563
|
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
2408
2564
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2409
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
2565
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2410
2566
|
return row ? toResource(row) : null;
|
|
2411
2567
|
},
|
|
2412
2568
|
async restoreResource(params) {
|
|
@@ -2417,7 +2573,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2417
2573
|
UPDATE slotlock.resources
|
|
2418
2574
|
SET archived_at = NULL
|
|
2419
2575
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2420
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
2576
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2421
2577
|
return row ? toResource(row) : null;
|
|
2422
2578
|
},
|
|
2423
2579
|
async deleteResource(params) {
|
|
@@ -2465,6 +2621,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2465
2621
|
// A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
|
|
2466
2622
|
// cancelled and moved like any other; one on a resource with no tenant stays tenantless.
|
|
2467
2623
|
const id = randomUUID();
|
|
2624
|
+
// The buffers written, for the overlap answer if the write loses.
|
|
2625
|
+
let bufferBeforeMs = 0;
|
|
2626
|
+
let bufferAfterMs = 0;
|
|
2468
2627
|
for (let attempt = 1;; attempt++) {
|
|
2469
2628
|
try {
|
|
2470
2629
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2485,24 +2644,27 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2485
2644
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
2486
2645
|
if (ruling.rule)
|
|
2487
2646
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2488
|
-
|
|
2647
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2648
|
+
bufferAfterMs = ruling.turnaroundMs;
|
|
2649
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2489
2650
|
if (resource.tenant_ref !== null &&
|
|
2490
2651
|
!(await recurrenceCoversWrite(tx, {
|
|
2491
2652
|
tenantRef: resource.tenant_ref,
|
|
2492
2653
|
resourceId: params.resourceId,
|
|
2493
|
-
|
|
2494
|
-
end: occupied,
|
|
2654
|
+
...occupied,
|
|
2495
2655
|
}))) {
|
|
2496
2656
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2497
2657
|
}
|
|
2498
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2658
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2499
2659
|
const [row] = await tx `
|
|
2500
2660
|
INSERT INTO slotlock.reservations (
|
|
2501
|
-
id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2661
|
+
id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2662
|
+
buffer_before_ms, source
|
|
2502
2663
|
) VALUES (
|
|
2503
2664
|
${id}, ${params.resourceId}, ${resource.tenant_ref},
|
|
2504
2665
|
${resource.tenant_ref === null ? null : id},
|
|
2505
|
-
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${
|
|
2666
|
+
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
|
|
2667
|
+
${params.source ?? null}
|
|
2506
2668
|
)
|
|
2507
2669
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2508
2670
|
buffer_after_ms, revision, source`;
|
|
@@ -2523,8 +2685,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2523
2685
|
}
|
|
2524
2686
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2525
2687
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2526
|
-
|
|
2527
|
-
|
|
2688
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2689
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2690
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
|
|
2528
2691
|
}
|
|
2529
2692
|
}
|
|
2530
2693
|
},
|
|
@@ -2535,6 +2698,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2535
2698
|
if (params.revision !== 1)
|
|
2536
2699
|
return { ok: false, code: 'invalid_revision' };
|
|
2537
2700
|
let written = params.bufferAfterMs;
|
|
2701
|
+
let writtenBefore = 0;
|
|
2538
2702
|
for (let attempt = 1;; attempt++) {
|
|
2539
2703
|
try {
|
|
2540
2704
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2579,23 +2743,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2579
2743
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2580
2744
|
const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2581
2745
|
written = bufferAfterMs;
|
|
2582
|
-
|
|
2746
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
2747
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
|
|
2583
2748
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2584
2749
|
tenantRef: params.tenantRef,
|
|
2585
2750
|
resourceId: params.resourceId,
|
|
2586
|
-
|
|
2587
|
-
end: occupied,
|
|
2751
|
+
...occupied,
|
|
2588
2752
|
}))) {
|
|
2589
2753
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2590
2754
|
}
|
|
2591
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2755
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2592
2756
|
const rows = await tx `
|
|
2593
2757
|
INSERT INTO slotlock.reservations (
|
|
2594
2758
|
resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2595
|
-
revision, source
|
|
2759
|
+
buffer_before_ms, revision, source
|
|
2596
2760
|
) VALUES (
|
|
2597
2761
|
${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
|
|
2598
|
-
${params.end}, ${bufferAfterMs}, ${
|
|
2762
|
+
${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
|
|
2763
|
+
${params.source ?? null}
|
|
2599
2764
|
)
|
|
2600
2765
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2601
2766
|
buffer_after_ms, revision, source`;
|
|
@@ -2615,8 +2780,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2615
2780
|
code !== SERIALIZATION_FAILURE) {
|
|
2616
2781
|
throw err;
|
|
2617
2782
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
2783
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
2784
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2785
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
2620
2786
|
}
|
|
2621
2787
|
}
|
|
2622
2788
|
},
|
|
@@ -2626,6 +2792,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2626
2792
|
return invalid;
|
|
2627
2793
|
let currentReservationId;
|
|
2628
2794
|
let bufferAfterMs = params.bufferAfterMs;
|
|
2795
|
+
let bufferBeforeMs = 0;
|
|
2629
2796
|
for (let attempt = 1;; attempt++) {
|
|
2630
2797
|
try {
|
|
2631
2798
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2668,21 +2835,23 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2668
2835
|
if (ruling.rule)
|
|
2669
2836
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2670
2837
|
bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2838
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2839
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2671
2840
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2672
2841
|
tenantRef: params.tenantRef,
|
|
2673
2842
|
resourceId: params.resourceId,
|
|
2674
|
-
|
|
2675
|
-
end: occupiedEnd({ end: params.end, bufferAfterMs }),
|
|
2843
|
+
...occupied,
|
|
2676
2844
|
}))) {
|
|
2677
2845
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2678
2846
|
}
|
|
2679
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2847
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2680
2848
|
const rows = await tx `
|
|
2681
2849
|
UPDATE slotlock.reservations
|
|
2682
2850
|
SET resource_id = ${params.resourceId},
|
|
2683
2851
|
starts_at = ${params.start},
|
|
2684
2852
|
ends_at = ${params.end},
|
|
2685
2853
|
buffer_after_ms = ${bufferAfterMs},
|
|
2854
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
2686
2855
|
revision = ${params.revision},
|
|
2687
2856
|
source = ${params.source ?? null},
|
|
2688
2857
|
-- A check-in is for the time it was made for: a move to another start clears it.
|
|
@@ -2709,9 +2878,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2709
2878
|
code !== SERIALIZATION_FAILURE) {
|
|
2710
2879
|
throw err;
|
|
2711
2880
|
}
|
|
2712
|
-
const occupied =
|
|
2713
|
-
await rollSeriesBehind(sql, params.resourceId,
|
|
2714
|
-
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId);
|
|
2881
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2882
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2883
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId, undefined, bufferBeforeMs);
|
|
2715
2884
|
}
|
|
2716
2885
|
}
|
|
2717
2886
|
},
|
|
@@ -2825,8 +2994,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2825
2994
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
2826
2995
|
});
|
|
2827
2996
|
const ttlSeconds = params.ttlMs / 1000;
|
|
2828
|
-
// The
|
|
2997
|
+
// The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
|
|
2998
|
+
// leading buffer.
|
|
2829
2999
|
let written = bufferAfterMs;
|
|
3000
|
+
let writtenBefore = 0;
|
|
2830
3001
|
for (let attempt = 1;; attempt++) {
|
|
2831
3002
|
try {
|
|
2832
3003
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2875,7 +3046,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2875
3046
|
const [held] = await tx `
|
|
2876
3047
|
SELECT count(*)::int AS count,
|
|
2877
3048
|
COALESCE(sum(extract(epoch FROM
|
|
2878
|
-
ends_at + buffer_after_ms * interval '1 millisecond'
|
|
3049
|
+
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
3050
|
+
- starts_at)), 0)::float8
|
|
2879
3051
|
AS seconds
|
|
2880
3052
|
FROM slotlock.reservations
|
|
2881
3053
|
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
@@ -2891,10 +3063,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2891
3063
|
if (ruling.rule)
|
|
2892
3064
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2893
3065
|
written = Math.max(bufferAfterMs, ruling.turnaroundMs);
|
|
2894
|
-
|
|
3066
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
3067
|
+
const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
2895
3068
|
if (heldSeconds !== null &&
|
|
2896
3069
|
maxHeldHoursPerHolder !== undefined &&
|
|
2897
|
-
heldSeconds + (reach.getTime() -
|
|
3070
|
+
heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
|
|
2898
3071
|
maxHeldHoursPerHolder * 3_600) {
|
|
2899
3072
|
return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
|
|
2900
3073
|
}
|
|
@@ -2902,18 +3075,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2902
3075
|
!(await recurrenceCoversWrite(tx, {
|
|
2903
3076
|
tenantRef: resource.tenant_ref,
|
|
2904
3077
|
resourceId: params.resourceId,
|
|
2905
|
-
|
|
2906
|
-
end: reach,
|
|
3078
|
+
...reach,
|
|
2907
3079
|
}))) {
|
|
2908
3080
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2909
3081
|
}
|
|
2910
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
3082
|
+
await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
|
|
2911
3083
|
const [row] = await tx `
|
|
2912
3084
|
INSERT INTO slotlock.reservations (
|
|
2913
|
-
resource_id, starts_at, ends_at, buffer_after_ms,
|
|
2914
|
-
holder_ref, hold_group_id
|
|
3085
|
+
resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
|
|
3086
|
+
expires_at, source, holder_ref, hold_group_id
|
|
2915
3087
|
) VALUES (
|
|
2916
|
-
${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
|
|
3088
|
+
${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
|
|
2917
3089
|
statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
|
|
2918
3090
|
${holderRef ?? null}, ${params.groupId ?? null}
|
|
2919
3091
|
)
|
|
@@ -2947,8 +3119,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2947
3119
|
}
|
|
2948
3120
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2949
3121
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2950
|
-
|
|
2951
|
-
|
|
3122
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
3123
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
3124
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
2952
3125
|
}
|
|
2953
3126
|
}
|
|
2954
3127
|
},
|
|
@@ -3200,15 +3373,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3200
3373
|
// Expired holds are dead occupation: excluded here so findNextAvailable can offer their
|
|
3201
3374
|
// space (the acquiring transaction reaps the row itself when it takes the window).
|
|
3202
3375
|
const rows = await sql `
|
|
3203
|
-
SELECT
|
|
3376
|
+
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
3377
|
+
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
3204
3378
|
FROM slotlock.reservations
|
|
3205
3379
|
WHERE resource_id = ${resourceId}
|
|
3206
|
-
AND
|
|
3380
|
+
AND ${occupiedStartsBefore(sql, window.end)}
|
|
3207
3381
|
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
3208
3382
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
3209
|
-
ORDER BY starts_at
|
|
3383
|
+
ORDER BY COALESCE(occupied_starts_at, starts_at)
|
|
3210
3384
|
LIMIT ${options.limit ?? null}`;
|
|
3211
|
-
return rows.map((r) => ({ start: r.
|
|
3385
|
+
return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
|
|
3212
3386
|
},
|
|
3213
3387
|
async putCalendarEvent(params) {
|
|
3214
3388
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
@@ -3266,6 +3440,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3266
3440
|
});
|
|
3267
3441
|
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
3268
3442
|
let attempted = expanded?.occurrences ?? [];
|
|
3443
|
+
// The buffers the attempt kept, for the overlap answer if it loses.
|
|
3444
|
+
let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
|
|
3269
3445
|
let storedEventId;
|
|
3270
3446
|
for (let attempt = 1;; attempt++) {
|
|
3271
3447
|
try {
|
|
@@ -3403,6 +3579,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3403
3579
|
AND hold_group_id = ${hold.hold_group_id}::uuid))`;
|
|
3404
3580
|
}
|
|
3405
3581
|
let turnaroundMs = 0;
|
|
3582
|
+
let bufferBeforeMs = 0;
|
|
3406
3583
|
if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
|
|
3407
3584
|
const judge = await bookingJudge(tx, params.resourceId, defaultHours);
|
|
3408
3585
|
for (const [index, occurrence] of occurrences.entries()) {
|
|
@@ -3412,9 +3589,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3412
3589
|
return { ok: false, code: 'booking_rule_violated', rule };
|
|
3413
3590
|
}
|
|
3414
3591
|
turnaroundMs = judge.turnaroundMs;
|
|
3592
|
+
bufferBeforeMs = judge.bufferBeforeMs;
|
|
3415
3593
|
}
|
|
3594
|
+
attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
|
|
3416
3595
|
const span = occurrenceSpan(occurrences);
|
|
3417
|
-
const reach = span && {
|
|
3596
|
+
const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
|
|
3418
3597
|
if ((event.transparency ?? 'opaque') === 'opaque' &&
|
|
3419
3598
|
reach &&
|
|
3420
3599
|
!(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
|
|
@@ -3452,6 +3631,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3452
3631
|
revision = ${revision},
|
|
3453
3632
|
source = ${params.source ?? null},
|
|
3454
3633
|
buffer_after_ms = ${turnaroundMs},
|
|
3634
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
3455
3635
|
updated_at = now()
|
|
3456
3636
|
WHERE id = ${eventId}
|
|
3457
3637
|
AND tenant_ref = ${params.tenantRef}
|
|
@@ -3464,7 +3644,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3464
3644
|
summary, description, location, status, transparency, organizer, attendees,
|
|
3465
3645
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
3466
3646
|
materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
|
|
3467
|
-
buffer_after_ms
|
|
3647
|
+
buffer_after_ms, buffer_before_ms
|
|
3468
3648
|
) VALUES (
|
|
3469
3649
|
${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
|
|
3470
3650
|
${params.resourceId},
|
|
@@ -3478,7 +3658,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3478
3658
|
${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
|
|
3479
3659
|
${materializationWindow.start}, ${materializationWindow.end},
|
|
3480
3660
|
${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
|
|
3481
|
-
${revision}, ${params.source ?? null}, ${turnaroundMs}
|
|
3661
|
+
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
3482
3662
|
)`;
|
|
3483
3663
|
}
|
|
3484
3664
|
// The normalized children are the queryable/canonical representation. The JSON
|
|
@@ -3492,6 +3672,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3492
3672
|
occurrences,
|
|
3493
3673
|
revision,
|
|
3494
3674
|
bufferAfterMs: turnaroundMs,
|
|
3675
|
+
bufferBeforeMs,
|
|
3495
3676
|
});
|
|
3496
3677
|
await tx `
|
|
3497
3678
|
INSERT INTO slotlock.calendar_event_commands (
|
|
@@ -3522,11 +3703,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3522
3703
|
code !== SERIALIZATION_FAILURE) {
|
|
3523
3704
|
throw error;
|
|
3524
3705
|
}
|
|
3525
|
-
const
|
|
3526
|
-
if (
|
|
3706
|
+
const span = occurrenceSpan(attempted);
|
|
3707
|
+
if (span) {
|
|
3708
|
+
const reach = occupiedTime({ ...span, ...attemptedBuffers });
|
|
3527
3709
|
await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
|
|
3528
3710
|
}
|
|
3529
|
-
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
|
|
3711
|
+
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
|
|
3530
3712
|
}
|
|
3531
3713
|
}
|
|
3532
3714
|
},
|
|
@@ -3932,7 +4114,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3932
4114
|
slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
|
|
3933
4115
|
slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
|
|
3934
4116
|
COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
|
|
3935
|
-
WHERE booking.calendar_event_id = event.id), 0) AS buffer
|
|
4117
|
+
WHERE booking.calendar_event_id = event.id), 0) AS buffer,
|
|
4118
|
+
COALESCE((SELECT max(booking.buffer_before_ms) FROM slotlock.reservations booking
|
|
4119
|
+
WHERE booking.calendar_event_id = event.id), 0) AS buffer_before
|
|
3936
4120
|
FROM slotlock.calendar_events event
|
|
3937
4121
|
WHERE event.tenant_ref = ${params.tenantRef}
|
|
3938
4122
|
AND event.recurrence_rule IS NULL
|
|
@@ -3986,6 +4170,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3986
4170
|
],
|
|
3987
4171
|
revision,
|
|
3988
4172
|
bufferAfterMs: Number(event.buffer),
|
|
4173
|
+
bufferBeforeMs: Number(event.buffer_before),
|
|
3989
4174
|
});
|
|
3990
4175
|
});
|
|
3991
4176
|
moved++;
|
|
@@ -4298,6 +4483,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4298
4483
|
ORDER BY source`;
|
|
4299
4484
|
return rows.map((row) => ({
|
|
4300
4485
|
source: row.source,
|
|
4486
|
+
sourceId: slotlockCoverageSourceId(params.tenantRef, row.source),
|
|
4301
4487
|
start: row.starts_at,
|
|
4302
4488
|
end: row.ends_at,
|
|
4303
4489
|
revision: Number(row.revision),
|
|
@@ -4321,15 +4507,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4321
4507
|
AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
|
|
4322
4508
|
ORDER BY observed_at LIMIT ${params.limit ?? 100}
|
|
4323
4509
|
FOR UPDATE SKIP LOCKED)
|
|
4324
|
-
RETURNING coverage.
|
|
4510
|
+
RETURNING coverage.tenant_ref, coverage.resource_id, coverage.source, coverage.observed_at
|
|
4325
4511
|
), reported AS (
|
|
4326
4512
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
4327
|
-
SELECT ${params.tenantRef},
|
|
4328
|
-
|
|
4329
|
-
'resource_id',
|
|
4330
|
-
'observed_at',
|
|
4513
|
+
SELECT ${params.tenantRef}, named.resource_id, 'coverage.stale', named.source_id,
|
|
4514
|
+
jsonb_build_object(
|
|
4515
|
+
'resource_id', named.resource_id, 'source_id', named.source_id,
|
|
4516
|
+
'observed_at', named.observed_at),
|
|
4331
4517
|
'slotlock:maintenance'
|
|
4332
|
-
FROM stale
|
|
4518
|
+
FROM (SELECT resource_id, observed_at, ${sql.unsafe(COVERAGE_SOURCE_ID)} AS source_id FROM stale) named
|
|
4333
4519
|
RETURNING 1
|
|
4334
4520
|
)
|
|
4335
4521
|
SELECT count(*)::int AS count FROM reported`;
|
|
@@ -4742,18 +4928,28 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4742
4928
|
continue;
|
|
4743
4929
|
// Only resources whose window looks free are tried: a hold refused keeps its locks
|
|
4744
4930
|
// until the transaction ends, so trying every member of a full pool would pile them up.
|
|
4931
|
+
// A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
|
|
4745
4932
|
const candidates = await tx `
|
|
4746
|
-
SELECT
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4933
|
+
SELECT resources.id
|
|
4934
|
+
FROM slotlock.resources,
|
|
4935
|
+
LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective,
|
|
4936
|
+
LATERAL (SELECT
|
|
4937
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0)
|
|
4938
|
+
AS before_minutes,
|
|
4939
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 1440)::int, 0)
|
|
4940
|
+
AS after_minutes) buffers
|
|
4941
|
+
WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
|
|
4942
|
+
AND (resources.id = ${entry.resource_id}::uuid
|
|
4943
|
+
OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
|
|
4750
4944
|
AND NOT EXISTS (
|
|
4751
4945
|
SELECT 1 FROM slotlock.reservations taken
|
|
4752
|
-
WHERE taken.resource_id =
|
|
4753
|
-
AND tstzrange(
|
|
4754
|
-
|
|
4946
|
+
WHERE taken.resource_id = resources.id
|
|
4947
|
+
AND tstzrange(COALESCE(taken.occupied_starts_at, taken.starts_at),
|
|
4948
|
+
COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
|
|
4949
|
+
&& tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
|
|
4950
|
+
${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
|
|
4755
4951
|
AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
|
|
4756
|
-
ORDER BY
|
|
4952
|
+
ORDER BY resources.id
|
|
4757
4953
|
LIMIT ${OFFER_CANDIDATES}`;
|
|
4758
4954
|
for (const { id: resourceId } of candidates) {
|
|
4759
4955
|
const hold = await inner.acquireHold({
|
|
@@ -4818,23 +5014,26 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4818
5014
|
});
|
|
4819
5015
|
}
|
|
4820
5016
|
const owner = await sql `
|
|
4821
|
-
SELECT id, COALESCE((
|
|
4822
|
-
|
|
5017
|
+
SELECT id, COALESCE((effective.effective_booking_rules->>'requireCertain')::boolean, false)
|
|
5018
|
+
AS require_certain
|
|
5019
|
+
FROM slotlock.resources,
|
|
5020
|
+
LATERAL (SELECT ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
|
|
4823
5021
|
WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
|
|
4824
5022
|
if (!owner[0]) {
|
|
4825
5023
|
throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
|
|
4826
5024
|
code: 'resource_tenant_mismatch',
|
|
4827
5025
|
});
|
|
4828
5026
|
}
|
|
5027
|
+
// Each row's occupied time: its buffers are as busy as the booking.
|
|
4829
5028
|
const busyRows = await sql `
|
|
4830
|
-
SELECT reservation.starts_at,
|
|
5029
|
+
SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
|
|
4831
5030
|
COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
|
|
4832
5031
|
FROM slotlock.reservations reservation
|
|
4833
5032
|
WHERE reservation.resource_id = ${params.resourceId}
|
|
4834
|
-
AND
|
|
5033
|
+
AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
|
|
4835
5034
|
AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
|
|
4836
5035
|
AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
|
|
4837
|
-
ORDER BY
|
|
5036
|
+
ORDER BY 1
|
|
4838
5037
|
LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
|
|
4839
5038
|
if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
|
|
4840
5039
|
throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
|
|
@@ -4953,7 +5152,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4953
5152
|
throw notFound();
|
|
4954
5153
|
const [row] = await sql `
|
|
4955
5154
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
|
|
4956
|
-
archived_at, statement_timestamp() AS now
|
|
5155
|
+
archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
4957
5156
|
FROM slotlock.resources
|
|
4958
5157
|
WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
|
|
4959
5158
|
if (!row)
|
|
@@ -4962,9 +5161,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4962
5161
|
return null;
|
|
4963
5162
|
const resource = toResource(row);
|
|
4964
5163
|
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
4965
|
-
// Only what a booking made now may take: the resource's booking rules
|
|
5164
|
+
// Only what a booking made now may take: the resource's booking rules (as they apply) bound
|
|
5165
|
+
// the search.
|
|
4966
5166
|
const search = bookingSearch({
|
|
4967
|
-
rules: resource.
|
|
5167
|
+
rules: resource.effectiveBookingRules,
|
|
4968
5168
|
timezone: resource.timezone,
|
|
4969
5169
|
window: asked,
|
|
4970
5170
|
now: row.now,
|
|
@@ -4972,9 +5172,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4972
5172
|
});
|
|
4973
5173
|
if (!search)
|
|
4974
5174
|
return null;
|
|
4975
|
-
const { window
|
|
4976
|
-
// A slot's
|
|
4977
|
-
const reach =
|
|
5175
|
+
const { window } = search;
|
|
5176
|
+
// A slot's buffers may run past the window: the time they can reach must be proven free too.
|
|
5177
|
+
const reach = bookingSearchReach(search);
|
|
4978
5178
|
let busy;
|
|
4979
5179
|
if (tenantRef === null) {
|
|
4980
5180
|
// No event, coverage or series can belong to a resource with no tenant: only its rows.
|
|
@@ -4982,7 +5182,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4982
5182
|
}
|
|
4983
5183
|
else {
|
|
4984
5184
|
busy = [];
|
|
4985
|
-
const parts =
|
|
5185
|
+
const parts = [
|
|
5186
|
+
...(reach.start < window.start ? [{ start: reach.start, end: window.start }] : []),
|
|
5187
|
+
window,
|
|
5188
|
+
...(reach.end > window.end ? [{ start: window.end, end: reach.end }] : []),
|
|
5189
|
+
];
|
|
4986
5190
|
for (const part of parts) {
|
|
4987
5191
|
const freeBusy = await this.getFreeBusy({
|
|
4988
5192
|
tenantRef,
|
|
@@ -5001,12 +5205,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5001
5205
|
busy.push(...freeBusy.busy);
|
|
5002
5206
|
}
|
|
5003
5207
|
}
|
|
5004
|
-
// A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
|
|
5005
5208
|
return findNextAvailable({
|
|
5006
|
-
busy: busy
|
|
5007
|
-
start: new Date(interval.start.getTime() - bufferAfterMs),
|
|
5008
|
-
end: interval.end,
|
|
5009
|
-
})),
|
|
5209
|
+
busy: bookingSearchBlocked(busy, search),
|
|
5010
5210
|
windows: expandRules(rules, window, resource.timezone),
|
|
5011
5211
|
durationMs: params.durationMs,
|
|
5012
5212
|
...(search.align ? { align: search.align } : {}),
|