slotlock 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +492 -0
- package/CONTRIBUTING.md +12 -5
- package/README.md +50 -1544
- package/SECURITY.md +5 -2
- package/SPEC.md +1 -1
- package/agent-server.d.ts +160 -8
- package/agent-server.js +560 -49
- package/agent-store-backend.d.ts +39 -7
- package/agent-store-backend.js +1439 -104
- package/api-keys.d.ts +69 -3
- package/api-keys.js +218 -29
- package/application-api.d.ts +59 -6
- package/application-api.js +842 -39
- package/approvals.d.ts +88 -0
- package/approvals.js +293 -0
- package/assistant.d.ts +58 -27
- package/assistant.js +151 -53
- package/billing.d.ts +114 -0
- package/billing.js +258 -0
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +42 -6
- package/config.js +114 -7
- package/dashboard-icon.d.ts +1 -0
- package/dashboard-icon.js +5 -0
- package/dashboard.d.ts +203 -7
- package/dashboard.js +1558 -64
- package/ddl.d.ts +67 -35
- package/ddl.js +2732 -162
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +53 -35
- package/event-feed.js +201 -64
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/index.d.ts +15 -4
- package/index.js +8 -2
- package/interop.js +1 -4
- package/maintenance.d.ts +24 -10
- package/maintenance.js +59 -28
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.d.ts +41 -0
- package/metrics.js +22 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/openapi.json +14675 -5957
- package/otel.d.ts +86 -0
- package/otel.js +273 -0
- package/overage.d.ts +33 -0
- package/overage.js +115 -0
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/partner-billing.d.ts +81 -0
- package/partner-billing.js +333 -0
- package/partner-names.d.ts +8 -0
- package/partner-names.js +10 -0
- package/partners.d.ts +147 -0
- package/partners.js +375 -0
- package/plans.d.ts +92 -0
- package/plans.js +115 -0
- package/quotas.d.ts +108 -0
- package/quotas.js +258 -0
- package/rate-limit.d.ts +27 -2
- package/rate-limit.js +106 -23
- package/resource-profile.d.ts +135 -0
- package/resource-profile.js +263 -0
- package/rest-api.d.ts +15 -14
- package/rest-api.js +79 -15
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/sandbox.d.ts +70 -0
- package/sandbox.js +295 -0
- package/self-host.d.ts +46 -4
- package/self-host.js +990 -41
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +482 -165
- package/store.js +1460 -484
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/teams.d.ts +25 -0
- package/teams.js +63 -0
- package/timezone.d.ts +25 -33
- package/timezone.js +50 -53
- package/types.d.ts +130 -57
- package/usage.d.ts +106 -0
- package/usage.js +238 -0
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- package/weekly-hours.js +1 -1
package/store.js
CHANGED
|
@@ -1,53 +1,52 @@
|
|
|
1
|
-
// DB mechanics for the Slotlock core
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
|
|
2
|
+
import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
|
|
3
|
+
import { readSlotlockTenantUsage } from './usage.js';
|
|
4
|
+
import { slotlockPlanReadRefusal } from './plans.js';
|
|
4
5
|
import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
|
|
5
6
|
import { bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
|
|
6
7
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
8
|
import ICAL from 'ical.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
|
+
import { SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
|
|
9
10
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
10
11
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
11
12
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
12
13
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
13
|
-
|
|
14
|
+
import { APPROVAL_COLUMNS, approvalByRequestKey, approvalPoliciesFor, beginApprovalGrant, declineApproval, deleteApprovalPolicy, expireApprovals, finishApprovalGrant, getApproval, listApprovalPolicies, listApprovals, putApprovalPolicy, recordApprovalChange, toApproval, } from './approvals.js';
|
|
14
15
|
const EXCLUSION_VIOLATION = '23P01';
|
|
16
|
+
/** Holds one approval may own: a bundle's resources, or a move's new time in pieces. */
|
|
17
|
+
const SLOTLOCK_MAX_APPROVAL_HOLDS = 20;
|
|
18
|
+
/** Approvals one requester, and one tenant, may have waiting at once. */
|
|
19
|
+
const SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER = 50;
|
|
20
|
+
const SLOTLOCK_MAX_PENDING_APPROVALS = 500;
|
|
15
21
|
/**
|
|
16
|
-
* Concurrent inserts
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* wins cleanly or loses with a proper 23P01 — both structured. Never surfaced raw.
|
|
22
|
+
* Concurrent inserts on the same EXCLUDE range can deadlock inside the constraint check itself
|
|
23
|
+
* (intrinsic to Postgres EXCLUDE). The victim is fully rolled back, so one retry either wins or
|
|
24
|
+
* loses with a structured 23P01.
|
|
20
25
|
*/
|
|
21
26
|
const DEADLOCK_DETECTED = '40P01';
|
|
22
27
|
const SERIALIZATION_FAILURE = '40001';
|
|
23
|
-
/** Holds are negotiation-scoped; a week is already generous.
|
|
28
|
+
/** Holds are negotiation-scoped; a week is already generous. */
|
|
24
29
|
const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
25
|
-
/**
|
|
30
|
+
/** Guards against a caller unit error occupying a resource indefinitely. */
|
|
26
31
|
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
|
-
*/
|
|
32
|
+
/** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
|
|
31
33
|
const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
|
|
32
34
|
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
33
35
|
/**
|
|
34
|
-
* Longest one-off event
|
|
35
|
-
*
|
|
36
|
-
* series is bounded by its 367-day materialization window instead.
|
|
36
|
+
* Longest one-off event (ten years and some leap days); a recurring series is bounded by its
|
|
37
|
+
* materialization window instead.
|
|
37
38
|
*/
|
|
38
39
|
export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
|
|
39
40
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
40
41
|
const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* past its end, so that a search of the full 367 days from any moment of the day is complete.
|
|
43
|
+
* The 367-day search window plus two days: the rolling horizon starts a day before the current UTC
|
|
44
|
+
* day and must reach 367 days past its end.
|
|
45
45
|
*/
|
|
46
46
|
export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
|
|
47
47
|
const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
|
|
48
48
|
const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
|
|
49
49
|
const MAX_RESOURCE_LIST_LIMIT = 1_000;
|
|
50
|
-
/** Highest per-tenant resource cap `createResource` accepts. */
|
|
51
50
|
const MAX_TENANT_RESOURCE_LIMIT = 100_000;
|
|
52
51
|
const MAX_CALENDAR_LIST_LIMIT = 1_000;
|
|
53
52
|
const MAX_FREE_BUSY_INTERVALS = 10_000;
|
|
@@ -66,7 +65,7 @@ const MAX_WAITLIST_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
|
|
|
66
65
|
const DEFAULT_OFFER_TTL_MS = 15 * 60 * 1000;
|
|
67
66
|
/** How long before its start a booking may be checked in. */
|
|
68
67
|
export const SLOTLOCK_CHECK_IN_OPENS_MINUTES = 60;
|
|
69
|
-
/** Free-looking resources one entry tries before the next
|
|
68
|
+
/** Free-looking resources one entry tries before the next: a race can take one first. */
|
|
70
69
|
const OFFER_CANDIDATES = 3;
|
|
71
70
|
/** Default hard ceiling for retained active + tombstoned identities per agent principal. */
|
|
72
71
|
export const SLOTLOCK_AGENT_OWNER_EVENT_QUOTA = 1_000;
|
|
@@ -86,8 +85,8 @@ const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
|
|
|
86
85
|
/** Store-owned coverage source for bounded, locally materialized recurring events. */
|
|
87
86
|
export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
|
|
88
87
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
88
|
+
* From a day before the current UTC day to SLOTLOCK_MATERIALIZATION_WINDOW_DAYS past its end;
|
|
89
|
+
* shared by the writer, maintenance and readiness.
|
|
91
90
|
*/
|
|
92
91
|
export function calendarEventRollingHorizon(now = new Date()) {
|
|
93
92
|
if (!Number.isFinite(now.getTime()))
|
|
@@ -97,8 +96,8 @@ export function calendarEventRollingHorizon(now = new Date()) {
|
|
|
97
96
|
return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
|
|
98
97
|
}
|
|
99
98
|
/**
|
|
100
|
-
* A stored series expanded from
|
|
101
|
-
*
|
|
99
|
+
* A stored series expanded from its last cursor, or `null` when there is no usable cursor or the
|
|
100
|
+
* expansion is refused.
|
|
102
101
|
*/
|
|
103
102
|
function resumeStoredSeries(event, window, found) {
|
|
104
103
|
if (!found?.recurrence_cursor)
|
|
@@ -116,15 +115,13 @@ function resumeStoredSeries(event, window, found) {
|
|
|
116
115
|
throw error;
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
|
-
/** A series cursor as the jsonb column takes it. */
|
|
120
118
|
function seriesCursorJson(tx, cursor) {
|
|
121
119
|
return cursor === null ? null : tx.json(cursor);
|
|
122
120
|
}
|
|
123
121
|
/**
|
|
124
|
-
*
|
|
125
|
-
* the
|
|
126
|
-
*
|
|
127
|
-
* fits the 367-day cap (it rolls once the horizon is close enough).
|
|
122
|
+
* From the horizon's start to the later of the two ends, so the end never moves back. `null` keeps
|
|
123
|
+
* the stored window: it already covers the horizon, or no window from the horizon's start fits the
|
|
124
|
+
* 367-day cap yet.
|
|
128
125
|
*/
|
|
129
126
|
function rolledWindow(stored, horizon) {
|
|
130
127
|
if (stored.start <= horizon.start && stored.end >= horizon.end)
|
|
@@ -134,21 +131,18 @@ function rolledWindow(stored, horizon) {
|
|
|
134
131
|
return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
|
|
135
132
|
}
|
|
136
133
|
/**
|
|
137
|
-
* How far past a rule's UNTIL, read as UTC, an occurrence can still end
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* whole day where a zone moved across the date line. Three days covers them together.
|
|
134
|
+
* How far past a rule's UNTIL, read as UTC, an occurrence can still end. UNTIL bounds starts (RFC
|
|
135
|
+
* 5545 §3.3.10), but a floating or date-only UNTIL is a wall time in any zone (UTC-12 to UTC+14),
|
|
136
|
+
* skipped hours move starts forward, and clock changes stretch ends: three days covers them
|
|
137
|
+
* together.
|
|
142
138
|
*/
|
|
143
139
|
const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
|
|
144
140
|
/** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
|
|
145
141
|
const SERIES_COUNT_SEARCH_WINDOWS = 30;
|
|
146
142
|
/**
|
|
147
|
-
* A time by which every occurrence of a
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
* start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
|
|
151
|
-
* the expansion reads it today, so a later reading of its end may be longer.
|
|
143
|
+
* A time by which every occurrence of a series ends, or `null` when it may go on: no UNTIL or COUNT
|
|
144
|
+
* as ical.js reads it, or the COUNT occurrences are not all found within
|
|
145
|
+
* SERIES_COUNT_SEARCH_WINDOWS windows or the search budget.
|
|
152
146
|
*/
|
|
153
147
|
function seriesEndsBy(event) {
|
|
154
148
|
const recurrence = event.recurrence;
|
|
@@ -185,10 +179,8 @@ function seriesEndsBy(event) {
|
|
|
185
179
|
return Number.isFinite(ends.getTime()) ? ends : null;
|
|
186
180
|
}
|
|
187
181
|
/**
|
|
188
|
-
* The latest end among a COUNT series' occurrences, expanded window by window
|
|
189
|
-
*
|
|
190
|
-
* fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
|
|
191
|
-
* there are no more. An occurrence is counted in the window its start falls in.
|
|
182
|
+
* The latest end among a COUNT series' occurrences, expanded window by window with exceptions left
|
|
183
|
+
* out (so a moved one is found in its own place), or `null` when fewer than `count` turn up.
|
|
192
184
|
*/
|
|
193
185
|
function lastCountedOccurrenceEnd(event, count) {
|
|
194
186
|
const rrule = event.recurrence?.rrule;
|
|
@@ -225,16 +217,15 @@ function lastCountedOccurrenceEnd(event, count) {
|
|
|
225
217
|
return null;
|
|
226
218
|
}
|
|
227
219
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
220
|
+
* recurrence_ends_by as text: NULL for a one-off, 'infinity' for no known end. Bind it as text
|
|
221
|
+
* (`::text::timestamptz`): postgres.js serializes timestamptz through Date, which cannot hold
|
|
222
|
+
* 'infinity'.
|
|
231
223
|
*/
|
|
232
224
|
function recurrenceEndsByColumn(event) {
|
|
233
225
|
if (!event.recurrence)
|
|
234
226
|
return null;
|
|
235
227
|
return seriesEndsBy(event)?.toISOString() ?? 'infinity';
|
|
236
228
|
}
|
|
237
|
-
/** The roll's `after`, checked, or `undefined` for a pass's first batch. */
|
|
238
229
|
function rollCursor(after) {
|
|
239
230
|
if (after === undefined)
|
|
240
231
|
return undefined;
|
|
@@ -250,8 +241,8 @@ function rollCursor(after) {
|
|
|
250
241
|
return after;
|
|
251
242
|
}
|
|
252
243
|
/**
|
|
253
|
-
*
|
|
254
|
-
*
|
|
244
|
+
* A new transaction on a client (READ COMMITTED when asked), or a savepoint inside the caller's,
|
|
245
|
+
* whose isolation cannot change.
|
|
255
246
|
*/
|
|
256
247
|
async function inTransaction(executor, callback, isolation) {
|
|
257
248
|
if ('begin' in executor) {
|
|
@@ -272,12 +263,9 @@ async function assertReadCommitted(tx) {
|
|
|
272
263
|
throw invalidIsolationError();
|
|
273
264
|
}
|
|
274
265
|
/**
|
|
275
|
-
* Run deployment SQL
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* themselves as well; this covers the statements the store sends around them. Every name read
|
|
279
|
-
* before the pin is schema-qualified. The caller's search_path is restored afterwards; a failure
|
|
280
|
-
* aborts the transaction, which reverts the pin as well.
|
|
266
|
+
* Run deployment SQL with names resolved only in pg_catalog and pg_temp. The deployment role owns
|
|
267
|
+
* every table, so a function found in a schema another role can write to ("$user", public) would
|
|
268
|
+
* run with that ownership. The caller's search_path is restored afterwards.
|
|
281
269
|
*/
|
|
282
270
|
async function withPinnedSearchPath(tx, operation) {
|
|
283
271
|
const [caller] = await tx `
|
|
@@ -288,10 +276,8 @@ async function withPinnedSearchPath(tx, operation) {
|
|
|
288
276
|
return result;
|
|
289
277
|
}
|
|
290
278
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
|
|
294
|
-
* migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
|
|
279
|
+
* Map the DDL's refusals: SQLSTATE 42501 (another role controls the schema; findings as JSON in
|
|
280
|
+
* DETAIL) to `unsafe_slotlock_schema`, and 55000 to `schema_newer`. Anything else passes through.
|
|
295
281
|
*/
|
|
296
282
|
function schemaControlRefusal(error) {
|
|
297
283
|
const failure = error;
|
|
@@ -322,7 +308,6 @@ function schemaControlRefusal(error) {
|
|
|
322
308
|
reasons,
|
|
323
309
|
});
|
|
324
310
|
}
|
|
325
|
-
/** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
|
|
326
311
|
const LOCK_NOT_AVAILABLE = '55P03';
|
|
327
312
|
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
|
|
328
313
|
const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -341,10 +326,10 @@ function migrationSettings(options) {
|
|
|
341
326
|
return { lockTimeoutMs, statementTimeoutMs, attempts };
|
|
342
327
|
}
|
|
343
328
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
329
|
+
* One migration step: in a transaction (or savepoint), names pinned to pg_catalog, serialized with
|
|
330
|
+
* every other migration, under the lock and statement timeouts. A lock timeout or deadlock is
|
|
331
|
+
* rolled back and retried up to `attempts` times. The caller's own timeouts are restored
|
|
332
|
+
* afterwards.
|
|
348
333
|
*/
|
|
349
334
|
async function inMigration(executor, options, step) {
|
|
350
335
|
const settings = migrationSettings(options);
|
|
@@ -367,9 +352,8 @@ async function inMigration(executor, options, step) {
|
|
|
367
352
|
}));
|
|
368
353
|
}
|
|
369
354
|
catch (error) {
|
|
370
|
-
// A deadlock
|
|
371
|
-
//
|
|
372
|
-
// it, so it is tried again like a step that timed out waiting.
|
|
355
|
+
// A deadlock victim (a writer took outbox then coverage, a rename batch the other order) lost
|
|
356
|
+
// its locks, so it retries like a lock timeout.
|
|
373
357
|
const code = pgCode(error);
|
|
374
358
|
if (code !== LOCK_NOT_AVAILABLE && code !== DEADLOCK_DETECTED)
|
|
375
359
|
throw error;
|
|
@@ -381,12 +365,10 @@ async function inMigration(executor, options, step) {
|
|
|
381
365
|
}
|
|
382
366
|
}
|
|
383
367
|
/**
|
|
384
|
-
* A digest of what the migration defines in the `slotlock` schema:
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
|
|
389
|
-
* with it pinned, as the migration does.
|
|
368
|
+
* A digest of what the migration defines in the `slotlock` schema: tables, columns, constraints
|
|
369
|
+
* (and whether validated), indexes, triggers, functions and PUBLIC grants. Grants to other roles,
|
|
370
|
+
* ownership and RLS are left out: applyTenantRls and grantApplicationRole change those later. Runs
|
|
371
|
+
* with search_path pinned, as deparsed definitions depend on it.
|
|
390
372
|
*/
|
|
391
373
|
async function slotlockCatalogDigest(tx) {
|
|
392
374
|
const [row] = await tx `
|
|
@@ -446,10 +428,9 @@ async function slotlockCatalogDigest(tx) {
|
|
|
446
428
|
return row?.digest ?? '';
|
|
447
429
|
}
|
|
448
430
|
/**
|
|
449
|
-
* Whether the schema is exactly what this release's applySchema last left:
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* what it must.
|
|
431
|
+
* Whether the schema is exactly what this release's applySchema last left: same version, DDL and
|
|
432
|
+
* catalog digest, and no table left unforced by an interrupted migration. Without the right to read
|
|
433
|
+
* it, the full migration runs instead.
|
|
453
434
|
*/
|
|
454
435
|
async function slotlockSchemaUpToDate(tx) {
|
|
455
436
|
const [present] = await tx `
|
|
@@ -476,29 +457,21 @@ async function slotlockSchemaUpToDate(tx) {
|
|
|
476
457
|
}
|
|
477
458
|
/**
|
|
478
459
|
* Rename the coverage changes an earlier release wrote (ddl.ts, slotlockCoverageChangeRenameSql),
|
|
479
|
-
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids,
|
|
480
|
-
*
|
|
481
|
-
*
|
|
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.
|
|
460
|
+
* in batches of SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH outbox ids, resuming from
|
|
461
|
+
* `schema_version.coverage_changes_renamed_through`. An older server still running can write more,
|
|
462
|
+
* so every migration runs a pass until one finds nothing.
|
|
485
463
|
*
|
|
486
|
-
* The head is read
|
|
487
|
-
*
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
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.
|
|
464
|
+
* The head is read once every transaction already writing to the outbox has ended, so nothing at or
|
|
465
|
+
* below it commits later. Forced RLS would hide other tenants' rows, so each batch lifts FORCE from
|
|
466
|
+
* calendar_coverage and outbox under ACCESS EXCLUSIVE (in writer lock order) and restores it before
|
|
467
|
+
* committing. A superuser or BYPASSRLS role instead holds SHARE ROW EXCLUSIVE on the outbox only
|
|
468
|
+
* while reading the head.
|
|
496
469
|
*/
|
|
497
470
|
async function renameEarlierCoverageChanges(executor, options) {
|
|
498
471
|
/**
|
|
499
|
-
* Run `work` able to read every tenant's
|
|
500
|
-
*
|
|
501
|
-
*
|
|
472
|
+
* Run `work` able to read every tenant's rows: as is for a superuser or BYPASSRLS role, else with
|
|
473
|
+
* FORCE lifted under ACCESS EXCLUSIVE (calendar_coverage, then outbox: writer lock order) and put
|
|
474
|
+
* back after.
|
|
502
475
|
*/
|
|
503
476
|
const everyTenant = async (tx, bypass, work) => {
|
|
504
477
|
const forced = bypass
|
|
@@ -521,8 +494,8 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
521
494
|
await tx.unsafe(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
|
522
495
|
return result;
|
|
523
496
|
};
|
|
524
|
-
// The
|
|
525
|
-
//
|
|
497
|
+
// The head, in its own short transaction: writes in flight hold ROW EXCLUSIVE from before they
|
|
498
|
+
// took their id, so none at or below it commits later.
|
|
526
499
|
const start = await inMigration(executor, options, async (tx) => {
|
|
527
500
|
const [state] = await tx `
|
|
528
501
|
SELECT version.coverage_changes_renamed AS renamed,
|
|
@@ -554,7 +527,6 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
554
527
|
]));
|
|
555
528
|
const read = row?.last ?? null;
|
|
556
529
|
renamedTotal += row?.renamed ?? 0;
|
|
557
|
-
// Saved with the batch, so a pass stopped part way goes on from here.
|
|
558
530
|
await tx `
|
|
559
531
|
UPDATE slotlock.schema_version
|
|
560
532
|
SET coverage_changes_renamed_through = ${read ?? head}::bigint,
|
|
@@ -567,7 +539,6 @@ async function renameEarlierCoverageChanges(executor, options) {
|
|
|
567
539
|
after = String(last);
|
|
568
540
|
}
|
|
569
541
|
}
|
|
570
|
-
/** Whether the rename of coverage changes earlier releases wrote still has a pass to run. */
|
|
571
542
|
async function coverageChangesRenamePending(tx) {
|
|
572
543
|
const [row] = await tx `
|
|
573
544
|
SELECT coverage_changes_renamed AS renamed FROM slotlock.schema_version WHERE singleton`;
|
|
@@ -586,8 +557,8 @@ async function slotlockConstraintUnvalidated(tx) {
|
|
|
586
557
|
return row?.unvalidated === true;
|
|
587
558
|
}
|
|
588
559
|
/**
|
|
589
|
-
* Whether every tenant table has forced RLS and only its tenant policy
|
|
590
|
-
*
|
|
560
|
+
* Whether every tenant table has forced RLS and only its tenant policy bound to `setting`, matching
|
|
561
|
+
* the fingerprint the DDL recorded.
|
|
591
562
|
*/
|
|
592
563
|
async function tenantRlsUpToDate(tx, setting) {
|
|
593
564
|
const [present] = await tx `
|
|
@@ -599,8 +570,17 @@ async function tenantRlsUpToDate(tx, setting) {
|
|
|
599
570
|
const [state] = await tx `
|
|
600
571
|
SELECT pg_catalog.bool_and(
|
|
601
572
|
rel.relrowsecurity AND rel.relforcerowsecurity
|
|
573
|
+
-- The tenant policy, and on a table a limited key is held to, the restrictive scope
|
|
574
|
+
-- policy, which can only narrow it; no other.
|
|
602
575
|
AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
|
|
603
|
-
WHERE every.polrelid = rel.oid
|
|
576
|
+
WHERE every.polrelid = rel.oid
|
|
577
|
+
AND (every.polname = 'tenant_isolation_v1'
|
|
578
|
+
OR NOT (every.polname = 'resource_scope_v1' AND NOT every.polpermissive
|
|
579
|
+
AND wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[])))) = 1
|
|
580
|
+
AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy scoped
|
|
581
|
+
WHERE scoped.polrelid = rel.oid AND scoped.polname = 'resource_scope_v1'
|
|
582
|
+
AND NOT scoped.polpermissive)
|
|
583
|
+
= CASE WHEN wanted.name = ANY (${Object.keys(SLOTLOCK_RESOURCE_SCOPED_TABLES)}::text[]) THEN 1 ELSE 0 END
|
|
604
584
|
AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
|
|
605
585
|
AND contract.tenant_context_setting = ${setting}
|
|
606
586
|
AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
|
|
@@ -622,9 +602,8 @@ async function tenantRlsUpToDate(tx, setting) {
|
|
|
622
602
|
return state?.current === true && state.matched === tables.length;
|
|
623
603
|
}
|
|
624
604
|
/**
|
|
625
|
-
* The schema version
|
|
626
|
-
*
|
|
627
|
-
* `slotlock migrate`, which grants it).
|
|
605
|
+
* The recorded schema version, or `null` when this role cannot read one: no schema, 0.2 or earlier,
|
|
606
|
+
* or the function not yet granted (`slotlock migrate` grants it).
|
|
628
607
|
*/
|
|
629
608
|
export async function readSlotlockSchemaVersion(sql) {
|
|
630
609
|
const [readable] = await sql `
|
|
@@ -638,9 +617,8 @@ export async function readSlotlockSchemaVersion(sql) {
|
|
|
638
617
|
return row?.version ?? null;
|
|
639
618
|
}
|
|
640
619
|
/**
|
|
641
|
-
* Refuse
|
|
642
|
-
*
|
|
643
|
-
* migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
|
|
620
|
+
* Refuse a schema another release migrated: `schema_outdated` (older or unversioned: run `slotlock
|
|
621
|
+
* migrate`) or `schema_newer`. Both carry `installed` and `expected`.
|
|
644
622
|
*/
|
|
645
623
|
export async function assertSlotlockSchemaCurrent(sql) {
|
|
646
624
|
const installed = await readSlotlockSchemaVersion(sql);
|
|
@@ -747,7 +725,6 @@ function toHold(row) {
|
|
|
747
725
|
...(row.hold_group_id ? { groupId: row.hold_group_id } : {}),
|
|
748
726
|
};
|
|
749
727
|
}
|
|
750
|
-
/** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
|
|
751
728
|
function pageCursor(after) {
|
|
752
729
|
if (after !== undefined &&
|
|
753
730
|
(!(after.start instanceof Date) ||
|
|
@@ -768,6 +745,9 @@ function pageLimit(limit) {
|
|
|
768
745
|
}
|
|
769
746
|
return value;
|
|
770
747
|
}
|
|
748
|
+
function sandboxTestOnlyError() {
|
|
749
|
+
return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
|
|
750
|
+
}
|
|
771
751
|
function invalidIdentityError(message) {
|
|
772
752
|
return Object.assign(new Error(message), { code: 'invalid_identity' });
|
|
773
753
|
}
|
|
@@ -975,44 +955,57 @@ async function replaceCalendarEventOccurrences(tx, params) {
|
|
|
975
955
|
WHERE event_id = ${params.eventId} AND reservation_id IS NOT NULL
|
|
976
956
|
)`;
|
|
977
957
|
await tx `DELETE FROM slotlock.calendar_event_occurrences WHERE event_id = ${params.eventId}`;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
958
|
+
if (params.occurrences.length === 0)
|
|
959
|
+
return;
|
|
960
|
+
// Three statements for the whole series, not three per occurrence: a series of 2,000 holds the
|
|
961
|
+
// tenant's locks for as long as its round trips take, and every write queued behind it waits.
|
|
962
|
+
const starts = params.occurrences.map((occurrence) => occurrence.start.toISOString());
|
|
963
|
+
const ends = params.occurrences.map((occurrence) => occurrence.end.toISOString());
|
|
964
|
+
const reservationIds = params.occurrences.map(() => '');
|
|
965
|
+
if ((params.event.transparency ?? 'opaque') === 'opaque') {
|
|
966
|
+
const occupied = params.occurrences.map((occurrence) => occupiedTime({
|
|
967
|
+
...occurrence,
|
|
968
|
+
bufferBeforeMs: params.bufferBeforeMs ?? 0,
|
|
969
|
+
bufferAfterMs: params.bufferAfterMs ?? 0,
|
|
970
|
+
}));
|
|
971
|
+
await reapExpiredHoldsAcross(tx, params.resourceId, occupied);
|
|
972
|
+
const externalRefs = params.occurrences.map((occurrence) => `calendar:${params.eventId}:${createHash('sha256').update(occurrence.recurrenceId).digest('hex')}`);
|
|
973
|
+
const inserted = await tx `
|
|
974
|
+
INSERT INTO slotlock.reservations (
|
|
975
|
+
resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
976
|
+
buffer_after_ms, buffer_before_ms, revision, source, calendar_event_id
|
|
977
|
+
)
|
|
978
|
+
SELECT ${params.resourceId}, ${params.tenantRef}, occurrence.external_ref,
|
|
979
|
+
occurrence.starts_at, occurrence.ends_at, ${params.bufferAfterMs ?? 0},
|
|
980
|
+
${params.bufferBeforeMs ?? 0}, ${params.revision},
|
|
981
|
+
${`calendar-event:${params.eventId}`}, ${params.eventId}
|
|
982
|
+
FROM ROWS FROM (pg_catalog.unnest(${externalRefs}::text[]),
|
|
983
|
+
pg_catalog.unnest(${starts}::text[]::timestamptz[]),
|
|
984
|
+
pg_catalog.unnest(${ends}::text[]::timestamptz[]))
|
|
985
|
+
WITH ORDINALITY AS occurrence(external_ref, starts_at, ends_at, ordinal)
|
|
986
|
+
ORDER BY occurrence.ordinal
|
|
987
|
+
RETURNING id, external_ref`;
|
|
988
|
+
const byRef = new Map(inserted.map((row) => [row.external_ref, row.id]));
|
|
989
|
+
externalRefs.forEach((externalRef, index) => {
|
|
990
|
+
const id = byRef.get(externalRef);
|
|
991
|
+
if (!id)
|
|
1003
992
|
throw new Error('slotlock: calendar occurrence reservation insert returned no row');
|
|
1004
|
-
|
|
1005
|
-
}
|
|
1006
|
-
await tx `
|
|
1007
|
-
INSERT INTO slotlock.calendar_event_occurrences (
|
|
1008
|
-
event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
|
|
1009
|
-
) VALUES (
|
|
1010
|
-
${params.eventId}, ${params.tenantRef}, ${occurrence.recurrenceId},
|
|
1011
|
-
${occurrence.start}, ${occurrence.end}, ${reservationId}
|
|
1012
|
-
)`;
|
|
993
|
+
reservationIds[index] = id;
|
|
994
|
+
});
|
|
1013
995
|
}
|
|
996
|
+
await tx `
|
|
997
|
+
INSERT INTO slotlock.calendar_event_occurrences (
|
|
998
|
+
event_id, tenant_ref, recurrence_id, starts_at, ends_at, reservation_id
|
|
999
|
+
)
|
|
1000
|
+
SELECT ${params.eventId}, ${params.tenantRef}, occurrence.recurrence_id,
|
|
1001
|
+
occurrence.starts_at, occurrence.ends_at, NULLIF(occurrence.reservation_id, '')::uuid
|
|
1002
|
+
FROM ROWS FROM (pg_catalog.unnest(${params.occurrences.map((occurrence) => occurrence.recurrenceId)}::text[]),
|
|
1003
|
+
pg_catalog.unnest(${starts}::text[]::timestamptz[]),
|
|
1004
|
+
pg_catalog.unnest(${ends}::text[]::timestamptz[]),
|
|
1005
|
+
pg_catalog.unnest(${reservationIds}::text[]))
|
|
1006
|
+
WITH ORDINALITY AS occurrence(recurrence_id, starts_at, ends_at, reservation_id, ordinal)
|
|
1007
|
+
ORDER BY occurrence.ordinal`;
|
|
1014
1008
|
}
|
|
1015
|
-
/** A recurring series' materialization window: finite, forward, and at most 369 days. */
|
|
1016
1009
|
function seriesWindow(window) {
|
|
1017
1010
|
if (!isFiniteWindow(window) ||
|
|
1018
1011
|
window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
|
|
@@ -1028,9 +1021,9 @@ function calendarWindow(window) {
|
|
|
1028
1021
|
return { start: new Date(window.start), end: new Date(window.end) };
|
|
1029
1022
|
}
|
|
1030
1023
|
/**
|
|
1031
|
-
* A one-off event's
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1024
|
+
* A one-off event's window must contain the event. It may exceed the 367-day cap only by being
|
|
1025
|
+
* exactly the event's interval, as the `slotlock_calendar_events_materialization_window_bounded`
|
|
1026
|
+
* CHECK admits.
|
|
1034
1027
|
*/
|
|
1035
1028
|
function oneOffEventWindow(event, requested) {
|
|
1036
1029
|
const duration = event.end.getTime() - event.start.getTime();
|
|
@@ -1049,17 +1042,15 @@ function calendarCommandHash(value) {
|
|
|
1049
1042
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
1050
1043
|
}
|
|
1051
1044
|
/**
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
* midnight: with it in the hash, a retry after midnight was refused as a different request.
|
|
1045
|
+
* A put's identity and the event it asks for, not its storage window: a series' window moves with
|
|
1046
|
+
* the UTC day, so a retry after midnight must still match.
|
|
1055
1047
|
*/
|
|
1056
1048
|
function putCommandHash(params) {
|
|
1057
1049
|
return calendarCommandHash(putCommandValue(params));
|
|
1058
1050
|
}
|
|
1059
1051
|
/**
|
|
1060
|
-
* The hash 0.3.0 and earlier stored
|
|
1061
|
-
*
|
|
1062
|
-
* for a series. Remove once no stored command can be that old (commands are pruned after 30 days).
|
|
1052
|
+
* The hash 0.3.0 and earlier stored (window included), so an older stored command still replays.
|
|
1053
|
+
* Remove once no stored command can be that old (pruned after 30 days).
|
|
1063
1054
|
*/
|
|
1064
1055
|
export function legacyPutCommandHash(params) {
|
|
1065
1056
|
return calendarCommandHash({
|
|
@@ -1079,7 +1070,7 @@ function putCommandValue(params) {
|
|
|
1079
1070
|
expectedRevision: params.command.expectedRevision,
|
|
1080
1071
|
resourceId: params.command.resourceId,
|
|
1081
1072
|
source: params.command.source ?? null,
|
|
1082
|
-
// Only when present, so a create without a hold keeps
|
|
1073
|
+
// Only when present, so a create without a hold keeps its hash.
|
|
1083
1074
|
...(params.command.fromHold ? { fromHold: params.command.fromHold.holdId } : {}),
|
|
1084
1075
|
event: {
|
|
1085
1076
|
...params.event,
|
|
@@ -1138,8 +1129,7 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
|
|
|
1138
1129
|
}
|
|
1139
1130
|
/**
|
|
1140
1131
|
* The end of every event cancellation, under the event's identity and row locks: remove the event
|
|
1141
|
-
*
|
|
1142
|
-
* the command in `commandOwnerRef`'s namespace for replay.
|
|
1132
|
+
* and its occupation, leave the tombstone at `revision`, and record the command for replay.
|
|
1143
1133
|
*/
|
|
1144
1134
|
async function finishCalendarCancel(tx, params) {
|
|
1145
1135
|
const eventId = params.eventId ?? randomUUID();
|
|
@@ -1179,8 +1169,8 @@ async function finishCalendarCancel(tx, params) {
|
|
|
1179
1169
|
};
|
|
1180
1170
|
}
|
|
1181
1171
|
/**
|
|
1182
|
-
* The row an event write lost to. The write was rolled back, so the event's own rows
|
|
1183
|
-
*
|
|
1172
|
+
* The row an event write lost to. The write was rolled back, so the event's own previous rows are
|
|
1173
|
+
* excluded: they were to be replaced.
|
|
1184
1174
|
*/
|
|
1185
1175
|
async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffers = { bufferBeforeMs: 0, bufferAfterMs: 0 }) {
|
|
1186
1176
|
for (const occurrence of occurrences) {
|
|
@@ -1191,10 +1181,9 @@ async function findCalendarOverlap(sql, resourceId, occurrences, eventId, buffer
|
|
|
1191
1181
|
return { ok: false, code: 'overlap', conflictingReservationId: null };
|
|
1192
1182
|
}
|
|
1193
1183
|
/**
|
|
1194
|
-
* What the resource's booking rules
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
* hours is judged against `defaultHours`.
|
|
1184
|
+
* What the resource's effective booking rules say about booking `interval` now (database clock):
|
|
1185
|
+
* the rule broken, if any, and the buffers to keep. A resource without its own hours is judged
|
|
1186
|
+
* against `defaultHours`.
|
|
1198
1187
|
*/
|
|
1199
1188
|
async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
1200
1189
|
const judge = await bookingJudge(tx, resourceId, defaultHours);
|
|
@@ -1207,8 +1196,8 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
|
|
|
1207
1196
|
/** How recently a sync must have read a resource whose booking rules require certainty. */
|
|
1208
1197
|
const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
1209
1198
|
/**
|
|
1210
|
-
* The resource's booking rules
|
|
1211
|
-
*
|
|
1199
|
+
* The resource's effective booking rules, read once, as a check of any interval booked now
|
|
1200
|
+
* (database clock) and the buffers to keep.
|
|
1212
1201
|
*/
|
|
1213
1202
|
async function bookingJudge(tx, resourceId, defaultHours) {
|
|
1214
1203
|
const [row] = await tx `
|
|
@@ -1231,7 +1220,6 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1231
1220
|
effective_booking_rules: row.effective_booking_rules,
|
|
1232
1221
|
});
|
|
1233
1222
|
const hours = availabilityRules ?? defaultHours;
|
|
1234
|
-
// Every sync covering the resource, and whether it was read within a day, by the same clock.
|
|
1235
1223
|
const coverage = rules?.requireCertain === true
|
|
1236
1224
|
? await tx `
|
|
1237
1225
|
SELECT starts_at, ends_at,
|
|
@@ -1241,12 +1229,11 @@ async function bookingJudge(tx, resourceId, defaultHours) {
|
|
|
1241
1229
|
: [];
|
|
1242
1230
|
const turnaroundMs = bookingBufferAfterMs(rules);
|
|
1243
1231
|
const bufferBeforeMs = bookingBufferBeforeMs(rules);
|
|
1244
|
-
// The booking and the buffers it keeps, as a search judges the slot.
|
|
1245
1232
|
const uncertain = (interval) => coverage.some((source) => !source.fresh ||
|
|
1246
1233
|
source.starts_at.getTime() > interval.start.getTime() - bufferBeforeMs ||
|
|
1247
1234
|
source.ends_at.getTime() < interval.end.getTime() + turnaroundMs);
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1235
|
+
// Later occurrences are judged on step and hours only: notice and advance concern when the
|
|
1236
|
+
// booking was made.
|
|
1250
1237
|
const later = rules === undefined
|
|
1251
1238
|
? undefined
|
|
1252
1239
|
: {
|
|
@@ -1271,29 +1258,59 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
|
|
|
1271
1258
|
return (row.resource_id === params.resourceId &&
|
|
1272
1259
|
row.starts_at.getTime() === params.start.getTime() &&
|
|
1273
1260
|
row.ends_at.getTime() === params.end.getTime() &&
|
|
1274
|
-
// As asked, or raised to the resource's turnaround when it was stored.
|
|
1275
1261
|
(buffer === params.bufferAfterMs || buffer === Math.max(params.bufferAfterMs, turnaroundMs)) &&
|
|
1276
1262
|
Number(row.revision) === params.revision &&
|
|
1277
1263
|
row.source === (params.source ?? null));
|
|
1278
1264
|
}
|
|
1279
1265
|
async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
1280
|
-
// JSON
|
|
1281
|
-
// U+0000). The database hashes it to one transaction-scoped lock key.
|
|
1266
|
+
// JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
|
|
1282
1267
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
|
|
1283
1268
|
}
|
|
1284
1269
|
/**
|
|
1285
|
-
*
|
|
1286
|
-
*
|
|
1270
|
+
* Lock a resource a write is about to occupy time on, with its composite parent, in id order, to
|
|
1271
|
+
* the end of the transaction (RFC 0004 A7): writes to one resource, or one composite's family,
|
|
1272
|
+
* take turns. Without it, writers racing for the same time each insert, then wait at the
|
|
1273
|
+
* exclusion constraint for one another, and PostgreSQL breaks each such deadlock only after its
|
|
1274
|
+
* deadlock_timeout (a second), so a burst of agents for one slot could take many seconds. With
|
|
1275
|
+
* it, each meets the winner's committed row and is refused at once. NO KEY UPDATE, so foreign
|
|
1276
|
+
* keys to the resource are not blocked; an archive or delete waits for the write, as before. The
|
|
1277
|
+
* resource's own row, or null when there is none (for the tenant, when one is given).
|
|
1278
|
+
*/
|
|
1279
|
+
async function lockResourceForWrite(tx, resourceId, tenantRef) {
|
|
1280
|
+
const rows = await tx `
|
|
1281
|
+
SELECT id, tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
1282
|
+
WHERE (id = ${resourceId}
|
|
1283
|
+
OR id = (SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}))
|
|
1284
|
+
AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
|
|
1285
|
+
ORDER BY id
|
|
1286
|
+
FOR NO KEY UPDATE`;
|
|
1287
|
+
const own = rows.find((row) => row.id === resourceId);
|
|
1288
|
+
return own ? { tenant_ref: own.tenant_ref, archived: own.archived } : null;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Whether the resource is the tenant's (`missing` when not), and whether it is archived, locked
|
|
1292
|
+
* for the write that follows (lockResourceForWrite).
|
|
1287
1293
|
*/
|
|
1288
1294
|
async function tenantResourceState(tx, resourceId, tenantRef) {
|
|
1289
|
-
const
|
|
1290
|
-
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
1291
|
-
WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
|
|
1292
|
-
FOR SHARE`;
|
|
1295
|
+
const row = await readResourceForWrite(tx, resourceId, tenantRef);
|
|
1293
1296
|
if (!row)
|
|
1294
1297
|
return 'missing';
|
|
1295
1298
|
return row.archived ? 'archived' : 'active';
|
|
1296
1299
|
}
|
|
1300
|
+
/**
|
|
1301
|
+
* A resource a write is about to check its booking against, key-share locked so it is not deleted
|
|
1302
|
+
* meanwhile. It does not make writers wait for each other: lockResourceForWrite does, later, right
|
|
1303
|
+
* before the occupation is written, and checks again that the resource is there and not archived.
|
|
1304
|
+
* (KEY SHARE never conflicts with the NO KEY UPDATE that follows, so two writers cannot deadlock
|
|
1305
|
+
* upgrading.)
|
|
1306
|
+
*/
|
|
1307
|
+
async function readResourceForWrite(tx, resourceId, tenantRef) {
|
|
1308
|
+
const [row] = await tx `
|
|
1309
|
+
SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
1310
|
+
WHERE id = ${resourceId} AND (${tenantRef}::text IS NULL OR tenant_ref = ${tenantRef})
|
|
1311
|
+
FOR KEY SHARE`;
|
|
1312
|
+
return row ?? null;
|
|
1313
|
+
}
|
|
1297
1314
|
function pgCode(err) {
|
|
1298
1315
|
let cur = err;
|
|
1299
1316
|
while (cur && typeof cur === 'object') {
|
|
@@ -1332,10 +1349,19 @@ function invalidPoolError() {
|
|
|
1332
1349
|
code: 'invalid_pool',
|
|
1333
1350
|
});
|
|
1334
1351
|
}
|
|
1352
|
+
/** A resource's own columns, as toResource reads them. */
|
|
1353
|
+
const RESOURCE_COLUMNS = `id, external_ref, tenant_ref, timezone, availability_rules, booking_rules, pool_ref,
|
|
1354
|
+
visibility, archived_at, name, description, kind, capacity, labels, attributes, location_ref,
|
|
1355
|
+
parent_id, part_index,
|
|
1356
|
+
(SELECT place.name FROM slotlock.locations place
|
|
1357
|
+
WHERE place.tenant_ref = resources.tenant_ref AND place.ref = resources.location_ref) AS location_name,
|
|
1358
|
+
ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
|
|
1359
|
+
WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
|
|
1360
|
+
ORDER BY member.group_ref) AS group_refs`;
|
|
1335
1361
|
/**
|
|
1336
|
-
* A resource row's booking rules
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1362
|
+
* A resource row's effective booking rules (jsonb; NULL for none): tenant defaults, overlaid by its
|
|
1363
|
+
* pool's, overlaid by its own, field by field. Reads from a row of slotlock.resources named
|
|
1364
|
+
* `resources` (no alias).
|
|
1339
1365
|
*/
|
|
1340
1366
|
const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
1341
1367
|
COALESCE((SELECT defaults.rules FROM slotlock.booking_rule_defaults defaults
|
|
@@ -1347,7 +1373,7 @@ const EFFECTIVE_BOOKING_RULES = `NULLIF(
|
|
|
1347
1373
|
'{}'::jsonb) AS effective_booking_rules`;
|
|
1348
1374
|
/** What rules Slotlock did not write read as: no booking passes them. */
|
|
1349
1375
|
const REFUSE_EVERY_BOOKING = Object.freeze({ maxAdvanceDays: 1, minNoticeMinutes: 525_600 });
|
|
1350
|
-
/** Rules
|
|
1376
|
+
/** Rules written validated, so anything else read back refuses every booking. */
|
|
1351
1377
|
function storedBookingRules(value) {
|
|
1352
1378
|
try {
|
|
1353
1379
|
return parseBookingRules(value);
|
|
@@ -1368,8 +1394,37 @@ function toResource(row) {
|
|
|
1368
1394
|
resource.archivedAt = row.archived_at;
|
|
1369
1395
|
if (row.pool_ref !== null && row.pool_ref !== undefined)
|
|
1370
1396
|
resource.pool = row.pool_ref;
|
|
1397
|
+
// The profile, each part only when it says something.
|
|
1398
|
+
if (typeof row.name === 'string')
|
|
1399
|
+
resource.name = row.name;
|
|
1400
|
+
if (typeof row.description === 'string')
|
|
1401
|
+
resource.description = row.description;
|
|
1402
|
+
if (row.kind !== undefined && row.kind !== 'other' && SLOTLOCK_RESOURCE_KINDS.includes(row.kind)) {
|
|
1403
|
+
resource.kind = row.kind;
|
|
1404
|
+
}
|
|
1405
|
+
if (typeof row.capacity === 'number')
|
|
1406
|
+
resource.capacity = row.capacity;
|
|
1407
|
+
if (Array.isArray(row.labels) && row.labels.length > 0)
|
|
1408
|
+
resource.labels = [...row.labels];
|
|
1409
|
+
if (row.attributes !== null &&
|
|
1410
|
+
typeof row.attributes === 'object' &&
|
|
1411
|
+
!Array.isArray(row.attributes) &&
|
|
1412
|
+
Object.keys(row.attributes).length > 0) {
|
|
1413
|
+
resource.attributes = { ...row.attributes };
|
|
1414
|
+
}
|
|
1415
|
+
if (typeof row.location_ref === 'string') {
|
|
1416
|
+
resource.location = row.location_ref;
|
|
1417
|
+
if (typeof row.location_name === 'string')
|
|
1418
|
+
resource.locationName = row.location_name;
|
|
1419
|
+
}
|
|
1420
|
+
if (Array.isArray(row.group_refs) && row.group_refs.length > 0)
|
|
1421
|
+
resource.groups = [...row.group_refs];
|
|
1422
|
+
if (typeof row.parent_id === 'string' && typeof row.part_index === 'number') {
|
|
1423
|
+
resource.parentId = row.parent_id;
|
|
1424
|
+
resource.partIndex = row.part_index;
|
|
1425
|
+
}
|
|
1371
1426
|
if (row.availability_rules !== null && row.availability_rules !== undefined) {
|
|
1372
|
-
// Written
|
|
1427
|
+
// Written validated; anything else reads as closed.
|
|
1373
1428
|
try {
|
|
1374
1429
|
resource.availabilityRules = parseAvailabilityRules(row.availability_rules);
|
|
1375
1430
|
}
|
|
@@ -1378,14 +1433,12 @@ function toResource(row) {
|
|
|
1378
1433
|
}
|
|
1379
1434
|
}
|
|
1380
1435
|
if (row.booking_rules !== null && row.booking_rules !== undefined) {
|
|
1381
|
-
// Written
|
|
1382
|
-
// every booking, never as no rule.
|
|
1436
|
+
// Written validated; anything else refuses every booking, never as no rule.
|
|
1383
1437
|
resource.bookingRules = storedBookingRules(row.booking_rules);
|
|
1384
1438
|
}
|
|
1385
1439
|
if (row.effective_booking_rules !== null && row.effective_booking_rules !== undefined) {
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
// invalid in itself was not written by Slotlock, and refuses every booking, as stored rules do.
|
|
1440
|
+
// Levels are validated alone, so only their combination can disagree (an offset over a shorter
|
|
1441
|
+
// step): that one is read modulo the step. A field invalid in itself refuses every booking.
|
|
1389
1442
|
const { rules, dropped } = parseEffectiveBookingRules(row.effective_booking_rules);
|
|
1390
1443
|
if (dropped.length > 0) {
|
|
1391
1444
|
console.warn(JSON.stringify({ message: 'slotlock refused invalid booking rules', resource: row.id, dropped }));
|
|
@@ -1397,7 +1450,6 @@ function toResource(row) {
|
|
|
1397
1450
|
}
|
|
1398
1451
|
return resource;
|
|
1399
1452
|
}
|
|
1400
|
-
/** Validated rules as a jsonb parameter, or null for "use the default". */
|
|
1401
1453
|
function availabilityParameter(sql, rules) {
|
|
1402
1454
|
// sql.json, not JSON.stringify: a string bound to jsonb is stored as a JSON string.
|
|
1403
1455
|
if (rules === null)
|
|
@@ -1408,23 +1460,137 @@ function availabilityParameter(sql, rules) {
|
|
|
1408
1460
|
durationMinutes,
|
|
1409
1461
|
})));
|
|
1410
1462
|
}
|
|
1463
|
+
/** What the location and group guards (ddl.ts) refuse with, by their HINT. */
|
|
1464
|
+
const GROUPING_REFUSALS = new Set([
|
|
1465
|
+
'pool_order_limit_reached',
|
|
1466
|
+
'composite_too_deep',
|
|
1467
|
+
'composite_parts_limit',
|
|
1468
|
+
'composite_busy',
|
|
1469
|
+
'location_too_deep',
|
|
1470
|
+
'location_cycle',
|
|
1471
|
+
'location_limit_reached',
|
|
1472
|
+
'group_limit_reached',
|
|
1473
|
+
'resource_group_limit_reached',
|
|
1474
|
+
]);
|
|
1475
|
+
/**
|
|
1476
|
+
* A location or group write PostgreSQL refused, as the store's code: a guard's HINT, or the code
|
|
1477
|
+
* `foreignKeys` gives a foreign key that failed. Anything else as it was.
|
|
1478
|
+
*/
|
|
1479
|
+
function groupingRefusal(error, foreignKeys = {}) {
|
|
1480
|
+
const failure = error;
|
|
1481
|
+
const coded = (code) => Object.assign(new Error(typeof failure?.message === 'string' ? failure.message : code, { cause: error }), { code });
|
|
1482
|
+
if (failure?.code === '23514' && typeof failure.hint === 'string' && GROUPING_REFUSALS.has(failure.hint)) {
|
|
1483
|
+
return coded(failure.hint);
|
|
1484
|
+
}
|
|
1485
|
+
if (failure?.code === '23503' && typeof failure.constraint_name === 'string') {
|
|
1486
|
+
const code = foreignKeys[failure.constraint_name];
|
|
1487
|
+
if (code !== undefined)
|
|
1488
|
+
return coded(code);
|
|
1489
|
+
}
|
|
1490
|
+
return error;
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Run a location or group write in a savepoint of its own (a transaction when there is none), and
|
|
1494
|
+
* map what PostgreSQL refuses (groupingRefusal). A failed statement leaves the caller's transaction
|
|
1495
|
+
* usable, and postgres.js reports the mapped error rather than the statement's.
|
|
1496
|
+
*/
|
|
1497
|
+
async function guardedWrite(executor, write, foreignKeys = {}) {
|
|
1498
|
+
try {
|
|
1499
|
+
return await inTransaction(executor, write);
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
throw groupingRefusal(error, foreignKeys);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
/** A resource's location that the tenant does not have. */
|
|
1506
|
+
const RESOURCE_LOCATION_MISSING = Object.freeze({ slotlock_resources_location_fkey: 'location_not_found' });
|
|
1507
|
+
/** The nearest time zone a location, or one above it, has; null when none has one. */
|
|
1508
|
+
async function locationTimezone(executor, tenantRef, location) {
|
|
1509
|
+
const [row] = await executor `
|
|
1510
|
+
WITH RECURSIVE up(ref, parent_ref, timezone, steps) AS (
|
|
1511
|
+
SELECT place.ref, place.parent_ref, place.timezone, 1 FROM slotlock.locations place
|
|
1512
|
+
WHERE place.tenant_ref = ${tenantRef} AND place.ref = ${location}
|
|
1513
|
+
UNION ALL
|
|
1514
|
+
SELECT place.ref, place.parent_ref, place.timezone, up.steps + 1
|
|
1515
|
+
FROM slotlock.locations place JOIN up ON place.tenant_ref = ${tenantRef} AND place.ref = up.parent_ref
|
|
1516
|
+
WHERE up.timezone IS NULL AND up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
1517
|
+
)
|
|
1518
|
+
SELECT timezone FROM up WHERE timezone IS NOT NULL ORDER BY steps LIMIT 1`;
|
|
1519
|
+
return row?.timezone ?? null;
|
|
1520
|
+
}
|
|
1521
|
+
const toLocation = (row) => ({
|
|
1522
|
+
ref: row.ref,
|
|
1523
|
+
name: row.name,
|
|
1524
|
+
parentRef: row.parent_ref,
|
|
1525
|
+
timezone: row.timezone,
|
|
1526
|
+
resources: Number(row.resources),
|
|
1527
|
+
});
|
|
1528
|
+
const toGroup = (row) => ({
|
|
1529
|
+
ref: row.ref,
|
|
1530
|
+
name: row.name,
|
|
1531
|
+
description: row.description,
|
|
1532
|
+
members: Number(row.members),
|
|
1533
|
+
});
|
|
1534
|
+
/**
|
|
1535
|
+
* A checked profile change as statement parameters: each field's value (null when not given or
|
|
1536
|
+
* cleared) and whether it was given. Labels travel as one comma-joined string, which a label never
|
|
1537
|
+
* contains: postgres.js binds an array as text[] only once the pool knows the server's types.
|
|
1538
|
+
*/
|
|
1539
|
+
function profileParameters(change) {
|
|
1540
|
+
return {
|
|
1541
|
+
name: change.name ?? null,
|
|
1542
|
+
setName: change.name !== undefined,
|
|
1543
|
+
description: change.description ?? null,
|
|
1544
|
+
setDescription: change.description !== undefined,
|
|
1545
|
+
kind: change.kind ?? null,
|
|
1546
|
+
setKind: change.kind !== undefined,
|
|
1547
|
+
capacity: change.capacity ?? null,
|
|
1548
|
+
setCapacity: change.capacity !== undefined,
|
|
1549
|
+
labels: change.labels === undefined ? null : change.labels.join(','),
|
|
1550
|
+
setLabels: change.labels !== undefined,
|
|
1551
|
+
attributes: change.attributes === undefined ? null : JSON.stringify(change.attributes),
|
|
1552
|
+
setAttributes: change.attributes !== undefined,
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1411
1555
|
async function upsertResource(executor, params) {
|
|
1412
1556
|
const visibility = params.visibility ?? 'shared';
|
|
1413
1557
|
const asked = params.visibility ?? null;
|
|
1558
|
+
const profile = profileParameters(params.profile ?? {});
|
|
1414
1559
|
let rows;
|
|
1415
|
-
// An existing identity keeps
|
|
1416
|
-
//
|
|
1560
|
+
// An existing identity keeps its visibility: re-creating with another is refused, so a private
|
|
1561
|
+
// one is never made shared. Its profile takes the fields given, and keeps the rest.
|
|
1417
1562
|
if (params.tenantRef !== undefined && params.externalRef !== undefined) {
|
|
1418
1563
|
rows = await executor `
|
|
1419
|
-
INSERT INTO slotlock.resources (
|
|
1420
|
-
|
|
1564
|
+
INSERT INTO slotlock.resources (
|
|
1565
|
+
external_ref, tenant_ref, timezone, visibility, name, description, kind, capacity, labels, attributes,
|
|
1566
|
+
location_ref
|
|
1567
|
+
)
|
|
1568
|
+
VALUES (
|
|
1569
|
+
${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility},
|
|
1570
|
+
${profile.name}::text, ${profile.description}::text, COALESCE(${profile.kind}::text, 'other'),
|
|
1571
|
+
${profile.capacity}::integer,
|
|
1572
|
+
COALESCE(pg_catalog.string_to_array(${profile.labels}::text, ','), '{}'::text[]),
|
|
1573
|
+
COALESCE(${profile.attributes}::text::jsonb, '{}'::jsonb),
|
|
1574
|
+
${params.location ?? null}::text
|
|
1575
|
+
)
|
|
1421
1576
|
ON CONFLICT (tenant_ref, external_ref)
|
|
1422
1577
|
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
|
|
1423
|
-
DO UPDATE SET timezone = EXCLUDED.timezone
|
|
1578
|
+
DO UPDATE SET timezone = EXCLUDED.timezone,
|
|
1579
|
+
name = CASE WHEN ${profile.setName}::boolean THEN EXCLUDED.name ELSE slotlock.resources.name END,
|
|
1580
|
+
description = CASE WHEN ${profile.setDescription}::boolean THEN EXCLUDED.description
|
|
1581
|
+
ELSE slotlock.resources.description END,
|
|
1582
|
+
kind = CASE WHEN ${profile.setKind}::boolean THEN EXCLUDED.kind ELSE slotlock.resources.kind END,
|
|
1583
|
+
capacity = CASE WHEN ${profile.setCapacity}::boolean THEN EXCLUDED.capacity ELSE slotlock.resources.capacity END,
|
|
1584
|
+
labels = CASE WHEN ${profile.setLabels}::boolean THEN EXCLUDED.labels ELSE slotlock.resources.labels END,
|
|
1585
|
+
attributes = CASE WHEN ${profile.setAttributes}::boolean THEN EXCLUDED.attributes
|
|
1586
|
+
ELSE slotlock.resources.attributes END,
|
|
1587
|
+
location_ref = CASE WHEN ${params.location !== undefined}::boolean THEN EXCLUDED.location_ref
|
|
1588
|
+
ELSE slotlock.resources.location_ref END
|
|
1424
1589
|
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1425
|
-
RETURNING
|
|
1590
|
+
RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1426
1591
|
}
|
|
1427
1592
|
else if (params.externalRef !== undefined) {
|
|
1593
|
+
// A legacy resource (no tenant) has no profile.
|
|
1428
1594
|
rows = await executor `
|
|
1429
1595
|
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1430
1596
|
VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
@@ -1432,13 +1598,13 @@ async function upsertResource(executor, params) {
|
|
|
1432
1598
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
|
|
1433
1599
|
DO UPDATE SET external_ref = EXCLUDED.external_ref
|
|
1434
1600
|
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1435
|
-
RETURNING
|
|
1601
|
+
RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1436
1602
|
}
|
|
1437
1603
|
else {
|
|
1438
1604
|
rows = await executor `
|
|
1439
1605
|
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1440
1606
|
VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
1441
|
-
RETURNING
|
|
1607
|
+
RETURNING ${executor.unsafe(RESOURCE_COLUMNS)}, ${executor.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
1442
1608
|
}
|
|
1443
1609
|
const row = rows[0];
|
|
1444
1610
|
if (!row) {
|
|
@@ -1449,11 +1615,9 @@ async function upsertResource(executor, params) {
|
|
|
1449
1615
|
return toResource(row);
|
|
1450
1616
|
}
|
|
1451
1617
|
/**
|
|
1452
|
-
* Rematerialize one stored recurring master over
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
* and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
|
|
1456
|
-
* either rolls back whole.
|
|
1618
|
+
* Rematerialize one stored recurring master over rolledWindow: from its last expansion's cursor
|
|
1619
|
+
* when trustworthy, else its start. False when there is nothing to roll. A collision throws 23P01
|
|
1620
|
+
* and an over-budget rule CalendarExpansionLimitError; run it in a savepoint.
|
|
1457
1621
|
*/
|
|
1458
1622
|
async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
1459
1623
|
const [row] = await tx `
|
|
@@ -1499,8 +1663,8 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
|
1499
1663
|
return true;
|
|
1500
1664
|
}
|
|
1501
1665
|
/**
|
|
1502
|
-
* Work out and store when a series stored before 0.3 ends
|
|
1503
|
-
*
|
|
1666
|
+
* Work out and store when a series stored before 0.3 ends, as its next roll would; its occurrences
|
|
1667
|
+
* are left as they are.
|
|
1504
1668
|
*/
|
|
1505
1669
|
async function learnSeriesEnd(tx, tenantRef, eventId) {
|
|
1506
1670
|
const [row] = await tx `
|
|
@@ -1522,7 +1686,6 @@ async function learnSeriesEnd(tx, tenantRef, eventId) {
|
|
|
1522
1686
|
SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
|
|
1523
1687
|
WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
|
|
1524
1688
|
}
|
|
1525
|
-
/** Put a master the roll could not extend behind the others for an hour. */
|
|
1526
1689
|
async function deferRoll(executor, tenantRef, eventId) {
|
|
1527
1690
|
await executor `
|
|
1528
1691
|
UPDATE slotlock.calendar_events
|
|
@@ -1531,24 +1694,19 @@ async function deferRoll(executor, tenantRef, eventId) {
|
|
|
1531
1694
|
}
|
|
1532
1695
|
/**
|
|
1533
1696
|
* Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
|
|
1534
|
-
* part of [start, end)
|
|
1535
|
-
*
|
|
1536
|
-
*
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
* write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
|
|
1540
|
-
* past the horizon and so does the write, it collides, or its rule is over the budget). Series are
|
|
1541
|
-
* rolled in id order; an event's update holds its own row while it rolls the others, so it and a
|
|
1542
|
-
* write rolling the same series in that order can deadlock, which the database breaks (the writer
|
|
1543
|
-
* retries once, then answers an overlap).
|
|
1697
|
+
* part of [start, end) a roll can reach. A write must not land where a series is not stored yet, or
|
|
1698
|
+
* the series' next roll would collide with it. A series behind is first rolled to the horizon in a
|
|
1699
|
+
* savepoint, so the write meets its occurrences under the exclusion arbiter; false when one cannot
|
|
1700
|
+
* be. Series roll in id order; a deadlock with a concurrent writer is broken by the database
|
|
1701
|
+
* (retried once, then an overlap).
|
|
1544
1702
|
*/
|
|
1545
1703
|
async function recurrenceCoversWrite(tx, params) {
|
|
1546
1704
|
const horizon = calendarEventRollingHorizon();
|
|
1547
1705
|
if (params.end <= horizon.start)
|
|
1548
1706
|
return true;
|
|
1549
1707
|
const from = params.start > horizon.start ? params.start : horizon.start;
|
|
1550
|
-
// LEAST and GREATEST ignore
|
|
1551
|
-
//
|
|
1708
|
+
// LEAST and GREATEST ignore NULL arguments (PostgreSQL 16): a series with no known end is behind
|
|
1709
|
+
// wherever it is stored short of the write's end.
|
|
1552
1710
|
const behind = () => tx `
|
|
1553
1711
|
SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
|
|
1554
1712
|
COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
|
|
@@ -1578,8 +1736,8 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1578
1736
|
if (due.length === 0)
|
|
1579
1737
|
return true;
|
|
1580
1738
|
if (params.end > horizon.end) {
|
|
1581
|
-
// A series stored before 0.3
|
|
1582
|
-
//
|
|
1739
|
+
// A series stored before 0.3 works out its end now, and is left out if it ends before the write
|
|
1740
|
+
// or is stored through its end.
|
|
1583
1741
|
if (due.some((row) => row.ends_by_unknown)) {
|
|
1584
1742
|
for (const { id, ends_by_unknown } of due) {
|
|
1585
1743
|
if (ends_by_unknown)
|
|
@@ -1589,7 +1747,6 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1589
1747
|
if (due.length === 0)
|
|
1590
1748
|
return true;
|
|
1591
1749
|
}
|
|
1592
|
-
// No roll stores a series past the horizon: one going on past it stays behind such a write.
|
|
1593
1750
|
if (due.some((row) => !row.ends_in_horizon))
|
|
1594
1751
|
return false;
|
|
1595
1752
|
}
|
|
@@ -1607,9 +1764,8 @@ async function recurrenceCoversWrite(tx, params) {
|
|
|
1607
1764
|
return (await behind()).length === 0;
|
|
1608
1765
|
}
|
|
1609
1766
|
/**
|
|
1610
|
-
* Whether an event already occupies
|
|
1611
|
-
*
|
|
1612
|
-
* to where the series on the resource are stored.
|
|
1767
|
+
* Whether an event already occupies all the time `occurrences` would: such a write (a rename) takes
|
|
1768
|
+
* no time a series could later need.
|
|
1613
1769
|
*/
|
|
1614
1770
|
async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
|
|
1615
1771
|
const held = await tx `
|
|
@@ -1620,7 +1776,6 @@ async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
|
|
|
1620
1776
|
AND reservation.resource_id = ${resourceId}`;
|
|
1621
1777
|
return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
|
|
1622
1778
|
}
|
|
1623
|
-
/** From the earliest start to the latest end of an event's occurrences, or null for none. */
|
|
1624
1779
|
function occurrenceSpan(occurrences) {
|
|
1625
1780
|
const [first] = occurrences;
|
|
1626
1781
|
if (!first)
|
|
@@ -1636,10 +1791,9 @@ function occurrenceSpan(occurrences) {
|
|
|
1636
1791
|
return { start, end };
|
|
1637
1792
|
}
|
|
1638
1793
|
/**
|
|
1639
|
-
* After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
* it fails, the series are left to maintenance and the write is still answered as a loss.
|
|
1794
|
+
* After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series the
|
|
1795
|
+
* write's transaction rolled and lost, so the loss can name the blocking occurrence. Upkeep only:
|
|
1796
|
+
* on failure, maintenance rolls them later.
|
|
1643
1797
|
*/
|
|
1644
1798
|
async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
|
|
1645
1799
|
try {
|
|
@@ -1677,35 +1831,30 @@ function tenantLimitsOption(limits) {
|
|
|
1677
1831
|
return valid;
|
|
1678
1832
|
}
|
|
1679
1833
|
/**
|
|
1680
|
-
* Serialize
|
|
1681
|
-
*
|
|
1834
|
+
* Serialize a tenant's writes adding rows of one limited kind, so each counts what earlier ones
|
|
1835
|
+
* committed. Taken after the identity locks, before any row lock.
|
|
1682
1836
|
*/
|
|
1683
1837
|
async function lockTenantLimit(tx, tenantRef, kind) {
|
|
1684
1838
|
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
1685
1839
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
|
|
1686
1840
|
}
|
|
1687
1841
|
/**
|
|
1688
|
-
* Rows
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
* event idempotency records and stray retirements at least as long (the store's `retentionDays`).
|
|
1842
|
+
* Rows added within this many days count whatever time they hold, so back-dated bookings and
|
|
1843
|
+
* cancellations add at most the limit per window. Maintenance keeps idempotency records and stray
|
|
1844
|
+
* retirements at least this long.
|
|
1692
1845
|
*/
|
|
1693
1846
|
const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
|
|
1694
1847
|
/**
|
|
1695
|
-
* The rows a tenant
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
* however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
|
|
1699
|
-
* at it.
|
|
1848
|
+
* The rows a tenant's `kind` limit counts: what is still to come plus what it added in the last
|
|
1849
|
+
* TENANT_LIMIT_RECENT_DAYS days. Each part stops at `limit` rows read through an index, so a count
|
|
1850
|
+
* costs at most the limit.
|
|
1700
1851
|
*/
|
|
1701
1852
|
async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
1702
1853
|
let rows;
|
|
1703
1854
|
switch (kind) {
|
|
1704
1855
|
case 'holds':
|
|
1705
|
-
// Every held row
|
|
1706
|
-
//
|
|
1707
|
-
// each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
|
|
1708
|
-
// is forgotten. An answer whose hold is still a held row is left out: that row counts.
|
|
1856
|
+
// Every held row until deleted (expired ones too), plus each keyed hold's kept answer once
|
|
1857
|
+
// its row is gone.
|
|
1709
1858
|
rows = await tx `
|
|
1710
1859
|
SELECT (
|
|
1711
1860
|
(SELECT count(*) FROM (
|
|
@@ -1725,9 +1874,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1725
1874
|
)::int AS count`;
|
|
1726
1875
|
break;
|
|
1727
1876
|
case 'reservations':
|
|
1728
|
-
// Bookings still to come; bookings
|
|
1729
|
-
//
|
|
1730
|
-
// tombstone is kept for good, and stops counting once it is no longer recent.
|
|
1877
|
+
// Bookings still to come; ended bookings and cancellations made recently; and stray
|
|
1878
|
+
// retirements until pruned.
|
|
1731
1879
|
rows = await tx `
|
|
1732
1880
|
WITH recent AS (
|
|
1733
1881
|
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
@@ -1755,9 +1903,9 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1755
1903
|
)::int AS count`;
|
|
1756
1904
|
break;
|
|
1757
1905
|
case 'events':
|
|
1758
|
-
// One-off events still to come and series not known to have ended;
|
|
1759
|
-
// and cancellations
|
|
1760
|
-
//
|
|
1906
|
+
// One-off events still to come and series not known to have ended; recent ended events and
|
|
1907
|
+
// cancellations; and agent cancellations until pruned. The owner CHECK makes every owner but
|
|
1908
|
+
// 'internal' an 'agent:' one.
|
|
1761
1909
|
rows = await tx `
|
|
1762
1910
|
WITH recent AS (
|
|
1763
1911
|
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
@@ -1802,9 +1950,8 @@ async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
|
1802
1950
|
return count;
|
|
1803
1951
|
}
|
|
1804
1952
|
/**
|
|
1805
|
-
* Delete up to `limit` of a tenant's holds
|
|
1806
|
-
*
|
|
1807
|
-
* predicate on the row as it now is.
|
|
1953
|
+
* Delete up to `limit` of a tenant's expired holds, oldest first. Rows a reaper or confirmer has
|
|
1954
|
+
* locked are skipped, and the delete rechecks the predicate.
|
|
1808
1955
|
*/
|
|
1809
1956
|
async function deleteExpiredHolds(tx, tenantRef, limit) {
|
|
1810
1957
|
const [row] = await tx `
|
|
@@ -1828,8 +1975,8 @@ async function deleteExpiredHolds(tx, tenantRef, limit) {
|
|
|
1828
1975
|
return Number(row?.deleted ?? 0);
|
|
1829
1976
|
}
|
|
1830
1977
|
/**
|
|
1831
|
-
* Delete up to `limit`
|
|
1832
|
-
*
|
|
1978
|
+
* Delete up to `limit` keyed hold answers whose hold has expired, so a retry makes a new hold;
|
|
1979
|
+
* locked answers are skipped.
|
|
1833
1980
|
*/
|
|
1834
1981
|
async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
1835
1982
|
const [row] = await tx `
|
|
@@ -1852,8 +1999,8 @@ async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
|
1852
1999
|
return Number(row?.deleted ?? 0);
|
|
1853
2000
|
}
|
|
1854
2001
|
/**
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
2002
|
+
* A booking's occupied time as the exclusion constraint reads it: start less its leading buffer to
|
|
2003
|
+
* end plus its trailing one.
|
|
1857
2004
|
*/
|
|
1858
2005
|
function occupiedTime(params) {
|
|
1859
2006
|
return {
|
|
@@ -1862,8 +2009,8 @@ function occupiedTime(params) {
|
|
|
1862
2009
|
};
|
|
1863
2010
|
}
|
|
1864
2011
|
/**
|
|
1865
|
-
*
|
|
1866
|
-
*
|
|
2012
|
+
* Occupied start before `end`, written so the (resource_id, starts_at, ends_at) index still
|
|
2013
|
+
* applies: no row starts more than a day after its occupied start.
|
|
1867
2014
|
*/
|
|
1868
2015
|
function occupiedStartsBefore(executor, end, alias) {
|
|
1869
2016
|
const column = (name) => executor.unsafe(alias === undefined ? name : `${alias}.${name}`);
|
|
@@ -1871,19 +2018,21 @@ function occupiedStartsBefore(executor, end, alias) {
|
|
|
1871
2018
|
AND COALESCE(${column('occupied_starts_at')}, ${column('starts_at')}) < ${end})`;
|
|
1872
2019
|
}
|
|
1873
2020
|
/**
|
|
1874
|
-
* Delete expired held rows whose occupied time
|
|
1875
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
* order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
|
|
1878
|
-
* deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
|
|
1879
|
-
* before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
|
|
2021
|
+
* Delete expired held rows whose occupied time overlaps the window, in id order under FOR UPDATE:
|
|
2022
|
+
* concurrent reapers lock in the same order (no deadlock) and the loser finds them gone. Runs right
|
|
2023
|
+
* before the caller's INSERT, so the EXCLUDE arbiter only sees live occupation.
|
|
1880
2024
|
*/
|
|
1881
2025
|
async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
2026
|
+
// A composite's parent and parts too (RFC 0003 G8): their expired holds' claims are in the way.
|
|
1882
2027
|
await tx `
|
|
1883
2028
|
DELETE FROM slotlock.reservations
|
|
1884
2029
|
WHERE id IN (
|
|
1885
2030
|
SELECT id FROM slotlock.reservations
|
|
1886
|
-
WHERE resource_id
|
|
2031
|
+
WHERE resource_id IN (
|
|
2032
|
+
SELECT ${resourceId}::uuid
|
|
2033
|
+
UNION ALL SELECT family.parent_id FROM slotlock.resources family
|
|
2034
|
+
WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
|
|
2035
|
+
UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
|
|
1887
2036
|
AND status = 'held'
|
|
1888
2037
|
AND expires_at <= statement_timestamp()
|
|
1889
2038
|
AND ${occupiedStartsBefore(tx, end)}
|
|
@@ -1893,10 +2042,65 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
1893
2042
|
)`;
|
|
1894
2043
|
}
|
|
1895
2044
|
/**
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
1898
|
-
*
|
|
1899
|
-
|
|
2045
|
+
* reapExpiredHolds for many windows on one resource in one statement (a series' occurrences): the
|
|
2046
|
+
* same rows, in the same id order under FOR UPDATE, as one call per window would delete. The
|
|
2047
|
+
* envelope of the windows bounds the scan so the index applies; each row must still overlap one.
|
|
2048
|
+
*/
|
|
2049
|
+
async function reapExpiredHoldsAcross(tx, resourceId, windows) {
|
|
2050
|
+
if (windows.length === 0)
|
|
2051
|
+
return;
|
|
2052
|
+
let first = Number.POSITIVE_INFINITY;
|
|
2053
|
+
let last = Number.NEGATIVE_INFINITY;
|
|
2054
|
+
for (const window of windows) {
|
|
2055
|
+
first = Math.min(first, window.start.getTime());
|
|
2056
|
+
last = Math.max(last, window.end.getTime());
|
|
2057
|
+
}
|
|
2058
|
+
await tx `
|
|
2059
|
+
DELETE FROM slotlock.reservations
|
|
2060
|
+
WHERE id IN (
|
|
2061
|
+
SELECT held.id FROM slotlock.reservations held
|
|
2062
|
+
WHERE held.resource_id IN (
|
|
2063
|
+
SELECT ${resourceId}::uuid
|
|
2064
|
+
UNION ALL SELECT family.parent_id FROM slotlock.resources family
|
|
2065
|
+
WHERE family.id = ${resourceId} AND family.parent_id IS NOT NULL
|
|
2066
|
+
UNION ALL SELECT family.id FROM slotlock.resources family WHERE family.parent_id = ${resourceId})
|
|
2067
|
+
AND held.status = 'held'
|
|
2068
|
+
AND held.expires_at <= statement_timestamp()
|
|
2069
|
+
AND ${occupiedStartsBefore(tx, new Date(last), 'held')}
|
|
2070
|
+
AND COALESCE(held.occupied_ends_at, held.ends_at) > ${new Date(first)}
|
|
2071
|
+
AND EXISTS (
|
|
2072
|
+
SELECT 1
|
|
2073
|
+
FROM ROWS FROM (pg_catalog.unnest(${windows.map((window) => window.start.toISOString())}::text[]::timestamptz[]),
|
|
2074
|
+
pg_catalog.unnest(${windows.map((window) => window.end.toISOString())}::text[]::timestamptz[]))
|
|
2075
|
+
AS occupied(starts_at, ends_at)
|
|
2076
|
+
WHERE COALESCE(held.occupied_starts_at, held.starts_at) < occupied.ends_at
|
|
2077
|
+
AND COALESCE(held.occupied_ends_at, held.ends_at) > occupied.starts_at)
|
|
2078
|
+
ORDER BY held.id
|
|
2079
|
+
FOR UPDATE
|
|
2080
|
+
)`;
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* What else keeps a resource busy as part of a composite (RFC 0003 G8), as rows of
|
|
2084
|
+
* (occupied_starts_at, occupied_ends_at): a part, the bookings of its parent as a whole; a parent,
|
|
2085
|
+
* the bookings of any of its parts. Its own bookings are read with its own rows. Nothing for a
|
|
2086
|
+
* resource that is neither.
|
|
2087
|
+
*/
|
|
2088
|
+
function compositeBusy(executor, resourceId, window) {
|
|
2089
|
+
return executor `
|
|
2090
|
+
SELECT lower(claim.during) AS occupied_starts_at, upper(claim.during) AS occupied_ends_at
|
|
2091
|
+
FROM slotlock.resources me
|
|
2092
|
+
JOIN slotlock.composite_claims claim
|
|
2093
|
+
ON claim.resource_id = COALESCE(me.parent_id, me.id)
|
|
2094
|
+
AND claim.booked_resource_id <> me.id
|
|
2095
|
+
AND (me.parent_id IS NULL OR claim.span @> me.part_index)
|
|
2096
|
+
WHERE me.id = ${resourceId}
|
|
2097
|
+
AND claim.during && tstzrange(${window.start}, ${window.end}, '[)')
|
|
2098
|
+
AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())`;
|
|
2099
|
+
}
|
|
2100
|
+
/**
|
|
2101
|
+
* Structured answer after losing to the EXCLUDE arbiter: name the live winner.
|
|
2102
|
+
* conflictingReservationId is null in one narrow race (the blocker expired between the constraint
|
|
2103
|
+
* check and this lookup); an immediate retry reaps it and wins.
|
|
1900
2104
|
*/
|
|
1901
2105
|
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId, bufferBeforeMs = 0) {
|
|
1902
2106
|
const winners = await sql `
|
|
@@ -1917,7 +2121,27 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
|
|
|
1917
2121
|
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
1918
2122
|
ORDER BY starts_at
|
|
1919
2123
|
LIMIT 1`;
|
|
1920
|
-
|
|
2124
|
+
if (winners[0])
|
|
2125
|
+
return { ok: false, code: 'overlap', conflictingReservationId: winners[0].id };
|
|
2126
|
+
// Lost to a composite's claim (RFC 0003 G8): the booking on the whole, or on a part, in the way.
|
|
2127
|
+
const claimed = await sql `
|
|
2128
|
+
SELECT claim.reservation_id::text AS id
|
|
2129
|
+
FROM slotlock.resources me
|
|
2130
|
+
JOIN slotlock.composite_claims claim
|
|
2131
|
+
ON claim.resource_id = COALESCE(me.parent_id, me.id)
|
|
2132
|
+
AND claim.booked_resource_id <> me.id
|
|
2133
|
+
AND (me.parent_id IS NULL OR claim.span @> me.part_index)
|
|
2134
|
+
WHERE me.id = ${resourceId}
|
|
2135
|
+
AND (${excludeReservationId ?? null}::uuid IS NULL OR claim.reservation_id <> ${excludeReservationId ?? null}::uuid)
|
|
2136
|
+
AND claim.during && tstzrange(
|
|
2137
|
+
${start}::timestamptz - ${bufferBeforeMs} * interval '1 millisecond',
|
|
2138
|
+
${end}::timestamptz + ${bufferAfterMs} * interval '1 millisecond',
|
|
2139
|
+
'[)'
|
|
2140
|
+
)
|
|
2141
|
+
AND (claim.status <> 'held' OR claim.expires_at > statement_timestamp())
|
|
2142
|
+
ORDER BY lower(claim.during)
|
|
2143
|
+
LIMIT 1`;
|
|
2144
|
+
return { ok: false, code: 'overlap', conflictingReservationId: claimed[0]?.id ?? null };
|
|
1921
2145
|
}
|
|
1922
2146
|
/** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
|
|
1923
2147
|
function holdTenantRef(options) {
|
|
@@ -1967,7 +2191,7 @@ export function createTenantSlots(max) {
|
|
|
1967
2191
|
if (released)
|
|
1968
2192
|
return;
|
|
1969
2193
|
released = true;
|
|
1970
|
-
// Handed straight to the next in line, so the count
|
|
2194
|
+
// Handed straight to the next in line, so the count is unchanged.
|
|
1971
2195
|
const next = slots.waiting.shift();
|
|
1972
2196
|
if (next) {
|
|
1973
2197
|
next();
|
|
@@ -1991,6 +2215,14 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1991
2215
|
*/
|
|
1992
2216
|
export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
1993
2217
|
'createResource',
|
|
2218
|
+
'updateResourceProfile',
|
|
2219
|
+
'putLocation',
|
|
2220
|
+
'updateLocation',
|
|
2221
|
+
'deleteLocation',
|
|
2222
|
+
'putGroup',
|
|
2223
|
+
'deleteGroup',
|
|
2224
|
+
'addGroupMember',
|
|
2225
|
+
'removeGroupMember',
|
|
1994
2226
|
'archiveResource',
|
|
1995
2227
|
'restoreResource',
|
|
1996
2228
|
'deleteResource',
|
|
@@ -1998,6 +2230,17 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
1998
2230
|
'setResourceBookingRules',
|
|
1999
2231
|
'setBookingRuleDefaults',
|
|
2000
2232
|
'setResourcePool',
|
|
2233
|
+
'setResourceParent',
|
|
2234
|
+
'setPoolOrder',
|
|
2235
|
+
'notePoolBooking',
|
|
2236
|
+
'putApprovalPolicy',
|
|
2237
|
+
'deleteApprovalPolicy',
|
|
2238
|
+
'requestApproval',
|
|
2239
|
+
'beginApprovalGrant',
|
|
2240
|
+
'finishApprovalGrant',
|
|
2241
|
+
'declineApproval',
|
|
2242
|
+
'resetTestTenant',
|
|
2243
|
+
'fastForwardTestTenant',
|
|
2001
2244
|
'setTenantAvailability',
|
|
2002
2245
|
'createReservation',
|
|
2003
2246
|
'createExternalReservation',
|
|
@@ -2020,6 +2263,7 @@ export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
|
2020
2263
|
'leaveWaitlist',
|
|
2021
2264
|
'putAssistantSettings',
|
|
2022
2265
|
'deleteAssistantSettings',
|
|
2266
|
+
'resealAssistantKey',
|
|
2023
2267
|
'putAssistantConversation',
|
|
2024
2268
|
'settleAssistantAction',
|
|
2025
2269
|
'deleteAssistantConversation',
|
|
@@ -2033,9 +2277,8 @@ function staleSuspensionCheckError() {
|
|
|
2033
2277
|
return Object.assign(new Error('Slotlock writes for a tenant only in a READ COMMITTED transaction, whose suspension check sees every suspension committed before the tenant lock'), { code: 'invalid_transaction_isolation' });
|
|
2034
2278
|
}
|
|
2035
2279
|
/**
|
|
2036
|
-
* Reading a
|
|
2037
|
-
*
|
|
2038
|
-
* Name the remedy for each; any other failure passes through.
|
|
2280
|
+
* Reading a suspension needs EXECUTE on slotlock.tenant_suspended(text) (42501 without it) and a
|
|
2281
|
+
* schema this release migrated (42883 before). Name the remedy for each.
|
|
2039
2282
|
*/
|
|
2040
2283
|
function suspensionCheckRefusal(error) {
|
|
2041
2284
|
const code = pgCode(error);
|
|
@@ -2048,8 +2291,8 @@ function suspensionCheckRefusal(error) {
|
|
|
2048
2291
|
return error;
|
|
2049
2292
|
}
|
|
2050
2293
|
/**
|
|
2051
|
-
* A store on `sql`. With `refusal`, every method
|
|
2052
|
-
*
|
|
2294
|
+
* A store on `sql`. With `refusal`, every SLOTLOCK_TENANT_WRITE_METHODS method throws it before any
|
|
2295
|
+
* query.
|
|
2053
2296
|
*/
|
|
2054
2297
|
function buildSlotlockStore(sql, options, refusal) {
|
|
2055
2298
|
const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
|
|
@@ -2096,7 +2339,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2096
2339
|
? createTenantSlots(maxTenantTransactions)
|
|
2097
2340
|
: undefined;
|
|
2098
2341
|
const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
|
|
2099
|
-
createSlotlockTenantRlsDdl(configuredTenantSetting);
|
|
2342
|
+
createSlotlockTenantRlsDdl(configuredTenantSetting);
|
|
2100
2343
|
const storeOptions = {
|
|
2101
2344
|
tenantContextSetting: configuredTenantSetting,
|
|
2102
2345
|
agentOwnerEventQuota: configuredAgentOwnerEventQuota,
|
|
@@ -2107,9 +2350,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2107
2350
|
...(maxHeldHoursPerHolder !== undefined ? { maxHeldHoursPerHolder } : {}),
|
|
2108
2351
|
};
|
|
2109
2352
|
/**
|
|
2110
|
-
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null.
|
|
2111
|
-
*
|
|
2112
|
-
* read early stays an upper bound for the rest of the transaction.
|
|
2353
|
+
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. Writes that
|
|
2354
|
+
* only lower the count take no lock, so an early count stays an upper bound.
|
|
2113
2355
|
*/
|
|
2114
2356
|
async function tenantLimitReached(tx, tenantRef, kind) {
|
|
2115
2357
|
const limit = configuredTenantLimits[kind];
|
|
@@ -2119,10 +2361,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2119
2361
|
await lockTenantLimit(tx, tenantRef, kind);
|
|
2120
2362
|
if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
|
|
2121
2363
|
return null;
|
|
2122
|
-
//
|
|
2123
|
-
//
|
|
2124
|
-
// is read again. Counting expired rows until then keeps what a tenant stores within the limit
|
|
2125
|
-
// between maintenance passes, however short its holds.
|
|
2364
|
+
// At the limit, expired holds and their answers are deleted, as maintenance would, and the
|
|
2365
|
+
// count is read again.
|
|
2126
2366
|
if (kind !== 'holds' ||
|
|
2127
2367
|
(await deleteExpiredHolds(tx, tenantRef, limit)) +
|
|
2128
2368
|
(await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
|
|
@@ -2133,7 +2373,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2133
2373
|
}
|
|
2134
2374
|
/**
|
|
2135
2375
|
* The tenant whose limit a write on the resource counts against: the caller's, else the
|
|
2136
|
-
* resource's (
|
|
2376
|
+
* resource's (never changes, so read before any lock). Null with no limit.
|
|
2137
2377
|
*/
|
|
2138
2378
|
async function limitTenant(tx, kind, resourceId, tenantRef) {
|
|
2139
2379
|
if (configuredTenantLimits[kind] === undefined)
|
|
@@ -2147,12 +2387,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2147
2387
|
const store = {
|
|
2148
2388
|
...slotlockEventFeedMethods(sql, retentionDays),
|
|
2149
2389
|
async applySchema(migrationOptions) {
|
|
2150
|
-
//
|
|
2151
|
-
//
|
|
2152
|
-
//
|
|
2153
|
-
// crashes. A schema this release already left as it is changes nothing and locks no table,
|
|
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).
|
|
2390
|
+
// IF NOT EXISTS DDL is idempotent but not race-free: two fresh instances can collide on
|
|
2391
|
+
// pg_namespace/pg_extension indexes, so inMigration serializes bootstrap. An unchanged schema
|
|
2392
|
+
// locks no table unless a constraint is still NOT VALID or the coverage rename is unfinished.
|
|
2156
2393
|
const next = await inMigration(sql, migrationOptions, async (tx) => {
|
|
2157
2394
|
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
2158
2395
|
throw schemaControlRefusal(error);
|
|
@@ -2168,18 +2405,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2168
2405
|
});
|
|
2169
2406
|
return { validate: true, rename: await coverageChangesRenamePending(tx) };
|
|
2170
2407
|
});
|
|
2171
|
-
//
|
|
2172
|
-
//
|
|
2173
|
-
// (renameEarlierCoverageChanges); the feed serves any not renamed yet redacted (event-feed.ts,
|
|
2174
|
-
// slotlockServedChange).
|
|
2408
|
+
// Renamed in short batches after the structure commits, never under its locks; the feed
|
|
2409
|
+
// redacts any not yet renamed (event-feed.ts, slotlockServedChange).
|
|
2175
2410
|
if (next.rename)
|
|
2176
2411
|
await renameEarlierCoverageChanges(sql, migrationOptions);
|
|
2177
2412
|
if (!next.validate)
|
|
2178
2413
|
return;
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
// migration stopped before them runs whole again.
|
|
2414
|
+
// After the DDL commits its ACCESS EXCLUSIVE locks are gone, so validation blocks no serving
|
|
2415
|
+
// query (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Digests go last, so a migration stopped
|
|
2416
|
+
// earlier runs whole again.
|
|
2183
2417
|
await inMigration(sql, migrationOptions, async (tx) => {
|
|
2184
2418
|
await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
|
|
2185
2419
|
await tx `
|
|
@@ -2348,9 +2582,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2348
2582
|
if (keyId !== undefined && !isValidResourceId(keyId)) {
|
|
2349
2583
|
throw Object.assign(new Error('Slotlock key id must be a UUID'), { code: 'invalid_identity' });
|
|
2350
2584
|
}
|
|
2585
|
+
const scope = options?.resourceScope;
|
|
2586
|
+
if (scope !== undefined && !isSlotlockResourceScope(scope)) {
|
|
2587
|
+
throw Object.assign(new Error('Slotlock resource scope must name locations or groups by reference'), {
|
|
2588
|
+
code: 'invalid_identity',
|
|
2589
|
+
});
|
|
2590
|
+
}
|
|
2351
2591
|
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
2352
|
-
// A lock
|
|
2353
|
-
//
|
|
2592
|
+
// A lock timeout leaves the transaction unusable, so the reset below would fail with another
|
|
2593
|
+
// error: noted here.
|
|
2354
2594
|
let timedOut = false;
|
|
2355
2595
|
return inTransaction(sql, async (tx) => {
|
|
2356
2596
|
if (lockTimeout !== undefined) {
|
|
@@ -2358,9 +2598,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2358
2598
|
}
|
|
2359
2599
|
if (isolation !== undefined)
|
|
2360
2600
|
await assertReadCommitted(tx);
|
|
2361
|
-
// Held shared until the transaction ends: suspension and erasure
|
|
2362
|
-
//
|
|
2363
|
-
// (READ COMMITTED: a statement after the lock sees what committed before it was granted).
|
|
2601
|
+
// Held shared until the transaction ends: suspension and erasure take it exclusively, so
|
|
2602
|
+
// they wait for this transaction, and one starting after them sees them (READ COMMITTED).
|
|
2364
2603
|
const previous = await tx `
|
|
2365
2604
|
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
2366
2605
|
current_setting('slotlock.actor', true) AS actor,
|
|
@@ -2376,12 +2615,29 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2376
2615
|
set_config('slotlock.origin', COALESCE(${origin ?? null}::text,
|
|
2377
2616
|
NULLIF(current_setting('slotlock.origin', true), ''), 'library'), true) AS origin,
|
|
2378
2617
|
set_config('slotlock.key_id', ${keyId ?? ''}, true) AS key,
|
|
2618
|
+
current_setting(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, true) AS scope,
|
|
2379
2619
|
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
2380
2620
|
throw suspensionCheckRefusal(error);
|
|
2381
2621
|
});
|
|
2382
|
-
// A
|
|
2383
|
-
//
|
|
2384
|
-
|
|
2622
|
+
// A limited key's resources, read in the tenant's context (and within an enclosing
|
|
2623
|
+
// scope, if one is set), then held for the rest of the transaction.
|
|
2624
|
+
if (scope !== undefined) {
|
|
2625
|
+
await tx `
|
|
2626
|
+
SELECT set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, '{' || COALESCE((
|
|
2627
|
+
SELECT string_agg(held.id::text, ',' ORDER BY held.id)
|
|
2628
|
+
FROM slotlock.resources held
|
|
2629
|
+
WHERE held.tenant_ref = ${tenantRef}
|
|
2630
|
+
AND ((held.location_ref IS NOT NULL
|
|
2631
|
+
AND slotlock.location_path(held.tenant_ref, held.location_ref)
|
|
2632
|
+
&& pg_catalog.string_to_array(${scope.locations.join(',')}::text, ','))
|
|
2633
|
+
OR EXISTS (
|
|
2634
|
+
SELECT 1 FROM slotlock.resource_group_members member
|
|
2635
|
+
WHERE member.tenant_ref = held.tenant_ref AND member.resource_id = held.id
|
|
2636
|
+
AND member.group_ref = ANY (pg_catalog.string_to_array(${scope.groups.join(',')}::text, ','))))
|
|
2637
|
+
), '') || '}', true)`;
|
|
2638
|
+
}
|
|
2639
|
+
// A caller's REPEATABLE READ or SERIALIZABLE snapshot predates the lock, so it may miss a
|
|
2640
|
+
// suspension: its writes are refused, reads allowed.
|
|
2385
2641
|
const refusal = context?.suspended === true
|
|
2386
2642
|
? tenantSuspendedError
|
|
2387
2643
|
: previous[0]?.isolation !== 'read committed'
|
|
@@ -2399,7 +2655,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2399
2655
|
await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true),
|
|
2400
2656
|
set_config('slotlock.actor', ${previous[0]?.actor ?? ''}, true),
|
|
2401
2657
|
set_config('slotlock.origin', ${previous[0]?.origin ?? ''}, true),
|
|
2402
|
-
set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true)
|
|
2658
|
+
set_config('slotlock.key_id', ${previous[0]?.key ?? ''}, true),
|
|
2659
|
+
set_config(${SLOTLOCK_RESOURCE_SCOPE_SETTING}, ${context?.scope ?? ''}, true)`;
|
|
2403
2660
|
}
|
|
2404
2661
|
}, 'read committed')
|
|
2405
2662
|
.catch((error) => {
|
|
@@ -2412,10 +2669,51 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2412
2669
|
},
|
|
2413
2670
|
async createResource(params) {
|
|
2414
2671
|
assertValidResourceInput(params);
|
|
2672
|
+
if (params.profile !== undefined && params.tenantRef === undefined) {
|
|
2673
|
+
throw Object.assign(new Error('Slotlock gives a profile only to a resource with a tenant'), {
|
|
2674
|
+
code: 'invalid_resource_profile',
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2677
|
+
const { profile: given, maxTenantResources: _limit, location, ...identity } = params;
|
|
2678
|
+
if (location !== undefined) {
|
|
2679
|
+
if (params.tenantRef === undefined) {
|
|
2680
|
+
throw Object.assign(new Error('Slotlock gives a location only to a resource with a tenant'), {
|
|
2681
|
+
code: 'invalid_location',
|
|
2682
|
+
field: 'location',
|
|
2683
|
+
});
|
|
2684
|
+
}
|
|
2685
|
+
slotlockGroupingRef('invalid_location', location);
|
|
2686
|
+
// Without a time zone of its own, it takes its location's.
|
|
2687
|
+
if (identity.timezone === undefined) {
|
|
2688
|
+
const zone = await locationTimezone(sql, params.tenantRef, location);
|
|
2689
|
+
if (zone !== null)
|
|
2690
|
+
identity.timezone = zone;
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
const upsert = {
|
|
2694
|
+
...identity,
|
|
2695
|
+
...(given !== undefined ? { profile: parseSlotlockResourceProfile(given) } : {}),
|
|
2696
|
+
...(location !== undefined ? { location } : {}),
|
|
2697
|
+
};
|
|
2415
2698
|
const requested = params.maxTenantResources;
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2699
|
+
// The lower of the store's own limit and the tenant's plan (plans.ts), when it has one.
|
|
2700
|
+
const planned = params.tenantRef === undefined
|
|
2701
|
+
? undefined
|
|
2702
|
+
: (await sql `
|
|
2703
|
+
SELECT max_resources FROM slotlock.tenant_plan(${params.tenantRef}::text)`.catch((error) => {
|
|
2704
|
+
throw slotlockPlanReadRefusal(error);
|
|
2705
|
+
}))[0]?.max_resources;
|
|
2706
|
+
const storeLimit = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
|
|
2707
|
+
const configured = planned === undefined
|
|
2708
|
+
? storeLimit
|
|
2709
|
+
: storeLimit === undefined
|
|
2710
|
+
? planned
|
|
2711
|
+
: Math.min(planned, storeLimit);
|
|
2712
|
+
if (requested === undefined && configured === undefined) {
|
|
2713
|
+
if (location === undefined)
|
|
2714
|
+
return upsertResource(sql, upsert);
|
|
2715
|
+
return guardedWrite(sql, (tx) => upsertResource(tx, upsert), RESOURCE_LOCATION_MISSING);
|
|
2716
|
+
}
|
|
2419
2717
|
if (requested !== undefined &&
|
|
2420
2718
|
(!Number.isSafeInteger(requested) ||
|
|
2421
2719
|
requested < 1 ||
|
|
@@ -2436,7 +2734,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2436
2734
|
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
2437
2735
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
|
|
2438
2736
|
// READ COMMITTED: each statement below sees every create committed before the lock.
|
|
2439
|
-
// Re-creating a
|
|
2737
|
+
// Re-creating a known reference adds none.
|
|
2440
2738
|
const known = externalRef === null
|
|
2441
2739
|
? []
|
|
2442
2740
|
: await tx `
|
|
@@ -2449,7 +2747,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2449
2747
|
throw Object.assign(new Error(`Slotlock tenant already has ${max} resources, the most allowed here`), { code: 'resource_limit_reached', limit: max });
|
|
2450
2748
|
}
|
|
2451
2749
|
}
|
|
2452
|
-
|
|
2750
|
+
if (location === undefined)
|
|
2751
|
+
return upsertResource(tx, upsert);
|
|
2752
|
+
return guardedWrite(tx, (inner) => upsertResource(inner, upsert), RESOURCE_LOCATION_MISSING);
|
|
2453
2753
|
}, 'read committed');
|
|
2454
2754
|
},
|
|
2455
2755
|
async getResource(params) {
|
|
@@ -2460,13 +2760,193 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2460
2760
|
});
|
|
2461
2761
|
}
|
|
2462
2762
|
const rows = await sql `
|
|
2463
|
-
SELECT
|
|
2763
|
+
SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2464
2764
|
FROM slotlock.resources
|
|
2465
2765
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2466
2766
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
|
|
2467
2767
|
const row = rows[0];
|
|
2468
2768
|
return row ? toResource(row) : null;
|
|
2469
2769
|
},
|
|
2770
|
+
async updateResourceProfile(params) {
|
|
2771
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2772
|
+
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
2773
|
+
code: 'invalid_identity',
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
const profile = profileParameters(parseSlotlockResourceProfile(params.profile));
|
|
2777
|
+
const location = params.location === undefined || params.location === null
|
|
2778
|
+
? null
|
|
2779
|
+
: slotlockGroupingRef('invalid_location', params.location);
|
|
2780
|
+
const rows = await guardedWrite(sql, (tx) => tx `
|
|
2781
|
+
UPDATE slotlock.resources
|
|
2782
|
+
SET name = CASE WHEN ${profile.setName}::boolean THEN ${profile.name}::text ELSE name END,
|
|
2783
|
+
description = CASE WHEN ${profile.setDescription}::boolean
|
|
2784
|
+
THEN ${profile.description}::text ELSE description END,
|
|
2785
|
+
kind = CASE WHEN ${profile.setKind}::boolean THEN ${profile.kind}::text ELSE kind END,
|
|
2786
|
+
capacity = CASE WHEN ${profile.setCapacity}::boolean
|
|
2787
|
+
THEN ${profile.capacity}::integer ELSE capacity END,
|
|
2788
|
+
labels = CASE WHEN ${profile.setLabels}::boolean
|
|
2789
|
+
THEN pg_catalog.string_to_array(${profile.labels}::text, ',') ELSE labels END,
|
|
2790
|
+
attributes = CASE WHEN ${profile.setAttributes}::boolean
|
|
2791
|
+
THEN ${profile.attributes}::text::jsonb ELSE attributes END,
|
|
2792
|
+
location_ref = CASE WHEN ${params.location !== undefined}::boolean
|
|
2793
|
+
THEN ${location}::text ELSE location_ref END
|
|
2794
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2795
|
+
RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, RESOURCE_LOCATION_MISSING);
|
|
2796
|
+
const row = rows[0];
|
|
2797
|
+
return row ? toResource(row) : null;
|
|
2798
|
+
},
|
|
2799
|
+
async listLocations(params) {
|
|
2800
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2801
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2802
|
+
}
|
|
2803
|
+
const rows = await sql `
|
|
2804
|
+
WITH RECURSIVE tree(ref, name, parent_ref, timezone, path) AS (
|
|
2805
|
+
SELECT place.ref, place.name, place.parent_ref, place.timezone, ARRAY[place.ref]
|
|
2806
|
+
FROM slotlock.locations place
|
|
2807
|
+
WHERE place.tenant_ref = ${params.tenantRef} AND place.parent_ref IS NULL
|
|
2808
|
+
UNION ALL
|
|
2809
|
+
SELECT place.ref, place.name, place.parent_ref, place.timezone, tree.path || place.ref
|
|
2810
|
+
FROM slotlock.locations place
|
|
2811
|
+
JOIN tree ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = tree.ref
|
|
2812
|
+
WHERE cardinality(tree.path) <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
2813
|
+
)
|
|
2814
|
+
SELECT tree.ref, tree.name, tree.parent_ref, tree.timezone,
|
|
2815
|
+
(SELECT count(*) FROM slotlock.resources held
|
|
2816
|
+
WHERE held.tenant_ref = ${params.tenantRef} AND held.location_ref = tree.ref)::int AS resources
|
|
2817
|
+
FROM tree
|
|
2818
|
+
ORDER BY tree.path
|
|
2819
|
+
LIMIT ${SLOTLOCK_MAX_LOCATIONS}`;
|
|
2820
|
+
return rows.map(toLocation);
|
|
2821
|
+
},
|
|
2822
|
+
async putLocation(params) {
|
|
2823
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2824
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2825
|
+
}
|
|
2826
|
+
const location = parseSlotlockLocation(params);
|
|
2827
|
+
const [row] = await guardedWrite(sql, (tx) => tx `
|
|
2828
|
+
INSERT INTO slotlock.locations AS kept (tenant_ref, ref, name, parent_ref, timezone)
|
|
2829
|
+
VALUES (${params.tenantRef}, ${location.ref}, ${location.name ?? ''}, ${location.parentRef ?? null},
|
|
2830
|
+
${location.timezone ?? null})
|
|
2831
|
+
ON CONFLICT (tenant_ref, ref) DO UPDATE
|
|
2832
|
+
SET name = EXCLUDED.name,
|
|
2833
|
+
parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
|
|
2834
|
+
THEN EXCLUDED.parent_ref ELSE kept.parent_ref END,
|
|
2835
|
+
timezone = CASE WHEN ${location.timezone !== undefined}::boolean
|
|
2836
|
+
THEN EXCLUDED.timezone ELSE kept.timezone END
|
|
2837
|
+
RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
|
|
2838
|
+
(SELECT count(*) FROM slotlock.resources held
|
|
2839
|
+
WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
|
|
2840
|
+
return toLocation(row);
|
|
2841
|
+
},
|
|
2842
|
+
async updateLocation(params) {
|
|
2843
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2844
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2845
|
+
}
|
|
2846
|
+
const location = parseSlotlockLocation(params);
|
|
2847
|
+
const [row] = await guardedWrite(sql, (tx) => tx `
|
|
2848
|
+
UPDATE slotlock.locations AS kept
|
|
2849
|
+
SET name = COALESCE(${location.name ?? null}::text, kept.name),
|
|
2850
|
+
parent_ref = CASE WHEN ${location.parentRef !== undefined}::boolean
|
|
2851
|
+
THEN ${location.parentRef ?? null}::text ELSE kept.parent_ref END,
|
|
2852
|
+
timezone = CASE WHEN ${location.timezone !== undefined}::boolean
|
|
2853
|
+
THEN ${location.timezone ?? null}::text ELSE kept.timezone END
|
|
2854
|
+
WHERE kept.tenant_ref = ${params.tenantRef} AND kept.ref = ${location.ref}
|
|
2855
|
+
RETURNING kept.ref, kept.name, kept.parent_ref, kept.timezone,
|
|
2856
|
+
(SELECT count(*) FROM slotlock.resources held
|
|
2857
|
+
WHERE held.tenant_ref = kept.tenant_ref AND held.location_ref = kept.ref)::int AS resources`, { slotlock_locations_parent_fkey: 'location_not_found' });
|
|
2858
|
+
return row ? toLocation(row) : null;
|
|
2859
|
+
},
|
|
2860
|
+
async deleteLocation(params) {
|
|
2861
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2862
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2863
|
+
}
|
|
2864
|
+
const ref = slotlockGroupingRef('invalid_location', params.ref);
|
|
2865
|
+
const rows = await guardedWrite(sql, (tx) => tx `
|
|
2866
|
+
DELETE FROM slotlock.locations
|
|
2867
|
+
WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
|
|
2868
|
+
RETURNING ref`, {
|
|
2869
|
+
slotlock_locations_parent_fkey: 'location_in_use',
|
|
2870
|
+
slotlock_resources_location_fkey: 'location_in_use',
|
|
2871
|
+
});
|
|
2872
|
+
return rows.length > 0;
|
|
2873
|
+
},
|
|
2874
|
+
async listGroups(params) {
|
|
2875
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2876
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2877
|
+
}
|
|
2878
|
+
const rows = await sql `
|
|
2879
|
+
SELECT kept.ref, kept.name, kept.description,
|
|
2880
|
+
(SELECT count(*) FROM slotlock.resource_group_members member
|
|
2881
|
+
WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members
|
|
2882
|
+
FROM slotlock.resource_groups kept
|
|
2883
|
+
WHERE kept.tenant_ref = ${params.tenantRef}
|
|
2884
|
+
ORDER BY kept.ref
|
|
2885
|
+
LIMIT ${SLOTLOCK_MAX_RESOURCE_GROUPS}`;
|
|
2886
|
+
return rows.map(toGroup);
|
|
2887
|
+
},
|
|
2888
|
+
async putGroup(params) {
|
|
2889
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2890
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2891
|
+
}
|
|
2892
|
+
const group = parseSlotlockResourceGroup(params);
|
|
2893
|
+
const [row] = await guardedWrite(sql, (tx) => tx `
|
|
2894
|
+
INSERT INTO slotlock.resource_groups AS kept (tenant_ref, ref, name, description)
|
|
2895
|
+
VALUES (${params.tenantRef}, ${group.ref}, ${group.name}, ${group.description})
|
|
2896
|
+
ON CONFLICT (tenant_ref, ref) DO UPDATE
|
|
2897
|
+
SET name = EXCLUDED.name,
|
|
2898
|
+
description = CASE WHEN ${params.description !== undefined}::boolean
|
|
2899
|
+
THEN EXCLUDED.description ELSE kept.description END
|
|
2900
|
+
RETURNING kept.ref, kept.name, kept.description,
|
|
2901
|
+
(SELECT count(*) FROM slotlock.resource_group_members member
|
|
2902
|
+
WHERE member.tenant_ref = kept.tenant_ref AND member.group_ref = kept.ref)::int AS members`);
|
|
2903
|
+
return toGroup(row);
|
|
2904
|
+
},
|
|
2905
|
+
async deleteGroup(params) {
|
|
2906
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2907
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2908
|
+
}
|
|
2909
|
+
const ref = slotlockGroupingRef('invalid_group', params.ref);
|
|
2910
|
+
const rows = await sql `
|
|
2911
|
+
DELETE FROM slotlock.resource_groups
|
|
2912
|
+
WHERE tenant_ref = ${params.tenantRef} AND ref = ${ref}
|
|
2913
|
+
RETURNING ref`;
|
|
2914
|
+
return rows.length > 0;
|
|
2915
|
+
},
|
|
2916
|
+
async addGroupMember(params) {
|
|
2917
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2918
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2919
|
+
}
|
|
2920
|
+
const group = slotlockGroupingRef('invalid_group', params.group);
|
|
2921
|
+
if (!isValidResourceId(params.resourceId)) {
|
|
2922
|
+
throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
|
|
2923
|
+
code: 'resource_not_found',
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
const rows = await guardedWrite(sql, (tx) => tx `
|
|
2927
|
+
INSERT INTO slotlock.resource_group_members (tenant_ref, group_ref, resource_id)
|
|
2928
|
+
VALUES (${params.tenantRef}, ${group}, ${params.resourceId}::uuid)
|
|
2929
|
+
ON CONFLICT DO NOTHING
|
|
2930
|
+
RETURNING true AS added`, {
|
|
2931
|
+
slotlock_resource_group_members_group_fkey: 'group_not_found',
|
|
2932
|
+
slotlock_resource_group_members_resource_fkey: 'resource_not_found',
|
|
2933
|
+
});
|
|
2934
|
+
return rows.length > 0;
|
|
2935
|
+
},
|
|
2936
|
+
async removeGroupMember(params) {
|
|
2937
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2938
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2939
|
+
}
|
|
2940
|
+
const group = slotlockGroupingRef('invalid_group', params.group);
|
|
2941
|
+
if (!isValidResourceId(params.resourceId))
|
|
2942
|
+
return false;
|
|
2943
|
+
const rows = await sql `
|
|
2944
|
+
DELETE FROM slotlock.resource_group_members
|
|
2945
|
+
WHERE tenant_ref = ${params.tenantRef} AND group_ref = ${group}
|
|
2946
|
+
AND resource_id = ${params.resourceId}::uuid
|
|
2947
|
+
RETURNING group_ref`;
|
|
2948
|
+
return rows.length > 0;
|
|
2949
|
+
},
|
|
2470
2950
|
async setResourceAvailability(params) {
|
|
2471
2951
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2472
2952
|
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
@@ -2478,7 +2958,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2478
2958
|
UPDATE slotlock.resources
|
|
2479
2959
|
SET availability_rules = ${rules}::jsonb
|
|
2480
2960
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2481
|
-
RETURNING
|
|
2961
|
+
RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2482
2962
|
const row = rows[0];
|
|
2483
2963
|
return row ? toResource(row) : null;
|
|
2484
2964
|
},
|
|
@@ -2509,8 +2989,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2509
2989
|
ELSE statement_timestamp()
|
|
2510
2990
|
END
|
|
2511
2991
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2512
|
-
RETURNING
|
|
2513
|
-
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2992
|
+
RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2514
2993
|
const row = rows[0];
|
|
2515
2994
|
return row ? toResource(row) : null;
|
|
2516
2995
|
},
|
|
@@ -2539,8 +3018,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2539
3018
|
const tenantRef = params.tenantRef;
|
|
2540
3019
|
return inTransaction(sql, async (tx) => {
|
|
2541
3020
|
if (pool !== '') {
|
|
2542
|
-
// Counted under the tenant's lock,
|
|
2543
|
-
//
|
|
3021
|
+
// Counted under the tenant's lock, which only READ COMMITTED makes see every committed
|
|
3022
|
+
// row.
|
|
2544
3023
|
await assertReadCommitted(tx);
|
|
2545
3024
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${`slotlock:rule-defaults:${tenantRef}`}, 0))`;
|
|
2546
3025
|
const [counted] = await tx `
|
|
@@ -2589,8 +3068,25 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2589
3068
|
const rows = await sql `
|
|
2590
3069
|
UPDATE slotlock.resources SET pool_ref = ${params.pool}
|
|
2591
3070
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2592
|
-
RETURNING
|
|
2593
|
-
|
|
3071
|
+
RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
3072
|
+
const row = rows[0];
|
|
3073
|
+
return row ? toResource(row) : null;
|
|
3074
|
+
},
|
|
3075
|
+
async setResourceParent(params) {
|
|
3076
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
3077
|
+
!isValidResourceId(params.id) ||
|
|
3078
|
+
(params.parentId !== null && !isValidResourceId(params.parentId))) {
|
|
3079
|
+
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
3080
|
+
code: 'invalid_identity',
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
if (params.parentId === params.id) {
|
|
3084
|
+
throw Object.assign(new Error('Slotlock resource cannot be a part of itself'), { code: 'composite_too_deep' });
|
|
3085
|
+
}
|
|
3086
|
+
const rows = await guardedWrite(sql, (tx) => tx `
|
|
3087
|
+
UPDATE slotlock.resources SET parent_id = ${params.parentId}::uuid
|
|
3088
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
3089
|
+
RETURNING ${tx.unsafe(RESOURCE_COLUMNS)}, ${tx.unsafe(EFFECTIVE_BOOKING_RULES)}`, { slotlock_resources_parent_fkey: 'parent_not_found' });
|
|
2594
3090
|
const row = rows[0];
|
|
2595
3091
|
return row ? toResource(row) : null;
|
|
2596
3092
|
},
|
|
@@ -2600,28 +3096,94 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2600
3096
|
if (!isPoolName(params.pool))
|
|
2601
3097
|
throw invalidPoolError();
|
|
2602
3098
|
const rows = await sql `
|
|
2603
|
-
SELECT
|
|
2604
|
-
visibility, archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
3099
|
+
SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2605
3100
|
FROM slotlock.resources
|
|
2606
3101
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
2607
3102
|
AND archived_at IS NULL
|
|
2608
3103
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
2609
3104
|
ORDER BY id
|
|
2610
3105
|
LIMIT ${SLOTLOCK_MAX_POOL_MEMBERS}`;
|
|
2611
|
-
|
|
3106
|
+
const members = rows.map(toResource);
|
|
3107
|
+
if (params.ordered !== true || members.length < 2)
|
|
3108
|
+
return members;
|
|
3109
|
+
let [settings] = await sql `
|
|
3110
|
+
SELECT member_order, last_member::text FROM slotlock.pool_settings
|
|
3111
|
+
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}`;
|
|
3112
|
+
if (settings?.member_order === 'round_robin' && params.forBooking === true) {
|
|
3113
|
+
// Read again under the row's lock, held until the booking commits with the place it noted.
|
|
3114
|
+
[settings] = await sql `
|
|
3115
|
+
SELECT member_order, last_member::text FROM slotlock.pool_settings
|
|
3116
|
+
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
3117
|
+
FOR UPDATE`;
|
|
3118
|
+
}
|
|
3119
|
+
if (settings?.member_order === 'round_robin') {
|
|
3120
|
+
// From the first member after the one booked last, by id, round to the start.
|
|
3121
|
+
const after = settings.last_member;
|
|
3122
|
+
const next = after === null ? 0 : members.findIndex((member) => member.id > after);
|
|
3123
|
+
const start = next < 0 ? 0 : next;
|
|
3124
|
+
return [...members.slice(start), ...members.slice(0, start)];
|
|
3125
|
+
}
|
|
3126
|
+
if (settings?.member_order === 'least_booked') {
|
|
3127
|
+
// Bookings made on each in the last 30 days, holds and cancelled ones not counted.
|
|
3128
|
+
const counts = await sql `
|
|
3129
|
+
SELECT booked.resource_id::text, count(*)::int AS booked
|
|
3130
|
+
FROM slotlock.reservations booked
|
|
3131
|
+
WHERE booked.tenant_ref = ${params.tenantRef}
|
|
3132
|
+
AND booked.resource_id = ANY (${members.map((member) => member.id)}::uuid[])
|
|
3133
|
+
AND booked.status = 'confirmed'
|
|
3134
|
+
AND booked.created_at > statement_timestamp() - interval '30 days'
|
|
3135
|
+
GROUP BY booked.resource_id`;
|
|
3136
|
+
const booked = new Map(counts.map((row) => [row.resource_id, Number(row.booked)]));
|
|
3137
|
+
// Stable among equals: by id, as the database orders them.
|
|
3138
|
+
return [...members].sort((a, b) => (booked.get(a.id) ?? 0) - (booked.get(b.id) ?? 0) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
3139
|
+
}
|
|
3140
|
+
return members;
|
|
2612
3141
|
},
|
|
2613
3142
|
async listPools(params) {
|
|
2614
3143
|
if (!isValidIdentity(params.tenantRef))
|
|
2615
3144
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2616
3145
|
const rows = await sql `
|
|
2617
|
-
SELECT pool_ref AS pool, count(*)::int AS capacity
|
|
3146
|
+
SELECT pool_ref AS pool, count(*)::int AS capacity,
|
|
3147
|
+
COALESCE((SELECT settings.member_order FROM slotlock.pool_settings settings
|
|
3148
|
+
WHERE settings.tenant_ref = ${params.tenantRef}
|
|
3149
|
+
AND settings.pool_ref = resources.pool_ref), 'stable') AS member_order
|
|
2618
3150
|
FROM slotlock.resources
|
|
2619
3151
|
WHERE tenant_ref = ${params.tenantRef} AND pool_ref IS NOT NULL AND archived_at IS NULL
|
|
2620
3152
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
2621
3153
|
GROUP BY pool_ref
|
|
2622
3154
|
ORDER BY pool_ref
|
|
2623
3155
|
LIMIT 1000`;
|
|
2624
|
-
return rows;
|
|
3156
|
+
return rows.map((row) => ({ pool: row.pool, capacity: row.capacity, order: row.member_order }));
|
|
3157
|
+
},
|
|
3158
|
+
async setPoolOrder(params) {
|
|
3159
|
+
if (!isValidIdentity(params.tenantRef))
|
|
3160
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3161
|
+
if (!isPoolName(params.pool))
|
|
3162
|
+
throw invalidPoolError();
|
|
3163
|
+
if (!SLOTLOCK_POOL_ORDERS.includes(params.order)) {
|
|
3164
|
+
throw Object.assign(new Error(`Slotlock pool order must be one of ${SLOTLOCK_POOL_ORDERS.join(', ')}`), {
|
|
3165
|
+
code: 'invalid_pool_order',
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
// At most SLOTLOCK_MAX_POOL_SETTINGS pools a tenant (pool_settings_guard).
|
|
3169
|
+
await guardedWrite(sql, (tx) => tx `
|
|
3170
|
+
INSERT INTO slotlock.pool_settings (tenant_ref, pool_ref, member_order)
|
|
3171
|
+
VALUES (${params.tenantRef}, ${params.pool}, ${params.order})
|
|
3172
|
+
ON CONFLICT (tenant_ref, pool_ref)
|
|
3173
|
+
DO UPDATE SET member_order = EXCLUDED.member_order, updated_at = statement_timestamp()`);
|
|
3174
|
+
return params.order;
|
|
3175
|
+
},
|
|
3176
|
+
async notePoolBooking(params) {
|
|
3177
|
+
if (!isValidIdentity(params.tenantRef))
|
|
3178
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3179
|
+
if (!isPoolName(params.pool) || !isValidResourceId(params.resourceId))
|
|
3180
|
+
return;
|
|
3181
|
+
// Only a pool that goes round keeps its place; another has nothing to note.
|
|
3182
|
+
await sql `
|
|
3183
|
+
UPDATE slotlock.pool_settings
|
|
3184
|
+
SET last_member = ${params.resourceId}::uuid, updated_at = statement_timestamp()
|
|
3185
|
+
WHERE tenant_ref = ${params.tenantRef} AND pool_ref = ${params.pool}
|
|
3186
|
+
AND member_order = 'round_robin'`;
|
|
2625
3187
|
},
|
|
2626
3188
|
async setTenantAvailability(params) {
|
|
2627
3189
|
if (!isValidIdentity(params.tenantRef)) {
|
|
@@ -2653,28 +3215,87 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2653
3215
|
code: 'invalid_cursor',
|
|
2654
3216
|
});
|
|
2655
3217
|
}
|
|
3218
|
+
// The filters are checked as a profile is, so a bad one is refused rather than matching none.
|
|
3219
|
+
const place = params.location === undefined ? null : slotlockGroupingRef('invalid_location', params.location);
|
|
3220
|
+
const group = params.group === undefined ? null : slotlockGroupingRef('invalid_group', params.group);
|
|
3221
|
+
if (params.pool !== undefined && !isPoolName(params.pool))
|
|
3222
|
+
throw invalidPoolError();
|
|
3223
|
+
const pool = params.pool ?? null;
|
|
3224
|
+
const filter = parseSlotlockResourceProfile({
|
|
3225
|
+
...(params.kind !== undefined ? { kind: params.kind } : {}),
|
|
3226
|
+
...(params.labels !== undefined ? { labels: params.labels } : {}),
|
|
3227
|
+
});
|
|
2656
3228
|
const rows = await sql `
|
|
2657
|
-
SELECT
|
|
3229
|
+
SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}
|
|
2658
3230
|
FROM slotlock.resources
|
|
2659
3231
|
WHERE tenant_ref = ${params.tenantRef}
|
|
2660
3232
|
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
2661
3233
|
AND (${params.includeArchived === true} OR archived_at IS NULL)
|
|
2662
3234
|
AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
|
|
3235
|
+
AND (${filter.kind ?? null}::text IS NULL OR kind = ${filter.kind ?? null}::text)
|
|
3236
|
+
AND (${filter.labels === undefined || filter.labels.length === 0 ? null : filter.labels.join(',')}::text IS NULL
|
|
3237
|
+
OR labels @> pg_catalog.string_to_array(${filter.labels?.join(',') ?? ''}::text, ','))
|
|
3238
|
+
AND (${place}::text IS NULL OR location_ref IN (
|
|
3239
|
+
WITH RECURSIVE below(ref, steps) AS (
|
|
3240
|
+
SELECT place.ref, 1 FROM slotlock.locations place
|
|
3241
|
+
WHERE place.tenant_ref = ${params.tenantRef} AND place.ref = ${place}::text
|
|
3242
|
+
UNION ALL
|
|
3243
|
+
SELECT place.ref, below.steps + 1
|
|
3244
|
+
FROM slotlock.locations place
|
|
3245
|
+
JOIN below ON place.tenant_ref = ${params.tenantRef} AND place.parent_ref = below.ref
|
|
3246
|
+
WHERE below.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
|
|
3247
|
+
)
|
|
3248
|
+
SELECT ref FROM below))
|
|
3249
|
+
AND (${group}::text IS NULL OR EXISTS (
|
|
3250
|
+
SELECT 1 FROM slotlock.resource_group_members member
|
|
3251
|
+
WHERE member.tenant_ref = resources.tenant_ref AND member.resource_id = resources.id
|
|
3252
|
+
AND member.group_ref = ${group}::text))
|
|
3253
|
+
AND (${pool}::text IS NULL OR pool_ref = ${pool}::text)
|
|
2663
3254
|
ORDER BY id
|
|
2664
3255
|
LIMIT ${limit}`;
|
|
2665
3256
|
return rows.map(toResource);
|
|
2666
3257
|
},
|
|
3258
|
+
async readUsage(params) {
|
|
3259
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3260
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3261
|
+
}
|
|
3262
|
+
return readSlotlockTenantUsage(sql, params.tenantRef, params.from, params.to);
|
|
3263
|
+
},
|
|
3264
|
+
async rollUpUsage(params) {
|
|
3265
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3266
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3267
|
+
}
|
|
3268
|
+
const limit = params.limit ?? 5_000;
|
|
3269
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100_000) {
|
|
3270
|
+
throw Object.assign(new Error('Slotlock usage roll-up limit must be 1-100,000'), {
|
|
3271
|
+
code: 'invalid_limit',
|
|
3272
|
+
});
|
|
3273
|
+
}
|
|
3274
|
+
const [result] = await sql `
|
|
3275
|
+
SELECT slotlock.roll_up_usage(${params.tenantRef}::text, ${limit}::integer) AS read`;
|
|
3276
|
+
return result?.read ?? 0;
|
|
3277
|
+
},
|
|
3278
|
+
async countResources(params) {
|
|
3279
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3280
|
+
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
3281
|
+
code: 'invalid_identity',
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
const [counted] = await sql `
|
|
3285
|
+
SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef}`;
|
|
3286
|
+
return Number(counted?.count ?? 0);
|
|
3287
|
+
},
|
|
2667
3288
|
async archiveResource(params) {
|
|
2668
3289
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2669
3290
|
throw invalidIdentityError('Slotlock resource identity is invalid');
|
|
2670
3291
|
}
|
|
2671
|
-
//
|
|
2672
|
-
//
|
|
3292
|
+
// Clock time, not transaction start: a booking that committed holding the row's share lock
|
|
3293
|
+
// went in before this.
|
|
2673
3294
|
const [row] = await sql `
|
|
2674
3295
|
UPDATE slotlock.resources
|
|
2675
3296
|
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
2676
3297
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2677
|
-
RETURNING
|
|
3298
|
+
RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2678
3299
|
return row ? toResource(row) : null;
|
|
2679
3300
|
},
|
|
2680
3301
|
async restoreResource(params) {
|
|
@@ -2685,7 +3306,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2685
3306
|
UPDATE slotlock.resources
|
|
2686
3307
|
SET archived_at = NULL
|
|
2687
3308
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2688
|
-
RETURNING
|
|
3309
|
+
RETURNING ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}`;
|
|
2689
3310
|
return row ? toResource(row) : null;
|
|
2690
3311
|
},
|
|
2691
3312
|
async deleteResource(params) {
|
|
@@ -2715,8 +3336,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2715
3336
|
) AS live`;
|
|
2716
3337
|
if (live?.live)
|
|
2717
3338
|
return { ok: false, code: 'resource_in_use' };
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
3339
|
+
// Reservations, events and their children, coverage and hold answers all cascade from the
|
|
3340
|
+
// resource.
|
|
2720
3341
|
await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
|
|
2721
3342
|
return { ok: true, deleted: true };
|
|
2722
3343
|
});
|
|
@@ -2730,22 +3351,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2730
3351
|
if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
|
|
2731
3352
|
return { ok: false, code: 'resource_not_found' };
|
|
2732
3353
|
}
|
|
2733
|
-
// A booking on a tenant's resource takes the tenant and its own id as reference
|
|
2734
|
-
//
|
|
3354
|
+
// A booking on a tenant's resource takes the tenant and its own id as reference; one on a
|
|
3355
|
+
// tenantless resource stays tenantless.
|
|
2735
3356
|
const id = randomUUID();
|
|
2736
|
-
// The buffers written, for the overlap answer if the write loses.
|
|
2737
3357
|
let bufferBeforeMs = 0;
|
|
2738
3358
|
let bufferAfterMs = 0;
|
|
2739
3359
|
for (let attempt = 1;; attempt++) {
|
|
2740
3360
|
try {
|
|
2741
3361
|
return await inTransaction(sql, async (tx) => {
|
|
2742
3362
|
const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
|
|
2743
|
-
const
|
|
2744
|
-
SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
2745
|
-
WHERE id = ${params.resourceId}
|
|
2746
|
-
AND (${params.tenantRef ?? null}::text IS NULL
|
|
2747
|
-
OR tenant_ref = ${params.tenantRef ?? null})
|
|
2748
|
-
FOR SHARE`;
|
|
3363
|
+
const resource = await readResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
|
|
2749
3364
|
if (!resource)
|
|
2750
3365
|
return { ok: false, code: 'resource_not_found' };
|
|
2751
3366
|
if (resource.archived)
|
|
@@ -2767,6 +3382,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2767
3382
|
}))) {
|
|
2768
3383
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2769
3384
|
}
|
|
3385
|
+
// Writers to this resource take turns from here to the insert (lockResourceForWrite).
|
|
3386
|
+
const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef ?? null);
|
|
3387
|
+
if (!locked)
|
|
3388
|
+
return { ok: false, code: 'resource_not_found' };
|
|
3389
|
+
if (locked.archived)
|
|
3390
|
+
return { ok: false, code: 'resource_archived' };
|
|
2770
3391
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2771
3392
|
const [row] = await tx `
|
|
2772
3393
|
INSERT INTO slotlock.reservations (
|
|
@@ -2788,15 +3409,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2788
3409
|
catch (err) {
|
|
2789
3410
|
const code = pgCode(err);
|
|
2790
3411
|
if ((code === DEADLOCK_DETECTED || code === SERIALIZATION_FAILURE) && attempt === 1) {
|
|
2791
|
-
continue; // fully rolled back
|
|
3412
|
+
continue; // fully rolled back: one retry wins or loses with a clean 23P01
|
|
2792
3413
|
}
|
|
2793
3414
|
if (code !== EXCLUSION_VIOLATION &&
|
|
2794
3415
|
code !== DEADLOCK_DETECTED &&
|
|
2795
3416
|
code !== SERIALIZATION_FAILURE) {
|
|
2796
3417
|
throw err;
|
|
2797
3418
|
}
|
|
2798
|
-
// A
|
|
2799
|
-
//
|
|
3419
|
+
// A second contention abort is a structured loss too (possibly with a null winner), never
|
|
3420
|
+
// a raw throw.
|
|
2800
3421
|
const occupied = occupiedTime({ ...params, bufferBeforeMs, bufferAfterMs });
|
|
2801
3422
|
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
2802
3423
|
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs, undefined, undefined, bufferBeforeMs);
|
|
@@ -2821,8 +3442,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2821
3442
|
if (cancelled[0])
|
|
2822
3443
|
return { ok: false, code: 'reservation_cancelled' };
|
|
2823
3444
|
// A calendar event's occupation carries a reference in this namespace too, but it is
|
|
2824
|
-
// not a booking: create, reschedule and cancel
|
|
2825
|
-
//
|
|
3445
|
+
// not a booking: create, reschedule and cancel treat it as a reference no booking
|
|
3446
|
+
// holds.
|
|
2826
3447
|
const existing = await tx `
|
|
2827
3448
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2828
3449
|
buffer_after_ms, revision, source
|
|
@@ -2833,7 +3454,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2833
3454
|
const found = existing[0];
|
|
2834
3455
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
2835
3456
|
if (found) {
|
|
2836
|
-
//
|
|
3457
|
+
// Moved on since: a delayed create is stale, as a delayed move is.
|
|
2837
3458
|
if (params.revision < Number(found.revision)) {
|
|
2838
3459
|
return { ok: false, code: 'stale_revision' };
|
|
2839
3460
|
}
|
|
@@ -2864,6 +3485,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2864
3485
|
}))) {
|
|
2865
3486
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2866
3487
|
}
|
|
3488
|
+
// Writers to this resource take turns from here to the insert (lockResourceForWrite).
|
|
3489
|
+
const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
|
|
3490
|
+
if (!locked)
|
|
3491
|
+
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
3492
|
+
if (locked.archived)
|
|
3493
|
+
return { ok: false, code: 'resource_archived' };
|
|
2867
3494
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2868
3495
|
const rows = await tx `
|
|
2869
3496
|
INSERT INTO slotlock.reservations (
|
|
@@ -2956,6 +3583,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
2956
3583
|
}))) {
|
|
2957
3584
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2958
3585
|
}
|
|
3586
|
+
// Writers to this resource take turns from here to the insert (lockResourceForWrite).
|
|
3587
|
+
const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
|
|
3588
|
+
if (!locked)
|
|
3589
|
+
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
3590
|
+
if (locked.archived)
|
|
3591
|
+
return { ok: false, code: 'resource_archived' };
|
|
2959
3592
|
await reapExpiredHolds(tx, params.resourceId, occupied.start, occupied.end);
|
|
2960
3593
|
const rows = await tx `
|
|
2961
3594
|
UPDATE slotlock.reservations
|
|
@@ -3034,8 +3667,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3034
3667
|
}
|
|
3035
3668
|
}
|
|
3036
3669
|
if (!found) {
|
|
3037
|
-
//
|
|
3038
|
-
//
|
|
3670
|
+
// This reference is a calendar event's occupation, not a booking: nothing to cancel or
|
|
3671
|
+
// retire.
|
|
3039
3672
|
const [occupation] = await tx `
|
|
3040
3673
|
SELECT id FROM slotlock.reservations
|
|
3041
3674
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
@@ -3049,7 +3682,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3049
3682
|
return { ok: false, code: 'reservation_limit_reached', limit };
|
|
3050
3683
|
}
|
|
3051
3684
|
// A reference no booking holds yet is retired too, so a create delivered after its cancel
|
|
3052
|
-
// cannot bring
|
|
3685
|
+
// cannot bring it back.
|
|
3053
3686
|
await tx `
|
|
3054
3687
|
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
|
|
3055
3688
|
VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
|
|
@@ -3064,9 +3697,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3064
3697
|
if (!isFiniteWindow({ start: params.start, end: params.end })) {
|
|
3065
3698
|
return { ok: false, code: 'invalid_window' };
|
|
3066
3699
|
}
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
// (Invalid Date back to the caller). All refused pre-table.
|
|
3700
|
+
// NaN/Infinity would raise a raw 22008 in make_interval, and a huge ttl could exceed the JS
|
|
3701
|
+
// Date range.
|
|
3070
3702
|
if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
|
|
3071
3703
|
return { ok: false, code: 'invalid_ttl' };
|
|
3072
3704
|
}
|
|
@@ -3106,8 +3738,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3106
3738
|
...(holderRef !== undefined ? { holderRef } : {}),
|
|
3107
3739
|
});
|
|
3108
3740
|
const ttlSeconds = params.ttlMs / 1000;
|
|
3109
|
-
// The
|
|
3110
|
-
|
|
3741
|
+
// The holder's caps: the store's, lowered by its key's own (RFC 0004 A5), never raised.
|
|
3742
|
+
const asked = params.holderCaps;
|
|
3743
|
+
const holdCap = Math.min(maxHoldsPerHolder, asked?.maxHolds ?? Number.POSITIVE_INFINITY);
|
|
3744
|
+
const heldHoursCap = asked?.maxHeldHours !== undefined
|
|
3745
|
+
? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, asked.maxHeldHours)
|
|
3746
|
+
: maxHeldHoursPerHolder;
|
|
3747
|
+
if ((asked?.maxHolds !== undefined && !(Number.isSafeInteger(asked.maxHolds) && asked.maxHolds >= 1)) ||
|
|
3748
|
+
(asked?.maxHeldHours !== undefined && !(Number.isSafeInteger(asked.maxHeldHours) && asked.maxHeldHours >= 1))) {
|
|
3749
|
+
return { ok: false, code: 'invalid_identity' };
|
|
3750
|
+
}
|
|
3751
|
+
// The buffer asked for, raised to the resource's turnaround, and its rule's leading buffer.
|
|
3111
3752
|
let written = bufferAfterMs;
|
|
3112
3753
|
let writtenBefore = 0;
|
|
3113
3754
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3134,22 +3775,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3134
3775
|
}
|
|
3135
3776
|
}
|
|
3136
3777
|
const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
|
|
3137
|
-
const
|
|
3138
|
-
SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
3139
|
-
WHERE id = ${params.resourceId}
|
|
3140
|
-
AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
|
|
3141
|
-
FOR SHARE`;
|
|
3778
|
+
const resource = await readResourceForWrite(tx, params.resourceId, tenantRef ?? null);
|
|
3142
3779
|
if (!resource)
|
|
3143
3780
|
return { ok: false, code: 'resource_not_found' };
|
|
3144
3781
|
if (resource.archived)
|
|
3145
3782
|
return { ok: false, code: 'resource_archived' };
|
|
3146
3783
|
if (holdLimit !== null)
|
|
3147
3784
|
return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
|
|
3148
|
-
// Seconds the holder already holds, turnaround included, when its time is capped.
|
|
3149
3785
|
let heldSeconds = null;
|
|
3150
3786
|
if (holderRef !== undefined) {
|
|
3151
|
-
//
|
|
3152
|
-
//
|
|
3787
|
+
// Counted under a lock on the holder, so racing holds stop at the cap: an agent
|
|
3788
|
+
// cannot hold a resource's whole week.
|
|
3153
3789
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
3154
3790
|
'slotlock-holder',
|
|
3155
3791
|
tenantRef,
|
|
@@ -3165,10 +3801,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3165
3801
|
WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
|
|
3166
3802
|
AND expires_at > statement_timestamp()
|
|
3167
3803
|
AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
|
|
3168
|
-
if ((held?.count ?? 0) >=
|
|
3169
|
-
return { ok: false, code: 'hold_limit_reached', limit:
|
|
3804
|
+
if ((held?.count ?? 0) >= holdCap) {
|
|
3805
|
+
return { ok: false, code: 'hold_limit_reached', limit: holdCap };
|
|
3170
3806
|
}
|
|
3171
|
-
if (
|
|
3807
|
+
if (heldHoursCap !== undefined)
|
|
3172
3808
|
heldSeconds = held?.seconds ?? 0;
|
|
3173
3809
|
}
|
|
3174
3810
|
const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
|
|
@@ -3178,10 +3814,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3178
3814
|
writtenBefore = ruling.bufferBeforeMs;
|
|
3179
3815
|
const reach = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
3180
3816
|
if (heldSeconds !== null &&
|
|
3181
|
-
|
|
3817
|
+
heldHoursCap !== undefined &&
|
|
3182
3818
|
heldSeconds + (reach.end.getTime() - reach.start.getTime()) / 1000 >
|
|
3183
|
-
|
|
3184
|
-
return { ok: false, code: 'held_time_limit_reached', limit:
|
|
3819
|
+
heldHoursCap * 3_600) {
|
|
3820
|
+
return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
|
|
3185
3821
|
}
|
|
3186
3822
|
if (resource.tenant_ref !== null &&
|
|
3187
3823
|
!(await recurrenceCoversWrite(tx, {
|
|
@@ -3191,6 +3827,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3191
3827
|
}))) {
|
|
3192
3828
|
return { ok: false, code: 'recurrence_not_materialized' };
|
|
3193
3829
|
}
|
|
3830
|
+
// Writers to this resource take turns from here to the insert (lockResourceForWrite).
|
|
3831
|
+
const locked = await lockResourceForWrite(tx, params.resourceId, tenantRef ?? null);
|
|
3832
|
+
if (!locked)
|
|
3833
|
+
return { ok: false, code: 'resource_not_found' };
|
|
3834
|
+
if (locked.archived)
|
|
3835
|
+
return { ok: false, code: 'resource_archived' };
|
|
3194
3836
|
await reapExpiredHolds(tx, params.resourceId, reach.start, reach.end);
|
|
3195
3837
|
const [row] = await tx `
|
|
3196
3838
|
INSERT INTO slotlock.reservations (
|
|
@@ -3229,8 +3871,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3229
3871
|
code !== SERIALIZATION_FAILURE) {
|
|
3230
3872
|
throw err;
|
|
3231
3873
|
}
|
|
3232
|
-
// A
|
|
3233
|
-
//
|
|
3874
|
+
// A second contention abort is a structured loss too (possibly with a null winner), never
|
|
3875
|
+
// a raw throw.
|
|
3234
3876
|
const occupied = occupiedTime({ ...params, bufferBeforeMs: writtenBefore, bufferAfterMs: written });
|
|
3235
3877
|
await rollSeriesBehind(sql, params.resourceId, occupied.start, occupied.end);
|
|
3236
3878
|
return overlapLoss(sql, params.resourceId, params.start, params.end, written, undefined, undefined, writtenBefore);
|
|
@@ -3252,6 +3894,21 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3252
3894
|
AND hold.status = 'held' AND hold.expires_at > statement_timestamp()`;
|
|
3253
3895
|
return row ? toHold(row) : null;
|
|
3254
3896
|
},
|
|
3897
|
+
async getHolderHold(params) {
|
|
3898
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.holderRef)) {
|
|
3899
|
+
throw invalidIdentityError('Slotlock tenant and holder must be 1-500 UTF-8 bytes');
|
|
3900
|
+
}
|
|
3901
|
+
if (!isValidResourceId(params.holdId))
|
|
3902
|
+
return null;
|
|
3903
|
+
const [row] = await sql `
|
|
3904
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
3905
|
+
hold.expires_at, hold.source, hold.expires_at > statement_timestamp() AS live
|
|
3906
|
+
FROM slotlock.reservations hold
|
|
3907
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
3908
|
+
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
3909
|
+
AND hold.status = 'held' AND hold.holder_ref = ${params.holderRef}`;
|
|
3910
|
+
return row ? { hold: toHold(row), live: row.live } : null;
|
|
3911
|
+
},
|
|
3255
3912
|
async listHolds(params) {
|
|
3256
3913
|
if (!isValidIdentity(params.tenantRef)) {
|
|
3257
3914
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
@@ -3306,6 +3963,276 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3306
3963
|
source: row.source,
|
|
3307
3964
|
}));
|
|
3308
3965
|
},
|
|
3966
|
+
async describeBooking(params) {
|
|
3967
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3968
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3969
|
+
}
|
|
3970
|
+
if (!isValidResourceId(params.id))
|
|
3971
|
+
return null;
|
|
3972
|
+
const [row] = await sql `
|
|
3973
|
+
SELECT booking.resource_id, booking.status, booking.holder_ref,
|
|
3974
|
+
COALESCE(booking.occupied_starts_at, booking.starts_at) AS occupied_start,
|
|
3975
|
+
COALESCE(booking.occupied_ends_at, booking.ends_at) AS occupied_end,
|
|
3976
|
+
event.id AS event_id, event.external_ref AS event_ref, event.owner_ref AS event_owner_ref
|
|
3977
|
+
FROM slotlock.reservations booking
|
|
3978
|
+
JOIN slotlock.resources resource ON resource.id = booking.resource_id
|
|
3979
|
+
LEFT JOIN slotlock.calendar_events event
|
|
3980
|
+
ON event.id = booking.calendar_event_id AND event.tenant_ref = ${params.tenantRef}
|
|
3981
|
+
WHERE booking.id = ${params.id} AND resource.tenant_ref = ${params.tenantRef}`;
|
|
3982
|
+
if (!row)
|
|
3983
|
+
return null;
|
|
3984
|
+
return {
|
|
3985
|
+
id: params.id,
|
|
3986
|
+
resourceId: row.resource_id,
|
|
3987
|
+
status: row.status,
|
|
3988
|
+
holderRef: row.holder_ref,
|
|
3989
|
+
eventId: row.event_id,
|
|
3990
|
+
eventRef: row.event_ref,
|
|
3991
|
+
eventOwnerRef: row.event_owner_ref,
|
|
3992
|
+
start: row.occupied_start,
|
|
3993
|
+
end: row.occupied_end,
|
|
3994
|
+
};
|
|
3995
|
+
},
|
|
3996
|
+
async eventOccupancy(params) {
|
|
3997
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.ownerRef)) {
|
|
3998
|
+
throw invalidIdentityError('Slotlock tenant and owner must be 1-500 UTF-8 bytes');
|
|
3999
|
+
}
|
|
4000
|
+
const refs = params.externalRefs.filter((ref) => isValidIdentity(ref));
|
|
4001
|
+
if (refs.length === 0)
|
|
4002
|
+
return [];
|
|
4003
|
+
const rows = await sql `
|
|
4004
|
+
SELECT booking.resource_id,
|
|
4005
|
+
COALESCE(booking.occupied_starts_at, booking.starts_at) AS start,
|
|
4006
|
+
COALESCE(booking.occupied_ends_at, booking.ends_at) AS "end",
|
|
4007
|
+
event.recurrence_rule IS NOT NULL AS recurring
|
|
4008
|
+
FROM slotlock.calendar_events event
|
|
4009
|
+
JOIN slotlock.reservations booking ON booking.calendar_event_id = event.id
|
|
4010
|
+
WHERE event.tenant_ref = ${params.tenantRef} AND event.owner_ref = ${params.ownerRef}
|
|
4011
|
+
AND event.external_ref = ANY (${refs}::text[])
|
|
4012
|
+
ORDER BY booking.resource_id, 2, booking.id`;
|
|
4013
|
+
return rows.map((row) => ({ resourceId: row.resource_id, start: row.start, end: row.end, recurring: row.recurring }));
|
|
4014
|
+
},
|
|
4015
|
+
async putApprovalPolicy(params) {
|
|
4016
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4017
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4018
|
+
}
|
|
4019
|
+
return putApprovalPolicy(sql, params);
|
|
4020
|
+
},
|
|
4021
|
+
async listApprovalPolicies(params) {
|
|
4022
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4023
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4024
|
+
}
|
|
4025
|
+
return listApprovalPolicies(sql, params.tenantRef);
|
|
4026
|
+
},
|
|
4027
|
+
async deleteApprovalPolicy(params) {
|
|
4028
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4029
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4030
|
+
}
|
|
4031
|
+
return { deleted: await deleteApprovalPolicy(sql, params.tenantRef, params.ref) };
|
|
4032
|
+
},
|
|
4033
|
+
async approvalPoliciesFor(params) {
|
|
4034
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4035
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4036
|
+
}
|
|
4037
|
+
return approvalPoliciesFor(sql, params.tenantRef, params.resourceIds);
|
|
4038
|
+
},
|
|
4039
|
+
async requestApproval(params) {
|
|
4040
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.requesterRef)) {
|
|
4041
|
+
throw invalidIdentityError('Slotlock tenant and requester must be 1-500 UTF-8 bytes');
|
|
4042
|
+
}
|
|
4043
|
+
if (!/^[0-9a-f]{64}$/.test(params.requestKey)) {
|
|
4044
|
+
throw invalidIdentityError('Slotlock approval request key must be 64 hex characters');
|
|
4045
|
+
}
|
|
4046
|
+
if (params.holds.length < 1 ||
|
|
4047
|
+
params.holds.length > SLOTLOCK_MAX_APPROVAL_HOLDS ||
|
|
4048
|
+
params.holds.some((hold) => !isValidResourceId(hold.resourceId) || !(hold.end > hold.start))) {
|
|
4049
|
+
throw invalidIdentityError(`Slotlock approval holds must be 1-${SLOTLOCK_MAX_APPROVAL_HOLDS} intervals on resources`);
|
|
4050
|
+
}
|
|
4051
|
+
const { tenantRef } = params;
|
|
4052
|
+
try {
|
|
4053
|
+
return await inTransaction(sql, async (tx) => {
|
|
4054
|
+
// Retries of one request wait for each other, so the second answers the first's approval.
|
|
4055
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
4056
|
+
'slotlock-approval',
|
|
4057
|
+
tenantRef,
|
|
4058
|
+
params.requestKey,
|
|
4059
|
+
])}, 0))`;
|
|
4060
|
+
const prior = await approvalByRequestKey(tx, tenantRef, params.requestKey);
|
|
4061
|
+
if (prior) {
|
|
4062
|
+
const [same] = await tx `
|
|
4063
|
+
SELECT request = ${tx.json(params.request)}::jsonb AS same
|
|
4064
|
+
FROM slotlock.approvals WHERE id = ${prior.id}`;
|
|
4065
|
+
return same?.same
|
|
4066
|
+
? { ok: true, approval: prior, replayed: true }
|
|
4067
|
+
: { ok: false, code: 'idempotency_conflict' };
|
|
4068
|
+
}
|
|
4069
|
+
// Counted under a lock on the tenant's approvals, so racing requests stop at the caps.
|
|
4070
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-approvals', tenantRef])}, 0))`;
|
|
4071
|
+
const [pending] = await tx `
|
|
4072
|
+
SELECT count(*)::int AS tenant,
|
|
4073
|
+
(count(*) FILTER (WHERE requester ->> 'subject' = ${params.requesterRef}))::int AS requester
|
|
4074
|
+
FROM slotlock.approvals
|
|
4075
|
+
WHERE tenant_ref = ${tenantRef} AND status = 'pending' AND expires_at > statement_timestamp()`;
|
|
4076
|
+
if ((pending?.requester ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER) {
|
|
4077
|
+
return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS_PER_REQUESTER };
|
|
4078
|
+
}
|
|
4079
|
+
if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
|
|
4080
|
+
return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
|
|
4081
|
+
}
|
|
4082
|
+
const id = randomUUID();
|
|
4083
|
+
// The requester's own hold of the time gives way to the approval's.
|
|
4084
|
+
if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
|
|
4085
|
+
await tx `
|
|
4086
|
+
DELETE FROM slotlock.reservations
|
|
4087
|
+
WHERE id = ${params.consumeHold.holdId} AND status = 'held'
|
|
4088
|
+
AND holder_ref = ${params.consumeHold.holderRef}`;
|
|
4089
|
+
}
|
|
4090
|
+
const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
|
|
4091
|
+
// Locked as any booking write locks them, in id order, so a racing booking takes turns.
|
|
4092
|
+
for (const resourceId of [...new Set(holds.map((hold) => hold.resourceId))].sort()) {
|
|
4093
|
+
await lockResourceForWrite(tx, resourceId, tenantRef);
|
|
4094
|
+
}
|
|
4095
|
+
for (const hold of holds)
|
|
4096
|
+
await reapExpiredHolds(tx, hold.resourceId, hold.start, hold.end);
|
|
4097
|
+
const held = await tx `
|
|
4098
|
+
INSERT INTO slotlock.reservations (
|
|
4099
|
+
resource_id, starts_at, ends_at, buffer_after_ms, buffer_before_ms, status, expires_at,
|
|
4100
|
+
source, holder_ref, hold_group_id
|
|
4101
|
+
)
|
|
4102
|
+
SELECT wanted.resource_id, wanted.starts_at, wanted.ends_at, 0, 0, 'held', ${params.expiresAt},
|
|
4103
|
+
'slotlock:approval', ${`approval:${id}`}, ${id}::uuid
|
|
4104
|
+
FROM ROWS FROM (pg_catalog.unnest(${holds.map((hold) => hold.resourceId)}::uuid[]),
|
|
4105
|
+
pg_catalog.unnest(${holds.map((hold) => hold.start.toISOString())}::text[]::timestamptz[]),
|
|
4106
|
+
pg_catalog.unnest(${holds.map((hold) => hold.end.toISOString())}::text[]::timestamptz[]))
|
|
4107
|
+
AS wanted(resource_id, starts_at, ends_at)
|
|
4108
|
+
RETURNING id`;
|
|
4109
|
+
const [row] = await tx `
|
|
4110
|
+
INSERT INTO slotlock.approvals (
|
|
4111
|
+
id, tenant_ref, policy_ref, operation, request, requester, hold_ids, resource_ids,
|
|
4112
|
+
starts_at, ends_at, request_key, expires_at
|
|
4113
|
+
) VALUES (
|
|
4114
|
+
${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
|
|
4115
|
+
${tx.json(params.requester)}, ${held.map((hold) => hold.id)}::uuid[],
|
|
4116
|
+
${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
|
|
4117
|
+
${params.requestKey}, ${params.expiresAt}
|
|
4118
|
+
)
|
|
4119
|
+
RETURNING ${tx.unsafe(APPROVAL_COLUMNS)}`;
|
|
4120
|
+
if (!row)
|
|
4121
|
+
throw new Error('slotlock: approval insert returned no row');
|
|
4122
|
+
const approval = toApproval(row);
|
|
4123
|
+
await recordApprovalChange(tx, tenantRef, 'approval.requested', approval);
|
|
4124
|
+
return { ok: true, approval, replayed: false };
|
|
4125
|
+
}, 'read committed');
|
|
4126
|
+
}
|
|
4127
|
+
catch (err) {
|
|
4128
|
+
if (pgCode(err) === EXCLUSION_VIOLATION)
|
|
4129
|
+
return { ok: false, code: 'overlap' };
|
|
4130
|
+
throw err;
|
|
4131
|
+
}
|
|
4132
|
+
},
|
|
4133
|
+
async findApproval(params) {
|
|
4134
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4135
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4136
|
+
}
|
|
4137
|
+
if (!/^[0-9a-f]{64}$/.test(params.requestKey))
|
|
4138
|
+
return null;
|
|
4139
|
+
return approvalByRequestKey(sql, params.tenantRef, params.requestKey);
|
|
4140
|
+
},
|
|
4141
|
+
async getApproval(params) {
|
|
4142
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4143
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4144
|
+
}
|
|
4145
|
+
return getApproval(sql, params.tenantRef, params.id);
|
|
4146
|
+
},
|
|
4147
|
+
async listApprovals(params) {
|
|
4148
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4149
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4150
|
+
}
|
|
4151
|
+
const limit = params.limit ?? 50;
|
|
4152
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) {
|
|
4153
|
+
throw invalidIdentityError('Slotlock approval list limit must be 1-200');
|
|
4154
|
+
}
|
|
4155
|
+
return listApprovals(sql, {
|
|
4156
|
+
tenantRef: params.tenantRef,
|
|
4157
|
+
limit,
|
|
4158
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
4159
|
+
});
|
|
4160
|
+
},
|
|
4161
|
+
async beginApprovalGrant(params) {
|
|
4162
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4163
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4164
|
+
}
|
|
4165
|
+
return beginApprovalGrant(sql, params.tenantRef, params.id);
|
|
4166
|
+
},
|
|
4167
|
+
async finishApprovalGrant(params) {
|
|
4168
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
|
|
4169
|
+
throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
|
|
4170
|
+
}
|
|
4171
|
+
return finishApprovalGrant(sql, params.tenantRef, params.id, params.decidedBy);
|
|
4172
|
+
},
|
|
4173
|
+
async declineApproval(params) {
|
|
4174
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.decidedBy)) {
|
|
4175
|
+
throw invalidIdentityError('Slotlock tenant and decider must be 1-500 UTF-8 bytes');
|
|
4176
|
+
}
|
|
4177
|
+
if (params.reason !== undefined && params.reason.length > 500) {
|
|
4178
|
+
throw invalidIdentityError('Slotlock approval reason must be at most 500 characters');
|
|
4179
|
+
}
|
|
4180
|
+
return declineApproval(sql, params);
|
|
4181
|
+
},
|
|
4182
|
+
async resetTestTenant(params) {
|
|
4183
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4184
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4185
|
+
}
|
|
4186
|
+
if (!params.tenantRef.endsWith('~test'))
|
|
4187
|
+
throw sandboxTestOnlyError();
|
|
4188
|
+
const rows = await sql `
|
|
4189
|
+
SELECT * FROM slotlock.reset_test_tenant(${params.tenantRef}::text)`.catch((error) => {
|
|
4190
|
+
if (error.hint === 'sandbox_test_only')
|
|
4191
|
+
throw sandboxTestOnlyError();
|
|
4192
|
+
throw error;
|
|
4193
|
+
});
|
|
4194
|
+
return Object.fromEntries(rows.map((row) => [row.reset_table, Number(row.reset_rows)]));
|
|
4195
|
+
},
|
|
4196
|
+
async fastForwardTestTenant(params) {
|
|
4197
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4198
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4199
|
+
}
|
|
4200
|
+
if (!params.tenantRef.endsWith('~test'))
|
|
4201
|
+
throw sandboxTestOnlyError();
|
|
4202
|
+
if (!Number.isSafeInteger(params.minutes) || params.minutes < 1 || params.minutes > 525_600) {
|
|
4203
|
+
throw invalidIdentityError('Slotlock fast-forward is 1 to 525,600 minutes');
|
|
4204
|
+
}
|
|
4205
|
+
const [counted] = await sql `
|
|
4206
|
+
WITH lapsed_holds AS (
|
|
4207
|
+
UPDATE slotlock.reservations hold
|
|
4208
|
+
SET expires_at = statement_timestamp() - interval '1 millisecond'
|
|
4209
|
+
WHERE hold.status = 'held'
|
|
4210
|
+
AND hold.expires_at > statement_timestamp()
|
|
4211
|
+
AND hold.expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
|
|
4212
|
+
AND hold.resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${params.tenantRef})
|
|
4213
|
+
RETURNING 1
|
|
4214
|
+
), lapsed_approvals AS (
|
|
4215
|
+
UPDATE slotlock.approvals
|
|
4216
|
+
SET expires_at = statement_timestamp() - interval '1 millisecond'
|
|
4217
|
+
WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
|
|
4218
|
+
AND expires_at > statement_timestamp()
|
|
4219
|
+
AND expires_at <= statement_timestamp() + make_interval(mins => ${params.minutes})
|
|
4220
|
+
RETURNING 1
|
|
4221
|
+
)
|
|
4222
|
+
SELECT (SELECT count(*) FROM lapsed_holds)::int AS holds,
|
|
4223
|
+
(SELECT count(*) FROM lapsed_approvals)::int AS approvals`;
|
|
4224
|
+
return { holds: counted?.holds ?? 0, approvals: counted?.approvals ?? 0 };
|
|
4225
|
+
},
|
|
4226
|
+
async expireApprovals(params) {
|
|
4227
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
4228
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
4229
|
+
}
|
|
4230
|
+
const limit = params.limit ?? 100;
|
|
4231
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000) {
|
|
4232
|
+
throw invalidIdentityError('Slotlock approval expiry limit must be 1-1000');
|
|
4233
|
+
}
|
|
4234
|
+
return { expired: await expireApprovals(sql, params.tenantRef, limit) };
|
|
4235
|
+
},
|
|
3309
4236
|
async getReservation(params) {
|
|
3310
4237
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
|
|
3311
4238
|
throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
|
|
@@ -3344,13 +4271,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3344
4271
|
},
|
|
3345
4272
|
async confirmHold(holdId, options = {}) {
|
|
3346
4273
|
const tenantRef = holdTenantRef(options);
|
|
3347
|
-
// Not an id the store issues
|
|
4274
|
+
// Not an id the store issues (and a raw 22P02 in the query).
|
|
3348
4275
|
if (!isValidResourceId(holdId))
|
|
3349
4276
|
return { ok: false, code: 'hold_not_found' };
|
|
3350
4277
|
return inTransaction(sql, async (tx) => {
|
|
3351
|
-
//
|
|
3352
|
-
//
|
|
3353
|
-
// every booking write takes them.
|
|
4278
|
+
// A hold never changes resource, so the owner is read before the identity lock, taken
|
|
4279
|
+
// before the row lock as every booking write does.
|
|
3354
4280
|
const [owner] = await tx `
|
|
3355
4281
|
SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
|
|
3356
4282
|
FROM slotlock.reservations hold
|
|
@@ -3363,29 +4289,24 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3363
4289
|
if (bookingTenant !== null)
|
|
3364
4290
|
await lockExternalIdentity(tx, bookingTenant, holdId);
|
|
3365
4291
|
const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
|
|
3366
|
-
// The resource's
|
|
3367
|
-
// confirmation
|
|
3368
|
-
const
|
|
3369
|
-
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
3370
|
-
WHERE id = ${owner.resource_id}
|
|
3371
|
-
FOR SHARE`;
|
|
4292
|
+
// The resource's lock, as every booking write takes it: an archive waits for this
|
|
4293
|
+
// confirmation.
|
|
4294
|
+
const resource = await lockResourceForWrite(tx, owner.resource_id, null);
|
|
3372
4295
|
const [row] = await tx `
|
|
3373
4296
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
3374
|
-
revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live
|
|
4297
|
+
revision, source, status, (status = 'held' AND expires_at > statement_timestamp()) AS live,
|
|
4298
|
+
hold_group_id, holder_ref
|
|
3375
4299
|
FROM slotlock.reservations
|
|
3376
4300
|
WHERE id = ${holdId}
|
|
3377
4301
|
FOR UPDATE`;
|
|
3378
4302
|
if (!row) {
|
|
3379
|
-
//
|
|
3380
|
-
//
|
|
3381
|
-
// taken; both refusal codes are terminal for the caller.
|
|
4303
|
+
// A hold reaped by a rival while this confirm waited reads as hold_not_found; both
|
|
4304
|
+
// refusals are terminal for the caller.
|
|
3382
4305
|
return { ok: false, code: 'hold_not_found' };
|
|
3383
4306
|
}
|
|
3384
4307
|
if (row.status === 'confirmed') {
|
|
3385
|
-
//
|
|
3386
|
-
//
|
|
3387
|
-
// elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
|
|
3388
|
-
// booking with another reference was never this hold.
|
|
4308
|
+
// A retried confirm of an already-confirmed hold is success: reporting failure would make
|
|
4309
|
+
// callers double-book elsewhere. A booking with another reference was never this hold.
|
|
3389
4310
|
if (row.external_ref !== null && row.external_ref !== row.id) {
|
|
3390
4311
|
return { ok: false, code: 'hold_not_found' };
|
|
3391
4312
|
}
|
|
@@ -3393,7 +4314,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3393
4314
|
}
|
|
3394
4315
|
if (!row.live)
|
|
3395
4316
|
return { ok: false, code: 'hold_expired' };
|
|
3396
|
-
// Archived since the hold was taken: a confirmation would be a new booking on it.
|
|
3397
4317
|
if (resource?.archived !== false)
|
|
3398
4318
|
return { ok: false, code: 'resource_archived' };
|
|
3399
4319
|
if (bookingTenant !== null) {
|
|
@@ -3412,8 +4332,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3412
4332
|
if (bookingLimit !== null) {
|
|
3413
4333
|
return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
|
|
3414
4334
|
}
|
|
3415
|
-
// The
|
|
3416
|
-
//
|
|
4335
|
+
// The trigger already set the occupied end; the tenant and reference make the hold a
|
|
4336
|
+
// booking cancel and reschedule reach.
|
|
3417
4337
|
const [confirmed] = await tx `
|
|
3418
4338
|
UPDATE slotlock.reservations
|
|
3419
4339
|
SET status = 'confirmed', expires_at = NULL,
|
|
@@ -3423,6 +4343,14 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3423
4343
|
buffer_after_ms, revision, source`;
|
|
3424
4344
|
if (!confirmed)
|
|
3425
4345
|
throw new Error('slotlock: hold confirmation returned no row');
|
|
4346
|
+
// Its linked alternatives (one group, one holder) are released with it, as booking one
|
|
4347
|
+
// from an agent releases the rest.
|
|
4348
|
+
if (row.hold_group_id !== null && row.holder_ref !== null) {
|
|
4349
|
+
await tx `
|
|
4350
|
+
DELETE FROM slotlock.reservations
|
|
4351
|
+
WHERE status = 'held' AND hold_group_id = ${row.hold_group_id}::uuid
|
|
4352
|
+
AND holder_ref = ${row.holder_ref} AND id <> ${holdId}`;
|
|
4353
|
+
}
|
|
3426
4354
|
return { ok: true, reservation: toReservation(confirmed) };
|
|
3427
4355
|
});
|
|
3428
4356
|
},
|
|
@@ -3482,17 +4410,21 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3482
4410
|
});
|
|
3483
4411
|
}
|
|
3484
4412
|
}
|
|
3485
|
-
// Expired holds are
|
|
3486
|
-
//
|
|
4413
|
+
// Expired holds are excluded so findNextAvailable can offer their space; the acquiring
|
|
4414
|
+
// transaction reaps the row itself.
|
|
3487
4415
|
const rows = await sql `
|
|
3488
|
-
SELECT
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
4416
|
+
SELECT occupied_starts_at, occupied_ends_at FROM (
|
|
4417
|
+
SELECT COALESCE(occupied_starts_at, starts_at) AS occupied_starts_at,
|
|
4418
|
+
COALESCE(occupied_ends_at, ends_at) AS occupied_ends_at
|
|
4419
|
+
FROM slotlock.reservations
|
|
4420
|
+
WHERE resource_id = ${resourceId}
|
|
4421
|
+
AND ${occupiedStartsBefore(sql, window.end)}
|
|
4422
|
+
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
4423
|
+
AND (status <> 'held' OR expires_at > statement_timestamp())
|
|
4424
|
+
UNION ALL
|
|
4425
|
+
${compositeBusy(sql, resourceId, window)}
|
|
4426
|
+
) busy
|
|
4427
|
+
ORDER BY occupied_starts_at
|
|
3496
4428
|
LIMIT ${options.limit ?? null}`;
|
|
3497
4429
|
return rows.map((r) => ({ start: r.occupied_starts_at, end: r.occupied_ends_at }));
|
|
3498
4430
|
},
|
|
@@ -3532,8 +4464,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3532
4464
|
});
|
|
3533
4465
|
}
|
|
3534
4466
|
catch (error) {
|
|
3535
|
-
// A long-running series can
|
|
3536
|
-
//
|
|
4467
|
+
// A long-running series can outgrow the write's search budget from its start; a stored
|
|
4468
|
+
// one is expanded below from its last cursor.
|
|
3537
4469
|
if (!(error instanceof CalendarExpansionLimitError) || !event.recurrence)
|
|
3538
4470
|
throw error;
|
|
3539
4471
|
}
|
|
@@ -3552,7 +4484,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3552
4484
|
});
|
|
3553
4485
|
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
3554
4486
|
let attempted = expanded?.occurrences ?? [];
|
|
3555
|
-
// The buffers the attempt kept, for the overlap answer if it loses.
|
|
3556
4487
|
let attemptedBuffers = { bufferBeforeMs: 0, bufferAfterMs: 0 };
|
|
3557
4488
|
let storedEventId;
|
|
3558
4489
|
for (let attempt = 1;; attempt++) {
|
|
@@ -3580,8 +4511,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3580
4511
|
if (ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
|
|
3581
4512
|
await lockCalendarOwnerQuota(tx, params.tenantRef, ownerRef);
|
|
3582
4513
|
const commandCount = await calendarOwnerCommandCount(tx, params.tenantRef, ownerRef);
|
|
3583
|
-
//
|
|
3584
|
-
//
|
|
4514
|
+
// Reserve one cancellation slot for every possible live identity, so update churn
|
|
4515
|
+
// cannot leave a principal unable to release calendar space.
|
|
3585
4516
|
const putCommandLimit = configuredAgentOwnerCommandQuota - configuredAgentOwnerEventQuota;
|
|
3586
4517
|
if (commandCount >= putCommandLimit) {
|
|
3587
4518
|
return {
|
|
@@ -3668,12 +4599,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3668
4599
|
return { ok: false, code: 'hold_not_found' };
|
|
3669
4600
|
}
|
|
3670
4601
|
const [hold] = await tx `
|
|
3671
|
-
SELECT resource_id, starts_at, ends_at, hold_group_id
|
|
4602
|
+
SELECT resource_id, starts_at, ends_at, hold_group_id,
|
|
4603
|
+
expires_at > statement_timestamp() AS live
|
|
4604
|
+
FROM slotlock.reservations
|
|
3672
4605
|
WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
|
|
3673
|
-
AND expires_at > statement_timestamp()
|
|
3674
4606
|
FOR UPDATE`;
|
|
3675
4607
|
if (!hold)
|
|
3676
4608
|
return { ok: false, code: 'hold_not_found' };
|
|
4609
|
+
// The holder's own hold that lapsed and is not yet reaped: named as the application
|
|
4610
|
+
// API's confirm names it. Another holder's stays not found.
|
|
4611
|
+
if (!hold.live)
|
|
4612
|
+
return { ok: false, code: 'hold_expired' };
|
|
3677
4613
|
const [only] = occurrences;
|
|
3678
4614
|
if (hold.resource_id !== params.resourceId ||
|
|
3679
4615
|
occurrences.length !== 1 ||
|
|
@@ -3682,7 +4618,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3682
4618
|
only.end > hold.ends_at) {
|
|
3683
4619
|
return { ok: false, code: 'hold_mismatch' };
|
|
3684
4620
|
}
|
|
3685
|
-
// The hold and its alternatives give way to the event in this transaction.
|
|
3686
4621
|
await tx `
|
|
3687
4622
|
DELETE FROM slotlock.reservations
|
|
3688
4623
|
WHERE status = 'held' AND holder_ref = ${holderRef}
|
|
@@ -3773,9 +4708,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3773
4708
|
${revision}, ${params.source ?? null}, ${turnaroundMs}, ${bufferBeforeMs}
|
|
3774
4709
|
)`;
|
|
3775
4710
|
}
|
|
3776
|
-
// The normalized children are the
|
|
3777
|
-
//
|
|
4711
|
+
// The normalized children are canonical; the JSON columns above are a compatibility
|
|
4712
|
+
// mirror until older readers retire.
|
|
3778
4713
|
await replaceCalendarEventChildren(tx, params.tenantRef, eventId, event);
|
|
4714
|
+
// Writers to this resource take turns from here to its occupations (lockResourceForWrite).
|
|
4715
|
+
const locked = await lockResourceForWrite(tx, params.resourceId, params.tenantRef);
|
|
4716
|
+
if (!locked)
|
|
4717
|
+
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
4718
|
+
if (locked.archived)
|
|
4719
|
+
return { ok: false, code: 'resource_archived' };
|
|
3779
4720
|
await replaceCalendarEventOccurrences(tx, {
|
|
3780
4721
|
tenantRef: params.tenantRef,
|
|
3781
4722
|
eventId,
|
|
@@ -3945,11 +4886,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3945
4886
|
});
|
|
3946
4887
|
}
|
|
3947
4888
|
const after = rollCursor(params.after);
|
|
3948
|
-
// Due
|
|
3949
|
-
//
|
|
3950
|
-
// first: however many it cannot extend, they never stand in front of one it has not tried,
|
|
3951
|
-
// so every pass that is not capped by such new ones reaches the healthy series, and the ones
|
|
3952
|
-
// put back take turns.
|
|
4889
|
+
// Due masters, never-deferred ones first, then deferred ones longest-waiting first: masters
|
|
4890
|
+
// it cannot extend never starve healthy ones, and deferred ones take turns.
|
|
3953
4891
|
const candidates = await sql `
|
|
3954
4892
|
SELECT id, materialized_ends_at, roll_retry_at
|
|
3955
4893
|
FROM slotlock.calendar_events
|
|
@@ -3986,10 +4924,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
3986
4924
|
}
|
|
3987
4925
|
catch (error) {
|
|
3988
4926
|
if (error instanceof CalendarExpansionLimitError) {
|
|
3989
|
-
//
|
|
3990
|
-
//
|
|
3991
|
-
// earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
|
|
3992
|
-
// stays, and the masters after it still roll.
|
|
4927
|
+
// Over the expansion budget (or a rule this release refuses): nothing written, old
|
|
4928
|
+
// coverage kept, later masters still roll.
|
|
3993
4929
|
refused += 1;
|
|
3994
4930
|
refusedEventIds.push(candidate.id);
|
|
3995
4931
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
@@ -4002,16 +4938,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4002
4938
|
if (code === EXCLUSION_VIOLATION ||
|
|
4003
4939
|
code === DEADLOCK_DETECTED ||
|
|
4004
4940
|
code === SERIALIZATION_FAILURE) {
|
|
4005
|
-
// The savepoint rolled
|
|
4006
|
-
//
|
|
4941
|
+
// The savepoint rolled everything back, so old coverage stays truthful and callers
|
|
4942
|
+
// fail closed beyond it.
|
|
4007
4943
|
conflicts += 1;
|
|
4008
4944
|
conflictEventIds.push(candidate.id);
|
|
4009
4945
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
4010
4946
|
break;
|
|
4011
4947
|
}
|
|
4012
|
-
//
|
|
4013
|
-
//
|
|
4014
|
-
// budget, so no single row stops the tenant's roll.
|
|
4948
|
+
// Any other failure was rolled back with its savepoint too: defer it like an
|
|
4949
|
+
// over-budget one, so no single row stops the tenant's roll.
|
|
4015
4950
|
refused += 1;
|
|
4016
4951
|
refusedEventIds.push(candidate.id);
|
|
4017
4952
|
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
@@ -4178,8 +5113,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4178
5113
|
idempotent: true,
|
|
4179
5114
|
};
|
|
4180
5115
|
}
|
|
4181
|
-
//
|
|
4182
|
-
//
|
|
5116
|
+
// Owner and reference never change, so they are read before the identity lock, taken before
|
|
5117
|
+
// the row lock as every event write does.
|
|
4183
5118
|
const [identity] = await tx `
|
|
4184
5119
|
SELECT owner_ref, external_ref FROM slotlock.calendar_events
|
|
4185
5120
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
|
|
@@ -4190,7 +5125,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4190
5125
|
SELECT id, resource_id, revision FROM slotlock.calendar_events
|
|
4191
5126
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
|
|
4192
5127
|
FOR UPDATE`;
|
|
4193
|
-
// Cancelled while this waited on its lock.
|
|
4194
5128
|
if (!found)
|
|
4195
5129
|
return { ok: false, code: 'event_not_found' };
|
|
4196
5130
|
if (params.expectedResourceId !== undefined &&
|
|
@@ -4290,7 +5224,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4290
5224
|
catch (error) {
|
|
4291
5225
|
if (pgCode(error) !== EXCLUSION_VIOLATION)
|
|
4292
5226
|
throw error;
|
|
4293
|
-
// It stays where it was; whoever owns it decides.
|
|
4294
5227
|
await report(event, 'event.timezone_conflict');
|
|
4295
5228
|
conflicts++;
|
|
4296
5229
|
}
|
|
@@ -4307,8 +5240,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4307
5240
|
code: 'invalid_limit',
|
|
4308
5241
|
});
|
|
4309
5242
|
}
|
|
4310
|
-
// Never under the default: a shorter retention trims the feed, not how long a
|
|
4311
|
-
//
|
|
5243
|
+
// Never under the default: a shorter retention trims the feed, not how long a retry is
|
|
5244
|
+
// answered as the first was.
|
|
4312
5245
|
const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
|
|
4313
5246
|
if (!Number.isSafeInteger(keepDays) ||
|
|
4314
5247
|
keepDays < 1 ||
|
|
@@ -4318,11 +5251,10 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4318
5251
|
});
|
|
4319
5252
|
}
|
|
4320
5253
|
return inTransaction(sql, async (tx) => {
|
|
4321
|
-
// A 0.2 server still
|
|
4322
|
-
// createReservation bookings
|
|
4323
|
-
//
|
|
4324
|
-
//
|
|
4325
|
-
// first, as every booking write does; a tombstone of its id retired nothing and goes.
|
|
5254
|
+
// A 0.2 server still running after the migration wrote confirmed holds and
|
|
5255
|
+
// createReservation bookings without the tenant or a reference; give them both, as the
|
|
5256
|
+
// migration does. Each takes its reference's identity lock first, and a tombstone of its id
|
|
5257
|
+
// goes.
|
|
4326
5258
|
const legacy = await tx `
|
|
4327
5259
|
SELECT booking.id
|
|
4328
5260
|
FROM slotlock.reservations booking
|
|
@@ -4402,8 +5334,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4402
5334
|
code: 'invalid_limit',
|
|
4403
5335
|
});
|
|
4404
5336
|
}
|
|
4405
|
-
// Never under the default: a shorter retention trims the feed, not how long a
|
|
4406
|
-
//
|
|
5337
|
+
// Never under the default: a shorter retention trims the feed, not how long a retry is
|
|
5338
|
+
// answered as the first was.
|
|
4407
5339
|
const keepDays = params.retentionDays ?? Math.max(retentionDays, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS);
|
|
4408
5340
|
if (!Number.isSafeInteger(keepDays) ||
|
|
4409
5341
|
keepDays < 1 ||
|
|
@@ -4413,8 +5345,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4413
5345
|
});
|
|
4414
5346
|
}
|
|
4415
5347
|
return inTransaction(sql, async (tx) => {
|
|
4416
|
-
// Commands are
|
|
4417
|
-
// owner's
|
|
5348
|
+
// Commands are replay evidence, never resurrection authority: after the window every
|
|
5349
|
+
// owner's go.
|
|
4418
5350
|
const commands = await tx `
|
|
4419
5351
|
WITH candidates AS MATERIALIZED (
|
|
4420
5352
|
SELECT command.owner_ref, command.idempotency_key
|
|
@@ -4434,9 +5366,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4434
5366
|
RETURNING 1
|
|
4435
5367
|
)
|
|
4436
5368
|
SELECT count(*)::int AS deleted FROM deleted`;
|
|
4437
|
-
//
|
|
4438
|
-
//
|
|
4439
|
-
// row, so a row they hold is skipped and the delete repeats the selection predicate.
|
|
5369
|
+
// An agent-owned tombstone may follow once all its commands are gone; internal ones are
|
|
5370
|
+
// provider/sync authority and stay. Locked rows are skipped and the predicate rechecked.
|
|
4440
5371
|
const tombstones = await tx `
|
|
4441
5372
|
WITH candidates AS MATERIALIZED (
|
|
4442
5373
|
SELECT tombstone.owner_ref, tombstone.external_ref
|
|
@@ -4532,7 +5463,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4532
5463
|
if (params.revision !== currentRevision + 1) {
|
|
4533
5464
|
return { ok: false, code: 'revision_conflict' };
|
|
4534
5465
|
}
|
|
4535
|
-
// A fresh read ends a staleness: the next one is reported again.
|
|
4536
5466
|
await tx `
|
|
4537
5467
|
UPDATE slotlock.calendar_coverage
|
|
4538
5468
|
SET starts_at = ${window.start}, ends_at = ${window.end},
|
|
@@ -4686,7 +5616,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4686
5616
|
if (refusal)
|
|
4687
5617
|
return 0;
|
|
4688
5618
|
const tenantRef = params.tenantRef;
|
|
4689
|
-
// The same test twice: to find them, then under each reference's lock
|
|
5619
|
+
// The same test twice: to find them, then under each reference's lock.
|
|
4690
5620
|
const due = (tx, externalRef) => tx `
|
|
4691
5621
|
SELECT reservation.id, reservation.external_ref, reservation.revision, reservation.resource_id,
|
|
4692
5622
|
(resource.booking_rules->>'checkInMinutes')::int AS minutes
|
|
@@ -4717,14 +5647,15 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4717
5647
|
await tx `
|
|
4718
5648
|
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
|
|
4719
5649
|
VALUES (${tenantRef}, ${row.external_ref}, ${revision}, false)`;
|
|
5650
|
+
// The deletion is the change: the feed's trigger names it reservation.no_show (one change,
|
|
5651
|
+
// not a cancellation too), by maintenance, with the check-in window it missed.
|
|
5652
|
+
await tx `
|
|
5653
|
+
SELECT set_config('slotlock.ending', 'no_show', true),
|
|
5654
|
+
set_config('slotlock.ending_detail', ${String(row.minutes)}, true),
|
|
5655
|
+
set_config('slotlock.actor', 'slotlock:maintenance', true)`;
|
|
4720
5656
|
await tx `DELETE FROM slotlock.reservations WHERE id = ${row.id}`;
|
|
4721
5657
|
await tx `
|
|
4722
|
-
|
|
4723
|
-
VALUES (${tenantRef}, ${row.resource_id}, 'reservation.no_show', ${row.external_ref},
|
|
4724
|
-
jsonb_build_object('id', ${row.id}::uuid, 'reference', ${row.external_ref}::text,
|
|
4725
|
-
'resource_id', ${row.resource_id}::uuid, 'revision', ${revision}::bigint,
|
|
4726
|
-
'check_in_minutes', ${row.minutes}::int),
|
|
4727
|
-
'slotlock:maintenance')`;
|
|
5658
|
+
SELECT set_config('slotlock.ending', '', true), set_config('slotlock.ending_detail', '', true)`;
|
|
4728
5659
|
return true;
|
|
4729
5660
|
});
|
|
4730
5661
|
if (done)
|
|
@@ -4753,7 +5684,35 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4753
5684
|
if (!Number.isSafeInteger(offerTtlMs) || offerTtlMs < 60_000 || offerTtlMs > 86_400_000) {
|
|
4754
5685
|
return { ok: false, code: 'invalid_ttl' };
|
|
4755
5686
|
}
|
|
5687
|
+
const key = params.idempotencyKey;
|
|
5688
|
+
if (key !== undefined && !isValidIdentity(key))
|
|
5689
|
+
return { ok: false, code: 'invalid_identity' };
|
|
5690
|
+
const payloadHash = key === undefined
|
|
5691
|
+
? null
|
|
5692
|
+
: createHash('sha256')
|
|
5693
|
+
.update(JSON.stringify([
|
|
5694
|
+
params.resourceId ?? null,
|
|
5695
|
+
params.pool ?? null,
|
|
5696
|
+
window.start.toISOString(),
|
|
5697
|
+
window.end.toISOString(),
|
|
5698
|
+
holderRef,
|
|
5699
|
+
offerTtlMs,
|
|
5700
|
+
]))
|
|
5701
|
+
.digest('hex');
|
|
4756
5702
|
return inTransaction(sql, async (tx) => {
|
|
5703
|
+
// The tenant's waitlist lock first, which offers and every join take: a retry racing its
|
|
5704
|
+
// first finds the entry the first made.
|
|
5705
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify(['slotlock-waitlist', tenantRef])}, 0))`;
|
|
5706
|
+
if (key !== undefined) {
|
|
5707
|
+
const [earlier] = await tx `
|
|
5708
|
+
SELECT ${tx.unsafe(WAITLIST_COLUMNS)}, payload_hash FROM slotlock.waitlist_entries
|
|
5709
|
+
WHERE tenant_ref = ${tenantRef} AND idempotency_key = ${key}`;
|
|
5710
|
+
if (earlier) {
|
|
5711
|
+
if (earlier.payload_hash !== payloadHash)
|
|
5712
|
+
return { ok: false, code: 'idempotency_conflict' };
|
|
5713
|
+
return { ok: true, entry: toWaitlistEntry(earlier), replayed: true };
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
4757
5716
|
if (params.resourceId !== undefined) {
|
|
4758
5717
|
const [resource] = await tx `
|
|
4759
5718
|
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
@@ -4771,8 +5730,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4771
5730
|
if (!member)
|
|
4772
5731
|
return { ok: false, code: 'pool_not_found' };
|
|
4773
5732
|
}
|
|
4774
|
-
// Counted under the tenant's waitlist lock, which offers take too, so racing
|
|
4775
|
-
|
|
5733
|
+
// Counted under the tenant's waitlist lock (taken above), which offers take too, so racing
|
|
5734
|
+
// joins stop at the cap.
|
|
4776
5735
|
const [counts] = await tx `
|
|
4777
5736
|
SELECT count(*)::int AS tenant, count(*) FILTER (WHERE holder_ref = ${holderRef})::int AS holder
|
|
4778
5737
|
FROM slotlock.waitlist_entries WHERE tenant_ref = ${tenantRef} AND status = 'waiting'`;
|
|
@@ -4782,11 +5741,12 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4782
5741
|
}
|
|
4783
5742
|
const [row] = await tx `
|
|
4784
5743
|
INSERT INTO slotlock.waitlist_entries
|
|
4785
|
-
(tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms
|
|
5744
|
+
(tenant_ref, resource_id, pool_ref, starts_at, ends_at, holder_ref, offer_ttl_ms,
|
|
5745
|
+
idempotency_key, payload_hash)
|
|
4786
5746
|
VALUES (${tenantRef}, ${params.resourceId ?? null}, ${params.pool ?? null}, ${window.start},
|
|
4787
|
-
${window.end}, ${holderRef}, ${offerTtlMs})
|
|
5747
|
+
${window.end}, ${holderRef}, ${offerTtlMs}, ${key ?? null}, ${payloadHash})
|
|
4788
5748
|
RETURNING ${tx.unsafe(WAITLIST_COLUMNS)}`;
|
|
4789
|
-
return { ok: true, entry: toWaitlistEntry(row) };
|
|
5749
|
+
return { ok: true, entry: toWaitlistEntry(row), replayed: false };
|
|
4790
5750
|
});
|
|
4791
5751
|
},
|
|
4792
5752
|
async listWaitlist(params) {
|
|
@@ -4812,7 +5772,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4812
5772
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id))
|
|
4813
5773
|
return false;
|
|
4814
5774
|
return inTransaction(sql, async (tx) => {
|
|
4815
|
-
// Waits for an offer pass that has this entry locked, so it leaves what that pass decided.
|
|
4816
5775
|
const [left] = await tx `
|
|
4817
5776
|
DELETE FROM slotlock.waitlist_entries
|
|
4818
5777
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
@@ -4820,8 +5779,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4820
5779
|
RETURNING hold_id`;
|
|
4821
5780
|
if (!left)
|
|
4822
5781
|
return false;
|
|
4823
|
-
// An offer
|
|
4824
|
-
// one is a booking now and stays.
|
|
5782
|
+
// An untaken offer's hold goes too; a confirmed one is a booking now and stays.
|
|
4825
5783
|
if (left.hold_id) {
|
|
4826
5784
|
await buildSlotlockStore(tx, options, null).releaseHold(left.hold_id, { tenantRef: params.tenantRef });
|
|
4827
5785
|
}
|
|
@@ -4878,6 +5836,17 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
4878
5836
|
DELETE FROM slotlock.assistant_settings WHERE tenant_ref = ${params.tenantRef} RETURNING 1`;
|
|
4879
5837
|
return rows.length > 0;
|
|
4880
5838
|
},
|
|
5839
|
+
async resealAssistantKey(params) {
|
|
5840
|
+
if (!isValidIdentity(params.tenantRef))
|
|
5841
|
+
return false;
|
|
5842
|
+
if (params.to.length === 0 || params.to === params.from)
|
|
5843
|
+
return false;
|
|
5844
|
+
const rows = await sql `
|
|
5845
|
+
UPDATE slotlock.assistant_settings SET sealed_key = ${params.to}
|
|
5846
|
+
WHERE tenant_ref = ${params.tenantRef} AND sealed_key = ${params.from}
|
|
5847
|
+
RETURNING 1`;
|
|
5848
|
+
return rows.length > 0;
|
|
5849
|
+
},
|
|
4881
5850
|
async getAssistantConversation(params) {
|
|
4882
5851
|
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.memberRef))
|
|
4883
5852
|
return null;
|
|
@@ -5020,7 +5989,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5020
5989
|
WHERE tenant_ref = ${tenantRef} AND status <> 'waiting'
|
|
5021
5990
|
AND ends_at < statement_timestamp() - make_interval(days => ${retentionDays})`;
|
|
5022
5991
|
}
|
|
5023
|
-
// One page in join order; the caller passes `next` back for the rest.
|
|
5024
5992
|
const waiting = await tx `
|
|
5025
5993
|
SELECT ${tx.unsafe(WAITLIST_COLUMNS)} FROM slotlock.waitlist_entries
|
|
5026
5994
|
WHERE tenant_ref = ${tenantRef} AND status = 'waiting'
|
|
@@ -5028,19 +5996,16 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5028
5996
|
OR (created_at, id) > (${after?.createdAt ?? null}::timestamptz, ${after?.id ?? null}::uuid))
|
|
5029
5997
|
ORDER BY created_at, id
|
|
5030
5998
|
LIMIT ${limit}`;
|
|
5031
|
-
// Holds made through a store on this transaction, each in a savepoint of its own.
|
|
5032
5999
|
const inner = buildSlotlockStore(tx, options, null);
|
|
5033
6000
|
let offered = 0;
|
|
5034
6001
|
for (const entry of waiting) {
|
|
5035
|
-
// Locked so a leave waits for this offer, and skipped if it left first.
|
|
5036
6002
|
const [still] = await tx `
|
|
5037
6003
|
SELECT 1 FROM slotlock.waitlist_entries
|
|
5038
6004
|
WHERE id = ${entry.id} AND status = 'waiting' FOR UPDATE`;
|
|
5039
6005
|
if (!still)
|
|
5040
6006
|
continue;
|
|
5041
|
-
// Only resources
|
|
5042
|
-
// until the transaction ends
|
|
5043
|
-
// A hold there keeps the resource's buffers (its rules as they apply), so they must be free too.
|
|
6007
|
+
// Only resources that look free (buffers included) are tried: a refused hold keeps its
|
|
6008
|
+
// locks until the transaction ends.
|
|
5044
6009
|
const candidates = await tx `
|
|
5045
6010
|
SELECT resources.id
|
|
5046
6011
|
FROM slotlock.resources,
|
|
@@ -5136,7 +6101,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5136
6101
|
code: 'resource_tenant_mismatch',
|
|
5137
6102
|
});
|
|
5138
6103
|
}
|
|
5139
|
-
// Each row's occupied time: its buffers are as busy as the booking.
|
|
5140
6104
|
const busyRows = await sql `
|
|
5141
6105
|
SELECT COALESCE(reservation.occupied_starts_at, reservation.starts_at) AS starts_at,
|
|
5142
6106
|
COALESCE(reservation.occupied_ends_at, reservation.ends_at) AS occupied_ends_at
|
|
@@ -5145,6 +6109,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5145
6109
|
AND ${occupiedStartsBefore(sql, window.end, 'reservation')}
|
|
5146
6110
|
AND COALESCE(reservation.occupied_ends_at, reservation.ends_at) > ${window.start}
|
|
5147
6111
|
AND (reservation.status <> 'held' OR reservation.expires_at > statement_timestamp())
|
|
6112
|
+
UNION ALL
|
|
6113
|
+
${compositeBusy(sql, params.resourceId, window)}
|
|
5148
6114
|
ORDER BY 1
|
|
5149
6115
|
LIMIT ${MAX_FREE_BUSY_INTERVALS + 1}`;
|
|
5150
6116
|
if (busyRows.length > MAX_FREE_BUSY_INTERVALS) {
|
|
@@ -5187,8 +6153,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5187
6153
|
FROM recurring_sources
|
|
5188
6154
|
WHERE earliest_possible_at < ${window.end}`;
|
|
5189
6155
|
const localRecurrenceRequired = Number(localRecurrenceCoverage?.source_count ?? 0) > 0;
|
|
5190
|
-
//
|
|
5191
|
-
// writes do (bookingJudge)
|
|
6156
|
+
// Requiring certainty means every sync covering the resource, judged by the same day, as
|
|
6157
|
+
// writes do (bookingJudge).
|
|
5192
6158
|
const requireCertain = owner[0].require_certain;
|
|
5193
6159
|
const recordedSources = requireCertain
|
|
5194
6160
|
? (await sql `
|
|
@@ -5223,8 +6189,8 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5223
6189
|
let hasFreshOverlap = coverageRows.some((coverage) => coverage.fresh && coverage.starts_at < window.end && coverage.ends_at > window.start);
|
|
5224
6190
|
if (localRecurrenceRequired) {
|
|
5225
6191
|
if (localRecurrenceCoverage?.complete !== true) {
|
|
5226
|
-
//
|
|
5227
|
-
//
|
|
6192
|
+
// The intrinsic source first: the caller can repair it by rolling the horizon,
|
|
6193
|
+
// independent of providers.
|
|
5228
6194
|
missingSources.unshift(SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE);
|
|
5229
6195
|
}
|
|
5230
6196
|
hasFreshOverlap ||= localRecurrenceCoverage?.overlaps === true;
|
|
@@ -5263,8 +6229,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5263
6229
|
if (!isValidResourceId(params.resourceId))
|
|
5264
6230
|
throw notFound();
|
|
5265
6231
|
const [row] = await sql `
|
|
5266
|
-
SELECT
|
|
5267
|
-
archived_at, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
6232
|
+
SELECT ${sql.unsafe(RESOURCE_COLUMNS)}, ${sql.unsafe(EFFECTIVE_BOOKING_RULES)}, statement_timestamp() AS now
|
|
5268
6233
|
FROM slotlock.resources
|
|
5269
6234
|
WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
|
|
5270
6235
|
if (!row)
|
|
@@ -5273,8 +6238,6 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5273
6238
|
return null;
|
|
5274
6239
|
const resource = toResource(row);
|
|
5275
6240
|
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
5276
|
-
// Only what a booking made now may take: the resource's booking rules (as they apply) bound
|
|
5277
|
-
// the search.
|
|
5278
6241
|
const search = bookingSearch({
|
|
5279
6242
|
rules: resource.effectiveBookingRules,
|
|
5280
6243
|
timezone: resource.timezone,
|
|
@@ -5285,11 +6248,11 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5285
6248
|
if (!search)
|
|
5286
6249
|
return null;
|
|
5287
6250
|
const { window } = search;
|
|
5288
|
-
// A slot's buffers may run past the window:
|
|
6251
|
+
// A slot's buffers may run past the window: that reach must be proven free too.
|
|
5289
6252
|
const reach = bookingSearchReach(search);
|
|
5290
6253
|
let busy;
|
|
5291
6254
|
if (tenantRef === null) {
|
|
5292
|
-
//
|
|
6255
|
+
// A tenantless resource has no events, coverage or series: only its rows.
|
|
5293
6256
|
busy = await this.listBusy(params.resourceId, reach);
|
|
5294
6257
|
}
|
|
5295
6258
|
else {
|
|
@@ -5311,7 +6274,7 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5311
6274
|
? { coverageMaxAgeMs: params.coverageMaxAgeMs }
|
|
5312
6275
|
: {}),
|
|
5313
6276
|
});
|
|
5314
|
-
// Absence is never
|
|
6277
|
+
// Absence is never free time.
|
|
5315
6278
|
if (freeBusy.coverage.state !== 'complete')
|
|
5316
6279
|
return null;
|
|
5317
6280
|
busy.push(...freeBusy.busy);
|
|
@@ -5376,7 +6339,9 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5376
6339
|
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
5377
6340
|
}
|
|
5378
6341
|
const erased = await sql `
|
|
5379
|
-
SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)
|
|
6342
|
+
SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`.catch((error) => {
|
|
6343
|
+
throw billingActiveRefusal(error);
|
|
6344
|
+
});
|
|
5380
6345
|
const rows = {};
|
|
5381
6346
|
let total = 0;
|
|
5382
6347
|
for (const row of erased) {
|
|
@@ -5394,7 +6359,18 @@ function buildSlotlockStore(sql, options, refusal) {
|
|
|
5394
6359
|
};
|
|
5395
6360
|
return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
|
|
5396
6361
|
}
|
|
5397
|
-
/**
|
|
6362
|
+
/**
|
|
6363
|
+
* erase_tenant refuses a tenant whose plan names a live subscription (`billing_active`): it is
|
|
6364
|
+
* cancelled first, so it does not renew for an erased account.
|
|
6365
|
+
*/
|
|
6366
|
+
function billingActiveRefusal(error) {
|
|
6367
|
+
if (pgCode(error) !== '55006' || error.hint !== 'billing_active')
|
|
6368
|
+
return error;
|
|
6369
|
+
return Object.assign(new Error('Slotlock tenant has a live subscription: cancel it before erasing the tenant', {
|
|
6370
|
+
cause: error,
|
|
6371
|
+
}), { code: 'billing_active' });
|
|
6372
|
+
}
|
|
6373
|
+
/** The deployment role alone may suspend: others are refused EXECUTE on the functions. */
|
|
5398
6374
|
function suspensionRefusal(error) {
|
|
5399
6375
|
if (pgCode(error) !== '42501')
|
|
5400
6376
|
return error;
|