slotlock 0.5.1 → 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 +122 -0
- package/README.md +152 -19
- package/SPEC.md +10 -7
- package/agent-server.d.ts +52 -4
- package/agent-server.js +479 -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.d.ts +263 -0
- package/assistant.js +772 -0
- 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/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +64 -1
- package/dashboard.js +687 -26
- package/ddl.d.ts +5 -5
- package/ddl.js +594 -6
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +11 -8
- package/index.js +7 -5
- package/maintenance.d.ts +2 -1
- package/maintenance.js +33 -9
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +42 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +132 -3
- package/store.js +482 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +33 -2
- package/webhooks.js +102 -20
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',
|
|
@@ -1842,6 +1917,11 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
1842
1917
|
'checkInReservation',
|
|
1843
1918
|
'joinWaitlist',
|
|
1844
1919
|
'leaveWaitlist',
|
|
1920
|
+
'putAssistantSettings',
|
|
1921
|
+
'deleteAssistantSettings',
|
|
1922
|
+
'putAssistantConversation',
|
|
1923
|
+
'settleAssistantAction',
|
|
1924
|
+
'deleteAssistantConversation',
|
|
1845
1925
|
]);
|
|
1846
1926
|
function tenantSuspendedError() {
|
|
1847
1927
|
return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
|
|
@@ -2146,6 +2226,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2146
2226
|
code: 'invalid_identity',
|
|
2147
2227
|
});
|
|
2148
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
|
+
}
|
|
2149
2239
|
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
2150
2240
|
// A lock wait that timed out leaves the transaction unusable, so the reset below fails with
|
|
2151
2241
|
// another error in its place: noted here as it happens.
|
|
@@ -2162,6 +2252,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2162
2252
|
const previous = await tx `
|
|
2163
2253
|
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
2164
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,
|
|
2165
2257
|
current_setting('transaction_isolation') AS isolation,
|
|
2166
2258
|
pg_advisory_xact_lock_shared(
|
|
2167
2259
|
hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
|
|
@@ -2169,6 +2261,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2169
2261
|
const [context] = await tx `
|
|
2170
2262
|
SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
|
|
2171
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,
|
|
2172
2267
|
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
2173
2268
|
throw suspensionCheckRefusal(error);
|
|
2174
2269
|
});
|
|
@@ -2190,7 +2285,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2190
2285
|
}
|
|
2191
2286
|
finally {
|
|
2192
2287
|
await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
|
|
2193
|
-
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)`;
|
|
2194
2291
|
}
|
|
2195
2292
|
}, 'read committed')
|
|
2196
2293
|
.catch((error) => {
|
|
@@ -2251,7 +2348,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2251
2348
|
});
|
|
2252
2349
|
}
|
|
2253
2350
|
const rows = await sql `
|
|
2254
|
-
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)}
|
|
2255
2352
|
FROM slotlock.resources
|
|
2256
2353
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2257
2354
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
|
|
@@ -2269,7 +2366,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2269
2366
|
UPDATE slotlock.resources
|
|
2270
2367
|
SET availability_rules = ${rules}::jsonb
|
|
2271
2368
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2272
|
-
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)}`;
|
|
2273
2370
|
const row = rows[0];
|
|
2274
2371
|
return row ? toResource(row) : null;
|
|
2275
2372
|
},
|
|
@@ -2301,10 +2398,74 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2301
2398
|
END
|
|
2302
2399
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2303
2400
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2304
|
-
visibility, archived_at`;
|
|
2401
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2305
2402
|
const row = rows[0];
|
|
2306
2403
|
return row ? toResource(row) : null;
|
|
2307
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
|
+
},
|
|
2308
2469
|
async setResourcePool(params) {
|
|
2309
2470
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2310
2471
|
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
@@ -2317,7 +2478,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2317
2478
|
UPDATE slotlock.resources SET pool_ref = ${params.pool}
|
|
2318
2479
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2319
2480
|
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2320
|
-
visibility, archived_at`;
|
|
2481
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2321
2482
|
const row = rows[0];
|
|
2322
2483
|
return row ? toResource(row) : null;
|
|
2323
2484
|
},
|
|
@@ -2328,7 +2489,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2328
2489
|
throw invalidPoolError();
|
|
2329
2490
|
const rows = await sql `
|
|
2330
2491
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
2331
|
-
visibility, archived_at
|
|
2492
|
+
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2332
2493
|
FROM slotlock.resources
|
|
2333
2494
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
2334
2495
|
AND archived_at IS NULL
|
|
@@ -2381,7 +2542,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2381
2542
|
});
|
|
2382
2543
|
}
|
|
2383
2544
|
const rows = await sql `
|
|
2384
|
-
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)}
|
|
2385
2546
|
FROM slotlock.resources
|
|
2386
2547
|
WHERE tenant_ref = ${params.tenantRef}
|
|
2387
2548
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
@@ -2401,7 +2562,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2401
2562
|
UPDATE slotlock.resources
|
|
2402
2563
|
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
2403
2564
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2404
|
-
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)}`;
|
|
2405
2566
|
return row ? toResource(row) : null;
|
|
2406
2567
|
},
|
|
2407
2568
|
async restoreResource(params) {
|
|
@@ -2412,7 +2573,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2412
2573
|
UPDATE slotlock.resources
|
|
2413
2574
|
SET archived_at = NULL
|
|
2414
2575
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2415
|
-
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)}`;
|
|
2416
2577
|
return row ? toResource(row) : null;
|
|
2417
2578
|
},
|
|
2418
2579
|
async deleteResource(params) {
|
|
@@ -2460,6 +2621,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2460
2621
|
// A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
|
|
2461
2622
|
// cancelled and moved like any other; one on a resource with no tenant stays tenantless.
|
|
2462
2623
|
const id = randomUUID();
|
|
2624
|
+
// The buffers written, for the overlap answer if the write loses.
|
|
2625
|
+
let bufferBeforeMs = 0;
|
|
2626
|
+
let bufferAfterMs = 0;
|
|
2463
2627
|
for (let attempt = 1;; attempt++) {
|
|
2464
2628
|
try {
|
|
2465
2629
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2480,24 +2644,27 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2480
2644
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
2481
2645
|
if (ruling.rule)
|
|
2482
2646
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2483
|
-
|
|
2647
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2648
|
+
bufferAfterMs = ruling.turnaroundMs;
|
|
2649
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2484
2650
|
if (resource.tenant_ref !== null &&
|
|
2485
2651
|
!(await recurrenceCoversWrite(tx, {
|
|
2486
2652
|
tenantRef: resource.tenant_ref,
|
|
2487
2653
|
resourceId: params.resourceId,
|
|
2488
|
-
|
|
2489
|
-
end: occupied,
|
|
2654
|
+
...occupied,
|
|
2490
2655
|
}))) {
|
|
2491
2656
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2492
2657
|
}
|
|
2493
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2658
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2494
2659
|
const [row] = await tx `
|
|
2495
2660
|
INSERT INTO slotlock.reservations (
|
|
2496
|
-
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
|
|
2497
2663
|
) VALUES (
|
|
2498
2664
|
${id}, ${params.resourceId}, ${resource.tenant_ref},
|
|
2499
2665
|
${resource.tenant_ref === null ? null : id},
|
|
2500
|
-
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${
|
|
2666
|
+
${params.start}, ${params.end}, ${ruling.turnaroundMs}, ${ruling.bufferBeforeMs},
|
|
2667
|
+
${params.source ?? null}
|
|
2501
2668
|
)
|
|
2502
2669
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2503
2670
|
buffer_after_ms, revision, source`;
|
|
@@ -2518,8 +2685,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2518
2685
|
}
|
|
2519
2686
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2520
2687
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2521
|
-
|
|
2522
|
-
|
|
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);
|
|
2523
2691
|
}
|
|
2524
2692
|
}
|
|
2525
2693
|
},
|
|
@@ -2530,6 +2698,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2530
2698
|
if (params.revision !== 1)
|
|
2531
2699
|
return { ok: false, code: 'invalid_revision' };
|
|
2532
2700
|
let written = params.bufferAfterMs;
|
|
2701
|
+
let writtenBefore = 0;
|
|
2533
2702
|
for (let attempt = 1;; attempt++) {
|
|
2534
2703
|
try {
|
|
2535
2704
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2574,23 +2743,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2574
2743
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2575
2744
|
const bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2576
2745
|
written = bufferAfterMs;
|
|
2577
|
-
|
|
2746
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
2747
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs });
|
|
2578
2748
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2579
2749
|
tenantRef: params.tenantRef,
|
|
2580
2750
|
resourceId: params.resourceId,
|
|
2581
|
-
|
|
2582
|
-
end: occupied,
|
|
2751
|
+
...occupied,
|
|
2583
2752
|
}))) {
|
|
2584
2753
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2585
2754
|
}
|
|
2586
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2755
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2587
2756
|
const rows = await tx `
|
|
2588
2757
|
INSERT INTO slotlock.reservations (
|
|
2589
2758
|
resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2590
|
-
revision, source
|
|
2759
|
+
buffer_before_ms, revision, source
|
|
2591
2760
|
) VALUES (
|
|
2592
2761
|
${params.resourceId}, ${params.tenantRef}, ${params.externalRef}, ${params.start},
|
|
2593
|
-
${params.end}, ${bufferAfterMs}, ${
|
|
2762
|
+
${params.end}, ${bufferAfterMs}, ${writtenBefore}, ${params.revision},
|
|
2763
|
+
${params.source ?? null}
|
|
2594
2764
|
)
|
|
2595
2765
|
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2596
2766
|
buffer_after_ms, revision, source`;
|
|
@@ -2610,8 +2780,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2610
2780
|
code !== SERIALIZATION_FAILURE) {
|
|
2611
2781
|
throw err;
|
|
2612
2782
|
}
|
|
2613
|
-
|
|
2614
|
-
|
|
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);
|
|
2615
2786
|
}
|
|
2616
2787
|
}
|
|
2617
2788
|
},
|
|
@@ -2621,6 +2792,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2621
2792
|
return invalid;
|
|
2622
2793
|
let currentReservationId;
|
|
2623
2794
|
let bufferAfterMs = params.bufferAfterMs;
|
|
2795
|
+
let bufferBeforeMs = 0;
|
|
2624
2796
|
for (let attempt = 1;; attempt++) {
|
|
2625
2797
|
try {
|
|
2626
2798
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2663,21 +2835,23 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2663
2835
|
if (ruling.rule)
|
|
2664
2836
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2665
2837
|
bufferAfterMs = Math.max(params.bufferAfterMs, ruling.turnaroundMs);
|
|
2838
|
+
bufferBeforeMs = ruling.bufferBeforeMs;
|
|
2839
|
+
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2666
2840
|
if (!(await recurrenceCoversWrite(tx, {
|
|
2667
2841
|
tenantRef: params.tenantRef,
|
|
2668
2842
|
resourceId: params.resourceId,
|
|
2669
|
-
|
|
2670
|
-
end: occupiedEnd({ end: params.end, bufferAfterMs }),
|
|
2843
|
+
...occupied,
|
|
2671
2844
|
}))) {
|
|
2672
2845
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2673
2846
|
}
|
|
2674
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
2847
|
+
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2675
2848
|
const rows = await tx `
|
|
2676
2849
|
UPDATE slotlock.reservations
|
|
2677
2850
|
SET resource_id = ${params.resourceId},
|
|
2678
2851
|
starts_at = ${params.start},
|
|
2679
2852
|
ends_at = ${params.end},
|
|
2680
2853
|
buffer_after_ms = ${bufferAfterMs},
|
|
2854
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
2681
2855
|
revision = ${params.revision},
|
|
2682
2856
|
source = ${params.source ?? null},
|
|
2683
2857
|
-- A check-in is for the time it was made for: a move to another start clears it.
|
|
@@ -2704,9 +2878,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2704
2878
|
code !== SERIALIZATION_FAILURE) {
|
|
2705
2879
|
throw err;
|
|
2706
2880
|
}
|
|
2707
|
-
const occupied =
|
|
2708
|
-
await rollSeriesBehind(sql, params.resourceId,
|
|
2709
|
-
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);
|
|
2710
2884
|
}
|
|
2711
2885
|
}
|
|
2712
2886
|
},
|
|
@@ -2820,8 +2994,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2820
2994
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
2821
2995
|
});
|
|
2822
2996
|
const ttlSeconds = params.ttlMs / 1000;
|
|
2823
|
-
// The
|
|
2997
|
+
// The buffers written: the one asked for, raised to the resource's turnaround, and its rule's
|
|
2998
|
+
// leading buffer.
|
|
2824
2999
|
let written = bufferAfterMs;
|
|
3000
|
+
let writtenBefore = 0;
|
|
2825
3001
|
for (let attempt = 1;; attempt++) {
|
|
2826
3002
|
try {
|
|
2827
3003
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -2870,7 +3046,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2870
3046
|
const [held] = await tx `
|
|
2871
3047
|
SELECT count(*)::int AS count,
|
|
2872
3048
|
COALESCE(sum(extract(epoch FROM
|
|
2873
|
-
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
|
|
2874
3051
|
AS seconds
|
|
2875
3052
|
FROM slotlock.reservations
|
|
2876
3053
|
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
@@ -2886,10 +3063,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2886
3063
|
if (ruling.rule)
|
|
2887
3064
|
return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
|
|
2888
3065
|
written = Math.max(bufferAfterMs, ruling.turnaroundMs);
|
|
2889
|
-
|
|
3066
|
+
writtenBefore = ruling.bufferBeforeMs;
|
|
3067
|
+
const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
2890
3068
|
if (heldSeconds !== null &&
|
|
2891
3069
|
maxHeldHoursPerHolder !== undefined &&
|
|
2892
|
-
heldSeconds + (reach.getTime() -
|
|
3070
|
+
heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
|
|
2893
3071
|
maxHeldHoursPerHolder * 3_600) {
|
|
2894
3072
|
return { ok: false, code: 'held_time_limit_reached', limit: maxHeldHoursPerHolder };
|
|
2895
3073
|
}
|
|
@@ -2897,18 +3075,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2897
3075
|
!(await recurrenceCoversWrite(tx, {
|
|
2898
3076
|
tenantRef: resource.tenant_ref,
|
|
2899
3077
|
resourceId: params.resourceId,
|
|
2900
|
-
|
|
2901
|
-
end: reach,
|
|
3078
|
+
...reach,
|
|
2902
3079
|
}))) {
|
|
2903
3080
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2904
3081
|
}
|
|
2905
|
-
await reapExpiredHolds(tx, params.resourceId,
|
|
3082
|
+
await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
|
|
2906
3083
|
const [row] = await tx `
|
|
2907
3084
|
INSERT INTO slotlock.reservations (
|
|
2908
|
-
resource_id, starts_at, ends_at, buffer_after_ms,
|
|
2909
|
-
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
|
|
2910
3087
|
) VALUES (
|
|
2911
|
-
${params.resourceId}, ${params.start}, ${params.end}, ${written}, 'held',
|
|
3088
|
+
${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
|
|
2912
3089
|
statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
|
|
2913
3090
|
${holderRef ?? null}, ${params.groupId ?? null}
|
|
2914
3091
|
)
|
|
@@ -2942,8 +3119,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2942
3119
|
}
|
|
2943
3120
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
2944
3121
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2945
|
-
|
|
2946
|
-
|
|
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);
|
|
2947
3125
|
}
|
|
2948
3126
|
}
|
|
2949
3127
|
},
|
|
@@ -3195,15 +3373,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3195
3373
|
// Expired holds are dead occupation: excluded here so findNextAvailable can offer their
|
|
3196
3374
|
// space (the acquiring transaction reaps the row itself when it takes the window).
|
|
3197
3375
|
const rows = await sql `
|
|
3198
|
-
SELECT
|
|
3376
|
+
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
3377
|
+
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
3199
3378
|
FROM slotlock.reservations
|
|
3200
3379
|
WHERE resource_id = ${resourceId}
|
|
3201
|
-
AND
|
|
3380
|
+
AND ${occupiedStartsBefore(sql, window.end)}
|
|
3202
3381
|
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
3203
3382
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
3204
|
-
ORDER BY starts_at
|
|
3383
|
+
ORDER BY COALESCE(occupied_starts_at, starts_at)
|
|
3205
3384
|
LIMIT ${options.limit ?? null}`;
|
|
3206
|
-
return rows.map((r) => ({ start: r.
|
|
3385
|
+
return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
|
|
3207
3386
|
},
|
|
3208
3387
|
async putCalendarEvent(params) {
|
|
3209
3388
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
@@ -3261,6 +3440,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3261
3440
|
});
|
|
3262
3441
|
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
3263
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 };
|
|
3264
3445
|
let storedEventId;
|
|
3265
3446
|
for (let attempt = 1;; attempt++) {
|
|
3266
3447
|
try {
|
|
@@ -3398,6 +3579,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3398
3579
|
AND hold_group_id = ${hold.hold_group_id}::uuid))`;
|
|
3399
3580
|
}
|
|
3400
3581
|
let turnaroundMs = 0;
|
|
3582
|
+
let bufferBeforeMs = 0;
|
|
3401
3583
|
if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
|
|
3402
3584
|
const judge = await bookingJudge(tx, params.resourceId, defaultHours);
|
|
3403
3585
|
for (const [index, occurrence] of occurrences.entries()) {
|
|
@@ -3407,9 +3589,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3407
3589
|
return { ok: false, code: 'booking_rule_violated', rule };
|
|
3408
3590
|
}
|
|
3409
3591
|
turnaroundMs = judge.turnaroundMs;
|
|
3592
|
+
bufferBeforeMs = judge.bufferBeforeMs;
|
|
3410
3593
|
}
|
|
3594
|
+
attemptedBuffers = { bufferBeforeMs, bufferAfterMs: turnaroundMs };
|
|
3411
3595
|
const span = occurrenceSpan(occurrences);
|
|
3412
|
-
const reach = span && {
|
|
3596
|
+
const reach = span && occupiedTime({ ...span, bufferBeforeMs, bufferAfterMs: turnaroundMs });
|
|
3413
3597
|
if ((event.transparency ?? 'opaque') === 'opaque' &&
|
|
3414
3598
|
reach &&
|
|
3415
3599
|
!(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
|
|
@@ -3447,6 +3631,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3447
3631
|
revision = ${revision},
|
|
3448
3632
|
source = ${params.source ?? null},
|
|
3449
3633
|
buffer_after_ms = ${turnaroundMs},
|
|
3634
|
+
buffer_before_ms = ${bufferBeforeMs},
|
|
3450
3635
|
updated_at = now()
|
|
3451
3636
|
WHERE id = ${eventId}
|
|
3452
3637
|
AND tenant_ref = ${params.tenantRef}
|
|
@@ -3459,7 +3644,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3459
3644
|
summary, description, location, status, transparency, organizer, attendees,
|
|
3460
3645
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
3461
3646
|
materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source,
|
|
3462
|
-
buffer_after_ms
|
|
3647
|
+
buffer_after_ms, buffer_before_ms
|
|
3463
3648
|
) VALUES (
|
|
3464
3649
|
${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
|
|
3465
3650
|
${params.resourceId},
|
|
@@ -3473,7 +3658,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3473
3658
|
${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
|
|
3474
3659
|
${materializationWindow.start}, ${materializationWindow.end},
|
|
3475
3660
|
${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
|
|
3476
|
-
${revision}, ${params.source ?? null}, ${turnaroundMs}
|
|
3661
|
+
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
3477
3662
|
)`;
|
|
3478
3663
|
}
|
|
3479
3664
|
// The normalized children are the queryable/canonical representation. The JSON
|
|
@@ -3487,6 +3672,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3487
3672
|
occurrences,
|
|
3488
3673
|
revision,
|
|
3489
3674
|
bufferAfterMs: turnaroundMs,
|
|
3675
|
+
bufferBeforeMs,
|
|
3490
3676
|
});
|
|
3491
3677
|
await tx `
|
|
3492
3678
|
INSERT INTO slotlock.calendar_event_commands (
|
|
@@ -3517,11 +3703,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3517
3703
|
code !== SERIALIZATION_FAILURE) {
|
|
3518
3704
|
throw error;
|
|
3519
3705
|
}
|
|
3520
|
-
const
|
|
3521
|
-
if (
|
|
3706
|
+
const span = occurrenceSpan(attempted);
|
|
3707
|
+
if (span) {
|
|
3708
|
+
const reach = occupiedTime({ ...span, ...attemptedBuffers });
|
|
3522
3709
|
await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
|
|
3523
3710
|
}
|
|
3524
|
-
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
|
|
3711
|
+
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId, attemptedBuffers);
|
|
3525
3712
|
}
|
|
3526
3713
|
}
|
|
3527
3714
|
},
|
|
@@ -3927,7 +4114,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3927
4114
|
slotlock.zoned_instant(event.wall_starts_at, event.timezone) AS moved_start,
|
|
3928
4115
|
slotlock.zoned_instant(event.wall_ends_at, event.timezone) AS moved_end,
|
|
3929
4116
|
COALESCE((SELECT max(booking.buffer_after_ms) FROM slotlock.reservations booking
|
|
3930
|
-
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
|
|
3931
4120
|
FROM slotlock.calendar_events event
|
|
3932
4121
|
WHERE event.tenant_ref = ${params.tenantRef}
|
|
3933
4122
|
AND event.recurrence_rule IS NULL
|
|
@@ -3981,6 +4170,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3981
4170
|
],
|
|
3982
4171
|
revision,
|
|
3983
4172
|
bufferAfterMs: Number(event.buffer),
|
|
4173
|
+
bufferBeforeMs: Number(event.buffer_before),
|
|
3984
4174
|
});
|
|
3985
4175
|
});
|
|
3986
4176
|
moved++;
|
|
@@ -4293,6 +4483,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4293
4483
|
ORDER BY source`;
|
|
4294
4484
|
return rows.map((row) => ({
|
|
4295
4485
|
source: row.source,
|
|
4486
|
+
sourceId: slotlockCoverageSourceId(params.tenantRef, row.source),
|
|
4296
4487
|
start: row.starts_at,
|
|
4297
4488
|
end: row.ends_at,
|
|
4298
4489
|
revision: Number(row.revision),
|
|
@@ -4316,15 +4507,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4316
4507
|
AND observed_at < now() - make_interval(secs => ${maxAgeMs / 1_000})
|
|
4317
4508
|
ORDER BY observed_at LIMIT ${params.limit ?? 100}
|
|
4318
4509
|
FOR UPDATE SKIP LOCKED)
|
|
4319
|
-
RETURNING coverage.
|
|
4510
|
+
RETURNING coverage.tenant_ref, coverage.resource_id, coverage.source, coverage.observed_at
|
|
4320
4511
|
), reported AS (
|
|
4321
4512
|
INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
|
|
4322
|
-
SELECT ${params.tenantRef},
|
|
4323
|
-
|
|
4324
|
-
'resource_id',
|
|
4325
|
-
'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),
|
|
4326
4517
|
'slotlock:maintenance'
|
|
4327
|
-
FROM stale
|
|
4518
|
+
FROM (SELECT resource_id, observed_at, ${sql.unsafe(COVERAGE_SOURCE_ID)} AS source_id FROM stale) named
|
|
4328
4519
|
RETURNING 1
|
|
4329
4520
|
)
|
|
4330
4521
|
SELECT count(*)::int AS count FROM reported`;
|
|
@@ -4525,6 +4716,159 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4525
4716
|
return true;
|
|
4526
4717
|
});
|
|
4527
4718
|
},
|
|
4719
|
+
async getAssistantSettings(params) {
|
|
4720
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4721
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4722
|
+
const [row] = await sql `
|
|
4723
|
+
SELECT provider, base_url, model, sealed_key, key_hint, updated_by, updated_at
|
|
4724
|
+
FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef}`;
|
|
4725
|
+
if (!row)
|
|
4726
|
+
return null;
|
|
4727
|
+
return {
|
|
4728
|
+
provider: row.provider,
|
|
4729
|
+
baseUrl: row.base_url,
|
|
4730
|
+
model: row.model,
|
|
4731
|
+
sealedKey: row.sealed_key,
|
|
4732
|
+
keyHint: row.key_hint,
|
|
4733
|
+
updatedBy: row.updated_by,
|
|
4734
|
+
updatedAt: row.updated_at,
|
|
4735
|
+
};
|
|
4736
|
+
},
|
|
4737
|
+
async putAssistantSettings(params) {
|
|
4738
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4739
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4740
|
+
const keep = params.key === 'keep';
|
|
4741
|
+
const key = params.key === 'keep' ? null : params.key;
|
|
4742
|
+
const saved = await sql `
|
|
4743
|
+
INSERT INTO slotlock.assistant_settings AS stored
|
|
4744
|
+
(tenant_ref, provider, base_url, model, sealed_key, key_hint, updated_by, updated_at)
|
|
4745
|
+
VALUES (${params.tenantRef}, ${params.provider}, ${params.baseUrl}, ${params.model},
|
|
4746
|
+
${key?.sealed ?? null}, ${key?.hint ?? null}, ${params.updatedBy ?? null},
|
|
4747
|
+
statement_timestamp())
|
|
4748
|
+
ON CONFLICT (tenant_ref) DO UPDATE SET
|
|
4749
|
+
provider = EXCLUDED.provider,
|
|
4750
|
+
base_url = EXCLUDED.base_url,
|
|
4751
|
+
model = EXCLUDED.model,
|
|
4752
|
+
sealed_key = CASE WHEN ${keep}::boolean THEN stored.sealed_key ELSE EXCLUDED.sealed_key END,
|
|
4753
|
+
key_hint = CASE WHEN ${keep}::boolean THEN stored.key_hint ELSE EXCLUDED.key_hint END,
|
|
4754
|
+
updated_by = EXCLUDED.updated_by,
|
|
4755
|
+
updated_at = EXCLUDED.updated_at
|
|
4756
|
+
WHERE NOT ${keep}::boolean
|
|
4757
|
+
OR stored.sealed_key IS NULL
|
|
4758
|
+
OR (stored.provider = EXCLUDED.provider AND stored.base_url = EXCLUDED.base_url)
|
|
4759
|
+
RETURNING 1`;
|
|
4760
|
+
return { saved: saved.length > 0 };
|
|
4761
|
+
},
|
|
4762
|
+
async deleteAssistantSettings(params) {
|
|
4763
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4764
|
+
return false;
|
|
4765
|
+
const rows = await sql `
|
|
4766
|
+
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
|
|
4767
|
+
return rows.length > 0;
|
|
4768
|
+
},
|
|
4769
|
+
async getAssistantConversation(params) {
|
|
4770
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4771
|
+
return null;
|
|
4772
|
+
const [row] = await sql `
|
|
4773
|
+
SELECT messages, actions, updated_at, expires_at FROM slotlock.assistant_conversations
|
|
4774
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
4775
|
+
AND expires_at > statement_timestamp()`;
|
|
4776
|
+
if (!row)
|
|
4777
|
+
return null;
|
|
4778
|
+
return {
|
|
4779
|
+
messages: row.messages,
|
|
4780
|
+
actions: row.actions,
|
|
4781
|
+
updatedAt: row.updated_at,
|
|
4782
|
+
expiresAt: row.expires_at,
|
|
4783
|
+
};
|
|
4784
|
+
},
|
|
4785
|
+
async putAssistantConversation(params) {
|
|
4786
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef)) {
|
|
4787
|
+
throw invalidIdentityError('Slotlock tenant and member references must be 1-500 UTF-8 bytes');
|
|
4788
|
+
}
|
|
4789
|
+
if (!Number.isSafeInteger(params.ttlMs) || params.ttlMs < 1_000 || params.ttlMs > 86_400_000) {
|
|
4790
|
+
throw invalidIdentityError('Slotlock assistant conversation ttlMs must be 1 second to a day');
|
|
4791
|
+
}
|
|
4792
|
+
const messages = sql.json(params.messages);
|
|
4793
|
+
const added = sql.json(params.added);
|
|
4794
|
+
const keep = [...params.keep];
|
|
4795
|
+
await sql `
|
|
4796
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4797
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()`;
|
|
4798
|
+
await sql `
|
|
4799
|
+
INSERT INTO slotlock.assistant_conversations AS stored
|
|
4800
|
+
(tenant_ref, member_ref, messages, actions, updated_at, expires_at)
|
|
4801
|
+
VALUES (${params.tenantRef}, ${params.memberRef}, ${messages}, ${added},
|
|
4802
|
+
statement_timestamp(),
|
|
4803
|
+
statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))
|
|
4804
|
+
ON CONFLICT (tenant_ref, member_ref) DO UPDATE SET
|
|
4805
|
+
messages = EXCLUDED.messages,
|
|
4806
|
+
-- The stored ones kept, as they stand, then the new ones: never a settled one again.
|
|
4807
|
+
actions = (
|
|
4808
|
+
SELECT coalesce(jsonb_object_agg(existing.key, existing.value), '{}'::jsonb)
|
|
4809
|
+
FROM jsonb_each(stored.actions) AS existing
|
|
4810
|
+
WHERE existing.key = ANY(${keep}::text[])
|
|
4811
|
+
) || (
|
|
4812
|
+
SELECT coalesce(jsonb_object_agg(fresh.key, fresh.value), '{}'::jsonb)
|
|
4813
|
+
FROM jsonb_each(EXCLUDED.actions) AS fresh
|
|
4814
|
+
WHERE NOT stored.actions ? fresh.key
|
|
4815
|
+
),
|
|
4816
|
+
updated_at = EXCLUDED.updated_at,
|
|
4817
|
+
expires_at = EXCLUDED.expires_at`;
|
|
4818
|
+
await sql `
|
|
4819
|
+
SELECT slotlock.register_assistant_tenant(
|
|
4820
|
+
${params.tenantRef}::text,
|
|
4821
|
+
statement_timestamp() + make_interval(secs => ${params.ttlMs / 1_000}))`;
|
|
4822
|
+
},
|
|
4823
|
+
async settleAssistantAction(params) {
|
|
4824
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4825
|
+
return null;
|
|
4826
|
+
const patch = sql.json(params.patch);
|
|
4827
|
+
const [row] = await sql `
|
|
4828
|
+
UPDATE slotlock.assistant_conversations
|
|
4829
|
+
SET actions = jsonb_set(actions, ARRAY[${params.actionId}::text],
|
|
4830
|
+
(actions -> ${params.actionId}::text) || ${patch}::jsonb)
|
|
4831
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef}
|
|
4832
|
+
AND expires_at > statement_timestamp()
|
|
4833
|
+
AND actions -> ${params.actionId}::text ->> 'status' = ${params.from}
|
|
4834
|
+
RETURNING actions -> ${params.actionId}::text AS action`;
|
|
4835
|
+
return row?.action ?? null;
|
|
4836
|
+
},
|
|
4837
|
+
async pruneAssistantConversations(params) {
|
|
4838
|
+
if (!isValidIdentity(params.tenantRef))
|
|
4839
|
+
return 0;
|
|
4840
|
+
const limit = params.limit ?? 1_000;
|
|
4841
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
|
|
4842
|
+
throw invalidIdentityError('Slotlock assistant prune limit must be 1-10,000');
|
|
4843
|
+
}
|
|
4844
|
+
const rows = await sql `
|
|
4845
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4846
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
4847
|
+
AND member_ref IN (
|
|
4848
|
+
SELECT member_ref FROM slotlock.assistant_conversations
|
|
4849
|
+
WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
|
|
4850
|
+
LIMIT ${limit})
|
|
4851
|
+
AND expires_at <= statement_timestamp()
|
|
4852
|
+
RETURNING 1`;
|
|
4853
|
+
return rows.length;
|
|
4854
|
+
},
|
|
4855
|
+
async sweepAssistantConversations(params) {
|
|
4856
|
+
const limit = params?.limit ?? 10_000;
|
|
4857
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
|
|
4858
|
+
throw invalidIdentityError('Slotlock assistant sweep limit must be 1-100,000');
|
|
4859
|
+
}
|
|
4860
|
+
const [row] = await sql `
|
|
4861
|
+
SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
|
|
4862
|
+
return Number(row?.deleted ?? 0);
|
|
4863
|
+
},
|
|
4864
|
+
async deleteAssistantConversation(params) {
|
|
4865
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4866
|
+
return false;
|
|
4867
|
+
const rows = await sql `
|
|
4868
|
+
DELETE FROM slotlock.assistant_conversations
|
|
4869
|
+
WHERE tenant_ref = ${params.tenantRef} AND member_ref = ${params.memberRef} RETURNING 1`;
|
|
4870
|
+
return rows.length > 0;
|
|
4871
|
+
},
|
|
4528
4872
|
async offerWaitlist(params) {
|
|
4529
4873
|
if (!isValidIdentity(params.tenantRef)) {
|
|
4530
4874
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -4584,18 +4928,28 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4584
4928
|
continue;
|
|
4585
4929
|
// Only resources whose window looks free are tried: a hold refused keeps its locks
|
|
4586
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.
|
|
4587
4932
|
const candidates = await tx `
|
|
4588
|
-
SELECT
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
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}))
|
|
4592
4944
|
AND NOT EXISTS (
|
|
4593
4945
|
SELECT 1 FROM slotlock.reservations taken
|
|
4594
|
-
WHERE taken.resource_id =
|
|
4595
|
-
AND tstzrange(
|
|
4596
|
-
|
|
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), '[)')
|
|
4597
4951
|
AND (taken.status <> 'held' OR taken.expires_at > statement_timestamp()))
|
|
4598
|
-
ORDER BY
|
|
4952
|
+
ORDER BY resources.id
|
|
4599
4953
|
LIMIT ${OFFER_CANDIDATES}`;
|
|
4600
4954
|
for (const { id: resourceId } of candidates) {
|
|
4601
4955
|
const hold = await inner.acquireHold({
|
|
@@ -4660,23 +5014,26 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4660
5014
|
});
|
|
4661
5015
|
}
|
|
4662
5016
|
const owner = await sql `
|
|
4663
|
-
SELECT id, COALESCE((
|
|
4664
|
-
|
|
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
|
|
4665
5021
|
WHERE id = ${params.resourceId} AND tenant_ref = ${params.tenantRef}`;
|
|
4666
5022
|
if (!owner[0]) {
|
|
4667
5023
|
throw Object.assign(new Error('Slotlock resource belongs to another tenant'), {
|
|
4668
5024
|
code: 'resource_tenant_mismatch',
|
|
4669
5025
|
});
|
|
4670
5026
|
}
|
|
5027
|
+
// Each row's occupied time: its buffers are as busy as the booking.
|
|
4671
5028
|
const busyRows = await sql `
|
|
4672
|
-
SELECT reservation.starts_at,
|
|
5029
|
+
SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
|
|
4673
5030
|
COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
|
|
4674
5031
|
FROM slotlock.reservations reservation
|
|
4675
5032
|
WHERE reservation.resource_id = ${params.resourceId}
|
|
4676
|
-
AND
|
|
5033
|
+
AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
|
|
4677
5034
|
AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
|
|
4678
5035
|
AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
|
|
4679
|
-
ORDER BY
|
|
5036
|
+
ORDER BY 1
|
|
4680
5037
|
LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
|
|
4681
5038
|
if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
|
|
4682
5039
|
throw Object.assign(new Error('Slotlock free/busy work limit exceeded'), {
|
|
@@ -4795,7 +5152,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4795
5152
|
throw notFound();
|
|
4796
5153
|
const [row] = await sql `
|
|
4797
5154
|
SELECT id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref, visibility,
|
|
4798
|
-
archived_at, statement_timestamp() AS now
|
|
5155
|
+
archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
4799
5156
|
FROM slotlock.resources
|
|
4800
5157
|
WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
|
|
4801
5158
|
if (!row)
|
|
@@ -4804,9 +5161,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4804
5161
|
return null;
|
|
4805
5162
|
const resource = toResource(row);
|
|
4806
5163
|
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
4807
|
-
// 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.
|
|
4808
5166
|
const search = bookingSearch({
|
|
4809
|
-
rules: resource.
|
|
5167
|
+
rules: resource.effectiveBookingRules,
|
|
4810
5168
|
timezone: resource.timezone,
|
|
4811
5169
|
window: asked,
|
|
4812
5170
|
now: row.now,
|
|
@@ -4814,9 +5172,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4814
5172
|
});
|
|
4815
5173
|
if (!search)
|
|
4816
5174
|
return null;
|
|
4817
|
-
const { window
|
|
4818
|
-
// A slot's
|
|
4819
|
-
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);
|
|
4820
5178
|
let busy;
|
|
4821
5179
|
if (tenantRef === null) {
|
|
4822
5180
|
// No event, coverage or series can belong to a resource with no tenant: only its rows.
|
|
@@ -4824,7 +5182,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4824
5182
|
}
|
|
4825
5183
|
else {
|
|
4826
5184
|
busy = [];
|
|
4827
|
-
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
|
+
];
|
|
4828
5190
|
for (const part of parts) {
|
|
4829
5191
|
const freeBusy = await this.getFreeBusy({
|
|
4830
5192
|
tenantRef,
|
|
@@ -4843,12 +5205,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4843
5205
|
busy.push(...freeBusy.busy);
|
|
4844
5206
|
}
|
|
4845
5207
|
}
|
|
4846
|
-
// A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
|
|
4847
5208
|
return findNextAvailable({
|
|
4848
|
-
busy: busy
|
|
4849
|
-
start: new Date(interval.start.getTime() - bufferAfterMs),
|
|
4850
|
-
end: interval.end,
|
|
4851
|
-
})),
|
|
5209
|
+
busy: bookingSearchBlocked(busy, search),
|
|
4852
5210
|
windows: expandRules(rules, window, resource.timezone),
|
|
4853
5211
|
durationMs: params.durationMs,
|
|
4854
5212
|
...(search.align ? { align: search.align } : {}),
|