slotlock 0.5.2 → 0.6.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 +194 -0
- package/README.md +159 -66
- package/SECURITY.md +26 -13
- package/SPEC.md +10 -7
- package/agent-server.d.ts +47 -4
- package/agent-server.js +492 -147
- 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 +70 -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 +21 -4
- package/ddl.js +555 -7
- package/event-feed.d.ts +54 -4
- package/event-feed.js +107 -8
- package/index.d.ts +8 -7
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1309 -82
- package/package.json +1 -1
- package/rest-api.js +12 -0
- package/self-host.js +58 -6
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +35 -3
- package/store.js +447 -135
- package/types.d.ts +14 -1
- package/webhooks.d.ts +10 -2
- package/webhooks.js +11 -3
package/store.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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, 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
|
-
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';
|
|
8
|
+
import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
9
9
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
10
10
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
11
11
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.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
|
|
@@ -338,8 +343,8 @@ function migrationSettings(options) {
|
|
|
338
343
|
/**
|
|
339
344
|
* Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
|
|
340
345
|
* in pg_catalog only, serialized with every other migration of this database, and with the lock and
|
|
341
|
-
* statement timeouts set. A step that waits past its lock timeout is
|
|
342
|
-
* after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
|
|
346
|
+
* statement timeouts set. A step that waits past its lock timeout, or is ended as a deadlock's
|
|
347
|
+
* victim, is rolled back and tried again after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
|
|
343
348
|
*/
|
|
344
349
|
async function inMigration(executor, options, step) {
|
|
345
350
|
const settings = migrationSettings(options);
|
|
@@ -362,10 +367,14 @@ async function inMigration(executor, options, step) {
|
|
|
362
367
|
}));
|
|
363
368
|
}
|
|
364
369
|
catch (error) {
|
|
365
|
-
|
|
370
|
+
// A deadlock's victim (DEADLOCK_DETECTED: a caller's transaction took the outbox, then a
|
|
371
|
+
// coverage row, while a rename batch took them in the other order, say) lost its locks with
|
|
372
|
+
// it, so it is tried again like a step that timed out waiting.
|
|
373
|
+
const code = pgCode(error);
|
|
374
|
+
if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
|
|
366
375
|
throw error;
|
|
367
376
|
if (attempt >= settings.attempts) {
|
|
368
|
-
throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
|
|
377
|
+
throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table, or took them in another order. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
|
|
369
378
|
}
|
|
370
379
|
await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
|
|
371
380
|
}
|
|
@@ -465,6 +474,105 @@ async function slotlockSchemaUpToDate(tx) {
|
|
|
465
474
|
}
|
|
466
475
|
return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
|
|
467
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
|
|
479
|
+
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, each a transaction of its own,
|
|
480
|
+
* from where the last pass stopped (`schema_version.coverage_changes_renamed_through`, saved with
|
|
481
|
+
* each batch) up to the last change when this pass starts. The first pass is the 0.6 upgrade's; an
|
|
482
|
+
* earlier release's server still running then can write more, so every later migration runs a pass
|
|
483
|
+
* too, until one finds nothing to rename: that marks the rename done and no migration reads the
|
|
484
|
+
* outbox for it again.
|
|
485
|
+
*
|
|
486
|
+
* The head is read only once every transaction already writing to the outbox has ended, so no
|
|
487
|
+
* change at or below it can commit after the pass has read past it. Forced RLS would hide every
|
|
488
|
+
* tenant's rows from the deployment role, so a role it binds lifts FORCE from calendar_coverage and
|
|
489
|
+
* outbox for each batch and puts it back before the batch commits: each batch holds ACCESS
|
|
490
|
+
* EXCLUSIVE on both, taken in the order writers take them (a source, then its change), for one
|
|
491
|
+
* primary-key range of the outbox. A table an operator left unforced stays so. A superuser or
|
|
492
|
+
* BYPASSRLS role reads every row as it is: it holds SHARE ROW EXCLUSIVE on the outbox (which waits
|
|
493
|
+
* for the writes in flight and holds off new ones, but no read) only for the short transaction that
|
|
494
|
+
* reads the head. Changes an earlier release's server writes after the upgrade are put in shape as
|
|
495
|
+
* they are inserted (ddl.ts, slotlock.outbox_coverage_in_shape); the passes are for those before.
|
|
496
|
+
*/
|
|
497
|
+
async function renameEarlierCoverageChanges(executor, options) {
|
|
498
|
+
/**
|
|
499
|
+
* Run `work` able to read every tenant's outbox and coverage rows: as it is for a superuser or a
|
|
500
|
+
* BYPASSRLS role, else with FORCE lifted from the tables that have it, under ACCESS EXCLUSIVE
|
|
501
|
+
* (calendar_coverage, then outbox: the order every writer locks them in), and put back after.
|
|
502
|
+
*/
|
|
503
|
+
const everyTenant = async (tx, bypass, work) => {
|
|
504
|
+
const forced = bypass
|
|
505
|
+
? []
|
|
506
|
+
: await tx `
|
|
507
|
+
SELECT rel.relname::text AS name
|
|
508
|
+
FROM pg_catalog.pg_class rel
|
|
509
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
510
|
+
WHERE ns.nspname = 'slotlock' AND rel.relname IN ('calendar_coverage', 'outbox')
|
|
511
|
+
AND rel.relforcerowsecurity
|
|
512
|
+
ORDER BY rel.relname`;
|
|
513
|
+
const lifted = forced.map(({ name }) => `slotlock.${name}`);
|
|
514
|
+
if (lifted.length > 0) {
|
|
515
|
+
await tx.unsafe(`LOCK TABLE ${lifted.join(', ')} IN ACCESS EXCLUSIVE MODE`);
|
|
516
|
+
for (const table of lifted)
|
|
517
|
+
await tx.unsafe(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY`);
|
|
518
|
+
}
|
|
519
|
+
const result = await work();
|
|
520
|
+
for (const table of lifted)
|
|
521
|
+
await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
|
522
|
+
return result;
|
|
523
|
+
};
|
|
524
|
+
// The pass's head, in a short transaction of its own: once the writes in flight have ended (each
|
|
525
|
+
// holds ROW EXCLUSIVE from before it took its id), none at or below it can commit later.
|
|
526
|
+
const start = await inMigration(executor, options, async (tx) => {
|
|
527
|
+
const [state] = await tx `
|
|
528
|
+
SELECT version.coverage_changes_renamed AS renamed,
|
|
529
|
+
version.coverage_changes_renamed_through::text AS through,
|
|
530
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_roles
|
|
531
|
+
WHERE rolname = current_user AND (rolsuper OR rolbypassrls)) AS bypass
|
|
532
|
+
FROM slotlock.schema_version version
|
|
533
|
+
WHERE version.singleton`;
|
|
534
|
+
if (state === undefined || state.renamed)
|
|
535
|
+
return null;
|
|
536
|
+
if (state.bypass)
|
|
537
|
+
await tx.unsafe('LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE');
|
|
538
|
+
const [top] = await everyTenant(tx, state.bypass, () => tx `SELECT COALESCE(max(id), 0)::text AS head FROM slotlock.outbox`);
|
|
539
|
+
return { head: top?.head ?? '0', through: state.through, bypass: state.bypass };
|
|
540
|
+
});
|
|
541
|
+
if (start === null)
|
|
542
|
+
return;
|
|
543
|
+
const { head, bypass } = start;
|
|
544
|
+
const firstPass = start.through === null;
|
|
545
|
+
const rename = slotlockCoverageChangeRenameSql('$1::bigint', '$2::bigint', '$3::int');
|
|
546
|
+
let after = start.through ?? '0';
|
|
547
|
+
let renamedTotal = 0;
|
|
548
|
+
for (;;) {
|
|
549
|
+
const last = await inMigration(executor, options, async (tx) => {
|
|
550
|
+
const [row] = await everyTenant(tx, bypass, () => tx.unsafe(rename, [
|
|
551
|
+
after,
|
|
552
|
+
head,
|
|
553
|
+
SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH,
|
|
554
|
+
]));
|
|
555
|
+
const read = row?.last ?? null;
|
|
556
|
+
renamedTotal += row?.renamed ?? 0;
|
|
557
|
+
// Saved with the batch, so a pass stopped part way goes on from here.
|
|
558
|
+
await tx `
|
|
559
|
+
UPDATE slotlock.schema_version
|
|
560
|
+
SET coverage_changes_renamed_through = ${read ?? head}::bigint,
|
|
561
|
+
coverage_changes_renamed = ${read === null && !firstPass && renamedTotal === 0}
|
|
562
|
+
WHERE singleton`;
|
|
563
|
+
return read;
|
|
564
|
+
});
|
|
565
|
+
if (last === null)
|
|
566
|
+
return;
|
|
567
|
+
after = String(last);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
|
|
571
|
+
async function coverageChangesRenamePending(tx) {
|
|
572
|
+
const [row] = await tx `
|
|
573
|
+
SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
|
|
574
|
+
return row !== undefined && !row.renamed;
|
|
575
|
+
}
|
|
468
576
|
/** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
|
|
469
577
|
async function slotlockConstraintUnvalidated(tx) {
|
|
470
578
|
const [row] = await tx `
|
|
@@ -870,18 +978,23 @@ async function replaceCalendarEventOccurrences(tx, params) {
|
|
|
870
978
|
for (const occurrence of params.occurrences) {
|
|
871
979
|
let reservationId = null;
|
|
872
980
|
if ((params.event.transparency ?? 'opaque') === 'opaque') {
|
|
873
|
-
const occupied =
|
|
874
|
-
|
|
981
|
+
const occupied = occupiedTime({
|
|
982
|
+
...occurrence,
|
|
983
|
+
bufferBeforeMs: params.bufferBeforeMs ?? 0,
|
|
984
|
+
bufferAfterMs: params.bufferAfterMs ?? 0,
|
|
985
|
+
});
|
|
986
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
875
987
|
const derivedExternalRef = `calendar:${params.eventId}:${createHash('sha256')
|
|
876
988
|
.update(occurrence.recurrenceId)
|
|
877
989
|
.digest('hex')}`;
|
|
878
990
|
const reservations = await tx `
|
|
879
991
|
INSERT INTO slotlock.reservations (
|
|
880
992
|
resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
881
|
-
buffer_after_ms, revision, source, calendar_event_id
|
|
993
|
+
buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
|
|
882
994
|
) VALUES (
|
|
883
995
|
${params.resourceId}, ${params.tenantRef}, ${derivedExternalRef},
|
|
884
|
-
${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
|
|
996
|
+
${occurrence.start}, ${occurrence.end}, ${params.bufferAfterMs ?? 0},
|
|
997
|
+
${params.bufferBeforeMs ?? 0}, ${params.revision},
|
|
885
998
|
${`calendar-event:${params.eventId}`}, ${params.eventId}
|
|
886
999
|
)
|
|
887
1000
|
RETURNING id`;
|
|
@@ -1069,46 +1182,53 @@ async function finishCalendarCancel(tx, params) {
|
|
|
1069
1182
|
* The row an event write lost to. The write was rolled back, so the event's own rows from before
|
|
1070
1183
|
* it are still there and are left out: they were to be replaced, and never block the event.
|
|
1071
1184
|
*/
|
|
1072
|
-
async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
|
|
1185
|
+
async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
|
|
1073
1186
|
for (const occurrence of occurrences) {
|
|
1074
|
-
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end,
|
|
1187
|
+
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, buffers.bufferAfterMs, undefined, eventId, buffers.bufferBeforeMs);
|
|
1075
1188
|
if (result.conflictingReservationId !== null)
|
|
1076
1189
|
return result;
|
|
1077
1190
|
}
|
|
1078
1191
|
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
1079
1192
|
}
|
|
1080
1193
|
/**
|
|
1081
|
-
* What the resource's booking rules
|
|
1082
|
-
*
|
|
1083
|
-
* A resource without its own
|
|
1194
|
+
* What the resource's booking rules (as they apply: its own over its pool's and tenant's defaults)
|
|
1195
|
+
* say about a booking of `interval` made now (by the database's clock, at this statement): the rule
|
|
1196
|
+
* it breaks, if any, and the buffers it must keep before and after it. A resource without its own
|
|
1197
|
+
* hours is judged against `defaultHours`.
|
|
1084
1198
|
*/
|
|
1085
1199
|
async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
1086
1200
|
const judge = await bookingJudge(tx, resourceId, defaultHours);
|
|
1087
|
-
return {
|
|
1201
|
+
return {
|
|
1202
|
+
rule: judge.broken(interval),
|
|
1203
|
+
turnaroundMs: judge.turnaroundMs,
|
|
1204
|
+
bufferBeforeMs: judge.bufferBeforeMs,
|
|
1205
|
+
};
|
|
1088
1206
|
}
|
|
1089
1207
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1090
1208
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1091
1209
|
/**
|
|
1092
|
-
* The resource's booking rules, read once, as a check of any interval booked now (by
|
|
1093
|
-
* clock at this statement) and the
|
|
1210
|
+
* The resource's booking rules as they apply, read once, as a check of any interval booked now (by
|
|
1211
|
+
* the database's clock at this statement) and the buffers a booking keeps.
|
|
1094
1212
|
*/
|
|
1095
1213
|
async function bookingJudge(tx, resourceId, defaultHours) {
|
|
1096
1214
|
const [row] = await tx `
|
|
1097
|
-
SELECT
|
|
1215
|
+
SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}, availability_rules, timezone,
|
|
1216
|
+
statement_timestamp() AS now
|
|
1098
1217
|
FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1099
|
-
if (!row || row.
|
|
1100
|
-
return { broken: () => null, turnaroundMs: 0 };
|
|
1218
|
+
if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
|
|
1219
|
+
return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
|
|
1101
1220
|
}
|
|
1102
|
-
const {
|
|
1221
|
+
const { effectiveBookingRules: rules, availabilityRules } = toResource({
|
|
1103
1222
|
id: resourceId,
|
|
1104
1223
|
external_ref: null,
|
|
1105
1224
|
tenant_ref: null,
|
|
1106
1225
|
timezone: row.timezone,
|
|
1107
1226
|
availability_rules: row.availability_rules,
|
|
1108
|
-
booking_rules:
|
|
1227
|
+
booking_rules: null,
|
|
1109
1228
|
pool_ref: null,
|
|
1110
1229
|
visibility: 'shared',
|
|
1111
1230
|
archived_at: null,
|
|
1231
|
+
effective_booking_rules: row.effective_booking_rules,
|
|
1112
1232
|
});
|
|
1113
1233
|
const hours = availabilityRules ?? defaultHours;
|
|
1114
1234
|
// Every sync covering the resource, and whether it was read within a day, by the same clock.
|
|
@@ -1120,9 +1240,10 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1120
1240
|
FROM slotlock.calendar_coverage WHERE resource_id = ${resourceId}`
|
|
1121
1241
|
: [];
|
|
1122
1242
|
const turnaroundMs = bookingBufferAfterMs(rules);
|
|
1123
|
-
|
|
1243
|
+
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
1244
|
+
// The booking and the buffers it keeps, as a search judges the slot.
|
|
1124
1245
|
const uncertain = (interval) => coverage.some((source) => !source.fresh ||
|
|
1125
|
-
source.starts_at > interval.start ||
|
|
1246
|
+
source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
|
|
1126
1247
|
source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
|
|
1127
1248
|
// A later occurrence of a series is judged on its step and hours only: notice and advance are
|
|
1128
1249
|
// about when the booking was made, which its first occurrence answers.
|
|
@@ -1130,6 +1251,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1130
1251
|
? undefined
|
|
1131
1252
|
: {
|
|
1132
1253
|
...(rules.slotStepMinutes !== undefined ? { slotStepMinutes: rules.slotStepMinutes } : {}),
|
|
1254
|
+
...(rules.stepOffsetMinutes !== undefined ? { stepOffsetMinutes: rules.stepOffsetMinutes } : {}),
|
|
1133
1255
|
...(rules.enforceHours !== undefined ? { enforceHours: rules.enforceHours } : {}),
|
|
1134
1256
|
};
|
|
1135
1257
|
return {
|
|
@@ -1141,6 +1263,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1141
1263
|
now: row.now,
|
|
1142
1264
|
}) ?? (uncertain(interval) ? 'uncertain' : null),
|
|
1143
1265
|
turnaroundMs,
|
|
1266
|
+
bufferBeforeMs,
|
|
1144
1267
|
};
|
|
1145
1268
|
}
|
|
1146
1269
|
function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
@@ -1153,9 +1276,6 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1153
1276
|
Number(row.revision) === params.revision &&
|
|
1154
1277
|
row.source === (params.source ?? null));
|
|
1155
1278
|
}
|
|
1156
|
-
function occupiedEnd(params) {
|
|
1157
|
-
return new Date(params.end.getTime() + params.bufferAfterMs);
|
|
1158
|
-
}
|
|
1159
1279
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1160
1280
|
// JSON encoding is unambiguous for this pair and contains no NUL byte (Postgres text rejects
|
|
1161
1281
|
// U+0000). The database hashes it to one transaction-scoped lock key.
|
|
@@ -1185,6 +1305,8 @@ function pgCode(err) {
|
|
|
1185
1305
|
}
|
|
1186
1306
|
/** Members a pool booking or search considers at most, in id order. */
|
|
1187
1307
|
export const SLOTLOCK_MAX_POOL_MEMBERS = 100;
|
|
1308
|
+
/** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
|
|
1309
|
+
export const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1_000;
|
|
1188
1310
|
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
1311
|
function toWaitlistEntry(row) {
|
|
1190
1312
|
return {
|
|
@@ -1210,6 +1332,30 @@ function invalidPoolError() {
|
|
|
1210
1332
|
code: 'invalid_pool',
|
|
1211
1333
|
});
|
|
1212
1334
|
}
|
|
1335
|
+
/**
|
|
1336
|
+
* A resource row's booking rules as they apply (jsonb; NULL for none): the tenant's defaults,
|
|
1337
|
+
* overlaid by its pool's, overlaid by its own, field by field, so one set at a narrower level wins.
|
|
1338
|
+
* Read from a row of slotlock.resources named `resources` (no alias).
|
|
1339
|
+
*/
|
|
1340
|
+
const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
1341
|
+
COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
1342
|
+
WHERE defaults.tenant_ref = resources.tenant_ref AND defaults.pool_ref = ''), '{}'::jsonb)
|
|
1343
|
+
|| COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
1344
|
+
WHERE defaults.tenant_ref = resources.tenant_ref
|
|
1345
|
+
AND defaults.pool_ref = resources.pool_ref), '{}'::jsonb)
|
|
1346
|
+
|| COALESCE(resources.booking_rules, '{}'::jsonb),
|
|
1347
|
+
'{}'::jsonb) AS effective_booking_rules`;
|
|
1348
|
+
/** What rules Slotlock did not write read as: no booking passes them. */
|
|
1349
|
+
const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
|
|
1350
|
+
/** Rules read back from storage: written validated, so anything else refuses every booking. */
|
|
1351
|
+
function storedBookingRules(value) {
|
|
1352
|
+
try {
|
|
1353
|
+
return parseBookingRules(value);
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return { ...REFUSE_EVERY_BOOKING };
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1213
1359
|
function toResource(row) {
|
|
1214
1360
|
const resource = {
|
|
1215
1361
|
id: row.id,
|
|
@@ -1234,11 +1380,19 @@ function toResource(row) {
|
|
|
1234
1380
|
if (row.booking_rules !== null && row.booking_rules !== undefined) {
|
|
1235
1381
|
// Written through setResourceBookingRules, which validates; anything else reads as refusing
|
|
1236
1382
|
// every booking, never as no rule.
|
|
1237
|
-
|
|
1238
|
-
|
|
1383
|
+
resource.bookingRules = storedBookingRules(row.booking_rules);
|
|
1384
|
+
}
|
|
1385
|
+
if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
|
|
1386
|
+
// Merged from levels each validated alone, so only their combination can disagree (an offset
|
|
1387
|
+
// from one level over a shorter step from another): that one is read modulo the step. A field
|
|
1388
|
+
// invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
|
|
1389
|
+
const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
|
|
1390
|
+
if (dropped.length > 0) {
|
|
1391
|
+
console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
|
|
1392
|
+
resource.effectiveBookingRules = { ...REFUSE_EVERY_BOOKING };
|
|
1239
1393
|
}
|
|
1240
|
-
|
|
1241
|
-
resource.
|
|
1394
|
+
else if (Object.keys(rules).length > 0) {
|
|
1395
|
+
resource.effectiveBookingRules = rules;
|
|
1242
1396
|
}
|
|
1243
1397
|
}
|
|
1244
1398
|
return resource;
|
|
@@ -1268,7 +1422,7 @@ async function upsertResource(executor, params) {
|
|
|
1268
1422
|
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
|
|
1269
1423
|
DO UPDATE SET timezone = EXCLUDED.timezone
|
|
1270
1424
|
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`;
|
|
1425
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1272
1426
|
}
|
|
1273
1427
|
else if (params.externalRef !== undefined) {
|
|
1274
1428
|
rows = await executor `
|
|
@@ -1278,13 +1432,13 @@ async function upsertResource(executor, params) {
|
|
|
1278
1432
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
|
|
1279
1433
|
DO UPDATE SET external_ref = EXCLUDED.external_ref
|
|
1280
1434
|
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`;
|
|
1435
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1282
1436
|
}
|
|
1283
1437
|
else {
|
|
1284
1438
|
rows = await executor `
|
|
1285
1439
|
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1286
1440
|
VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
1287
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at`;
|
|
1441
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1288
1442
|
}
|
|
1289
1443
|
const row = rows[0];
|
|
1290
1444
|
if (!row) {
|
|
@@ -1307,7 +1461,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1307
1461
|
summary, description, location, status, transparency, organizer, attendees,
|
|
1308
1462
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1309
1463
|
materialized_ends_at, recurrence_cursor, revision, source, created_at,
|
|
1310
|
-
updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms
|
|
1464
|
+
updated_at, recurrence_ends_by IS NULL AS ends_by_missing, buffer_after_ms, buffer_before_ms
|
|
1311
1465
|
FROM slotlock.calendar_events
|
|
1312
1466
|
WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
|
|
1313
1467
|
FOR UPDATE`;
|
|
@@ -1330,6 +1484,7 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1330
1484
|
occurrences,
|
|
1331
1485
|
revision: Number(row.revision),
|
|
1332
1486
|
bufferAfterMs: Number(row.buffer_after_ms ?? 0),
|
|
1487
|
+
bufferBeforeMs: Number(row.buffer_before_ms ?? 0),
|
|
1333
1488
|
});
|
|
1334
1489
|
// A series stored before 0.3 learns when it ends at its first roll.
|
|
1335
1490
|
const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
|
|
@@ -1697,8 +1852,28 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
|
1697
1852
|
return Number(row?.deleted ?? 0);
|
|
1698
1853
|
}
|
|
1699
1854
|
/**
|
|
1700
|
-
*
|
|
1701
|
-
* it
|
|
1855
|
+
* The time a booking of [start, end) keeping these buffers occupies, as the exclusion constraint
|
|
1856
|
+
* reads it: from its start less its leading buffer to its end plus its trailing one.
|
|
1857
|
+
*/
|
|
1858
|
+
function occupiedTime(params) {
|
|
1859
|
+
return {
|
|
1860
|
+
start: new Date(params.start.getTime() - (params.bufferBeforeMs ?? 0)),
|
|
1861
|
+
end: new Date(params.end.getTime() + (params.bufferAfterMs ?? 0)),
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* A row's occupied time starts before `end`: a condition on its occupied start that still reads the
|
|
1866
|
+
* (resource_id, starts_at, ends_at) index, since no row starts more than a day after it.
|
|
1867
|
+
*/
|
|
1868
|
+
function occupiedStartsBefore(executor, end, alias) {
|
|
1869
|
+
const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
|
|
1870
|
+
return executor `(${column('starts_at')} < ${new Date(end.getTime() + MAX_BUFFER_BEFORE_MS)}
|
|
1871
|
+
AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Delete expired held rows whose occupied time (buffers included, as the EXCLUDE constraint reads
|
|
1875
|
+
* it) overlaps the requested window, whose `start` and `end` are its own occupied start and end
|
|
1876
|
+
* (`occupiedTime`), in deterministic id
|
|
1702
1877
|
* order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
|
|
1703
1878
|
* deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
|
|
1704
1879
|
* before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
|
|
@@ -1711,7 +1886,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1711
1886
|
WHERE resource_id = ${resourceId}
|
|
1712
1887
|
AND status = 'held'
|
|
1713
1888
|
AND expires_at <= statement_timestamp()
|
|
1714
|
-
AND
|
|
1889
|
+
AND ${occupiedStartsBefore(tx, end)}
|
|
1715
1890
|
AND COALESCE(occupied_ends_at, ends_at) > ${start}
|
|
1716
1891
|
ORDER BY id
|
|
1717
1892
|
FOR UPDATE
|
|
@@ -1723,7 +1898,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1723
1898
|
* loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
|
|
1724
1899
|
* immediate retry reaps it and wins.
|
|
1725
1900
|
*/
|
|
1726
|
-
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
|
|
1901
|
+
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
|
|
1727
1902
|
const winners = await sql `
|
|
1728
1903
|
SELECT id FROM slotlock.reservations
|
|
1729
1904
|
WHERE resource_id = ${resourceId}
|
|
@@ -1731,12 +1906,12 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
|
|
|
1731
1906
|
AND (${excludeCalendarEventId ?? null}::uuid IS NULL
|
|
1732
1907
|
OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
|
|
1733
1908
|
AND tstzrange(
|
|
1734
|
-
starts_at,
|
|
1909
|
+
COALESCE(occupied_starts_at, starts_at),
|
|
1735
1910
|
COALESCE(occupied_ends_at, ends_at),
|
|
1736
1911
|
'[)'
|
|
1737
1912
|
) && tstzrange(
|
|
1738
|
-
${start},
|
|
1739
|
-
${end} + ${bufferAfterMs} * interval '1 millisecond',
|
|
1913
|
+
${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
|
|
1914
|
+
${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
|
|
1740
1915
|
'[)'
|
|
1741
1916
|
)
|
|
1742
1917
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
@@ -1821,6 +1996,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
1821
1996
|
'deleteResource',
|
|
1822
1997
|
'setResourceAvailability',
|
|
1823
1998
|
'setResourceBookingRules',
|
|
1999
|
+
'setBookingRuleDefaults',
|
|
1824
2000
|
'setResourcePool',
|
|
1825
2001
|
'setTenantAvailability',
|
|
1826
2002
|
'createReservation',
|
|
@@ -1975,19 +2151,30 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
1975
2151
|
// application instances can still collide on pg_namespace/pg_extension unique indexes.
|
|
1976
2152
|
// inMigration serializes bootstrap per database and the transaction releases the lock on
|
|
1977
2153
|
// crashes. A schema this release already left as it is changes nothing and locks no table,
|
|
1978
|
-
// unless old rows kept one of its constraints NOT VALID
|
|
1979
|
-
|
|
2154
|
+
// unless old rows kept one of its constraints NOT VALID (then only the validation runs again)
|
|
2155
|
+
// or the rename of earlier coverage changes is not done (then only a pass of it runs).
|
|
2156
|
+
const next = await inMigration(sql, migrationOptions, async (tx) => {
|
|
1980
2157
|
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
1981
2158
|
throw schemaControlRefusal(error);
|
|
1982
2159
|
});
|
|
1983
|
-
if (await slotlockSchemaUpToDate(tx))
|
|
1984
|
-
return
|
|
2160
|
+
if (await slotlockSchemaUpToDate(tx)) {
|
|
2161
|
+
return {
|
|
2162
|
+
validate: await slotlockConstraintUnvalidated(tx),
|
|
2163
|
+
rename: await coverageChangesRenamePending(tx),
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
1985
2166
|
await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
|
|
1986
2167
|
throw schemaControlRefusal(error);
|
|
1987
2168
|
});
|
|
1988
|
-
return true;
|
|
2169
|
+
return { validate: true, rename: await coverageChangesRenamePending(tx) };
|
|
1989
2170
|
});
|
|
1990
|
-
|
|
2171
|
+
// Changes an earlier release wrote are renamed in short batches once the structure commits,
|
|
2172
|
+
// never under its locks, by this migration and the next ones until a pass finds none
|
|
2173
|
+
// (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
|
|
2174
|
+
// slotlockServedChange).
|
|
2175
|
+
if (next.rename)
|
|
2176
|
+
await renameEarlierCoverageChanges(sql, migrationOptions);
|
|
2177
|
+
if (!next.validate)
|
|
1991
2178
|
return;
|
|
1992
2179
|
// Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
|
|
1993
2180
|
// a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
|
|
@@ -2151,6 +2338,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2151
2338
|
code: 'invalid_identity',
|
|
2152
2339
|
});
|
|
2153
2340
|
}
|
|
2341
|
+
const origin = options?.origin;
|
|
2342
|
+
if (origin !== undefined && !isSlotlockChangeOrigin(origin)) {
|
|
2343
|
+
throw Object.assign(new Error(`Slotlock origin must be one of ${SLOTLOCK_CHANGE_ORIGINS.join(', ')}`), {
|
|
2344
|
+
code: 'invalid_identity',
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
const keyId = options?.keyId;
|
|
2348
|
+
if (keyId !== undefined && !isValidResourceId(keyId)) {
|
|
2349
|
+
throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
|
|
2350
|
+
}
|
|
2154
2351
|
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
2155
2352
|
// A lock wait that timed out leaves the transaction unusable, so the reset below fails with
|
|
2156
2353
|
// another error in its place: noted here as it happens.
|
|
@@ -2167,6 +2364,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2167
2364
|
const previous = await tx `
|
|
2168
2365
|
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
2169
2366
|
current_setting('slotlock.actor', true) AS actor,
|
|
2367
|
+
current_setting('slotlock.origin', true) AS origin,
|
|
2368
|
+
current_setting('slotlock.key_id', true) AS key,
|
|
2170
2369
|
current_setting('transaction_isolation') AS isolation,
|
|
2171
2370
|
pg_advisory_xact_lock_shared(
|
|
2172
2371
|
hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
|
|
@@ -2174,6 +2373,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2174
2373
|
const [context] = await tx `
|
|
2175
2374
|
SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
|
|
2176
2375
|
set_config('slotlock.actor', ${actor ?? ''}, true) AS actor,
|
|
2376
|
+
set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
|
|
2377
|
+
NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
|
|
2378
|
+
set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
|
|
2177
2379
|
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
2178
2380
|
throw suspensionCheckRefusal(error);
|
|
2179
2381
|
});
|
|
@@ -2195,7 +2397,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2195
2397
|
}
|
|
2196
2398
|
finally {
|
|
2197
2399
|
await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
|
|
2198
|
-
set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true)
|
|
2400
|
+
set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
|
|
2401
|
+
set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
|
|
2402
|
+
set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)`;
|
|
2199
2403
|
}
|
|
2200
2404
|
}, 'read committed')
|
|
2201
2405
|
.catch((error) => {
|
|
@@ -2256,7 +2460,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2256
2460
|
});
|
|
2257
2461
|
}
|
|
2258
2462
|
const rows = await sql `
|
|
2259
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
|
|
2463
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2260
2464
|
FROM slotlock.resources
|
|
2261
2465
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2262
2466
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
|
|
@@ -2274,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2274
2478
|
UPDATE slotlock.resources
|
|
2275
2479
|
SET availability_rules = ${rules}::jsonb
|
|
2276
2480
|
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`;
|
|
2481
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2278
2482
|
const row = rows[0];
|
|
2279
2483
|
return row ? toResource(row) : null;
|
|
2280
2484
|
},
|
|
@@ -2306,10 +2510,74 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2306
2510
|
END
|
|
2307
2511
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2308
2512
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2309
|
-
visibility, archived_at`;
|
|
2513
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2310
2514
|
const row = rows[0];
|
|
2311
2515
|
return row ? toResource(row) : null;
|
|
2312
2516
|
},
|
|
2517
|
+
async setBookingRuleDefaults(params) {
|
|
2518
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2519
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2520
|
+
}
|
|
2521
|
+
if (params.pool !== null && !isPoolName(params.pool))
|
|
2522
|
+
throw invalidPoolError();
|
|
2523
|
+
const pool = params.pool ?? '';
|
|
2524
|
+
if (params.rules === null) {
|
|
2525
|
+
await sql `
|
|
2526
|
+
DELETE FROM slotlock.booking_rule_defaults
|
|
2527
|
+
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${pool}`;
|
|
2528
|
+
return null;
|
|
2529
|
+
}
|
|
2530
|
+
let rules;
|
|
2531
|
+
try {
|
|
2532
|
+
rules = parseBookingRuleDefaults(params.rules);
|
|
2533
|
+
}
|
|
2534
|
+
catch (error) {
|
|
2535
|
+
throw Object.assign(new Error(error.message), {
|
|
2536
|
+
code: 'invalid_booking_rules',
|
|
2537
|
+
});
|
|
2538
|
+
}
|
|
2539
|
+
const tenantRef = params.tenantRef;
|
|
2540
|
+
return inTransaction(sql, async (tx) => {
|
|
2541
|
+
if (pool !== '') {
|
|
2542
|
+
// Counted under the tenant's lock, so racing writes stop at the cap.
|
|
2543
|
+
// The count after the lock sees every committed row only under READ COMMITTED.
|
|
2544
|
+
await assertReadCommitted(tx);
|
|
2545
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
|
|
2546
|
+
const [counted] = await tx `
|
|
2547
|
+
SELECT count(*)::int AS pools, bool_or(pool_ref = ${pool}) IS TRUE AS exists
|
|
2548
|
+
FROM slotlock.booking_rule_defaults
|
|
2549
|
+
WHERE tenant_ref = ${tenantRef} AND pool_ref <> ''`;
|
|
2550
|
+
if (!counted?.exists && (counted?.pools ?? 0) >= SLOTLOCK_MAX_POOL_RULE_DEFAULTS) {
|
|
2551
|
+
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 });
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
await tx `
|
|
2555
|
+
INSERT INTO slotlock.booking_rule_defaults (tenant_ref, pool_ref, rules)
|
|
2556
|
+
VALUES (${tenantRef}, ${pool}, ${tx.json({ ...rules })}::jsonb)
|
|
2557
|
+
ON CONFLICT (tenant_ref, pool_ref)
|
|
2558
|
+
DO UPDATE SET rules = EXCLUDED.rules, updated_at = statement_timestamp()`;
|
|
2559
|
+
return rules;
|
|
2560
|
+
});
|
|
2561
|
+
},
|
|
2562
|
+
async getBookingRuleDefaults(params) {
|
|
2563
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2564
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2565
|
+
}
|
|
2566
|
+
const rows = await sql `
|
|
2567
|
+
SELECT pool_ref, rules FROM slotlock.booking_rule_defaults
|
|
2568
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
2569
|
+
ORDER BY pool_ref
|
|
2570
|
+
LIMIT ${SLOTLOCK_MAX_POOL_RULE_DEFAULTS + 1}`;
|
|
2571
|
+
const defaults = { tenant: null, pools: {} };
|
|
2572
|
+
for (const row of rows) {
|
|
2573
|
+
const rules = storedBookingRules(row.rules);
|
|
2574
|
+
if (row.pool_ref === '')
|
|
2575
|
+
defaults.tenant = rules;
|
|
2576
|
+
else
|
|
2577
|
+
defaults.pools[row.pool_ref] = rules;
|
|
2578
|
+
}
|
|
2579
|
+
return defaults;
|
|
2580
|
+
},
|
|
2313
2581
|
async setResourcePool(params) {
|
|
2314
2582
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2315
2583
|
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
@@ -2322,7 +2590,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2322
2590
|
UPDATE slotlock.resources SET pool_ref = ${params.pool}
|
|
2323
2591
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2324
2592
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2325
|
-
visibility, archived_at`;
|
|
2593
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2326
2594
|
const row = rows[0];
|
|
2327
2595
|
return row ? toResource(row) : null;
|
|
2328
2596
|
},
|
|
@@ -2333,7 +2601,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2333
2601
|
throw invalidPoolError();
|
|
2334
2602
|
const rows = await sql `
|
|
2335
2603
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2336
|
-
visibility, archived_at
|
|
2604
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2337
2605
|
FROM slotlock.resources
|
|
2338
2606
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
2339
2607
|
AND archived_at IS NULL
|
|
@@ -2386,7 +2654,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2386
2654
|
});
|
|
2387
2655
|
}
|
|
2388
2656
|
const rows = await sql `
|
|
2389
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at
|
|
2657
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2390
2658
|
FROM slotlock.resources
|
|
2391
2659
|
WHERE tenant_ref = ${params.tenantRef}
|
|
2392
2660
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
@@ -2406,7 +2674,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2406
2674
|
UPDATE slotlock.resources
|
|
2407
2675
|
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
2408
2676
|
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`;
|
|
2677
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2410
2678
|
return row ? toResource(row) : null;
|
|
2411
2679
|
},
|
|
2412
2680
|
async restoreResource(params) {
|
|
@@ -2417,7 +2685,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2417
2685
|
UPDATE slotlock.resources
|
|
2418
2686
|
SET archived_at = NULL
|
|
2419
2687
|
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`;
|
|
2688
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2421
2689
|
return row ? toResource(row) : null;
|
|
2422
2690
|
},
|
|
2423
2691
|
async deleteResource(params) {
|
|
@@ -2465,6 +2733,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2465
2733
|
// A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
|
|
2466
2734
|
// cancelled and moved like any other; one on a resource with no tenant stays tenantless.
|
|
2467
2735
|
const id = randomUUID();
|
|
2736
|
+
// The buffers written, for the overlap answer if the write loses.
|
|
2737
|
+
let bufferBeforeMs = 0;
|
|
2738
|
+
let bufferAfterMs = 0;
|
|
2468
2739
|
for (let attempt = 1;; attempt++) {
|
|
2469
2740
|
try {
|
|
2470
2741
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2485,24 +2756,27 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2485
2756
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
2486
2757
|
if (ruling.rule)
|
|
2487
2758
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2488
|
-
|
|
2759
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2760
|
+
bufferAfterMs = ruling.turnaroundMs;
|
|
2761
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2489
2762
|
if (resource.tenant_ref !== null &&
|
|
2490
2763
|
!(await recurrenceCoversWrite(tx, {
|
|
2491
2764
|
tenantRef: resource.tenant_ref,
|
|
2492
2765
|
resourceId: params.resourceId,
|
|
2493
|
-
|
|
2494
|
-
end: occupied,
|
|
2766
|
+
...occupied,
|
|
2495
2767
|
}))) {
|
|
2496
2768
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2497
2769
|
}
|
|
2498
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2770
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2499
2771
|
const [row] = await tx `
|
|
2500
2772
|
INSERT INTO slotlock.reservations (
|
|
2501
|
-
id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2773
|
+
id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2774
|
+
buffer_before_ms, source
|
|
2502
2775
|
) VALUES (
|
|
2503
2776
|
${id}, ${params.resourceId}, ${resource.tenant_ref},
|
|
2504
2777
|
${resource.tenant_ref === null ? null : id},
|
|
2505
|
-
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${
|
|
2778
|
+
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
|
|
2779
|
+
${params.source ?? null}
|
|
2506
2780
|
)
|
|
2507
2781
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2508
2782
|
buffer_after_ms, revision, source`;
|
|
@@ -2523,8 +2797,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2523
2797
|
}
|
|
2524
2798
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2525
2799
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2526
|
-
|
|
2527
|
-
|
|
2800
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2801
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2802
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
|
|
2528
2803
|
}
|
|
2529
2804
|
}
|
|
2530
2805
|
},
|
|
@@ -2535,6 +2810,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2535
2810
|
if (params.revision !== 1)
|
|
2536
2811
|
return { ok: false, code: 'invalid_revision' };
|
|
2537
2812
|
let written = params.bufferAfterMs;
|
|
2813
|
+
let writtenBefore = 0;
|
|
2538
2814
|
for (let attempt = 1;; attempt++) {
|
|
2539
2815
|
try {
|
|
2540
2816
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2579,23 +2855,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2579
2855
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2580
2856
|
const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2581
2857
|
written = bufferAfterMs;
|
|
2582
|
-
|
|
2858
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
2859
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
|
|
2583
2860
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2584
2861
|
tenantRef: params.tenantRef,
|
|
2585
2862
|
resourceId: params.resourceId,
|
|
2586
|
-
|
|
2587
|
-
end: occupied,
|
|
2863
|
+
...occupied,
|
|
2588
2864
|
}))) {
|
|
2589
2865
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2590
2866
|
}
|
|
2591
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2867
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2592
2868
|
const rows = await tx `
|
|
2593
2869
|
INSERT INTO slotlock.reservations (
|
|
2594
2870
|
resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2595
|
-
revision, source
|
|
2871
|
+
buffer_before_ms, revision, source
|
|
2596
2872
|
) VALUES (
|
|
2597
2873
|
${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
|
|
2598
|
-
${params.end}, ${bufferAfterMs}, ${
|
|
2874
|
+
${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
|
|
2875
|
+
${params.source ?? null}
|
|
2599
2876
|
)
|
|
2600
2877
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2601
2878
|
buffer_after_ms, revision, source`;
|
|
@@ -2615,8 +2892,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2615
2892
|
code !== SERIALIZATION_FAILURE) {
|
|
2616
2893
|
throw err;
|
|
2617
2894
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
2895
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
2896
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2897
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
2620
2898
|
}
|
|
2621
2899
|
}
|
|
2622
2900
|
},
|
|
@@ -2626,6 +2904,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2626
2904
|
return invalid;
|
|
2627
2905
|
let currentReservationId;
|
|
2628
2906
|
let bufferAfterMs = params.bufferAfterMs;
|
|
2907
|
+
let bufferBeforeMs = 0;
|
|
2629
2908
|
for (let attempt = 1;; attempt++) {
|
|
2630
2909
|
try {
|
|
2631
2910
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2668,21 +2947,23 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2668
2947
|
if (ruling.rule)
|
|
2669
2948
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2670
2949
|
bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2950
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2951
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2671
2952
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2672
2953
|
tenantRef: params.tenantRef,
|
|
2673
2954
|
resourceId: params.resourceId,
|
|
2674
|
-
|
|
2675
|
-
end: occupiedEnd({ end: params.end, bufferAfterMs }),
|
|
2955
|
+
...occupied,
|
|
2676
2956
|
}))) {
|
|
2677
2957
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2678
2958
|
}
|
|
2679
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2959
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2680
2960
|
const rows = await tx `
|
|
2681
2961
|
UPDATE slotlock.reservations
|
|
2682
2962
|
SET resource_id = ${params.resourceId},
|
|
2683
2963
|
starts_at = ${params.start},
|
|
2684
2964
|
ends_at = ${params.end},
|
|
2685
2965
|
buffer_after_ms = ${bufferAfterMs},
|
|
2966
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
2686
2967
|
revision = ${params.revision},
|
|
2687
2968
|
source = ${params.source ?? null},
|
|
2688
2969
|
-- A check-in is for the time it was made for: a move to another start clears it.
|
|
@@ -2709,9 +2990,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2709
2990
|
code !== SERIALIZATION_FAILURE) {
|
|
2710
2991
|
throw err;
|
|
2711
2992
|
}
|
|
2712
|
-
const occupied =
|
|
2713
|
-
await rollSeriesBehind(sql, params.resourceId,
|
|
2714
|
-
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId);
|
|
2993
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2994
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2995
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, currentReservationId, undefined, bufferBeforeMs);
|
|
2715
2996
|
}
|
|
2716
2997
|
}
|
|
2717
2998
|
},
|
|
@@ -2825,8 +3106,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2825
3106
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
2826
3107
|
});
|
|
2827
3108
|
const ttlSeconds = params.ttlMs / 1000;
|
|
2828
|
-
// The
|
|
3109
|
+
// The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
|
|
3110
|
+
// leading buffer.
|
|
2829
3111
|
let written = bufferAfterMs;
|
|
3112
|
+
let writtenBefore = 0;
|
|
2830
3113
|
for (let attempt = 1;; attempt++) {
|
|
2831
3114
|
try {
|
|
2832
3115
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2875,7 +3158,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2875
3158
|
const [held] = await tx `
|
|
2876
3159
|
SELECT count(*)::int AS count,
|
|
2877
3160
|
COALESCE(sum(extract(epoch FROM
|
|
2878
|
-
ends_at + buffer_after_ms * interval '1 millisecond'
|
|
3161
|
+
ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
|
|
3162
|
+
- starts_at)), 0)::float8
|
|
2879
3163
|
AS seconds
|
|
2880
3164
|
FROM slotlock.reservations
|
|
2881
3165
|
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
@@ -2891,10 +3175,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2891
3175
|
if (ruling.rule)
|
|
2892
3176
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2893
3177
|
written = Math.max(bufferAfterMs, ruling.turnaroundMs);
|
|
2894
|
-
|
|
3178
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
3179
|
+
const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
2895
3180
|
if (heldSeconds !== null &&
|
|
2896
3181
|
maxHeldHoursPerHolder !== undefined &&
|
|
2897
|
-
heldSeconds + (reach.getTime() -
|
|
3182
|
+
heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
|
|
2898
3183
|
maxHeldHoursPerHolder * 3_600) {
|
|
2899
3184
|
return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
|
|
2900
3185
|
}
|
|
@@ -2902,18 +3187,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2902
3187
|
!(await recurrenceCoversWrite(tx, {
|
|
2903
3188
|
tenantRef: resource.tenant_ref,
|
|
2904
3189
|
resourceId: params.resourceId,
|
|
2905
|
-
|
|
2906
|
-
end: reach,
|
|
3190
|
+
...reach,
|
|
2907
3191
|
}))) {
|
|
2908
3192
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2909
3193
|
}
|
|
2910
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
3194
|
+
await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
|
|
2911
3195
|
const [row] = await tx `
|
|
2912
3196
|
INSERT INTO slotlock.reservations (
|
|
2913
|
-
resource_id, starts_at, ends_at, buffer_after_ms,
|
|
2914
|
-
holder_ref, hold_group_id
|
|
3197
|
+
resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status,
|
|
3198
|
+
expires_at, source, holder_ref, hold_group_id
|
|
2915
3199
|
) VALUES (
|
|
2916
|
-
${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
|
|
3200
|
+
${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
|
|
2917
3201
|
statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
|
|
2918
3202
|
${holderRef ?? null}, ${params.groupId ?? null}
|
|
2919
3203
|
)
|
|
@@ -2947,8 +3231,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2947
3231
|
}
|
|
2948
3232
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2949
3233
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2950
|
-
|
|
2951
|
-
|
|
3234
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
3235
|
+
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
3236
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
2952
3237
|
}
|
|
2953
3238
|
}
|
|
2954
3239
|
},
|
|
@@ -3200,15 +3485,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3200
3485
|
// Expired holds are dead occupation: excluded here so findNextAvailable can offer their
|
|
3201
3486
|
// space (the acquiring transaction reaps the row itself when it takes the window).
|
|
3202
3487
|
const rows = await sql `
|
|
3203
|
-
SELECT
|
|
3488
|
+
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
3489
|
+
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
3204
3490
|
FROM slotlock.reservations
|
|
3205
3491
|
WHERE resource_id = ${resourceId}
|
|
3206
|
-
AND
|
|
3492
|
+
AND ${occupiedStartsBefore(sql, window.end)}
|
|
3207
3493
|
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
3208
3494
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
3209
|
-
ORDER BY starts_at
|
|
3495
|
+
ORDER BY COALESCE(occupied_starts_at, starts_at)
|
|
3210
3496
|
LIMIT ${options.limit ?? null}`;
|
|
3211
|
-
return rows.map((r) => ({ start: r.
|
|
3497
|
+
return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
|
|
3212
3498
|
},
|
|
3213
3499
|
async putCalendarEvent(params) {
|
|
3214
3500
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
@@ -3266,6 +3552,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3266
3552
|
});
|
|
3267
3553
|
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
3268
3554
|
let attempted = expanded?.occurrences ?? [];
|
|
3555
|
+
// The buffers the attempt kept, for the overlap answer if it loses.
|
|
3556
|
+
let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
|
|
3269
3557
|
let storedEventId;
|
|
3270
3558
|
for (let attempt = 1;; attempt++) {
|
|
3271
3559
|
try {
|
|
@@ -3403,6 +3691,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3403
3691
|
AND hold_group_id = ${hold.hold_group_id}::uuid))`;
|
|
3404
3692
|
}
|
|
3405
3693
|
let turnaroundMs = 0;
|
|
3694
|
+
let bufferBeforeMs = 0;
|
|
3406
3695
|
if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
|
|
3407
3696
|
const judge = await bookingJudge(tx, params.resourceId, defaultHours);
|
|
3408
3697
|
for (const [index, occurrence] of occurrences.entries()) {
|
|
@@ -3412,9 +3701,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3412
3701
|
return { ok: false, code: 'booking_rule_violated', rule };
|
|
3413
3702
|
}
|
|
3414
3703
|
turnaroundMs = judge.turnaroundMs;
|
|
3704
|
+
bufferBeforeMs = judge.bufferBeforeMs;
|
|
3415
3705
|
}
|
|
3706
|
+
attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
|
|
3416
3707
|
const span = occurrenceSpan(occurrences);
|
|
3417
|
-
const reach = span && {
|
|
3708
|
+
const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
|
|
3418
3709
|
if ((event.transparency ?? 'opaque') === 'opaque' &&
|
|
3419
3710
|
reach &&
|
|
3420
3711
|
!(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
|
|
@@ -3452,6 +3743,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3452
3743
|
revision = ${revision},
|
|
3453
3744
|
source = ${params.source ?? null},
|
|
3454
3745
|
buffer_after_ms = ${turnaroundMs},
|
|
3746
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
3455
3747
|
updated_at = now()
|
|
3456
3748
|
WHERE id = ${eventId}
|
|
3457
3749
|
AND tenant_ref = ${params.tenantRef}
|
|
@@ -3464,7 +3756,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3464
3756
|
summary, description, location, status, transparency, organizer, attendees,
|
|
3465
3757
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
3466
3758
|
materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
|
|
3467
|
-
buffer_after_ms
|
|
3759
|
+
buffer_after_ms, buffer_before_ms
|
|
3468
3760
|
) VALUES (
|
|
3469
3761
|
${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
|
|
3470
3762
|
${params.resourceId},
|
|
@@ -3478,7 +3770,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3478
3770
|
${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
|
|
3479
3771
|
${materializationWindow.start}, ${materializationWindow.end},
|
|
3480
3772
|
${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
|
|
3481
|
-
${revision}, ${params.source ?? null}, ${turnaroundMs}
|
|
3773
|
+
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
3482
3774
|
)`;
|
|
3483
3775
|
}
|
|
3484
3776
|
// The normalized children are the queryable/canonical representation. The JSON
|
|
@@ -3492,6 +3784,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3492
3784
|
occurrences,
|
|
3493
3785
|
revision,
|
|
3494
3786
|
bufferAfterMs: turnaroundMs,
|
|
3787
|
+
bufferBeforeMs,
|
|
3495
3788
|
});
|
|
3496
3789
|
await tx `
|
|
3497
3790
|
INSERT INTO slotlock.calendar_event_commands (
|
|
@@ -3522,11 +3815,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3522
3815
|
code !== SERIALIZATION_FAILURE) {
|
|
3523
3816
|
throw error;
|
|
3524
3817
|
}
|
|
3525
|
-
const
|
|
3526
|
-
if (
|
|
3818
|
+
const span = occurrenceSpan(attempted);
|
|
3819
|
+
if (span) {
|
|
3820
|
+
const reach = occupiedTime({ ...span, ...attemptedBuffers });
|
|
3527
3821
|
await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
|
|
3528
3822
|
}
|
|
3529
|
-
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
|
|
3823
|
+
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
|
|
3530
3824
|
}
|
|
3531
3825
|
}
|
|
3532
3826
|
},
|
|
@@ -3932,7 +4226,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3932
4226
|
slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
|
|
3933
4227
|
slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
|
|
3934
4228
|
COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
|
|
3935
|
-
WHERE booking.calendar_event_id = event.id), 0) AS buffer
|
|
4229
|
+
WHERE booking.calendar_event_id = event.id), 0) AS buffer,
|
|
4230
|
+
COALESCE((SELECT max(booking.buffer_before_ms) FROM slotlock.reservations booking
|
|
4231
|
+
WHERE booking.calendar_event_id = event.id), 0) AS buffer_before
|
|
3936
4232
|
FROM slotlock.calendar_events event
|
|
3937
4233
|
WHERE event.tenant_ref = ${params.tenantRef}
|
|
3938
4234
|
AND event.recurrence_rule IS NULL
|
|
@@ -3986,6 +4282,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3986
4282
|
],
|
|
3987
4283
|
revision,
|
|
3988
4284
|
bufferAfterMs: Number(event.buffer),
|
|
4285
|
+
bufferBeforeMs: Number(event.buffer_before),
|
|
3989
4286
|
});
|
|
3990
4287
|
});
|
|
3991
4288
|
moved++;
|
|
@@ -4291,13 +4588,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4291
4588
|
}
|
|
4292
4589
|
const maxAgeMs = params.maxAgeMs ?? 24 * 60 * 60 * 1000;
|
|
4293
4590
|
const rows = await sql `
|
|
4294
|
-
SELECT source, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
|
|
4591
|
+
SELECT source, source_id, starts_at, ends_at, revision, observed_at, last_error, last_error_at,
|
|
4295
4592
|
observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000}) AS stale
|
|
4296
4593
|
FROM slotlock.calendar_coverage
|
|
4297
4594
|
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
4298
4595
|
ORDER BY source`;
|
|
4299
4596
|
return rows.map((row) => ({
|
|
4300
4597
|
source: row.source,
|
|
4598
|
+
sourceId: row.source_id,
|
|
4301
4599
|
start: row.starts_at,
|
|
4302
4600
|
end: row.ends_at,
|
|
4303
4601
|
revision: Number(row.revision),
|
|
@@ -4321,13 +4619,13 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4321
4619
|
AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
|
|
4322
4620
|
ORDER BY observed_at LIMIT ${params.limit ?? 100}
|
|
4323
4621
|
FOR UPDATE SKIP LOCKED)
|
|
4324
|
-
RETURNING coverage.resource_id, coverage.
|
|
4622
|
+
RETURNING coverage.resource_id, coverage.source_id, coverage.observed_at
|
|
4325
4623
|
), reported AS (
|
|
4326
4624
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
4327
|
-
SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.
|
|
4328
|
-
|
|
4329
|
-
'resource_id', stale.resource_id, '
|
|
4330
|
-
'observed_at', stale.observed_at
|
|
4625
|
+
SELECT ${params.tenantRef}, stale.resource_id, 'coverage.stale', stale.source_id,
|
|
4626
|
+
jsonb_build_object(
|
|
4627
|
+
'resource_id', stale.resource_id, 'source_id', stale.source_id,
|
|
4628
|
+
'observed_at', stale.observed_at),
|
|
4331
4629
|
'slotlock:maintenance'
|
|
4332
4630
|
FROM stale
|
|
4333
4631
|
RETURNING 1
|
|
@@ -4742,18 +5040,28 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4742
5040
|
continue;
|
|
4743
5041
|
// Only resources whose window looks free are tried: a hold refused keeps its locks
|
|
4744
5042
|
// until the transaction ends, so trying every member of a full pool would pile them up.
|
|
5043
|
+
// A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
|
|
4745
5044
|
const candidates = await tx `
|
|
4746
|
-
SELECT
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
5045
|
+
SELECT resources.id
|
|
5046
|
+
FROM slotlock.resources,
|
|
5047
|
+
LATERAL (SELECT ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}) effective,
|
|
5048
|
+
LATERAL (SELECT
|
|
5049
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'bufferBeforeMinutes')::numeric, 0), 1440)::int, 0)
|
|
5050
|
+
AS before_minutes,
|
|
5051
|
+
COALESCE(least(greatest((effective.effective_booking_rules->>'turnaroundMinutes')::numeric, 0), 1440)::int, 0)
|
|
5052
|
+
AS after_minutes) buffers
|
|
5053
|
+
WHERE resources.tenant_ref = ${tenantRef} AND resources.archived_at IS NULL
|
|
5054
|
+
AND (resources.id = ${entry.resource_id}::uuid
|
|
5055
|
+
OR (${entry.resource_id}::uuid IS NULL AND resources.pool_ref = ${entry.pool_ref}))
|
|
4750
5056
|
AND NOT EXISTS (
|
|
4751
5057
|
SELECT 1 FROM slotlock.reservations taken
|
|
4752
|
-
WHERE taken.resource_id =
|
|
4753
|
-
AND tstzrange(
|
|
4754
|
-
|
|
5058
|
+
WHERE taken.resource_id = resources.id
|
|
5059
|
+
AND tstzrange(COALESCE(taken.occupied_starts_at, taken.starts_at),
|
|
5060
|
+
COALESCE(taken.occupied_ends_at, taken.ends_at), '[)')
|
|
5061
|
+
&& tstzrange(${entry.starts_at}::timestamptz - make_interval(mins => buffers.before_minutes),
|
|
5062
|
+
${entry.ends_at}::timestamptz + make_interval(mins => buffers.after_minutes), '[)')
|
|
4755
5063
|
AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
|
|
4756
|
-
ORDER BY
|
|
5064
|
+
ORDER BY resources.id
|
|
4757
5065
|
LIMIT ${OFFER_CANDIDATES}`;
|
|
4758
5066
|
for (const { id: resourceId } of candidates) {
|
|
4759
5067
|
const hold = await inner.acquireHold({
|
|
@@ -4818,23 +5126,26 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4818
5126
|
});
|
|
4819
5127
|
}
|
|
4820
5128
|
const owner = await sql `
|
|
4821
|
-
SELECT id, COALESCE((
|
|
4822
|
-
|
|
5129
|
+
SELECT id, COALESCE((effective.effective_booking_rules->>'requireCertain')::boolean, false)
|
|
5130
|
+
AS require_certain
|
|
5131
|
+
FROM slotlock.resources,
|
|
5132
|
+
LATERAL (SELECT ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}) effective
|
|
4823
5133
|
WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
|
|
4824
5134
|
if (!owner[0]) {
|
|
4825
5135
|
throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
|
|
4826
5136
|
code: 'resource_tenant_mismatch',
|
|
4827
5137
|
});
|
|
4828
5138
|
}
|
|
5139
|
+
// Each row's occupied time: its buffers are as busy as the booking.
|
|
4829
5140
|
const busyRows = await sql `
|
|
4830
|
-
SELECT reservation.starts_at,
|
|
5141
|
+
SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
|
|
4831
5142
|
COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
|
|
4832
5143
|
FROM slotlock.reservations reservation
|
|
4833
5144
|
WHERE reservation.resource_id = ${params.resourceId}
|
|
4834
|
-
AND
|
|
5145
|
+
AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
|
|
4835
5146
|
AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
|
|
4836
5147
|
AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
|
|
4837
|
-
ORDER BY
|
|
5148
|
+
ORDER BY 1
|
|
4838
5149
|
LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
|
|
4839
5150
|
if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
|
|
4840
5151
|
throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
|
|
@@ -4953,7 +5264,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4953
5264
|
throw notFound();
|
|
4954
5265
|
const [row] = await sql `
|
|
4955
5266
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
|
|
4956
|
-
archived_at, statement_timestamp() AS now
|
|
5267
|
+
archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
4957
5268
|
FROM slotlock.resources
|
|
4958
5269
|
WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
|
|
4959
5270
|
if (!row)
|
|
@@ -4962,9 +5273,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4962
5273
|
return null;
|
|
4963
5274
|
const resource = toResource(row);
|
|
4964
5275
|
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
4965
|
-
// Only what a booking made now may take: the resource's booking rules
|
|
5276
|
+
// Only what a booking made now may take: the resource's booking rules (as they apply) bound
|
|
5277
|
+
// the search.
|
|
4966
5278
|
const search = bookingSearch({
|
|
4967
|
-
rules: resource.
|
|
5279
|
+
rules: resource.effectiveBookingRules,
|
|
4968
5280
|
timezone: resource.timezone,
|
|
4969
5281
|
window: asked,
|
|
4970
5282
|
now: row.now,
|
|
@@ -4972,9 +5284,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4972
5284
|
});
|
|
4973
5285
|
if (!search)
|
|
4974
5286
|
return null;
|
|
4975
|
-
const { window
|
|
4976
|
-
// A slot's
|
|
4977
|
-
const reach =
|
|
5287
|
+
const { window } = search;
|
|
5288
|
+
// A slot's buffers may run past the window: the time they can reach must be proven free too.
|
|
5289
|
+
const reach = bookingSearchReach(search);
|
|
4978
5290
|
let busy;
|
|
4979
5291
|
if (tenantRef === null) {
|
|
4980
5292
|
// No event, coverage or series can belong to a resource with no tenant: only its rows.
|
|
@@ -4982,7 +5294,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4982
5294
|
}
|
|
4983
5295
|
else {
|
|
4984
5296
|
busy = [];
|
|
4985
|
-
const parts =
|
|
5297
|
+
const parts = [
|
|
5298
|
+
...(reach.start < window.start ? [{ start: reach.start, end: window.start }] : []),
|
|
5299
|
+
window,
|
|
5300
|
+
...(reach.end > window.end ? [{ start: window.end, end: reach.end }] : []),
|
|
5301
|
+
];
|
|
4986
5302
|
for (const part of parts) {
|
|
4987
5303
|
const freeBusy = await this.getFreeBusy({
|
|
4988
5304
|
tenantRef,
|
|
@@ -5001,12 +5317,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5001
5317
|
busy.push(...freeBusy.busy);
|
|
5002
5318
|
}
|
|
5003
5319
|
}
|
|
5004
|
-
// A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
|
|
5005
5320
|
return findNextAvailable({
|
|
5006
|
-
busy: busy
|
|
5007
|
-
start: new Date(interval.start.getTime() - bufferAfterMs),
|
|
5008
|
-
end: interval.end,
|
|
5009
|
-
})),
|
|
5321
|
+
busy: bookingSearchBlocked(busy, search),
|
|
5010
5322
|
windows: expandRules(rules, window, resource.timezone),
|
|
5011
5323
|
durationMs: params.durationMs,
|
|
5012
5324
|
...(search.align ? { align: search.align } : {}),
|