slotlock 0.1.1 → 0.3.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 +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/store.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// Domain policy (who may reserve, pricing, holds) belongs to future callers; this layer owns the
|
|
3
3
|
// reliable how: idempotent schema apply, EXCLUDE-arbitrated inserts with a structured conflict.
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
-
import
|
|
5
|
+
import ICAL from 'ical.js';
|
|
6
|
+
import { SLOTLOCK_CORE_DDL, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockTenantLockKey, } from './ddl.js';
|
|
6
7
|
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
7
|
-
import { expandRules } from './rules.js';
|
|
8
|
+
import { SLOTLOCK_CALENDAR_HORIZON_DAYS, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
|
|
8
9
|
import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
|
|
9
10
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
10
11
|
/** Postgres exclusion_violation — the EXCLUDE constraint fired. */
|
|
@@ -21,7 +22,7 @@ const SERIALIZATION_FAILURE = '40001';
|
|
|
21
22
|
const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
23
|
/** Turnaround cannot occupy a resource indefinitely because of a caller/unit error. */
|
|
23
24
|
const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
24
|
-
export
|
|
25
|
+
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
25
26
|
/**
|
|
26
27
|
* Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
|
|
27
28
|
* or long-term rental, short enough that a unit error cannot occupy a resource for ever. A recurring
|
|
@@ -30,6 +31,13 @@ export const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
|
|
|
30
31
|
export const SLOTLOCK_MAX_EVENT_DURATION_DAYS = 3_660;
|
|
31
32
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
32
33
|
const MAX_CALENDAR_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
|
|
34
|
+
/**
|
|
35
|
+
* Longest window a recurring series is stored (materialized) for: the 367-day search window plus
|
|
36
|
+
* two days. The rolling horizon starts a day before the current UTC day and must reach 367 days
|
|
37
|
+
* past its end, so that a search of the full 367 days from any moment of the day is complete.
|
|
38
|
+
*/
|
|
39
|
+
export const SLOTLOCK_MATERIALIZATION_WINDOW_DAYS = SLOTLOCK_CALENDAR_HORIZON_DAYS + 2;
|
|
40
|
+
const MAX_MATERIALIZATION_WINDOW_MS = SLOTLOCK_MATERIALIZATION_WINDOW_DAYS * DAY_MS;
|
|
33
41
|
const MAX_EVENT_DURATION_MS = SLOTLOCK_MAX_EVENT_DURATION_DAYS * DAY_MS;
|
|
34
42
|
const MAX_RESOURCE_LIST_LIMIT = 1_000;
|
|
35
43
|
/** Highest per-tenant resource cap `createResource` accepts. */
|
|
@@ -57,13 +65,16 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3
|
|
|
57
65
|
const AGENT_EVENT_OWNER_PATTERN = /^agent:[0-9a-f]{64}$/;
|
|
58
66
|
/** Store-owned coverage source for bounded, locally materialized recurring events. */
|
|
59
67
|
export const SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE = 'slotlock:local-recurrence-materialization';
|
|
60
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Stable daily target shared by the event writer, maintenance worker, and readiness proof: from a
|
|
70
|
+
* day before the current UTC day to 367 days past its end (SLOTLOCK_MATERIALIZATION_WINDOW_DAYS).
|
|
71
|
+
*/
|
|
61
72
|
export function calendarEventRollingHorizon(now = new Date()) {
|
|
62
73
|
if (!Number.isFinite(now.getTime()))
|
|
63
74
|
throw invalidWindowError();
|
|
64
75
|
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
65
76
|
const start = new Date(midnight - DAY_MS);
|
|
66
|
-
return { start, end: new Date(start.getTime() +
|
|
77
|
+
return { start, end: new Date(start.getTime() + MAX_MATERIALIZATION_WINDOW_MS) };
|
|
67
78
|
}
|
|
68
79
|
/**
|
|
69
80
|
* A stored series expanded from the cursor its last expansion left, or `null` when there is none it
|
|
@@ -89,11 +100,127 @@ function resumeStoredSeries(event, window, found) {
|
|
|
89
100
|
function seriesCursorJson(tx, cursor) {
|
|
90
101
|
return cursor === null ? null : tx.json(cursor);
|
|
91
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The window a roll to `horizon` gives a series stored for `stored`: from the horizon's start to
|
|
105
|
+
* the later of the two ends, so its end never moves back. `null` keeps the stored window: it covers
|
|
106
|
+
* the horizon already, or it ends so far past it that no window from the horizon's start to its end
|
|
107
|
+
* fits the 367-day cap (it rolls once the horizon is close enough).
|
|
108
|
+
*/
|
|
109
|
+
function rolledWindow(stored, horizon) {
|
|
110
|
+
if (stored.start <= horizon.start && stored.end >= horizon.end)
|
|
111
|
+
return null;
|
|
112
|
+
if (stored.end.getTime() - horizon.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS)
|
|
113
|
+
return null;
|
|
114
|
+
return { start: horizon.start, end: stored.end > horizon.end ? stored.end : horizon.end };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* How far past a rule's UNTIL, read as UTC, an occurrence can still end, beyond the series' own
|
|
118
|
+
* length. UNTIL bounds an occurrence's start (RFC 5545 §3.3.10), but a floating or date-only UNTIL
|
|
119
|
+
* is a wall time in a zone from UTC-12 to UTC+14, or a whole day; a start in an hour the clocks skip
|
|
120
|
+
* moves forward; and an occurrence's wall-clock end can pass its exact length by a clock change, a
|
|
121
|
+
* whole day where a zone moved across the date line. Three days covers them together.
|
|
122
|
+
*/
|
|
123
|
+
const SERIES_UNTIL_SLACK_MS = 3 * DAY_MS;
|
|
124
|
+
/** Stored-series windows a COUNT rule is followed through, from its start, to find its end. */
|
|
125
|
+
const SERIES_COUNT_SEARCH_WINDOWS = 30;
|
|
126
|
+
/**
|
|
127
|
+
* A time by which every occurrence of a recurring series ends, moved ones included, or `null` when
|
|
128
|
+
* it may go on: its rule has neither UNTIL nor COUNT as ical.js reads it (as the expansion does),
|
|
129
|
+
* or its COUNT occurrences are not all found within SERIES_COUNT_SEARCH_WINDOWS windows from its
|
|
130
|
+
* start, or that search is over its budget. A COUNT series ends a day after its last occurrence as
|
|
131
|
+
* the expansion reads it today, so a later reading of its end may be longer.
|
|
132
|
+
*/
|
|
133
|
+
function seriesEndsBy(event) {
|
|
134
|
+
const recurrence = event.recurrence;
|
|
135
|
+
if (!recurrence)
|
|
136
|
+
return null;
|
|
137
|
+
let rule;
|
|
138
|
+
try {
|
|
139
|
+
rule = ICAL.Recur.fromString(recurrence.rrule);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
let endsBy;
|
|
145
|
+
if (rule.until) {
|
|
146
|
+
// ical.js stops at the first occurrence past UNTIL; a floating or date UNTIL reads as UTC.
|
|
147
|
+
const untilMs = rule.until.toUnixTime() * 1000;
|
|
148
|
+
endsBy = untilMs + (event.end.getTime() - event.start.getTime()) + SERIES_UNTIL_SLACK_MS;
|
|
149
|
+
}
|
|
150
|
+
else if (rule.count) {
|
|
151
|
+
const last = lastCountedOccurrenceEnd(event, rule.count);
|
|
152
|
+
if (last === null)
|
|
153
|
+
return null;
|
|
154
|
+
endsBy = last + DAY_MS;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
for (const exception of recurrence.exceptions ?? []) {
|
|
160
|
+
if (!exception.cancelled && exception.end && exception.end.getTime() > endsBy) {
|
|
161
|
+
endsBy = exception.end.getTime();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const ends = new Date(endsBy);
|
|
165
|
+
return Number.isFinite(ends.getTime()) ? ends : null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The latest end among a COUNT series' occurrences, expanded window by window from its start with
|
|
169
|
+
* exceptions left out (so a cancelled or moved one is still found in its own place), or `null` when
|
|
170
|
+
* fewer than `count` turn up. ical.js yields at most COUNT occurrences, so once `count` are found
|
|
171
|
+
* there are no more. An occurrence is counted in the window its start falls in.
|
|
172
|
+
*/
|
|
173
|
+
function lastCountedOccurrenceEnd(event, count) {
|
|
174
|
+
const rrule = event.recurrence?.rrule;
|
|
175
|
+
if (rrule === undefined)
|
|
176
|
+
return null;
|
|
177
|
+
const series = { ...event, recurrence: { rrule } };
|
|
178
|
+
let cursor = null;
|
|
179
|
+
let found = 0;
|
|
180
|
+
let lastEnd = Number.NEGATIVE_INFINITY;
|
|
181
|
+
let from = event.start.getTime();
|
|
182
|
+
try {
|
|
183
|
+
for (let window = 0; window < SERIES_COUNT_SEARCH_WINDOWS; window++) {
|
|
184
|
+
const to = from + MAX_MATERIALIZATION_WINDOW_MS;
|
|
185
|
+
const expansion = expandCalendarEventSeries(series, { start: new Date(from), end: new Date(to) }, { cursor, stored: true });
|
|
186
|
+
for (const occurrence of expansion.occurrences) {
|
|
187
|
+
if (occurrence.start.getTime() < from)
|
|
188
|
+
continue;
|
|
189
|
+
found += 1;
|
|
190
|
+
if (occurrence.end.getTime() > lastEnd)
|
|
191
|
+
lastEnd = occurrence.end.getTime();
|
|
192
|
+
}
|
|
193
|
+
if (found >= count)
|
|
194
|
+
return lastEnd;
|
|
195
|
+
cursor = expansion.cursor;
|
|
196
|
+
from = to;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof CalendarExpansionLimitError || error instanceof CalendarContractError) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The recurrence_ends_by column for an event, as text: NULL for a one-off, 'infinity' for no known
|
|
209
|
+
* end. Bind it as text (`::text::timestamptz`): postgres.js serializes a timestamptz parameter
|
|
210
|
+
* through Date, which cannot hold 'infinity'.
|
|
211
|
+
*/
|
|
212
|
+
function recurrenceEndsByColumn(event) {
|
|
213
|
+
if (!event.recurrence)
|
|
214
|
+
return null;
|
|
215
|
+
return seriesEndsBy(event)?.toISOString() ?? 'infinity';
|
|
216
|
+
}
|
|
92
217
|
/** The roll's `after`, checked, or `undefined` for a pass's first batch. */
|
|
93
218
|
function rollCursor(after) {
|
|
94
219
|
if (after === undefined)
|
|
95
220
|
return undefined;
|
|
96
|
-
|
|
221
|
+
const retryAt = after.rollRetryAt ?? null;
|
|
222
|
+
if ((retryAt !== null && (!(retryAt instanceof Date) || !Number.isFinite(retryAt.getTime()))) ||
|
|
223
|
+
!(after.materializedEndsAt instanceof Date) ||
|
|
97
224
|
!Number.isFinite(after.materializedEndsAt.getTime()) ||
|
|
98
225
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(after.eventId)) {
|
|
99
226
|
throw Object.assign(new Error('Slotlock horizon cursor must be a previous batch\'s `next`'), {
|
|
@@ -143,10 +270,18 @@ async function withPinnedSearchPath(tx, operation) {
|
|
|
143
270
|
/**
|
|
144
271
|
* The deployment DDL refuses a `slotlock` schema that another role controls with SQLSTATE 42501 and
|
|
145
272
|
* its findings as a JSON array in DETAIL. Surface that as `unsafe_slotlock_schema` with `reasons`,
|
|
146
|
-
* keeping the database's error (and its HINT) as `cause
|
|
273
|
+
* keeping the database's error (and its HINT) as `cause`. It refuses a schema a newer release
|
|
274
|
+
* migrated with SQLSTATE 55000: surface that as `schema_newer`. Any other failure passes through.
|
|
147
275
|
*/
|
|
148
276
|
function schemaControlRefusal(error) {
|
|
149
277
|
const failure = error;
|
|
278
|
+
if (failure?.code === '55000' &&
|
|
279
|
+
typeof failure.message === 'string' &&
|
|
280
|
+
failure.message.startsWith(SLOTLOCK_SCHEMA_NEWER_REFUSAL)) {
|
|
281
|
+
return Object.assign(new Error(failure.message, { cause: error }), {
|
|
282
|
+
code: 'schema_newer',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
150
285
|
if (failure?.code !== '42501' ||
|
|
151
286
|
typeof failure.message !== 'string' ||
|
|
152
287
|
!failure.message.startsWith(SLOTLOCK_SCHEMA_CONTROL_REFUSAL)) {
|
|
@@ -167,6 +302,236 @@ function schemaControlRefusal(error) {
|
|
|
167
302
|
reasons,
|
|
168
303
|
});
|
|
169
304
|
}
|
|
305
|
+
/** PostgreSQL lock_not_available: a lock wait passed lock_timeout. */
|
|
306
|
+
const LOCK_NOT_AVAILABLE = '55P03';
|
|
307
|
+
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 1_000;
|
|
308
|
+
const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
309
|
+
const DEFAULT_MIGRATION_ATTEMPTS = 5;
|
|
310
|
+
const SLOTLOCK_CORE_DDL_SHA256 = createHash('sha256').update(SLOTLOCK_CORE_DDL).digest('hex');
|
|
311
|
+
function migrationSettings(options) {
|
|
312
|
+
const lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_MIGRATION_LOCK_TIMEOUT_MS;
|
|
313
|
+
const statementTimeoutMs = options?.statementTimeoutMs ?? DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS;
|
|
314
|
+
const attempts = options?.attempts ?? DEFAULT_MIGRATION_ATTEMPTS;
|
|
315
|
+
const whole = (value, max) => Number.isSafeInteger(value) && value >= 1 && value <= max;
|
|
316
|
+
if (!whole(lockTimeoutMs, 600_000) ||
|
|
317
|
+
!whole(statementTimeoutMs, 86_400_000) ||
|
|
318
|
+
!whole(attempts, 20)) {
|
|
319
|
+
throw Object.assign(new Error('Slotlock migration lockTimeoutMs must be 1-600000, statementTimeoutMs 1-86400000 and attempts 1-20, whole numbers'), { code: 'invalid_migration_options' });
|
|
320
|
+
}
|
|
321
|
+
return { lockTimeoutMs, statementTimeoutMs, attempts };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Run one migration step: in a transaction (or a savepoint of the caller's), with names resolved
|
|
325
|
+
* in pg_catalog only, serialized with every other migration of this database, and with the lock and
|
|
326
|
+
* statement timeouts set. A step that waits past its lock timeout is rolled back and tried again
|
|
327
|
+
* after a pause, up to `attempts` times. The caller's own timeouts are put back afterwards.
|
|
328
|
+
*/
|
|
329
|
+
async function inMigration(executor, options, step) {
|
|
330
|
+
const settings = migrationSettings(options);
|
|
331
|
+
for (let attempt = 1;; attempt++) {
|
|
332
|
+
try {
|
|
333
|
+
return await inTransaction(executor, (tx) => withPinnedSearchPath(tx, async () => {
|
|
334
|
+
// Taken before the timeouts are set: waiting for another migration holds up no query.
|
|
335
|
+
await tx `SELECT pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(${SCHEMA_APPLY_LOCK}, 0))`;
|
|
336
|
+
const [caller] = await tx `
|
|
337
|
+
SELECT pg_catalog.current_setting('lock_timeout') AS lock,
|
|
338
|
+
pg_catalog.current_setting('statement_timeout') AS statement`;
|
|
339
|
+
await tx `
|
|
340
|
+
SELECT pg_catalog.set_config('lock_timeout', ${`${settings.lockTimeoutMs}ms`}, true),
|
|
341
|
+
pg_catalog.set_config('statement_timeout', ${`${settings.statementTimeoutMs}ms`}, true)`;
|
|
342
|
+
const result = await step(tx);
|
|
343
|
+
await tx `
|
|
344
|
+
SELECT pg_catalog.set_config('lock_timeout', ${caller?.lock ?? '0'}, true),
|
|
345
|
+
pg_catalog.set_config('statement_timeout', ${caller?.statement ?? '0'}, true)`;
|
|
346
|
+
return result;
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
if (pgCode(error) !== LOCK_NOT_AVAILABLE)
|
|
351
|
+
throw error;
|
|
352
|
+
if (attempt >= settings.attempts) {
|
|
353
|
+
throw Object.assign(new Error(`Slotlock migration gave up after ${attempt} attempts to take its locks within ${settings.lockTimeoutMs} ms: a long transaction holds a slotlock table. Run it again once that transaction ends`, { cause: error }), { code: 'schema_lock_timeout' });
|
|
354
|
+
}
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(attempt * 250, 2_000) + Math.floor(Math.random() * 250)));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* A digest of what the migration defines in the `slotlock` schema: its tables and columns,
|
|
361
|
+
* constraints (and whether each is validated), indexes, triggers and functions, and whether PUBLIC
|
|
362
|
+
* holds any right on them. Grants to other roles, ownership and row-level security are left out:
|
|
363
|
+
* applyTenantRls and grantApplicationRole change those after applySchema has recorded the digest.
|
|
364
|
+
* Objects of an extension are left out too. Deparsed definitions depend on search_path, so this runs
|
|
365
|
+
* with it pinned, as the migration does.
|
|
366
|
+
*/
|
|
367
|
+
async function slotlockCatalogDigest(tx) {
|
|
368
|
+
const [row] = await tx `
|
|
369
|
+
WITH schema AS (
|
|
370
|
+
SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = 'slotlock'
|
|
371
|
+
), members AS (
|
|
372
|
+
SELECT objid FROM pg_catalog.pg_depend WHERE deptype = 'e'
|
|
373
|
+
), items (item) AS (
|
|
374
|
+
SELECT 'relation ' || rel.relname || ' ' || rel.relkind::text || ' '
|
|
375
|
+
|| EXISTS (SELECT 1 FROM pg_catalog.aclexplode(rel.relacl) acl WHERE acl.grantee = 0)::text
|
|
376
|
+
FROM pg_catalog.pg_class rel, schema
|
|
377
|
+
WHERE rel.relnamespace = schema.oid AND rel.oid NOT IN (SELECT objid FROM members)
|
|
378
|
+
UNION ALL
|
|
379
|
+
SELECT 'column ' || rel.relname || '.' || att.attname || ' '
|
|
380
|
+
|| pg_catalog.format_type(att.atttypid, att.atttypmod) || ' ' || att.attnotnull::text || ' '
|
|
381
|
+
|| COALESCE(pg_catalog.pg_get_expr(def.adbin, def.adrelid), '')
|
|
382
|
+
FROM pg_catalog.pg_attribute att
|
|
383
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = att.attrelid
|
|
384
|
+
JOIN schema ON rel.relnamespace = schema.oid
|
|
385
|
+
LEFT JOIN pg_catalog.pg_attrdef def ON def.adrelid = att.attrelid AND def.adnum = att.attnum
|
|
386
|
+
WHERE rel.relkind IN ('r', 'p') AND att.attnum > 0 AND NOT att.attisdropped
|
|
387
|
+
UNION ALL
|
|
388
|
+
SELECT 'constraint ' || rel.relname || '.' || con.conname || ' '
|
|
389
|
+
|| pg_catalog.pg_get_constraintdef(con.oid) || ' ' || con.convalidated::text
|
|
390
|
+
FROM pg_catalog.pg_constraint con
|
|
391
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
392
|
+
JOIN schema ON rel.relnamespace = schema.oid
|
|
393
|
+
UNION ALL
|
|
394
|
+
SELECT 'index ' || pg_catalog.pg_get_indexdef(idx.indexrelid)
|
|
395
|
+
FROM pg_catalog.pg_index idx
|
|
396
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = idx.indrelid
|
|
397
|
+
JOIN schema ON rel.relnamespace = schema.oid
|
|
398
|
+
UNION ALL
|
|
399
|
+
SELECT 'trigger ' || pg_catalog.pg_get_triggerdef(trg.oid) || ' ' || trg.tgenabled::text
|
|
400
|
+
FROM pg_catalog.pg_trigger trg
|
|
401
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = trg.tgrelid
|
|
402
|
+
JOIN schema ON rel.relnamespace = schema.oid
|
|
403
|
+
WHERE NOT trg.tgisinternal
|
|
404
|
+
UNION ALL
|
|
405
|
+
SELECT 'function ' || fn.oid::pg_catalog.regprocedure::text || ' ' || fn.prosecdef::text || ' '
|
|
406
|
+
|| fn.provolatile::text || ' ' || COALESCE(fn.proconfig::text, '') || ' '
|
|
407
|
+
|| pg_catalog.md5(fn.prosrc) || ' '
|
|
408
|
+
|| EXISTS (SELECT 1 FROM pg_catalog.aclexplode(
|
|
409
|
+
COALESCE(fn.proacl, pg_catalog.acldefault('f', fn.proowner))) acl
|
|
410
|
+
WHERE acl.grantee = 0)::text
|
|
411
|
+
FROM pg_catalog.pg_proc fn, schema
|
|
412
|
+
WHERE fn.pronamespace = schema.oid AND fn.oid NOT IN (SELECT objid FROM members)
|
|
413
|
+
UNION ALL
|
|
414
|
+
SELECT 'extension ' || ext.extname || ' ' || ext.extnamespace::pg_catalog.regnamespace::text
|
|
415
|
+
FROM pg_catalog.pg_extension ext
|
|
416
|
+
WHERE ext.extname = 'btree_gist'
|
|
417
|
+
)
|
|
418
|
+
SELECT pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
|
|
419
|
+
COALESCE(pg_catalog.string_agg(item, pg_catalog.chr(10) ORDER BY item), ''),
|
|
420
|
+
'UTF8')), 'hex') AS digest
|
|
421
|
+
FROM items`;
|
|
422
|
+
return row?.digest ?? '';
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Whether the schema is exactly what this release's applySchema last left: the same version, DDL
|
|
426
|
+
* and catalog digest, and no table left without FORCE by an interrupted migration. Reading needs
|
|
427
|
+
* the right to (the deployment role owns the table); without it the full migration runs and refuses
|
|
428
|
+
* what it must.
|
|
429
|
+
*/
|
|
430
|
+
async function slotlockSchemaUpToDate(tx) {
|
|
431
|
+
const [present] = await tx `
|
|
432
|
+
SELECT COALESCE(pg_catalog.has_table_privilege(
|
|
433
|
+
pg_catalog.to_regclass('slotlock.schema_version'), 'SELECT'), false) AS versioned,
|
|
434
|
+
COALESCE(pg_catalog.has_table_privilege(
|
|
435
|
+
pg_catalog.to_regclass('slotlock.deployment_lifted_row_security'), 'SELECT'), false)
|
|
436
|
+
AS lifted`;
|
|
437
|
+
if (!present?.versioned || !present.lifted)
|
|
438
|
+
return false;
|
|
439
|
+
const [recorded] = await tx `
|
|
440
|
+
SELECT version, ddl_sha256, catalog_sha256,
|
|
441
|
+
EXISTS (SELECT 1 FROM slotlock.deployment_lifted_row_security) AS lifted
|
|
442
|
+
FROM slotlock.schema_version
|
|
443
|
+
WHERE singleton`;
|
|
444
|
+
if (!recorded ||
|
|
445
|
+
recorded.lifted ||
|
|
446
|
+
recorded.version !== SLOTLOCK_SCHEMA_VERSION ||
|
|
447
|
+
recorded.ddl_sha256 !== SLOTLOCK_CORE_DDL_SHA256 ||
|
|
448
|
+
recorded.catalog_sha256 === null) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
return recorded.catalog_sha256 === (await slotlockCatalogDigest(tx));
|
|
452
|
+
}
|
|
453
|
+
/** Whether a constraint in the `slotlock` schema is still NOT VALID (old rows broke it). */
|
|
454
|
+
async function slotlockConstraintUnvalidated(tx) {
|
|
455
|
+
const [row] = await tx `
|
|
456
|
+
SELECT EXISTS (
|
|
457
|
+
SELECT 1
|
|
458
|
+
FROM pg_catalog.pg_constraint con
|
|
459
|
+
JOIN pg_catalog.pg_class rel ON rel.oid = con.conrelid
|
|
460
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
461
|
+
WHERE ns.nspname = 'slotlock' AND NOT con.convalidated
|
|
462
|
+
) AS unvalidated`;
|
|
463
|
+
return row?.unvalidated === true;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Whether every tenant table has forced RLS and only its tenant policy, bound to `setting`, whose
|
|
467
|
+
* live fingerprint (computed as the RLS DDL computes it) matches the contract the DDL recorded.
|
|
468
|
+
*/
|
|
469
|
+
async function tenantRlsUpToDate(tx, setting) {
|
|
470
|
+
const [present] = await tx `
|
|
471
|
+
SELECT COALESCE(pg_catalog.has_table_privilege(
|
|
472
|
+
pg_catalog.to_regclass('slotlock.rls_policy_contracts'), 'SELECT'), false) AS readable`;
|
|
473
|
+
if (!present?.readable)
|
|
474
|
+
return false;
|
|
475
|
+
const tables = [...SLOTLOCK_TENANT_TABLES];
|
|
476
|
+
const [state] = await tx `
|
|
477
|
+
SELECT pg_catalog.bool_and(
|
|
478
|
+
rel.relrowsecurity AND rel.relforcerowsecurity
|
|
479
|
+
AND (SELECT pg_catalog.count(*) FROM pg_catalog.pg_policy every
|
|
480
|
+
WHERE every.polrelid = rel.oid) = 1
|
|
481
|
+
AND pg_catalog.obj_description(pol.oid, 'pg_policy') = ${`slotlock:tenant-context:${setting}`}
|
|
482
|
+
AND contract.tenant_context_setting = ${setting}
|
|
483
|
+
AND contract.policy_fingerprint = pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
|
|
484
|
+
pg_catalog.concat_ws(
|
|
485
|
+
pg_catalog.chr(31), live.policyname, live.permissive, live.roles::text, live.cmd,
|
|
486
|
+
COALESCE(live.qual, ''), COALESCE(live.with_check, '')
|
|
487
|
+
),
|
|
488
|
+
'UTF8')), 'hex')
|
|
489
|
+
) AS current,
|
|
490
|
+
pg_catalog.count(*)::int AS matched
|
|
491
|
+
FROM pg_catalog.unnest(${tables}::text[]) AS wanted(name)
|
|
492
|
+
JOIN pg_catalog.pg_namespace ns ON ns.nspname = 'slotlock'
|
|
493
|
+
JOIN pg_catalog.pg_class rel ON rel.relnamespace = ns.oid AND rel.relname = wanted.name
|
|
494
|
+
JOIN pg_catalog.pg_policy pol ON pol.polrelid = rel.oid AND pol.polname = 'tenant_isolation_v1'
|
|
495
|
+
JOIN pg_catalog.pg_policies live
|
|
496
|
+
ON live.schemaname = 'slotlock' AND live.tablename = wanted.name
|
|
497
|
+
AND live.policyname = 'tenant_isolation_v1'
|
|
498
|
+
JOIN slotlock.rls_policy_contracts contract ON contract.table_name = wanted.name`;
|
|
499
|
+
return state?.current === true && state.matched === tables.length;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The schema version recorded by the last migration, or `null` when there is none this role can
|
|
503
|
+
* read: no schema, a schema from 0.2 or earlier, or a role not yet granted the function (run
|
|
504
|
+
* `slotlock migrate`, which grants it).
|
|
505
|
+
*/
|
|
506
|
+
export async function readSlotlockSchemaVersion(sql) {
|
|
507
|
+
const [readable] = await sql `
|
|
508
|
+
SELECT COALESCE(pg_catalog.has_function_privilege(
|
|
509
|
+
pg_catalog.to_regprocedure('slotlock.installed_schema_version()'), 'EXECUTE'), false)
|
|
510
|
+
AS readable`;
|
|
511
|
+
if (!readable?.readable)
|
|
512
|
+
return null;
|
|
513
|
+
const [row] = await sql `
|
|
514
|
+
SELECT slotlock.installed_schema_version() AS version`;
|
|
515
|
+
return row?.version ?? null;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Refuse to serve a schema another release migrated: `schema_outdated` when it is older than this
|
|
519
|
+
* release (or unversioned), so `slotlock migrate` must run first; `schema_newer` when a newer release
|
|
520
|
+
* migrated it, whose schema this release's code does not know. Both carry `installed` and `expected`.
|
|
521
|
+
*/
|
|
522
|
+
export async function assertSlotlockSchemaCurrent(sql) {
|
|
523
|
+
const installed = await readSlotlockSchemaVersion(sql);
|
|
524
|
+
if (installed === SLOTLOCK_SCHEMA_VERSION)
|
|
525
|
+
return;
|
|
526
|
+
const newer = installed !== null && installed > SLOTLOCK_SCHEMA_VERSION;
|
|
527
|
+
throw Object.assign(new Error(newer
|
|
528
|
+
? `The slotlock schema is version ${installed}, migrated by a newer release; this release serves version ${SLOTLOCK_SCHEMA_VERSION}. Serve it with that release or a later one`
|
|
529
|
+
: `The slotlock schema is ${installed === null ? 'unversioned (0.2 or earlier)' : `version ${installed}`}; this release serves version ${SLOTLOCK_SCHEMA_VERSION}. Run \`slotlock migrate\` with this release first`), {
|
|
530
|
+
code: newer ? 'schema_newer' : 'schema_outdated',
|
|
531
|
+
installed,
|
|
532
|
+
expected: SLOTLOCK_SCHEMA_VERSION,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
170
535
|
function isFiniteWindow(window) {
|
|
171
536
|
return (window.start instanceof Date &&
|
|
172
537
|
window.end instanceof Date &&
|
|
@@ -174,11 +539,6 @@ function isFiniteWindow(window) {
|
|
|
174
539
|
Number.isFinite(window.end.getTime()) &&
|
|
175
540
|
window.end.getTime() > window.start.getTime());
|
|
176
541
|
}
|
|
177
|
-
function invalidWindowError() {
|
|
178
|
-
return Object.assign(new Error('Slotlock window must contain two finite dates with end > start'), {
|
|
179
|
-
code: 'invalid_window',
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
542
|
function isValidIdentity(value) {
|
|
183
543
|
return value !== undefined && value.trim().length > 0 && Buffer.byteLength(value, 'utf8') <= 500;
|
|
184
544
|
}
|
|
@@ -188,7 +548,15 @@ function isValidEventOwnerRef(value) {
|
|
|
188
548
|
function isValidResourceId(value) {
|
|
189
549
|
return UUID_PATTERN.test(value);
|
|
190
550
|
}
|
|
551
|
+
function assertValidVisibility(visibility) {
|
|
552
|
+
if (visibility !== undefined && visibility !== 'shared' && visibility !== 'private') {
|
|
553
|
+
throw Object.assign(new Error("Slotlock resource visibility must be 'shared' or 'private'"), {
|
|
554
|
+
code: 'invalid_visibility',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
191
558
|
function assertValidResourceInput(params) {
|
|
559
|
+
assertValidVisibility(params.visibility);
|
|
192
560
|
if (params.externalRef !== undefined &&
|
|
193
561
|
(params.externalRef.trim().length === 0 || Buffer.byteLength(params.externalRef, 'utf8') > 500)) {
|
|
194
562
|
throw Object.assign(new Error('Slotlock externalRef must be 1-500 UTF-8 bytes'), {
|
|
@@ -244,6 +612,41 @@ function toReservation(row) {
|
|
|
244
612
|
source: row.source,
|
|
245
613
|
};
|
|
246
614
|
}
|
|
615
|
+
function toHold(row) {
|
|
616
|
+
return {
|
|
617
|
+
id: row.id,
|
|
618
|
+
resourceId: row.resource_id,
|
|
619
|
+
start: row.starts_at,
|
|
620
|
+
end: row.ends_at,
|
|
621
|
+
bufferAfterMs: Number(row.buffer_after_ms),
|
|
622
|
+
expiresAt: row.expires_at,
|
|
623
|
+
source: row.source,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** A keyset cursor `{ start, id }` from the last row of a previous page, checked. */
|
|
627
|
+
function pageCursor(after) {
|
|
628
|
+
if (after !== undefined &&
|
|
629
|
+
(!(after.start instanceof Date) ||
|
|
630
|
+
!Number.isFinite(after.start.getTime()) ||
|
|
631
|
+
!UUID_PATTERN.test(after.id))) {
|
|
632
|
+
throw Object.assign(new Error('Slotlock page cursor is invalid'), {
|
|
633
|
+
code: 'invalid_cursor',
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
return { start: after?.start ?? null, id: after?.id ?? null };
|
|
637
|
+
}
|
|
638
|
+
function pageLimit(limit) {
|
|
639
|
+
const value = limit ?? 100;
|
|
640
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_CALENDAR_LIST_LIMIT) {
|
|
641
|
+
throw Object.assign(new Error('Slotlock list limit must be 1-1000'), {
|
|
642
|
+
code: 'invalid_limit',
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return value;
|
|
646
|
+
}
|
|
647
|
+
function invalidIdentityError(message) {
|
|
648
|
+
return Object.assign(new Error(message), { code: 'invalid_identity' });
|
|
649
|
+
}
|
|
247
650
|
function eventExceptionsForJson(exceptions) {
|
|
248
651
|
return (exceptions ?? []).map((exception) => {
|
|
249
652
|
const result = {
|
|
@@ -479,6 +882,14 @@ async function replaceCalendarEventOccurrences(tx, params) {
|
|
|
479
882
|
)`;
|
|
480
883
|
}
|
|
481
884
|
}
|
|
885
|
+
/** A recurring series' materialization window: finite, forward, and at most 369 days. */
|
|
886
|
+
function seriesWindow(window) {
|
|
887
|
+
if (!isFiniteWindow(window) ||
|
|
888
|
+
window.end.getTime() - window.start.getTime() > MAX_MATERIALIZATION_WINDOW_MS) {
|
|
889
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
890
|
+
}
|
|
891
|
+
return { start: new Date(window.start), end: new Date(window.end) };
|
|
892
|
+
}
|
|
482
893
|
function calendarWindow(window) {
|
|
483
894
|
if (!isFiniteWindow(window) ||
|
|
484
895
|
window.end.getTime() - window.start.getTime() > MAX_CALENDAR_WINDOW_MS) {
|
|
@@ -499,7 +910,7 @@ function oneOffEventWindow(event, requested) {
|
|
|
499
910
|
if (!isFiniteWindow(window) ||
|
|
500
911
|
window.start > event.start ||
|
|
501
912
|
window.end < event.end ||
|
|
502
|
-
window.end.getTime() - window.start.getTime() > Math.max(
|
|
913
|
+
window.end.getTime() - window.start.getTime() > Math.max(MAX_MATERIALIZATION_WINDOW_MS, duration)) {
|
|
503
914
|
throw new CalendarContractError('invalid_icalendar');
|
|
504
915
|
}
|
|
505
916
|
return { start: new Date(window.start), end: new Date(window.end) };
|
|
@@ -575,9 +986,55 @@ async function calendarOwnerCommandCount(tx, tenantRef, ownerRef) {
|
|
|
575
986
|
}
|
|
576
987
|
return count;
|
|
577
988
|
}
|
|
578
|
-
|
|
989
|
+
/**
|
|
990
|
+
* The end of every event cancellation, under the event's identity and row locks: remove the event
|
|
991
|
+
* (when there is one) and its occupation, leave the identity's tombstone at `revision`, and record
|
|
992
|
+
* the command in `commandOwnerRef`'s namespace for replay.
|
|
993
|
+
*/
|
|
994
|
+
async function finishCalendarCancel(tx, params) {
|
|
995
|
+
const eventId = params.eventId ?? randomUUID();
|
|
996
|
+
if (params.eventId !== null) {
|
|
997
|
+
await tx `
|
|
998
|
+
DELETE FROM slotlock.reservations
|
|
999
|
+
WHERE id IN (
|
|
1000
|
+
SELECT reservation_id FROM slotlock.calendar_event_occurrences
|
|
1001
|
+
WHERE event_id = ${eventId} AND reservation_id IS NOT NULL
|
|
1002
|
+
)`;
|
|
1003
|
+
await tx `
|
|
1004
|
+
DELETE FROM slotlock.calendar_events
|
|
1005
|
+
WHERE id = ${eventId}
|
|
1006
|
+
AND tenant_ref = ${params.tenantRef}
|
|
1007
|
+
AND owner_ref = ${params.ownerRef}`;
|
|
1008
|
+
}
|
|
1009
|
+
await tx `
|
|
1010
|
+
INSERT INTO slotlock.calendar_event_tombstones (
|
|
1011
|
+
tenant_ref, owner_ref, external_ref, event_id, revision
|
|
1012
|
+
) VALUES (
|
|
1013
|
+
${params.tenantRef}, ${params.ownerRef}, ${params.externalRef}, ${eventId}, ${params.revision}
|
|
1014
|
+
)`;
|
|
1015
|
+
await tx `
|
|
1016
|
+
INSERT INTO slotlock.calendar_event_commands (
|
|
1017
|
+
tenant_ref, owner_ref, idempotency_key, operation, payload_hash, event_id, external_ref,
|
|
1018
|
+
result_revision, occurrence_count
|
|
1019
|
+
) VALUES (
|
|
1020
|
+
${params.tenantRef}, ${params.commandOwnerRef}, ${params.idempotencyKey}, 'cancel',
|
|
1021
|
+
${params.payloadHash}, ${eventId}, ${params.externalRef}, ${params.revision}, 0
|
|
1022
|
+
)`;
|
|
1023
|
+
return {
|
|
1024
|
+
ok: true,
|
|
1025
|
+
eventId,
|
|
1026
|
+
externalRef: params.externalRef,
|
|
1027
|
+
revision: params.revision,
|
|
1028
|
+
idempotent: false,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* The row an event write lost to. The write was rolled back, so the event's own rows from before
|
|
1033
|
+
* it are still there and are left out: they were to be replaced, and never block the event.
|
|
1034
|
+
*/
|
|
1035
|
+
async function findCalendarOverlap(sql, resourceId, occurrences, eventId) {
|
|
579
1036
|
for (const occurrence of occurrences) {
|
|
580
|
-
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end);
|
|
1037
|
+
const result = await overlapLoss(sql, resourceId, occurrence.start, occurrence.end, 0, undefined, eventId);
|
|
581
1038
|
if (result.conflictingReservationId !== null)
|
|
582
1039
|
return result;
|
|
583
1040
|
}
|
|
@@ -599,12 +1056,18 @@ async function lockExternalIdentity(tx, tenantRef, externalRef) {
|
|
|
599
1056
|
// U+0000). The database hashes it to one transaction-scoped lock key.
|
|
600
1057
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
|
|
601
1058
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1059
|
+
/**
|
|
1060
|
+
* Whether the resource is the tenant's (`missing` when not), and whether it is archived. The row is
|
|
1061
|
+
* share-locked to the end of the transaction, so it is neither archived nor deleted meanwhile.
|
|
1062
|
+
*/
|
|
1063
|
+
async function tenantResourceState(tx, resourceId, tenantRef) {
|
|
1064
|
+
const [row] = await tx `
|
|
1065
|
+
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
605
1066
|
WHERE id = ${resourceId} AND tenant_ref = ${tenantRef}
|
|
606
1067
|
FOR SHARE`;
|
|
607
|
-
|
|
1068
|
+
if (!row)
|
|
1069
|
+
return 'missing';
|
|
1070
|
+
return row.archived ? 'archived' : 'active';
|
|
608
1071
|
}
|
|
609
1072
|
function pgCode(err) {
|
|
610
1073
|
let cur = err;
|
|
@@ -621,7 +1084,10 @@ function toResource(row) {
|
|
|
621
1084
|
externalRef: row.external_ref,
|
|
622
1085
|
tenantRef: row.tenant_ref,
|
|
623
1086
|
timezone: row.timezone,
|
|
1087
|
+
visibility: row.visibility,
|
|
624
1088
|
};
|
|
1089
|
+
if (row.archived_at !== null)
|
|
1090
|
+
resource.archivedAt = row.archived_at;
|
|
625
1091
|
if (row.availability_rules !== null && row.availability_rules !== undefined) {
|
|
626
1092
|
// Written through setResourceAvailability, which validates; anything else reads as closed.
|
|
627
1093
|
try {
|
|
@@ -645,42 +1111,452 @@ function availabilityParameter(sql, rules) {
|
|
|
645
1111
|
})));
|
|
646
1112
|
}
|
|
647
1113
|
async function upsertResource(executor, params) {
|
|
1114
|
+
const visibility = params.visibility ?? 'shared';
|
|
1115
|
+
const asked = params.visibility ?? null;
|
|
648
1116
|
let rows;
|
|
1117
|
+
// An existing identity keeps the visibility it has: creating it again with none named returns it
|
|
1118
|
+
// as it is, and with another one is refused, so a private one is never made shared that way.
|
|
649
1119
|
if (params.tenantRef !== undefined && params.externalRef !== undefined) {
|
|
650
1120
|
rows = await executor `
|
|
651
|
-
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
|
|
652
|
-
VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'})
|
|
1121
|
+
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1122
|
+
VALUES (${params.externalRef}, ${params.tenantRef}, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
653
1123
|
ON CONFLICT (tenant_ref, external_ref)
|
|
654
1124
|
WHERE tenant_ref IS NOT NULL AND external_ref IS NOT NULL
|
|
655
1125
|
DO UPDATE SET timezone = EXCLUDED.timezone
|
|
656
|
-
|
|
1126
|
+
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1127
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
657
1128
|
}
|
|
658
1129
|
else if (params.externalRef !== undefined) {
|
|
659
1130
|
rows = await executor `
|
|
660
|
-
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
|
|
661
|
-
VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'})
|
|
1131
|
+
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1132
|
+
VALUES (${params.externalRef}, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
662
1133
|
ON CONFLICT (external_ref)
|
|
663
1134
|
WHERE tenant_ref IS NULL AND external_ref IS NOT NULL
|
|
664
1135
|
DO UPDATE SET external_ref = EXCLUDED.external_ref
|
|
665
|
-
|
|
1136
|
+
WHERE ${asked}::text IS NULL OR slotlock.resources.visibility = ${asked}::text
|
|
1137
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
666
1138
|
}
|
|
667
1139
|
else {
|
|
668
1140
|
rows = await executor `
|
|
669
|
-
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone)
|
|
670
|
-
VALUES (NULL, NULL, ${params.timezone ?? 'UTC'})
|
|
671
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
|
|
1141
|
+
INSERT INTO slotlock.resources (external_ref, tenant_ref, timezone, visibility)
|
|
1142
|
+
VALUES (NULL, NULL, ${params.timezone ?? 'UTC'}, ${visibility})
|
|
1143
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
672
1144
|
}
|
|
673
1145
|
const row = rows[0];
|
|
674
1146
|
if (!row) {
|
|
675
|
-
throw new Error('
|
|
1147
|
+
throw Object.assign(new Error('Slotlock resource visibility cannot be changed by creating it'), {
|
|
1148
|
+
code: 'resource_visibility_conflict',
|
|
1149
|
+
});
|
|
676
1150
|
}
|
|
677
1151
|
return toResource(row);
|
|
678
1152
|
}
|
|
679
1153
|
/**
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
*
|
|
1154
|
+
* Rematerialize one stored recurring master over the window a roll to `horizon` gives it
|
|
1155
|
+
* (rolledWindow): from where its last expansion stopped when that can be trusted, else its start.
|
|
1156
|
+
* False when there is no such master or nothing to roll. A collision throws the database's 23P01,
|
|
1157
|
+
* and a rule over the expansion budget CalendarExpansionLimitError; run it in a savepoint, which
|
|
1158
|
+
* either rolls back whole.
|
|
1159
|
+
*/
|
|
1160
|
+
async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
|
|
1161
|
+
const [row] = await tx `
|
|
1162
|
+
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
1163
|
+
summary, description, location, status, transparency, organizer, attendees,
|
|
1164
|
+
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1165
|
+
materialized_ends_at, recurrence_cursor, revision, source, created_at,
|
|
1166
|
+
updated_at, recurrence_ends_by IS NULL AS ends_by_missing
|
|
1167
|
+
FROM slotlock.calendar_events
|
|
1168
|
+
WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
|
|
1169
|
+
FOR UPDATE`;
|
|
1170
|
+
if (!row || row.recurrence_rule === null)
|
|
1171
|
+
return false;
|
|
1172
|
+
const rolled = rolledWindow({ start: row.materialized_starts_at, end: row.materialized_ends_at }, horizon);
|
|
1173
|
+
if (!rolled)
|
|
1174
|
+
return false;
|
|
1175
|
+
const childMap = await loadCalendarEventChildren(tx, [row.id]);
|
|
1176
|
+
const event = toCalendarEvent(row, childMap.get(row.id));
|
|
1177
|
+
const { occurrences, cursor } = expandCalendarEventSeries(event, rolled, {
|
|
1178
|
+
cursor: row.recurrence_cursor ?? null,
|
|
1179
|
+
stored: true,
|
|
1180
|
+
});
|
|
1181
|
+
await replaceCalendarEventOccurrences(tx, {
|
|
1182
|
+
tenantRef,
|
|
1183
|
+
eventId: row.id,
|
|
1184
|
+
resourceId: row.resource_id,
|
|
1185
|
+
event,
|
|
1186
|
+
occurrences,
|
|
1187
|
+
revision: Number(row.revision),
|
|
1188
|
+
});
|
|
1189
|
+
// A series stored before 0.3 learns when it ends at its first roll.
|
|
1190
|
+
const endsBy = row.ends_by_missing ? recurrenceEndsByColumn(event) : null;
|
|
1191
|
+
await tx `
|
|
1192
|
+
UPDATE slotlock.calendar_events
|
|
1193
|
+
SET materialized_starts_at = ${rolled.start},
|
|
1194
|
+
materialized_ends_at = ${rolled.end},
|
|
1195
|
+
recurrence_cursor = ${seriesCursorJson(tx, cursor)},
|
|
1196
|
+
recurrence_ends_by = COALESCE(recurrence_ends_by, ${endsBy}::text::timestamptz),
|
|
1197
|
+
roll_retry_at = NULL
|
|
1198
|
+
WHERE id = ${row.id} AND tenant_ref = ${tenantRef}`;
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Work out and store when a series stored before 0.3 ends (recurrence_ends_by), as its next roll
|
|
1203
|
+
* would; its stored occurrences are left as they are.
|
|
1204
|
+
*/
|
|
1205
|
+
async function learnSeriesEnd(tx, tenantRef, eventId) {
|
|
1206
|
+
const [row] = await tx `
|
|
1207
|
+
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
1208
|
+
summary, description, location, status, transparency, organizer, attendees,
|
|
1209
|
+
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1210
|
+
materialized_ends_at, recurrence_cursor, revision, source, created_at,
|
|
1211
|
+
updated_at
|
|
1212
|
+
FROM slotlock.calendar_events
|
|
1213
|
+
WHERE id = ${eventId} AND tenant_ref = ${tenantRef}
|
|
1214
|
+
AND recurrence_rule IS NOT NULL AND recurrence_ends_by IS NULL
|
|
1215
|
+
FOR UPDATE`;
|
|
1216
|
+
if (!row)
|
|
1217
|
+
return;
|
|
1218
|
+
const childMap = await loadCalendarEventChildren(tx, [row.id]);
|
|
1219
|
+
const event = toCalendarEvent(row, childMap.get(row.id));
|
|
1220
|
+
await tx `
|
|
1221
|
+
UPDATE slotlock.calendar_events
|
|
1222
|
+
SET recurrence_ends_by = ${recurrenceEndsByColumn(event)}::text::timestamptz
|
|
1223
|
+
WHERE id = ${row.id} AND tenant_ref = ${tenantRef} AND recurrence_ends_by IS NULL`;
|
|
1224
|
+
}
|
|
1225
|
+
/** Put a master the roll could not extend behind the others for an hour. */
|
|
1226
|
+
async function deferRoll(executor, tenantRef, eventId) {
|
|
1227
|
+
await executor `
|
|
1228
|
+
UPDATE slotlock.calendar_events
|
|
1229
|
+
SET roll_retry_at = now() + interval '1 hour'
|
|
1230
|
+
WHERE id = ${eventId} AND tenant_ref = ${tenantRef}`;
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Whether every opaque recurring series on the resource (but `exceptEventId`) is stored over the
|
|
1234
|
+
* part of [start, end) that a roll can ever reach: from the later of `start` and the rolling
|
|
1235
|
+
* horizon's start. A write there must not land where such a series is not stored, or the series'
|
|
1236
|
+
* next roll would collide with it and stop. A series known to end before that part
|
|
1237
|
+
* (recurrence_ends_by) cannot meet the write and is left out, as is one stored through its end. A
|
|
1238
|
+
* series behind is first rolled to the horizon, in a savepoint of the write's transaction, so the
|
|
1239
|
+
* write meets its occurrences under the exclusion arbiter; false when one cannot be (it goes on
|
|
1240
|
+
* past the horizon and so does the write, it collides, or its rule is over the budget). Series are
|
|
1241
|
+
* rolled in id order; an event's update holds its own row while it rolls the others, so it and a
|
|
1242
|
+
* write rolling the same series in that order can deadlock, which the database breaks (the writer
|
|
1243
|
+
* retries once, then answers an overlap).
|
|
1244
|
+
*/
|
|
1245
|
+
async function recurrenceCoversWrite(tx, params) {
|
|
1246
|
+
const horizon = calendarEventRollingHorizon();
|
|
1247
|
+
if (params.end <= horizon.start)
|
|
1248
|
+
return true;
|
|
1249
|
+
const from = params.start > horizon.start ? params.start : horizon.start;
|
|
1250
|
+
// LEAST and GREATEST ignore a NULL argument (PostgreSQL 16, Conditional Expressions): a series
|
|
1251
|
+
// with no known end is behind wherever it is stored short of the write's end.
|
|
1252
|
+
const behind = () => tx `
|
|
1253
|
+
SELECT event.id, event.recurrence_ends_by IS NULL AS ends_by_unknown,
|
|
1254
|
+
COALESCE(event.recurrence_ends_by <= ${horizon.end}::timestamptz, false)
|
|
1255
|
+
AS ends_in_horizon
|
|
1256
|
+
FROM slotlock.calendar_events event
|
|
1257
|
+
WHERE event.tenant_ref = ${params.tenantRef}
|
|
1258
|
+
AND event.resource_id = ${params.resourceId}
|
|
1259
|
+
AND event.recurrence_rule IS NOT NULL
|
|
1260
|
+
AND event.transparency = 'opaque'
|
|
1261
|
+
AND event.id IS DISTINCT FROM ${params.exceptEventId ?? null}::uuid
|
|
1262
|
+
AND (event.recurrence_ends_by IS NULL OR event.recurrence_ends_by > ${from}::timestamptz)
|
|
1263
|
+
AND (
|
|
1264
|
+
event.materialized_ends_at < LEAST(${params.end}::timestamptz, event.recurrence_ends_by)
|
|
1265
|
+
OR event.materialized_starts_at > GREATEST(
|
|
1266
|
+
${from}::timestamptz,
|
|
1267
|
+
LEAST(event.starts_at, COALESCE((
|
|
1268
|
+
SELECT min(exception.starts_at)
|
|
1269
|
+
FROM slotlock.calendar_event_exceptions exception
|
|
1270
|
+
WHERE exception.tenant_ref = event.tenant_ref
|
|
1271
|
+
AND exception.event_id = event.id
|
|
1272
|
+
AND exception.cancelled = false
|
|
1273
|
+
), event.starts_at))
|
|
1274
|
+
)
|
|
1275
|
+
)
|
|
1276
|
+
ORDER BY event.id`;
|
|
1277
|
+
let due = await behind();
|
|
1278
|
+
if (due.length === 0)
|
|
1279
|
+
return true;
|
|
1280
|
+
if (params.end > horizon.end) {
|
|
1281
|
+
// A series stored before 0.3 has not worked out when it ends; it does so now, and is left out
|
|
1282
|
+
// if it ends before the write or is stored through its end.
|
|
1283
|
+
if (due.some((row) => row.ends_by_unknown)) {
|
|
1284
|
+
for (const { id, ends_by_unknown } of due) {
|
|
1285
|
+
if (ends_by_unknown)
|
|
1286
|
+
await learnSeriesEnd(tx, params.tenantRef, id);
|
|
1287
|
+
}
|
|
1288
|
+
due = await behind();
|
|
1289
|
+
if (due.length === 0)
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
// No roll stores a series past the horizon: one going on past it stays behind such a write.
|
|
1293
|
+
if (due.some((row) => !row.ends_in_horizon))
|
|
1294
|
+
return false;
|
|
1295
|
+
}
|
|
1296
|
+
for (const { id } of due) {
|
|
1297
|
+
try {
|
|
1298
|
+
await tx.savepoint((sp) => rollStoredSeries(sp, params.tenantRef, id, horizon));
|
|
1299
|
+
}
|
|
1300
|
+
catch (error) {
|
|
1301
|
+
if (error instanceof CalendarExpansionLimitError || pgCode(error) === EXCLUSION_VIOLATION) {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
throw error;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
return (await behind()).length === 0;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Whether an event already occupies, on the resource, all the time `occurrences` would: such a
|
|
1311
|
+
* write (a rename, a new description) takes no time a series could later need, so it is not held
|
|
1312
|
+
* to where the series on the resource are stored.
|
|
1313
|
+
*/
|
|
1314
|
+
async function holdsEveryOccurrence(tx, eventId, resourceId, occurrences) {
|
|
1315
|
+
const held = await tx `
|
|
1316
|
+
SELECT reservation.starts_at AS start, reservation.ends_at AS end
|
|
1317
|
+
FROM slotlock.calendar_event_occurrences occurrence
|
|
1318
|
+
JOIN slotlock.reservations reservation ON reservation.id = occurrence.reservation_id
|
|
1319
|
+
WHERE occurrence.event_id = ${eventId}
|
|
1320
|
+
AND reservation.resource_id = ${resourceId}`;
|
|
1321
|
+
return occurrences.every((occurrence) => held.some(({ start, end }) => start <= occurrence.start && occurrence.end <= end));
|
|
1322
|
+
}
|
|
1323
|
+
/** From the earliest start to the latest end of an event's occurrences, or null for none. */
|
|
1324
|
+
function occurrenceSpan(occurrences) {
|
|
1325
|
+
const [first] = occurrences;
|
|
1326
|
+
if (!first)
|
|
1327
|
+
return null;
|
|
1328
|
+
let start = first.start;
|
|
1329
|
+
let end = first.end;
|
|
1330
|
+
for (const occurrence of occurrences) {
|
|
1331
|
+
if (occurrence.start < start)
|
|
1332
|
+
start = occurrence.start;
|
|
1333
|
+
if (occurrence.end > end)
|
|
1334
|
+
end = occurrence.end;
|
|
1335
|
+
}
|
|
1336
|
+
return { start, end };
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* After a write lost to the exclusion arbiter: roll, in a transaction of its own, the series on the
|
|
1340
|
+
* resource the write's transaction rolled with it (and lost), so they stay extended and the loss
|
|
1341
|
+
* can name the occurrence that blocked it. A resource with no tenant has no series. Upkeep only: if
|
|
1342
|
+
* it fails, the series are left to maintenance and the write is still answered as a loss.
|
|
1343
|
+
*/
|
|
1344
|
+
async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId) {
|
|
1345
|
+
try {
|
|
1346
|
+
await inTransaction(executor, async (tx) => {
|
|
1347
|
+
const [resource] = await tx `
|
|
1348
|
+
SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1349
|
+
if (!resource?.tenant_ref)
|
|
1350
|
+
return;
|
|
1351
|
+
await recurrenceCoversWrite(tx, {
|
|
1352
|
+
tenantRef: resource.tenant_ref,
|
|
1353
|
+
resourceId,
|
|
1354
|
+
start,
|
|
1355
|
+
end,
|
|
1356
|
+
...(exceptEventId !== undefined ? { exceptEventId } : {}),
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
// Rolled back whole (its own transaction, or a savepoint of the caller's).
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const MAX_TENANT_LIMIT = 10_000_000;
|
|
1365
|
+
function tenantLimitsOption(limits) {
|
|
1366
|
+
const valid = {};
|
|
1367
|
+
for (const kind of ['resources', 'holds', 'reservations', 'events', 'coverage']) {
|
|
1368
|
+
const value = limits?.[kind];
|
|
1369
|
+
if (value === undefined)
|
|
1370
|
+
continue;
|
|
1371
|
+
const max = kind === 'resources' ? MAX_TENANT_RESOURCE_LIMIT : MAX_TENANT_LIMIT;
|
|
1372
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max) {
|
|
1373
|
+
throw Object.assign(new Error(`Slotlock tenant limit ${kind} must be a whole number from 1 to ${max}`), { code: 'invalid_tenant_limits' });
|
|
1374
|
+
}
|
|
1375
|
+
valid[kind] = value;
|
|
1376
|
+
}
|
|
1377
|
+
return valid;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Serialize one tenant's writes that add rows of one limited kind, so each counts what every
|
|
1381
|
+
* earlier one committed. Taken before any row lock, after the write's identity locks.
|
|
1382
|
+
*/
|
|
1383
|
+
async function lockTenantLimit(tx, tenantRef, kind) {
|
|
1384
|
+
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
1385
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ tenantLimit: kind, tenantRef })}, 0))`;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Rows a tenant added within this many days count against its limits whatever time they hold, so
|
|
1389
|
+
* bookings and events dated in the past, and cancellations, add at most the limit in any such
|
|
1390
|
+
* window. Ended history is not pruned: over a longer time it can still grow. The same window
|
|
1391
|
+
* maintenance keeps event idempotency records and stray retirements for.
|
|
1392
|
+
*/
|
|
1393
|
+
const TENANT_LIMIT_RECENT_DAYS = SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
|
|
1394
|
+
/**
|
|
1395
|
+
* The rows a tenant has that its limit of `kind` counts: what is still to come, and what it added
|
|
1396
|
+
* in the last TENANT_LIMIT_RECENT_DAYS days; what ended before then is history and does not count.
|
|
1397
|
+
* Each part stops at `limit` rows, read through an index, so a count costs no more than the limit
|
|
1398
|
+
* however much history the tenant keeps; the total is at least `limit` exactly when the tenant is
|
|
1399
|
+
* at it.
|
|
1400
|
+
*/
|
|
1401
|
+
async function tenantLimitCount(tx, tenantRef, kind, limit) {
|
|
1402
|
+
let rows;
|
|
1403
|
+
switch (kind) {
|
|
1404
|
+
case 'holds':
|
|
1405
|
+
// Every held row, expired or not, until it is deleted: an expired hold no rival writes over
|
|
1406
|
+
// stays stored until maintenance or a hold at the limit deletes it. And the answer kept for
|
|
1407
|
+
// each keyed hold no longer held (released or confirmed, or its expired row deleted) until it
|
|
1408
|
+
// is forgotten. An answer whose hold is still a held row is left out: that row counts.
|
|
1409
|
+
rows = await tx `
|
|
1410
|
+
SELECT (
|
|
1411
|
+
(SELECT count(*) FROM (
|
|
1412
|
+
SELECT 1
|
|
1413
|
+
FROM slotlock.reservations hold
|
|
1414
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
1415
|
+
WHERE resource.tenant_ref = ${tenantRef} AND hold.status = 'held'
|
|
1416
|
+
LIMIT ${limit}) held)
|
|
1417
|
+
+ (SELECT count(*) FROM (
|
|
1418
|
+
SELECT 1 FROM slotlock.hold_commands answer
|
|
1419
|
+
WHERE answer.tenant_ref = ${tenantRef}
|
|
1420
|
+
AND NOT EXISTS (
|
|
1421
|
+
SELECT 1 FROM slotlock.reservations hold
|
|
1422
|
+
WHERE hold.id = answer.hold_id AND hold.status = 'held'
|
|
1423
|
+
)
|
|
1424
|
+
LIMIT ${limit}) kept)
|
|
1425
|
+
)::int AS count`;
|
|
1426
|
+
break;
|
|
1427
|
+
case 'reservations':
|
|
1428
|
+
// Bookings still to come; bookings that have ended and cancellations, made recently; and
|
|
1429
|
+
// references retired with no booking, until maintenance prunes them. A cancelled booking's
|
|
1430
|
+
// tombstone is kept for good, and stops counting once it is no longer recent.
|
|
1431
|
+
rows = await tx `
|
|
1432
|
+
WITH recent AS (
|
|
1433
|
+
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
1434
|
+
)
|
|
1435
|
+
SELECT (
|
|
1436
|
+
(SELECT count(*) FROM (
|
|
1437
|
+
SELECT 1 FROM slotlock.reservations
|
|
1438
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
|
|
1439
|
+
AND calendar_event_id IS NULL AND occupied_ends_at > now()
|
|
1440
|
+
LIMIT ${limit}) live)
|
|
1441
|
+
+ (SELECT count(*) FROM (
|
|
1442
|
+
SELECT 1 FROM slotlock.reservations, recent
|
|
1443
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref IS NOT NULL
|
|
1444
|
+
AND calendar_event_id IS NULL AND created_at > recent.since
|
|
1445
|
+
AND occupied_ends_at <= now()
|
|
1446
|
+
LIMIT ${limit}) ended)
|
|
1447
|
+
+ (SELECT count(*) FROM (
|
|
1448
|
+
SELECT 1 FROM slotlock.reservation_tombstones, recent
|
|
1449
|
+
WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
|
|
1450
|
+
LIMIT ${limit}) cancelled)
|
|
1451
|
+
+ (SELECT count(*) FROM (
|
|
1452
|
+
SELECT 1 FROM slotlock.reservation_tombstones, recent
|
|
1453
|
+
WHERE tenant_ref = ${tenantRef} AND stray AND cancelled_at <= recent.since
|
|
1454
|
+
LIMIT ${limit}) retired)
|
|
1455
|
+
)::int AS count`;
|
|
1456
|
+
break;
|
|
1457
|
+
case 'events':
|
|
1458
|
+
// One-off events still to come and series not known to have ended; events that have ended
|
|
1459
|
+
// and cancellations of any owner, made recently; and agent cancellations until maintenance
|
|
1460
|
+
// prunes them. The owner identity CHECK makes every owner but 'internal' an 'agent:' one.
|
|
1461
|
+
rows = await tx `
|
|
1462
|
+
WITH recent AS (
|
|
1463
|
+
SELECT now() - ${TENANT_LIMIT_RECENT_DAYS}::int * interval '1 day' AS since
|
|
1464
|
+
)
|
|
1465
|
+
SELECT (
|
|
1466
|
+
(SELECT count(*) FROM (
|
|
1467
|
+
SELECT 1 FROM slotlock.calendar_events
|
|
1468
|
+
WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NULL AND ends_at > now()
|
|
1469
|
+
LIMIT ${limit}) one_off)
|
|
1470
|
+
+ (SELECT count(*) FROM (
|
|
1471
|
+
SELECT 1 FROM slotlock.calendar_events
|
|
1472
|
+
WHERE tenant_ref = ${tenantRef} AND recurrence_rule IS NOT NULL
|
|
1473
|
+
AND (recurrence_ends_by IS NULL OR recurrence_ends_by > now())
|
|
1474
|
+
LIMIT ${limit}) series)
|
|
1475
|
+
+ (SELECT count(*) FROM (
|
|
1476
|
+
SELECT 1 FROM slotlock.calendar_events, recent
|
|
1477
|
+
WHERE tenant_ref = ${tenantRef} AND created_at > recent.since
|
|
1478
|
+
AND CASE WHEN recurrence_rule IS NULL THEN ends_at <= now()
|
|
1479
|
+
ELSE recurrence_ends_by <= now() END
|
|
1480
|
+
LIMIT ${limit}) ended)
|
|
1481
|
+
+ (SELECT count(*) FROM (
|
|
1482
|
+
SELECT 1 FROM slotlock.calendar_event_tombstones, recent
|
|
1483
|
+
WHERE tenant_ref = ${tenantRef} AND cancelled_at > recent.since
|
|
1484
|
+
LIMIT ${limit}) cancelled)
|
|
1485
|
+
+ (SELECT count(*) FROM (
|
|
1486
|
+
SELECT 1 FROM slotlock.calendar_event_tombstones, recent
|
|
1487
|
+
WHERE tenant_ref = ${tenantRef} AND owner_ref LIKE 'agent:%'
|
|
1488
|
+
AND cancelled_at <= recent.since
|
|
1489
|
+
LIMIT ${limit}) agent_cancelled)
|
|
1490
|
+
)::int AS count`;
|
|
1491
|
+
break;
|
|
1492
|
+
case 'coverage':
|
|
1493
|
+
rows = await tx `
|
|
1494
|
+
SELECT count(*)::int AS count FROM (
|
|
1495
|
+
SELECT 1 FROM slotlock.calendar_coverage WHERE tenant_ref = ${tenantRef} LIMIT ${limit}
|
|
1496
|
+
) kept`;
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
const count = Number(rows[0]?.count ?? 0);
|
|
1500
|
+
if (!Number.isSafeInteger(count) || count < 0)
|
|
1501
|
+
throw new Error('slotlock: invalid tenant row count');
|
|
1502
|
+
return count;
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Delete up to `limit` of a tenant's holds that have expired, oldest first; the number deleted. A
|
|
1506
|
+
* writer reaping or confirming a hold locks it: such a row is skipped, and the delete repeats the
|
|
1507
|
+
* predicate on the row as it now is.
|
|
1508
|
+
*/
|
|
1509
|
+
async function deleteExpiredHolds(tx, tenantRef, limit) {
|
|
1510
|
+
const [row] = await tx `
|
|
1511
|
+
WITH candidates AS MATERIALIZED (
|
|
1512
|
+
SELECT hold.id
|
|
1513
|
+
FROM slotlock.reservations hold
|
|
1514
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
1515
|
+
WHERE resource.tenant_ref = ${tenantRef}
|
|
1516
|
+
AND hold.status = 'held' AND hold.expires_at <= now()
|
|
1517
|
+
ORDER BY hold.expires_at, hold.id
|
|
1518
|
+
LIMIT ${limit}
|
|
1519
|
+
FOR UPDATE OF hold SKIP LOCKED
|
|
1520
|
+
), deleted AS (
|
|
1521
|
+
DELETE FROM slotlock.reservations hold
|
|
1522
|
+
USING candidates candidate
|
|
1523
|
+
WHERE hold.id = candidate.id
|
|
1524
|
+
AND hold.status = 'held' AND hold.expires_at <= now()
|
|
1525
|
+
RETURNING 1
|
|
1526
|
+
)
|
|
1527
|
+
SELECT count(*)::int AS deleted FROM deleted`;
|
|
1528
|
+
return Number(row?.deleted ?? 0);
|
|
1529
|
+
}
|
|
1530
|
+
/**
|
|
1531
|
+
* Delete up to `limit` of a tenant's keyed hold answers whose hold has expired, so a retry with one
|
|
1532
|
+
* of their keys makes a new hold; answers another transaction holds are skipped. The number deleted.
|
|
1533
|
+
*/
|
|
1534
|
+
async function forgetExpiredHoldAnswers(tx, tenantRef, limit) {
|
|
1535
|
+
const [row] = await tx `
|
|
1536
|
+
WITH candidates AS MATERIALIZED (
|
|
1537
|
+
SELECT answer.principal_ref, answer.idempotency_key
|
|
1538
|
+
FROM slotlock.hold_commands answer
|
|
1539
|
+
WHERE answer.tenant_ref = ${tenantRef} AND answer.expires_at <= now()
|
|
1540
|
+
ORDER BY answer.expires_at, answer.principal_ref, answer.idempotency_key
|
|
1541
|
+
LIMIT ${limit}
|
|
1542
|
+
FOR UPDATE OF answer SKIP LOCKED
|
|
1543
|
+
), deleted AS (
|
|
1544
|
+
DELETE FROM slotlock.hold_commands answer
|
|
1545
|
+
USING candidates candidate
|
|
1546
|
+
WHERE answer.tenant_ref = ${tenantRef}
|
|
1547
|
+
AND answer.principal_ref = candidate.principal_ref
|
|
1548
|
+
AND answer.idempotency_key = candidate.idempotency_key
|
|
1549
|
+
RETURNING 1
|
|
1550
|
+
)
|
|
1551
|
+
SELECT count(*)::int AS deleted FROM deleted`;
|
|
1552
|
+
return Number(row?.deleted ?? 0);
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Delete expired held rows whose occupied time (buffer included, as the EXCLUDE constraint reads
|
|
1556
|
+
* it) overlaps the requested window, whose `end` is its own occupied end, in deterministic id
|
|
1557
|
+
* order under FOR UPDATE — two concurrent reapers lock the same rows in the same order (no
|
|
1558
|
+
* deadlock), the loser simply finds them gone. Runs inside the caller's transaction, immediately
|
|
1559
|
+
* before its INSERT, so the EXCLUDE arbiter only ever sees LIVE occupation.
|
|
684
1560
|
*/
|
|
685
1561
|
async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
686
1562
|
await tx `
|
|
@@ -691,7 +1567,7 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
691
1567
|
AND status = 'held'
|
|
692
1568
|
AND expires_at <= now()
|
|
693
1569
|
AND starts_at < ${end}
|
|
694
|
-
AND ends_at > ${start}
|
|
1570
|
+
AND COALESCE(occupied_ends_at, ends_at) > ${start}
|
|
695
1571
|
ORDER BY id
|
|
696
1572
|
FOR UPDATE
|
|
697
1573
|
)`;
|
|
@@ -702,11 +1578,13 @@ async function reapExpiredHolds(tx, resourceId, start, end) {
|
|
|
702
1578
|
* loser's transaction start (constraint saw it) and this lookup (live filter hides it) — an
|
|
703
1579
|
* immediate retry reaps it and wins.
|
|
704
1580
|
*/
|
|
705
|
-
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId) {
|
|
1581
|
+
async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, excludeReservationId, excludeCalendarEventId) {
|
|
706
1582
|
const winners = await sql `
|
|
707
1583
|
SELECT id FROM slotlock.reservations
|
|
708
1584
|
WHERE resource_id = ${resourceId}
|
|
709
1585
|
AND (${excludeReservationId ?? null}::uuid IS NULL OR id <> ${excludeReservationId ?? null}::uuid)
|
|
1586
|
+
AND (${excludeCalendarEventId ?? null}::uuid IS NULL
|
|
1587
|
+
OR calendar_event_id IS DISTINCT FROM ${excludeCalendarEventId ?? null}::uuid)
|
|
710
1588
|
AND tstzrange(
|
|
711
1589
|
starts_at,
|
|
712
1590
|
COALESCE(occupied_ends_at, ends_at),
|
|
@@ -721,7 +1599,123 @@ async function overlapLoss(sql, resourceId, start, end, bufferAfterMs = 0, exclu
|
|
|
721
1599
|
LIMIT 1`;
|
|
722
1600
|
return { ok: false, code: 'overlap', conflictingReservationId: winners[0]?.id ?? null };
|
|
723
1601
|
}
|
|
1602
|
+
/** The tenant a hold call is limited to, or null for none; an invalid one is refused. */
|
|
1603
|
+
function holdTenantRef(options) {
|
|
1604
|
+
const tenantRef = options.tenantRef;
|
|
1605
|
+
if (tenantRef === undefined)
|
|
1606
|
+
return null;
|
|
1607
|
+
if (!isValidIdentity(tenantRef)) {
|
|
1608
|
+
throw Object.assign(new Error('Slotlock tenantRef must be 1-500 UTF-8 bytes'), {
|
|
1609
|
+
code: 'invalid_tenant_ref',
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
return tenantRef;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* At most `max` holders per tenant at once; the rest wait in order. An aborted signal takes a
|
|
1616
|
+
* waiter out of the line, rejecting with its reason. Each holder calls the release it gets once.
|
|
1617
|
+
*/
|
|
1618
|
+
export function createTenantSlots(max) {
|
|
1619
|
+
const tenants = new Map();
|
|
1620
|
+
return async (tenantRef, signal) => {
|
|
1621
|
+
signal?.throwIfAborted();
|
|
1622
|
+
let tenant = tenants.get(tenantRef);
|
|
1623
|
+
if (!tenant) {
|
|
1624
|
+
tenant = { running: 0, waiting: [] };
|
|
1625
|
+
tenants.set(tenantRef, tenant);
|
|
1626
|
+
}
|
|
1627
|
+
const slots = tenant;
|
|
1628
|
+
if (slots.running < max) {
|
|
1629
|
+
slots.running += 1;
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
await new Promise((resolve, reject) => {
|
|
1633
|
+
const start = () => {
|
|
1634
|
+
signal?.removeEventListener('abort', abort);
|
|
1635
|
+
resolve();
|
|
1636
|
+
};
|
|
1637
|
+
const abort = () => {
|
|
1638
|
+
slots.waiting.splice(slots.waiting.indexOf(start), 1);
|
|
1639
|
+
reject(signal?.reason);
|
|
1640
|
+
};
|
|
1641
|
+
slots.waiting.push(start);
|
|
1642
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
let released = false;
|
|
1646
|
+
return () => {
|
|
1647
|
+
if (released)
|
|
1648
|
+
return;
|
|
1649
|
+
released = true;
|
|
1650
|
+
// Handed straight to the next in line, so the count stays as it is.
|
|
1651
|
+
const next = slots.waiting.shift();
|
|
1652
|
+
if (next) {
|
|
1653
|
+
next();
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
slots.running -= 1;
|
|
1657
|
+
if (slots.running === 0)
|
|
1658
|
+
tenants.delete(tenantRef);
|
|
1659
|
+
};
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
function lockTimeoutError(cause) {
|
|
1663
|
+
return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
|
|
1664
|
+
}
|
|
724
1665
|
export function createSlotlockStore(sql, options = {}) {
|
|
1666
|
+
return buildSlotlockStore(sql, options, null);
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
|
|
1670
|
+
* Maintenance (rolling series, pruning, purging) and erasure are not among them.
|
|
1671
|
+
*/
|
|
1672
|
+
export const SLOTLOCK_TENANT_WRITE_METHODS = Object.freeze([
|
|
1673
|
+
'createResource',
|
|
1674
|
+
'archiveResource',
|
|
1675
|
+
'restoreResource',
|
|
1676
|
+
'deleteResource',
|
|
1677
|
+
'setResourceAvailability',
|
|
1678
|
+
'setTenantAvailability',
|
|
1679
|
+
'createReservation',
|
|
1680
|
+
'createExternalReservation',
|
|
1681
|
+
'rescheduleExternalReservation',
|
|
1682
|
+
'cancelExternalReservation',
|
|
1683
|
+
'acquireHold',
|
|
1684
|
+
'confirmHold',
|
|
1685
|
+
'releaseHold',
|
|
1686
|
+
'putCalendarEvent',
|
|
1687
|
+
'cancelCalendarEvent',
|
|
1688
|
+
'cancelCalendarEventById',
|
|
1689
|
+
'recordCalendarCoverage',
|
|
1690
|
+
]);
|
|
1691
|
+
function tenantSuspendedError() {
|
|
1692
|
+
return Object.assign(new Error('Slotlock tenant is suspended: it may read, and write nothing'), {
|
|
1693
|
+
code: 'tenant_suspended',
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
function staleSuspensionCheckError() {
|
|
1697
|
+
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' });
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Reading a tenant's suspension needs EXECUTE on slotlock.tenant_suspended(text), which PUBLIC does
|
|
1701
|
+
* not hold (SQLSTATE 42501 without it), and a schema this release migrated (42883 before then).
|
|
1702
|
+
* Name the remedy for each; any other failure passes through.
|
|
1703
|
+
*/
|
|
1704
|
+
function suspensionCheckRefusal(error) {
|
|
1705
|
+
const code = pgCode(error);
|
|
1706
|
+
if (code === '42501') {
|
|
1707
|
+
return Object.assign(new Error('Slotlock serving role may not run slotlock.tenant_suspended(text): grant it what this release needs with grantApplicationRole (`slotlock migrate` does), once per release', { cause: error }), { code: 'application_role_not_granted' });
|
|
1708
|
+
}
|
|
1709
|
+
if (code === '42883') {
|
|
1710
|
+
return Object.assign(new Error('The slotlock schema predates this release (it has no slotlock.tenant_suspended(text)): run applySchema (`slotlock migrate`) with this release first', { cause: error }), { code: 'schema_outdated' });
|
|
1711
|
+
}
|
|
1712
|
+
return error;
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* A store on `sql`. With `refusal`, every method in SLOTLOCK_TENANT_WRITE_METHODS throws what it
|
|
1716
|
+
* returns before any query (a suspended tenant, or a suspension check that could be stale).
|
|
1717
|
+
*/
|
|
1718
|
+
function buildSlotlockStore(sql, options, refusal) {
|
|
725
1719
|
const configuredTenantSetting = options.tenantContextSetting ?? SLOTLOCK_TENANT_CONTEXT_SETTING;
|
|
726
1720
|
const configuredAgentOwnerEventQuota = options.agentOwnerEventQuota ?? SLOTLOCK_AGENT_OWNER_EVENT_QUOTA;
|
|
727
1721
|
const configuredAgentOwnerCommandQuota = options.agentOwnerCommandQuota ?? SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA;
|
|
@@ -737,56 +1731,125 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
737
1731
|
configuredAgentOwnerCommandQuota > MAX_CONFIGURED_AGENT_OWNER_COMMAND_QUOTA) {
|
|
738
1732
|
throw Object.assign(new Error('Slotlock agent owner command quota must exceed its event quota and be at most 1000000'), { code: 'invalid_agent_owner_command_quota' });
|
|
739
1733
|
}
|
|
1734
|
+
const configuredTenantLimits = tenantLimitsOption(options.tenantLimits);
|
|
1735
|
+
const { maxTenantTransactions, tenantLockTimeoutMs } = options;
|
|
1736
|
+
const whole = (value, max) => value === undefined || (Number.isSafeInteger(value) && value >= 1 && value <= max);
|
|
1737
|
+
if (!whole(maxTenantTransactions, 1_000) || !whole(tenantLockTimeoutMs, 600_000)) {
|
|
1738
|
+
throw Object.assign(new Error('Slotlock maxTenantTransactions must be 1-1000 and tenantLockTimeoutMs 1-600000, whole numbers'), { code: 'invalid_tenant_transaction_options' });
|
|
1739
|
+
}
|
|
1740
|
+
// Only a client opens transactions of its own; inside a caller's, neither applies.
|
|
1741
|
+
const ownTransactions = 'begin' in sql;
|
|
1742
|
+
const tenantSlots = ownTransactions && maxTenantTransactions !== undefined
|
|
1743
|
+
? createTenantSlots(maxTenantTransactions)
|
|
1744
|
+
: undefined;
|
|
1745
|
+
const lockTimeout = ownTransactions && tenantLockTimeoutMs !== undefined ? `${tenantLockTimeoutMs}ms` : undefined;
|
|
740
1746
|
createSlotlockTenantRlsDdl(configuredTenantSetting); // Validate once before any scoped query.
|
|
741
|
-
|
|
742
|
-
|
|
1747
|
+
const storeOptions = {
|
|
1748
|
+
tenantContextSetting: configuredTenantSetting,
|
|
1749
|
+
agentOwnerEventQuota: configuredAgentOwnerEventQuota,
|
|
1750
|
+
agentOwnerCommandQuota: configuredAgentOwnerCommandQuota,
|
|
1751
|
+
tenantLimits: configuredTenantLimits,
|
|
1752
|
+
};
|
|
1753
|
+
/**
|
|
1754
|
+
* Under the tenant's lock for `kind`: the limit when the tenant is at it, else null. No limit for
|
|
1755
|
+
* `kind`, or no tenant, takes no lock. Writes that only lower the count take no lock, so a count
|
|
1756
|
+
* read early stays an upper bound for the rest of the transaction.
|
|
1757
|
+
*/
|
|
1758
|
+
async function tenantLimitReached(tx, tenantRef, kind) {
|
|
1759
|
+
const limit = configuredTenantLimits[kind];
|
|
1760
|
+
if (limit === undefined || tenantRef === null)
|
|
1761
|
+
return null;
|
|
1762
|
+
await assertReadCommitted(tx);
|
|
1763
|
+
await lockTenantLimit(tx, tenantRef, kind);
|
|
1764
|
+
if ((await tenantLimitCount(tx, tenantRef, kind, limit)) < limit)
|
|
1765
|
+
return null;
|
|
1766
|
+
// A hold, and a keyed hold's answer, keep their place only until the hold expires; at the
|
|
1767
|
+
// limit, the expired holds and their answers are deleted, as maintenance would, and the count
|
|
1768
|
+
// is read again. Counting expired rows until then keeps what a tenant stores within the limit
|
|
1769
|
+
// between maintenance passes, however short its holds.
|
|
1770
|
+
if (kind !== 'holds' ||
|
|
1771
|
+
(await deleteExpiredHolds(tx, tenantRef, limit)) +
|
|
1772
|
+
(await forgetExpiredHoldAnswers(tx, tenantRef, limit)) ===
|
|
1773
|
+
0) {
|
|
1774
|
+
return limit;
|
|
1775
|
+
}
|
|
1776
|
+
return (await tenantLimitCount(tx, tenantRef, kind, limit)) >= limit ? limit : null;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* The tenant whose limit a write on the resource counts against: the caller's, else the
|
|
1780
|
+
* resource's (a resource never changes tenant, so it is read before any lock). Null with no limit.
|
|
1781
|
+
*/
|
|
1782
|
+
async function limitTenant(tx, kind, resourceId, tenantRef) {
|
|
1783
|
+
if (configuredTenantLimits[kind] === undefined)
|
|
1784
|
+
return null;
|
|
1785
|
+
if (tenantRef !== undefined)
|
|
1786
|
+
return tenantRef;
|
|
1787
|
+
const [resource] = await tx `
|
|
1788
|
+
SELECT tenant_ref FROM slotlock.resources WHERE id = ${resourceId}`;
|
|
1789
|
+
return resource?.tenant_ref ?? null;
|
|
1790
|
+
}
|
|
1791
|
+
const store = {
|
|
1792
|
+
async applySchema(migrationOptions) {
|
|
743
1793
|
// PostgreSQL's IF NOT EXISTS DDL is idempotent after commit but not race-free: two fresh
|
|
744
1794
|
// application instances can still collide on pg_namespace/pg_extension unique indexes.
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
1795
|
+
// inMigration serializes bootstrap per database and the transaction releases the lock on
|
|
1796
|
+
// crashes. A schema this release already left as it is changes nothing and locks no table,
|
|
1797
|
+
// unless old rows kept one of its constraints NOT VALID: then only the validation runs again.
|
|
1798
|
+
const validate = await inMigration(sql, migrationOptions, async (tx) => {
|
|
1799
|
+
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
749
1800
|
throw schemaControlRefusal(error);
|
|
750
1801
|
});
|
|
751
|
-
|
|
1802
|
+
if (await slotlockSchemaUpToDate(tx))
|
|
1803
|
+
return slotlockConstraintUnvalidated(tx);
|
|
1804
|
+
await tx.unsafe(SLOTLOCK_STRUCTURE_DDL).catch((error) => {
|
|
1805
|
+
throw schemaControlRefusal(error);
|
|
1806
|
+
});
|
|
1807
|
+
return true;
|
|
1808
|
+
});
|
|
1809
|
+
if (!validate)
|
|
1810
|
+
return;
|
|
1811
|
+
// Once the DDL has committed, its ACCESS EXCLUSIVE locks are gone: validating holds none that
|
|
1812
|
+
// a serving query waits for (ddl.ts, DEPLOYMENT_VALIDATE_CONSTRAINTS). Inside a caller's
|
|
1813
|
+
// transaction both steps share it, and its locks, until it ends. The digests go last, so a
|
|
1814
|
+
// migration stopped before them runs whole again.
|
|
1815
|
+
await inMigration(sql, migrationOptions, async (tx) => {
|
|
1816
|
+
await tx.unsafe(SLOTLOCK_VALIDATION_DDL);
|
|
1817
|
+
await tx `
|
|
1818
|
+
UPDATE slotlock.schema_version
|
|
1819
|
+
SET ddl_sha256 = ${SLOTLOCK_CORE_DDL_SHA256},
|
|
1820
|
+
catalog_sha256 = ${await slotlockCatalogDigest(tx)}
|
|
1821
|
+
WHERE singleton`;
|
|
1822
|
+
});
|
|
752
1823
|
},
|
|
753
1824
|
async applyTenantRls(applyOptions) {
|
|
754
1825
|
const ddl = createSlotlockTenantRlsDdl(configuredTenantSetting);
|
|
755
|
-
|
|
756
|
-
|
|
1826
|
+
const { allowRebind, ...migrationOptions } = applyOptions ?? {};
|
|
1827
|
+
await inMigration(sql, migrationOptions, async (tx) => {
|
|
1828
|
+
await tx.unsafe(SLOTLOCK_DEPLOYMENT_CHECKS).catch((error) => {
|
|
1829
|
+
throw schemaControlRefusal(error);
|
|
1830
|
+
});
|
|
1831
|
+
if (await tenantRlsUpToDate(tx, configuredTenantSetting))
|
|
1832
|
+
return;
|
|
757
1833
|
const bindings = await tx `
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
'reservations',
|
|
766
|
-
'reservation_tombstones',
|
|
767
|
-
'reservation_conflict_archive',
|
|
768
|
-
'calendar_events',
|
|
769
|
-
'calendar_event_attendees',
|
|
770
|
-
'calendar_event_reminders',
|
|
771
|
-
'calendar_event_exceptions',
|
|
772
|
-
'calendar_event_occurrences',
|
|
773
|
-
'calendar_event_tombstones',
|
|
774
|
-
'calendar_event_commands',
|
|
775
|
-
'calendar_coverage'
|
|
776
|
-
)
|
|
777
|
-
AND obj_description(policy.oid, 'pg_policy') LIKE 'slotlock:tenant-context:%'`;
|
|
1834
|
+
SELECT DISTINCT obj_description(policy.oid, 'pg_policy') AS binding
|
|
1835
|
+
FROM pg_policy policy
|
|
1836
|
+
JOIN pg_class table_class ON table_class.oid = policy.polrelid
|
|
1837
|
+
JOIN pg_namespace namespace ON namespace.oid = table_class.relnamespace
|
|
1838
|
+
WHERE namespace.nspname = 'slotlock'
|
|
1839
|
+
AND table_class.relname IN ${tx([...SLOTLOCK_TENANT_TABLES])}
|
|
1840
|
+
AND obj_description(policy.oid, 'pg_policy') LIKE 'slotlock:tenant-context:%'`;
|
|
778
1841
|
const conflictingBinding = bindings.find(({ binding }) => binding !== `slotlock:tenant-context:${configuredTenantSetting}`);
|
|
779
|
-
if (conflictingBinding &&
|
|
1842
|
+
if (conflictingBinding && allowRebind !== true) {
|
|
780
1843
|
throw Object.assign(new Error(`Slotlock tenant RLS is already bound to ${conflictingBinding.binding.slice('slotlock:tenant-context:'.length)}`), { code: 'tenant_context_conflict' });
|
|
781
1844
|
}
|
|
782
1845
|
await tx.unsafe(ddl).catch((error) => {
|
|
783
1846
|
throw schemaControlRefusal(error);
|
|
784
1847
|
});
|
|
785
|
-
})
|
|
1848
|
+
});
|
|
786
1849
|
},
|
|
787
|
-
async grantApplicationRole(role) {
|
|
1850
|
+
async grantApplicationRole(role, migrationOptions) {
|
|
788
1851
|
const ddl = createSlotlockApplicationRoleGrantsDdl(role);
|
|
789
|
-
await
|
|
1852
|
+
await inMigration(sql, migrationOptions, (tx) => (async () => {
|
|
790
1853
|
// Every role the target belongs to counts, not only the target: a member can SET ROLE to
|
|
791
1854
|
// it or use its privileges, and one holding only ADMIN OPTION can grant itself either.
|
|
792
1855
|
// MEMBER also counts a PostgreSQL 16 grant made with none of ADMIN, INHERIT or SET, which
|
|
@@ -890,7 +1953,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
890
1953
|
throw Object.assign(new Error(`Slotlock application role ${role} could get around forced RLS or the exclusion arbiter, itself, through a role it belongs to or through PUBLIC (${check.reasons.join('; ')}); use a plain NOBYPASSRLS login role that owns nothing and can create nothing`), { code: 'unsafe_application_role', reasons: check.reasons });
|
|
891
1954
|
}
|
|
892
1955
|
await tx.unsafe(ddl);
|
|
893
|
-
}));
|
|
1956
|
+
})());
|
|
894
1957
|
},
|
|
895
1958
|
async withTenant(tenantRef, callback, options) {
|
|
896
1959
|
if (!isValidIdentity(tenantRef)) {
|
|
@@ -901,45 +1964,90 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
901
1964
|
const isolation = options?.isolation;
|
|
902
1965
|
if (isolation !== undefined && isolation !== 'read committed')
|
|
903
1966
|
throw invalidIsolationError();
|
|
1967
|
+
const release = await tenantSlots?.(tenantRef, options?.signal);
|
|
1968
|
+
// A lock wait that timed out leaves the transaction unusable, so the reset below fails with
|
|
1969
|
+
// another error in its place: noted here as it happens.
|
|
1970
|
+
let timedOut = false;
|
|
904
1971
|
return inTransaction(sql, async (tx) => {
|
|
1972
|
+
if (lockTimeout !== undefined) {
|
|
1973
|
+
await tx `SELECT set_config('lock_timeout', ${lockTimeout}, true)`;
|
|
1974
|
+
}
|
|
905
1975
|
if (isolation !== undefined)
|
|
906
1976
|
await assertReadCommitted(tx);
|
|
1977
|
+
// Held shared until the transaction ends: suspension and erasure, which take it
|
|
1978
|
+
// exclusively, wait for this transaction, and one that starts after them reads them below
|
|
1979
|
+
// (READ COMMITTED: a statement after the lock sees what committed before it was granted).
|
|
907
1980
|
const previous = await tx `
|
|
908
|
-
|
|
909
|
-
|
|
1981
|
+
SELECT current_setting(${configuredTenantSetting}, true) AS value,
|
|
1982
|
+
current_setting('transaction_isolation') AS isolation,
|
|
1983
|
+
pg_advisory_xact_lock_shared(
|
|
1984
|
+
hashtextextended(${slotlockTenantLockKey(tenantRef)}, 0)
|
|
1985
|
+
) AS locked`;
|
|
1986
|
+
const [context] = await tx `
|
|
1987
|
+
SELECT set_config(${configuredTenantSetting}, ${tenantRef}, true) AS tenant,
|
|
1988
|
+
slotlock.tenant_suspended(${tenantRef}) AS suspended`.catch((error) => {
|
|
1989
|
+
throw suspensionCheckRefusal(error);
|
|
1990
|
+
});
|
|
1991
|
+
// A caller's REPEATABLE READ or SERIALIZABLE snapshot was taken before the lock was
|
|
1992
|
+
// granted, so it may not show a suspension that committed while this transaction waited:
|
|
1993
|
+
// its writes are refused, and it may read.
|
|
1994
|
+
const refusal = context?.suspended === true
|
|
1995
|
+
? tenantSuspendedError
|
|
1996
|
+
: previous[0]?.isolation !== 'read committed'
|
|
1997
|
+
? staleSuspensionCheckError
|
|
1998
|
+
: null;
|
|
910
1999
|
try {
|
|
911
|
-
return await callback(
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
2000
|
+
return await callback(buildSlotlockStore(tx, storeOptions, refusal));
|
|
2001
|
+
}
|
|
2002
|
+
catch (error) {
|
|
2003
|
+
if (pgCode(error) === LOCK_NOT_AVAILABLE)
|
|
2004
|
+
timedOut = true;
|
|
2005
|
+
throw error;
|
|
916
2006
|
}
|
|
917
2007
|
finally {
|
|
918
2008
|
await tx `SELECT set_config(${configuredTenantSetting}, ${previous[0]?.value ?? ''}, true)`;
|
|
919
2009
|
}
|
|
920
|
-
},
|
|
2010
|
+
}, 'read committed')
|
|
2011
|
+
.catch((error) => {
|
|
2012
|
+
if (lockTimeout !== undefined && (timedOut || pgCode(error) === LOCK_NOT_AVAILABLE)) {
|
|
2013
|
+
throw lockTimeoutError(error);
|
|
2014
|
+
}
|
|
2015
|
+
throw error;
|
|
2016
|
+
})
|
|
2017
|
+
.finally(() => release?.());
|
|
921
2018
|
},
|
|
922
2019
|
async createResource(params) {
|
|
923
2020
|
assertValidResourceInput(params);
|
|
924
|
-
const
|
|
925
|
-
|
|
2021
|
+
const requested = params.maxTenantResources;
|
|
2022
|
+
const configured = params.tenantRef === undefined ? undefined : configuredTenantLimits.resources;
|
|
2023
|
+
if (requested === undefined && configured === undefined)
|
|
926
2024
|
return upsertResource(sql, params);
|
|
927
|
-
if (
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
2025
|
+
if (requested !== undefined &&
|
|
2026
|
+
(!Number.isSafeInteger(requested) ||
|
|
2027
|
+
requested < 1 ||
|
|
2028
|
+
requested > MAX_TENANT_RESOURCE_LIMIT ||
|
|
2029
|
+
params.tenantRef === undefined ||
|
|
2030
|
+
params.externalRef === undefined)) {
|
|
932
2031
|
throw Object.assign(new Error(`Slotlock maxTenantResources must be a whole number from 1 to ${MAX_TENANT_RESOURCE_LIMIT}, for a tenant-owned resource`), { code: 'invalid_resource_limit' });
|
|
933
2032
|
}
|
|
934
|
-
const
|
|
2033
|
+
const max = requested === undefined
|
|
2034
|
+
? configured
|
|
2035
|
+
: configured === undefined
|
|
2036
|
+
? requested
|
|
2037
|
+
: Math.min(requested, configured);
|
|
2038
|
+
const tenantRef = params.tenantRef;
|
|
2039
|
+
const externalRef = params.externalRef ?? null;
|
|
935
2040
|
return inTransaction(sql, async (tx) => {
|
|
936
2041
|
await assertReadCommitted(tx);
|
|
937
2042
|
// An object, so the key never equals a [tenantRef, externalRef] identity lock's.
|
|
938
2043
|
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify({ resourceCount: tenantRef })}, 0))`;
|
|
939
2044
|
// READ COMMITTED: each statement below sees every create committed before the lock.
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
2045
|
+
// Re-creating a resource the tenant has (by its reference) adds none.
|
|
2046
|
+
const known = externalRef === null
|
|
2047
|
+
? []
|
|
2048
|
+
: await tx `
|
|
2049
|
+
SELECT id FROM slotlock.resources
|
|
2050
|
+
WHERE tenant_ref = ${tenantRef} AND external_ref = ${externalRef}`;
|
|
943
2051
|
if (known.length === 0) {
|
|
944
2052
|
const [counted] = await tx `
|
|
945
2053
|
SELECT count(*) AS count FROM slotlock.resources WHERE tenant_ref = ${tenantRef}`;
|
|
@@ -951,15 +2059,17 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
951
2059
|
}, 'read committed');
|
|
952
2060
|
},
|
|
953
2061
|
async getResource(params) {
|
|
2062
|
+
assertValidVisibility(params.visibility);
|
|
954
2063
|
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
955
2064
|
throw Object.assign(new Error('Slotlock resource identity is invalid'), {
|
|
956
2065
|
code: 'invalid_identity',
|
|
957
2066
|
});
|
|
958
2067
|
}
|
|
959
2068
|
const rows = await sql `
|
|
960
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules
|
|
2069
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
|
|
961
2070
|
FROM slotlock.resources
|
|
962
|
-
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2071
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2072
|
+
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})`;
|
|
963
2073
|
const row = rows[0];
|
|
964
2074
|
return row ? toResource(row) : null;
|
|
965
2075
|
},
|
|
@@ -974,7 +2084,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
974
2084
|
UPDATE slotlock.resources
|
|
975
2085
|
SET availability_rules = ${rules}::jsonb
|
|
976
2086
|
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
977
|
-
RETURNING id, external_ref, tenant_ref, timezone, availability_rules`;
|
|
2087
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
978
2088
|
const row = rows[0];
|
|
979
2089
|
return row ? toResource(row) : null;
|
|
980
2090
|
},
|
|
@@ -1009,42 +2119,126 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1009
2119
|
});
|
|
1010
2120
|
}
|
|
1011
2121
|
const rows = await sql `
|
|
1012
|
-
SELECT id, external_ref, tenant_ref, timezone, availability_rules
|
|
2122
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
|
|
1013
2123
|
FROM slotlock.resources
|
|
1014
2124
|
WHERE tenant_ref = ${params.tenantRef}
|
|
2125
|
+
AND (${params.visibility ?? null}::text IS NULL OR visibility = ${params.visibility ?? null})
|
|
2126
|
+
AND (${params.includeArchived === true} OR archived_at IS NULL)
|
|
1015
2127
|
AND (${params.after ?? null}::uuid IS NULL OR id > ${params.after ?? null}::uuid)
|
|
1016
2128
|
ORDER BY id
|
|
1017
2129
|
LIMIT ${limit}`;
|
|
1018
2130
|
return rows.map(toResource);
|
|
1019
2131
|
},
|
|
2132
|
+
async archiveResource(params) {
|
|
2133
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2134
|
+
throw invalidIdentityError('Slotlock resource identity is invalid');
|
|
2135
|
+
}
|
|
2136
|
+
// Its clock time, not the transaction's start: a booking that committed holding the row's
|
|
2137
|
+
// share lock went in before this.
|
|
2138
|
+
const [row] = await sql `
|
|
2139
|
+
UPDATE slotlock.resources
|
|
2140
|
+
SET archived_at = COALESCE(archived_at, clock_timestamp())
|
|
2141
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2142
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
2143
|
+
return row ? toResource(row) : null;
|
|
2144
|
+
},
|
|
2145
|
+
async restoreResource(params) {
|
|
2146
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2147
|
+
throw invalidIdentityError('Slotlock resource identity is invalid');
|
|
2148
|
+
}
|
|
2149
|
+
const [row] = await sql `
|
|
2150
|
+
UPDATE slotlock.resources
|
|
2151
|
+
SET archived_at = NULL
|
|
2152
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2153
|
+
RETURNING id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at`;
|
|
2154
|
+
return row ? toResource(row) : null;
|
|
2155
|
+
},
|
|
2156
|
+
async deleteResource(params) {
|
|
2157
|
+
if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.id)) {
|
|
2158
|
+
throw invalidIdentityError('Slotlock resource identity is invalid');
|
|
2159
|
+
}
|
|
2160
|
+
return inTransaction(sql, async (tx) => {
|
|
2161
|
+
// The row lock waits for writers holding its share lock and keeps new ones out until done.
|
|
2162
|
+
const [resource] = await tx `
|
|
2163
|
+
SELECT id FROM slotlock.resources
|
|
2164
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.id}
|
|
2165
|
+
FOR UPDATE`;
|
|
2166
|
+
if (!resource)
|
|
2167
|
+
return { ok: true, deleted: false };
|
|
2168
|
+
const [live] = await tx `
|
|
2169
|
+
SELECT EXISTS (
|
|
2170
|
+
SELECT 1 FROM slotlock.reservations
|
|
2171
|
+
WHERE resource_id = ${params.id}
|
|
2172
|
+
AND calendar_event_id IS NULL
|
|
2173
|
+
AND (CASE WHEN status = 'held' THEN expires_at > now()
|
|
2174
|
+
ELSE COALESCE(occupied_ends_at, ends_at) > now() END)
|
|
2175
|
+
)
|
|
2176
|
+
OR EXISTS (
|
|
2177
|
+
SELECT 1 FROM slotlock.calendar_events
|
|
2178
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.id}
|
|
2179
|
+
AND (recurrence_rule IS NOT NULL OR ends_at > now())
|
|
2180
|
+
) AS live`;
|
|
2181
|
+
if (live?.live)
|
|
2182
|
+
return { ok: false, code: 'resource_in_use' };
|
|
2183
|
+
// Its rows go with it: reservations, events and their children, coverage and hold answers
|
|
2184
|
+
// all cascade from the resource.
|
|
2185
|
+
await tx `DELETE FROM slotlock.resources WHERE id = ${params.id}`;
|
|
2186
|
+
return { ok: true, deleted: true };
|
|
2187
|
+
});
|
|
2188
|
+
},
|
|
1020
2189
|
async createReservation(params) {
|
|
1021
2190
|
if (!isFiniteWindow({ start: params.start, end: params.end })) {
|
|
1022
2191
|
return { ok: false, code: 'invalid_window' };
|
|
1023
2192
|
}
|
|
2193
|
+
if (!isValidResourceId(params.resourceId))
|
|
2194
|
+
return { ok: false, code: 'resource_not_found' };
|
|
2195
|
+
if (params.tenantRef !== undefined && !isValidIdentity(params.tenantRef)) {
|
|
2196
|
+
return { ok: false, code: 'resource_not_found' };
|
|
2197
|
+
}
|
|
2198
|
+
// A booking on a tenant's resource takes the tenant and its own id as reference, so it can be
|
|
2199
|
+
// cancelled and moved like any other; one on a resource with no tenant stays tenantless.
|
|
2200
|
+
const id = randomUUID();
|
|
1024
2201
|
for (let attempt = 1;; attempt++) {
|
|
1025
2202
|
try {
|
|
1026
|
-
|
|
2203
|
+
return await inTransaction(sql, async (tx) => {
|
|
2204
|
+
const bookingLimit = await tenantLimitReached(tx, await limitTenant(tx, 'reservations', params.resourceId, params.tenantRef), 'reservations');
|
|
2205
|
+
const [resource] = await tx `
|
|
2206
|
+
SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
2207
|
+
WHERE id = ${params.resourceId}
|
|
2208
|
+
AND (${params.tenantRef ?? null}::text IS NULL
|
|
2209
|
+
OR tenant_ref = ${params.tenantRef ?? null})
|
|
2210
|
+
FOR SHARE`;
|
|
2211
|
+
if (!resource)
|
|
2212
|
+
return { ok: false, code: 'resource_not_found' };
|
|
2213
|
+
if (resource.archived)
|
|
2214
|
+
return { ok: false, code: 'resource_archived' };
|
|
2215
|
+
if (bookingLimit !== null && resource.tenant_ref !== null) {
|
|
2216
|
+
return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
|
|
2217
|
+
}
|
|
2218
|
+
if (resource.tenant_ref !== null &&
|
|
2219
|
+
!(await recurrenceCoversWrite(tx, {
|
|
2220
|
+
tenantRef: resource.tenant_ref,
|
|
2221
|
+
resourceId: params.resourceId,
|
|
2222
|
+
start: params.start,
|
|
2223
|
+
end: params.end,
|
|
2224
|
+
}))) {
|
|
2225
|
+
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2226
|
+
}
|
|
1027
2227
|
await reapExpiredHolds(tx, params.resourceId, params.start, params.end);
|
|
1028
|
-
|
|
1029
|
-
INSERT INTO slotlock.reservations (
|
|
1030
|
-
|
|
1031
|
-
|
|
2228
|
+
const [row] = await tx `
|
|
2229
|
+
INSERT INTO slotlock.reservations (
|
|
2230
|
+
id, resource_id, tenant_ref, external_ref, starts_at, ends_at, source
|
|
2231
|
+
) VALUES (
|
|
2232
|
+
${id}, ${params.resourceId}, ${resource.tenant_ref},
|
|
2233
|
+
${resource.tenant_ref === null ? null : id},
|
|
2234
|
+
${params.start}, ${params.end}, ${params.source ?? null}
|
|
2235
|
+
)
|
|
2236
|
+
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2237
|
+
buffer_after_ms, revision, source`;
|
|
2238
|
+
if (!row)
|
|
2239
|
+
throw new Error('slotlock: reservation insert returned no row');
|
|
2240
|
+
return { ok: true, reservation: toReservation(row) };
|
|
1032
2241
|
});
|
|
1033
|
-
const row = rows[0];
|
|
1034
|
-
if (!row)
|
|
1035
|
-
throw new Error('slotlock: reservation insert returned no row');
|
|
1036
|
-
const reservation = {
|
|
1037
|
-
id: row.id,
|
|
1038
|
-
resourceId: row.resource_id,
|
|
1039
|
-
tenantRef: null,
|
|
1040
|
-
externalRef: null,
|
|
1041
|
-
start: row.starts_at,
|
|
1042
|
-
end: row.ends_at,
|
|
1043
|
-
bufferAfterMs: 0,
|
|
1044
|
-
revision: 1,
|
|
1045
|
-
source: row.source,
|
|
1046
|
-
};
|
|
1047
|
-
return { ok: true, reservation };
|
|
1048
2242
|
}
|
|
1049
2243
|
catch (err) {
|
|
1050
2244
|
const code = pgCode(err);
|
|
@@ -1058,6 +2252,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1058
2252
|
}
|
|
1059
2253
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
1060
2254
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
2255
|
+
await rollSeriesBehind(sql, params.resourceId, params.start, params.end);
|
|
1061
2256
|
return overlapLoss(sql, params.resourceId, params.start, params.end);
|
|
1062
2257
|
}
|
|
1063
2258
|
}
|
|
@@ -1077,21 +2272,43 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1077
2272
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}`;
|
|
1078
2273
|
if (cancelled[0])
|
|
1079
2274
|
return { ok: false, code: 'reservation_cancelled' };
|
|
2275
|
+
// A calendar event's occupation carries a reference in this namespace too, but it is
|
|
2276
|
+
// not a booking: create, reschedule and cancel never read, replay, move or free it,
|
|
2277
|
+
// and answer as for a reference no booking holds. Bookings have their own unique index.
|
|
1080
2278
|
const existing = await tx `
|
|
1081
2279
|
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
1082
2280
|
buffer_after_ms, revision, source
|
|
1083
2281
|
FROM slotlock.reservations
|
|
1084
2282
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
2283
|
+
AND calendar_event_id IS NULL
|
|
1085
2284
|
FOR UPDATE`;
|
|
1086
2285
|
const found = existing[0];
|
|
1087
2286
|
if (found) {
|
|
2287
|
+
// The booking has moved on since: a delayed create is stale, as a delayed move is.
|
|
2288
|
+
if (params.revision < Number(found.revision)) {
|
|
2289
|
+
return { ok: false, code: 'stale_revision' };
|
|
2290
|
+
}
|
|
1088
2291
|
if (!sameExternalPayload(found, params)) {
|
|
1089
2292
|
return { ok: false, code: 'idempotency_conflict' };
|
|
1090
2293
|
}
|
|
1091
2294
|
return { ok: true, reservation: toReservation(found), idempotent: true };
|
|
1092
2295
|
}
|
|
1093
|
-
|
|
2296
|
+
const bookingLimit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
|
|
2297
|
+
const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
|
|
2298
|
+
if (resourceState === 'missing')
|
|
1094
2299
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
2300
|
+
if (resourceState === 'archived')
|
|
2301
|
+
return { ok: false, code: 'resource_archived' };
|
|
2302
|
+
if (bookingLimit !== null) {
|
|
2303
|
+
return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
|
|
2304
|
+
}
|
|
2305
|
+
if (!(await recurrenceCoversWrite(tx, {
|
|
2306
|
+
tenantRef: params.tenantRef,
|
|
2307
|
+
resourceId: params.resourceId,
|
|
2308
|
+
start: params.start,
|
|
2309
|
+
end: occupiedEnd(params),
|
|
2310
|
+
}))) {
|
|
2311
|
+
return { ok: false, code: 'recurrence_not_materialized' };
|
|
1095
2312
|
}
|
|
1096
2313
|
await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
|
|
1097
2314
|
const rows = await tx `
|
|
@@ -1120,6 +2337,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1120
2337
|
code !== SERIALIZATION_FAILURE) {
|
|
1121
2338
|
throw err;
|
|
1122
2339
|
}
|
|
2340
|
+
await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
|
|
1123
2341
|
return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs);
|
|
1124
2342
|
}
|
|
1125
2343
|
}
|
|
@@ -1138,6 +2356,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1138
2356
|
buffer_after_ms, revision, source
|
|
1139
2357
|
FROM slotlock.reservations
|
|
1140
2358
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
2359
|
+
AND calendar_event_id IS NULL
|
|
1141
2360
|
FOR UPDATE`;
|
|
1142
2361
|
const found = existing[0];
|
|
1143
2362
|
if (!found) {
|
|
@@ -1161,8 +2380,18 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1161
2380
|
if (params.revision !== currentRevision + 1) {
|
|
1162
2381
|
return { ok: false, code: 'revision_conflict' };
|
|
1163
2382
|
}
|
|
1164
|
-
|
|
2383
|
+
const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
|
|
2384
|
+
if (resourceState === 'missing')
|
|
1165
2385
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
2386
|
+
if (resourceState === 'archived')
|
|
2387
|
+
return { ok: false, code: 'resource_archived' };
|
|
2388
|
+
if (!(await recurrenceCoversWrite(tx, {
|
|
2389
|
+
tenantRef: params.tenantRef,
|
|
2390
|
+
resourceId: params.resourceId,
|
|
2391
|
+
start: params.start,
|
|
2392
|
+
end: occupiedEnd(params),
|
|
2393
|
+
}))) {
|
|
2394
|
+
return { ok: false, code: 'recurrence_not_materialized' };
|
|
1166
2395
|
}
|
|
1167
2396
|
await reapExpiredHolds(tx, params.resourceId, params.start, occupiedEnd(params));
|
|
1168
2397
|
const rows = await tx `
|
|
@@ -1192,6 +2421,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1192
2421
|
code !== SERIALIZATION_FAILURE) {
|
|
1193
2422
|
throw err;
|
|
1194
2423
|
}
|
|
2424
|
+
await rollSeriesBehind(sql, params.resourceId, params.start, occupiedEnd(params));
|
|
1195
2425
|
return overlapLoss(sql, params.resourceId, params.start, params.end, params.bufferAfterMs, currentReservationId);
|
|
1196
2426
|
}
|
|
1197
2427
|
}
|
|
@@ -1222,6 +2452,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1222
2452
|
const existing = await tx `
|
|
1223
2453
|
SELECT id, revision FROM slotlock.reservations
|
|
1224
2454
|
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
2455
|
+
AND calendar_event_id IS NULL
|
|
1225
2456
|
FOR UPDATE`;
|
|
1226
2457
|
const found = existing[0];
|
|
1227
2458
|
if (found) {
|
|
@@ -1232,9 +2463,26 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1232
2463
|
return { ok: false, code: 'revision_conflict' };
|
|
1233
2464
|
}
|
|
1234
2465
|
}
|
|
2466
|
+
if (!found) {
|
|
2467
|
+
// A calendar event's occupation row carries this reference: it is the event's, not a
|
|
2468
|
+
// booking's. Nothing is cancelled, and nothing retired: a tombstone would only grow.
|
|
2469
|
+
const [occupation] = await tx `
|
|
2470
|
+
SELECT id FROM slotlock.reservations
|
|
2471
|
+
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
2472
|
+
AND calendar_event_id IS NOT NULL
|
|
2473
|
+
LIMIT 1`;
|
|
2474
|
+
if (occupation)
|
|
2475
|
+
return { ok: true, cancelled: false, revision: params.revision };
|
|
2476
|
+
// Retiring a reference no booking holds adds a row; cancelling a booking replaces one.
|
|
2477
|
+
const limit = await tenantLimitReached(tx, params.tenantRef, 'reservations');
|
|
2478
|
+
if (limit !== null)
|
|
2479
|
+
return { ok: false, code: 'reservation_limit_reached', limit };
|
|
2480
|
+
}
|
|
2481
|
+
// A reference no booking holds yet is retired too, so a create delivered after its cancel
|
|
2482
|
+
// cannot bring the booking back. Only such a stray retirement is pruned past retention.
|
|
1235
2483
|
await tx `
|
|
1236
|
-
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision)
|
|
1237
|
-
VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision})`;
|
|
2484
|
+
INSERT INTO slotlock.reservation_tombstones (tenant_ref, external_ref, revision, stray)
|
|
2485
|
+
VALUES (${params.tenantRef}, ${params.externalRef}, ${params.revision}, ${!found})`;
|
|
1238
2486
|
if (!found)
|
|
1239
2487
|
return { ok: true, cancelled: false, revision: params.revision };
|
|
1240
2488
|
const deleted = await tx `
|
|
@@ -1252,31 +2500,101 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1252
2500
|
if (!Number.isFinite(params.ttlMs) || params.ttlMs <= 0 || params.ttlMs > MAX_HOLD_TTL_MS) {
|
|
1253
2501
|
return { ok: false, code: 'invalid_ttl' };
|
|
1254
2502
|
}
|
|
2503
|
+
const bufferAfterMs = params.bufferAfterMs ?? 0;
|
|
2504
|
+
if (!Number.isSafeInteger(bufferAfterMs) ||
|
|
2505
|
+
bufferAfterMs < 0 ||
|
|
2506
|
+
bufferAfterMs > MAX_BUFFER_AFTER_MS) {
|
|
2507
|
+
return { ok: false, code: 'invalid_buffer' };
|
|
2508
|
+
}
|
|
2509
|
+
const { tenantRef, principalRef, idempotencyKey } = params;
|
|
2510
|
+
if (tenantRef !== undefined && !isValidIdentity(tenantRef)) {
|
|
2511
|
+
return { ok: false, code: 'invalid_identity' };
|
|
2512
|
+
}
|
|
2513
|
+
const keyed = idempotencyKey !== undefined || principalRef !== undefined;
|
|
2514
|
+
if (keyed &&
|
|
2515
|
+
(tenantRef === undefined || !isValidIdentity(principalRef) || !isValidIdentity(idempotencyKey))) {
|
|
2516
|
+
return { ok: false, code: 'invalid_identity' };
|
|
2517
|
+
}
|
|
2518
|
+
if (!isValidResourceId(params.resourceId))
|
|
2519
|
+
return { ok: false, code: 'resource_not_found' };
|
|
2520
|
+
const occupied = occupiedEnd({ end: params.end, bufferAfterMs });
|
|
2521
|
+
const payloadHash = calendarCommandHash({
|
|
2522
|
+
operation: 'hold',
|
|
2523
|
+
resourceId: params.resourceId,
|
|
2524
|
+
start: params.start.toISOString(),
|
|
2525
|
+
end: params.end.toISOString(),
|
|
2526
|
+
ttlMs: params.ttlMs,
|
|
2527
|
+
bufferAfterMs,
|
|
2528
|
+
source: params.source ?? null,
|
|
2529
|
+
});
|
|
1255
2530
|
const ttlSeconds = params.ttlMs / 1000;
|
|
1256
2531
|
for (let attempt = 1;; attempt++) {
|
|
1257
2532
|
try {
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
2533
|
+
return await inTransaction(sql, async (tx) => {
|
|
2534
|
+
if (keyed) {
|
|
2535
|
+
await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
|
|
2536
|
+
'slotlock-hold-command',
|
|
2537
|
+
tenantRef,
|
|
2538
|
+
principalRef,
|
|
2539
|
+
idempotencyKey,
|
|
2540
|
+
])}, 0))`;
|
|
2541
|
+
const [prior] = await tx `
|
|
2542
|
+
SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
|
|
2543
|
+
source, payload_hash
|
|
2544
|
+
FROM slotlock.hold_commands
|
|
2545
|
+
WHERE tenant_ref = ${tenantRef ?? null} AND principal_ref = ${principalRef ?? null}
|
|
2546
|
+
AND idempotency_key = ${idempotencyKey ?? null}`;
|
|
2547
|
+
if (prior) {
|
|
2548
|
+
return prior.payload_hash === payloadHash
|
|
2549
|
+
? { ok: true, hold: toHold(prior), idempotent: true }
|
|
2550
|
+
: { ok: false, code: 'idempotency_conflict' };
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
const holdLimit = await tenantLimitReached(tx, await limitTenant(tx, 'holds', params.resourceId, tenantRef), 'holds');
|
|
2554
|
+
const [resource] = await tx `
|
|
2555
|
+
SELECT tenant_ref, archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
2556
|
+
WHERE id = ${params.resourceId}
|
|
2557
|
+
AND (${tenantRef ?? null}::text IS NULL OR tenant_ref = ${tenantRef ?? null})
|
|
2558
|
+
FOR SHARE`;
|
|
2559
|
+
if (!resource)
|
|
2560
|
+
return { ok: false, code: 'resource_not_found' };
|
|
2561
|
+
if (resource.archived)
|
|
2562
|
+
return { ok: false, code: 'resource_archived' };
|
|
2563
|
+
if (holdLimit !== null)
|
|
2564
|
+
return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
|
|
2565
|
+
if (resource.tenant_ref !== null &&
|
|
2566
|
+
!(await recurrenceCoversWrite(tx, {
|
|
2567
|
+
tenantRef: resource.tenant_ref,
|
|
2568
|
+
resourceId: params.resourceId,
|
|
2569
|
+
start: params.start,
|
|
2570
|
+
end: occupied,
|
|
2571
|
+
}))) {
|
|
2572
|
+
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2573
|
+
}
|
|
2574
|
+
await reapExpiredHolds(tx, params.resourceId, params.start, occupied);
|
|
2575
|
+
const [row] = await tx `
|
|
2576
|
+
INSERT INTO slotlock.reservations (
|
|
2577
|
+
resource_id, starts_at, ends_at, buffer_after_ms, status, expires_at, source
|
|
2578
|
+
) VALUES (
|
|
2579
|
+
${params.resourceId}, ${params.start}, ${params.end}, ${bufferAfterMs}, 'held',
|
|
2580
|
+
now() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null}
|
|
2581
|
+
)
|
|
2582
|
+
RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source`;
|
|
2583
|
+
if (!row)
|
|
2584
|
+
throw new Error('slotlock: hold insert returned no row');
|
|
2585
|
+
if (keyed) {
|
|
2586
|
+
await tx `
|
|
2587
|
+
INSERT INTO slotlock.hold_commands (
|
|
2588
|
+
tenant_ref, principal_ref, idempotency_key, payload_hash, hold_id, resource_id,
|
|
2589
|
+
starts_at, ends_at, buffer_after_ms, expires_at, source
|
|
2590
|
+
) VALUES (
|
|
2591
|
+
${tenantRef ?? null}, ${principalRef ?? null}, ${idempotencyKey ?? null},
|
|
2592
|
+
${payloadHash}, ${row.id}, ${row.resource_id}, ${row.starts_at}, ${row.ends_at},
|
|
2593
|
+
${row.buffer_after_ms}, ${row.expires_at}, ${row.source}
|
|
2594
|
+
)`;
|
|
2595
|
+
}
|
|
2596
|
+
return { ok: true, hold: toHold(row), idempotent: false };
|
|
1265
2597
|
});
|
|
1266
|
-
const row = rows[0];
|
|
1267
|
-
if (!row)
|
|
1268
|
-
throw new Error('slotlock: hold insert returned no row');
|
|
1269
|
-
return {
|
|
1270
|
-
ok: true,
|
|
1271
|
-
hold: {
|
|
1272
|
-
id: row.id,
|
|
1273
|
-
resourceId: row.resource_id,
|
|
1274
|
-
start: row.starts_at,
|
|
1275
|
-
end: row.ends_at,
|
|
1276
|
-
expiresAt: row.expires_at,
|
|
1277
|
-
source: row.source,
|
|
1278
|
-
},
|
|
1279
|
-
};
|
|
1280
2598
|
}
|
|
1281
2599
|
catch (err) {
|
|
1282
2600
|
const code = pgCode(err);
|
|
@@ -1290,73 +2608,204 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1290
2608
|
}
|
|
1291
2609
|
// A SECOND contention abort is treated as a loss too (structured, possibly with a null
|
|
1292
2610
|
// winner if both rivals rolled back) — symmetric with 40P01, never a raw throw.
|
|
1293
|
-
|
|
2611
|
+
await rollSeriesBehind(sql, params.resourceId, params.start, occupied);
|
|
2612
|
+
return overlapLoss(sql, params.resourceId, params.start, params.end, bufferAfterMs);
|
|
1294
2613
|
}
|
|
1295
2614
|
}
|
|
1296
2615
|
},
|
|
1297
|
-
async
|
|
2616
|
+
async getHold(params) {
|
|
2617
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2618
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2619
|
+
}
|
|
2620
|
+
if (!isValidResourceId(params.holdId))
|
|
2621
|
+
return null;
|
|
2622
|
+
const [row] = await sql `
|
|
2623
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
2624
|
+
hold.expires_at, hold.source
|
|
2625
|
+
FROM slotlock.reservations hold
|
|
2626
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
2627
|
+
WHERE hold.id = ${params.holdId} AND resource.tenant_ref = ${params.tenantRef}
|
|
2628
|
+
AND hold.status = 'held' AND hold.expires_at > now()`;
|
|
2629
|
+
return row ? toHold(row) : null;
|
|
2630
|
+
},
|
|
2631
|
+
async listHolds(params) {
|
|
2632
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2633
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2634
|
+
}
|
|
2635
|
+
if (!isValidResourceId(params.resourceId)) {
|
|
2636
|
+
throw invalidIdentityError('Slotlock resource id must be a UUID');
|
|
2637
|
+
}
|
|
2638
|
+
const limit = pageLimit(params.limit);
|
|
2639
|
+
const after = pageCursor(params.after);
|
|
1298
2640
|
const rows = await sql `
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
bufferAfterMs: 0,
|
|
1315
|
-
revision: 1,
|
|
1316
|
-
source: row.source,
|
|
1317
|
-
},
|
|
1318
|
-
};
|
|
2641
|
+
SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
|
|
2642
|
+
hold.expires_at, hold.source
|
|
2643
|
+
FROM slotlock.reservations hold
|
|
2644
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
2645
|
+
WHERE hold.resource_id = ${params.resourceId} AND resource.tenant_ref = ${params.tenantRef}
|
|
2646
|
+
AND hold.status = 'held' AND hold.expires_at > now()
|
|
2647
|
+
AND (${after.start}::timestamptz IS NULL
|
|
2648
|
+
OR (hold.starts_at, hold.id) > (${after.start}::timestamptz, ${after.id}::uuid))
|
|
2649
|
+
ORDER BY hold.starts_at, hold.id
|
|
2650
|
+
LIMIT ${limit}`;
|
|
2651
|
+
return rows.map(toHold);
|
|
2652
|
+
},
|
|
2653
|
+
async getReservation(params) {
|
|
2654
|
+
if (!isValidIdentity(params.tenantRef) || !isValidIdentity(params.externalRef)) {
|
|
2655
|
+
throw invalidIdentityError('Slotlock booking identity must be 1-500 UTF-8 bytes');
|
|
1319
2656
|
}
|
|
1320
|
-
const
|
|
1321
|
-
SELECT
|
|
1322
|
-
|
|
1323
|
-
FROM slotlock.reservations
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
externalRef: null,
|
|
1335
|
-
start: found.starts_at,
|
|
1336
|
-
end: found.ends_at,
|
|
1337
|
-
bufferAfterMs: 0,
|
|
1338
|
-
revision: 1,
|
|
1339
|
-
source: found.source,
|
|
1340
|
-
},
|
|
1341
|
-
};
|
|
2657
|
+
const [row] = await sql `
|
|
2658
|
+
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2659
|
+
revision, source
|
|
2660
|
+
FROM slotlock.reservations
|
|
2661
|
+
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
|
|
2662
|
+
AND calendar_event_id IS NULL`;
|
|
2663
|
+
return row ? toReservation(row) : null;
|
|
2664
|
+
},
|
|
2665
|
+
async listReservations(params) {
|
|
2666
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
2667
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2668
|
+
}
|
|
2669
|
+
if (!isValidResourceId(params.resourceId)) {
|
|
2670
|
+
throw invalidIdentityError('Slotlock resource id must be a UUID');
|
|
1342
2671
|
}
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
2672
|
+
const window = calendarWindow(params.window);
|
|
2673
|
+
const limit = pageLimit(params.limit);
|
|
2674
|
+
const after = pageCursor(params.after);
|
|
2675
|
+
const rows = await sql `
|
|
2676
|
+
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2677
|
+
revision, source
|
|
2678
|
+
FROM slotlock.reservations
|
|
2679
|
+
WHERE tenant_ref = ${params.tenantRef} AND resource_id = ${params.resourceId}
|
|
2680
|
+
AND calendar_event_id IS NULL
|
|
2681
|
+
AND starts_at < ${window.end}
|
|
2682
|
+
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
2683
|
+
AND (${after.start}::timestamptz IS NULL
|
|
2684
|
+
OR (starts_at, id) > (${after.start}::timestamptz, ${after.id}::uuid))
|
|
2685
|
+
ORDER BY starts_at, id
|
|
2686
|
+
LIMIT ${limit}`;
|
|
2687
|
+
return rows.map(toReservation);
|
|
2688
|
+
},
|
|
2689
|
+
async confirmHold(holdId, options = {}) {
|
|
2690
|
+
const tenantRef = holdTenantRef(options);
|
|
2691
|
+
// Not an id the store issues: it names no hold (and would be a raw 22P02 in the query).
|
|
2692
|
+
if (!isValidResourceId(holdId))
|
|
2693
|
+
return { ok: false, code: 'hold_not_found' };
|
|
2694
|
+
return inTransaction(sql, async (tx) => {
|
|
2695
|
+
// The tenant whose booking the hold becomes is its resource's; a hold never changes
|
|
2696
|
+
// resource, so it is read before the identity lock, which is taken before the row lock as
|
|
2697
|
+
// every booking write takes them.
|
|
2698
|
+
const [owner] = await tx `
|
|
2699
|
+
SELECT hold.resource_id, resource.tenant_ref AS resource_tenant
|
|
2700
|
+
FROM slotlock.reservations hold
|
|
2701
|
+
JOIN slotlock.resources resource ON resource.id = hold.resource_id
|
|
2702
|
+
WHERE hold.id = ${holdId} AND hold.calendar_event_id IS NULL
|
|
2703
|
+
AND (${tenantRef}::text IS NULL OR resource.tenant_ref = ${tenantRef})`;
|
|
2704
|
+
if (!owner)
|
|
2705
|
+
return { ok: false, code: 'hold_not_found' };
|
|
2706
|
+
const bookingTenant = owner.resource_tenant;
|
|
2707
|
+
if (bookingTenant !== null)
|
|
2708
|
+
await lockExternalIdentity(tx, bookingTenant, holdId);
|
|
2709
|
+
const bookingLimit = await tenantLimitReached(tx, bookingTenant, 'reservations');
|
|
2710
|
+
// The resource's share lock, as every booking write takes it: an archive waits for this
|
|
2711
|
+
// confirmation, and one that committed first is read here.
|
|
2712
|
+
const [resource] = await tx `
|
|
2713
|
+
SELECT archived_at IS NOT NULL AS archived FROM slotlock.resources
|
|
2714
|
+
WHERE id = ${owner.resource_id}
|
|
2715
|
+
FOR SHARE`;
|
|
2716
|
+
const [row] = await tx `
|
|
2717
|
+
SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at, buffer_after_ms,
|
|
2718
|
+
revision, source, status, (status = 'held' AND expires_at > now()) AS live
|
|
2719
|
+
FROM slotlock.reservations
|
|
2720
|
+
WHERE id = ${holdId}
|
|
2721
|
+
FOR UPDATE`;
|
|
2722
|
+
if (!row) {
|
|
2723
|
+
// NOTE: a hold reaped mid-flight by a rival (expired while this confirm waited on the
|
|
2724
|
+
// reaper's lock) reads as hold_not_found: the row is gone and the space was legitimately
|
|
2725
|
+
// taken; both refusal codes are terminal for the caller.
|
|
2726
|
+
return { ok: false, code: 'hold_not_found' };
|
|
2727
|
+
}
|
|
2728
|
+
if (row.status === 'confirmed') {
|
|
2729
|
+
// Idempotent confirm: a retried confirm (network replay) of an already-confirmed hold is
|
|
2730
|
+
// SUCCESS, the booking as it now is; reporting failure would make callers double-book
|
|
2731
|
+
// elsewhere. A confirmed hold's reference is its own id (none without a tenant); a
|
|
2732
|
+
// booking with another reference was never this hold.
|
|
2733
|
+
if (row.external_ref !== null && row.external_ref !== row.id) {
|
|
2734
|
+
return { ok: false, code: 'hold_not_found' };
|
|
2735
|
+
}
|
|
2736
|
+
return { ok: true, reservation: toReservation(row) };
|
|
2737
|
+
}
|
|
2738
|
+
if (!row.live)
|
|
2739
|
+
return { ok: false, code: 'hold_expired' };
|
|
2740
|
+
// Archived since the hold was taken: a confirmation would be a new booking on it.
|
|
2741
|
+
if (resource?.archived !== false)
|
|
2742
|
+
return { ok: false, code: 'resource_archived' };
|
|
2743
|
+
if (bookingTenant !== null) {
|
|
2744
|
+
const [retired] = await tx `
|
|
2745
|
+
SELECT revision FROM slotlock.reservation_tombstones
|
|
2746
|
+
WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}`;
|
|
2747
|
+
if (retired)
|
|
2748
|
+
return { ok: false, code: 'reservation_cancelled' };
|
|
2749
|
+
const [taken] = await tx `
|
|
2750
|
+
SELECT id FROM slotlock.reservations
|
|
2751
|
+
WHERE tenant_ref = ${bookingTenant} AND external_ref = ${holdId}
|
|
2752
|
+
AND calendar_event_id IS NULL`;
|
|
2753
|
+
if (taken)
|
|
2754
|
+
return { ok: false, code: 'idempotency_conflict' };
|
|
2755
|
+
}
|
|
2756
|
+
if (bookingLimit !== null) {
|
|
2757
|
+
return { ok: false, code: 'reservation_limit_reached', limit: bookingLimit };
|
|
2758
|
+
}
|
|
2759
|
+
// The occupied end is already the end plus the hold's buffer (the trigger set it); the
|
|
2760
|
+
// tenant and reference make the hold a booking that cancel and reschedule reach.
|
|
2761
|
+
const [confirmed] = await tx `
|
|
2762
|
+
UPDATE slotlock.reservations
|
|
2763
|
+
SET status = 'confirmed', expires_at = NULL,
|
|
2764
|
+
tenant_ref = ${bookingTenant}, external_ref = ${bookingTenant === null ? null : holdId}
|
|
2765
|
+
WHERE id = ${holdId}
|
|
2766
|
+
RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
|
|
2767
|
+
buffer_after_ms, revision, source`;
|
|
2768
|
+
if (!confirmed)
|
|
2769
|
+
throw new Error('slotlock: hold confirmation returned no row');
|
|
2770
|
+
return { ok: true, reservation: toReservation(confirmed) };
|
|
2771
|
+
});
|
|
1349
2772
|
},
|
|
1350
|
-
async releaseHold(holdId) {
|
|
2773
|
+
async releaseHold(holdId, options = {}) {
|
|
2774
|
+
const tenantRef = holdTenantRef(options);
|
|
2775
|
+
if (!isValidResourceId(holdId))
|
|
2776
|
+
return { ok: true, released: false };
|
|
1351
2777
|
const rows = await sql `
|
|
1352
2778
|
DELETE FROM slotlock.reservations
|
|
1353
2779
|
WHERE id = ${holdId} AND status = 'held'
|
|
2780
|
+
AND (${tenantRef}::text IS NULL
|
|
2781
|
+
OR resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef}))
|
|
1354
2782
|
RETURNING id`;
|
|
1355
2783
|
return { ok: true, released: rows.length > 0 };
|
|
1356
2784
|
},
|
|
1357
|
-
async listBusy(resourceId, window) {
|
|
2785
|
+
async listBusy(resourceId, window, options = {}) {
|
|
1358
2786
|
if (!isFiniteWindow(window))
|
|
1359
2787
|
throw invalidWindowError();
|
|
2788
|
+
if (!isValidResourceId(resourceId)) {
|
|
2789
|
+
throw invalidIdentityError('Slotlock resource id must be a UUID');
|
|
2790
|
+
}
|
|
2791
|
+
if (options.limit !== undefined && !(Number.isSafeInteger(options.limit) && options.limit > 0)) {
|
|
2792
|
+
throw Object.assign(new Error('Slotlock busy read limit must be a positive whole number'), {
|
|
2793
|
+
code: 'invalid_limit',
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
if (options.tenantRef !== undefined) {
|
|
2797
|
+
if (!isValidIdentity(options.tenantRef)) {
|
|
2798
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
2799
|
+
}
|
|
2800
|
+
const [owned] = await sql `
|
|
2801
|
+
SELECT id FROM slotlock.resources
|
|
2802
|
+
WHERE id = ${resourceId} AND tenant_ref = ${options.tenantRef}`;
|
|
2803
|
+
if (!owned) {
|
|
2804
|
+
throw Object.assign(new Error('Slotlock resource not found in this tenant'), {
|
|
2805
|
+
code: 'resource_not_found',
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
1360
2809
|
// Expired holds are dead occupation: excluded here so findNextAvailable can offer their
|
|
1361
2810
|
// space (the acquiring transaction reaps the row itself when it takes the window).
|
|
1362
2811
|
const rows = await sql `
|
|
@@ -1366,11 +2815,15 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1366
2815
|
AND starts_at < ${window.end}
|
|
1367
2816
|
AND COALESCE(occupied_ends_at, ends_at) > ${window.start}
|
|
1368
2817
|
AND (status <> 'held' OR expires_at > now())
|
|
1369
|
-
ORDER BY starts_at
|
|
2818
|
+
ORDER BY starts_at
|
|
2819
|
+
LIMIT ${options.limit ?? null}`;
|
|
1370
2820
|
return rows.map((r) => ({ start: r.starts_at, end: r.occupied_ends_at }));
|
|
1371
2821
|
},
|
|
1372
2822
|
async putCalendarEvent(params) {
|
|
1373
2823
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
2824
|
+
if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
|
|
2825
|
+
return { ok: false, code: 'invalid_identity' };
|
|
2826
|
+
}
|
|
1374
2827
|
if (!isValidIdentity(params.tenantRef) ||
|
|
1375
2828
|
!isValidEventOwnerRef(ownerRef) ||
|
|
1376
2829
|
!isValidIdentity(params.externalRef) ||
|
|
@@ -1390,7 +2843,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1390
2843
|
if (event.recurrence) {
|
|
1391
2844
|
if (!params.materializationWindow)
|
|
1392
2845
|
return { ok: false, code: 'invalid_event' };
|
|
1393
|
-
materializationWindow =
|
|
2846
|
+
materializationWindow = seriesWindow(params.materializationWindow);
|
|
1394
2847
|
}
|
|
1395
2848
|
else {
|
|
1396
2849
|
materializationWindow = oneOffEventWindow(event, params.materializationWindow);
|
|
@@ -1419,7 +2872,9 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1419
2872
|
event,
|
|
1420
2873
|
window: materializationWindow,
|
|
1421
2874
|
});
|
|
2875
|
+
const recurrenceEndsBy = recurrenceEndsByColumn(event);
|
|
1422
2876
|
let attempted = expanded?.occurrences ?? [];
|
|
2877
|
+
let storedEventId;
|
|
1423
2878
|
for (let attempt = 1;; attempt++) {
|
|
1424
2879
|
try {
|
|
1425
2880
|
return await inTransaction(sql, async (tx) => {
|
|
@@ -1467,9 +2922,11 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1467
2922
|
currentRevision: Number(tombstones[0].revision),
|
|
1468
2923
|
};
|
|
1469
2924
|
}
|
|
1470
|
-
|
|
2925
|
+
const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
|
|
2926
|
+
if (resourceState === 'missing')
|
|
1471
2927
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
1472
|
-
|
|
2928
|
+
if (resourceState === 'archived')
|
|
2929
|
+
return { ok: false, code: 'resource_archived' };
|
|
1473
2930
|
const existing = await tx `
|
|
1474
2931
|
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
1475
2932
|
summary, description, location, status, transparency, organizer, attendees,
|
|
@@ -1481,6 +2938,11 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1481
2938
|
AND external_ref = ${params.externalRef}
|
|
1482
2939
|
FOR UPDATE`;
|
|
1483
2940
|
const found = existing[0];
|
|
2941
|
+
storedEventId = found?.id;
|
|
2942
|
+
if (params.expectedResourceId !== undefined &&
|
|
2943
|
+
found?.resource_id !== params.expectedResourceId) {
|
|
2944
|
+
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
2945
|
+
}
|
|
1484
2946
|
const currentRevision = found ? Number(found.revision) : 0;
|
|
1485
2947
|
if (params.expectedRevision !== currentRevision) {
|
|
1486
2948
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
@@ -1511,8 +2973,26 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1511
2973
|
};
|
|
1512
2974
|
}
|
|
1513
2975
|
}
|
|
2976
|
+
if (!found) {
|
|
2977
|
+
const limit = await tenantLimitReached(tx, params.tenantRef, 'events');
|
|
2978
|
+
if (limit !== null)
|
|
2979
|
+
return { ok: false, code: 'event_limit_reached', limit };
|
|
2980
|
+
}
|
|
1514
2981
|
const eventId = found?.id ?? randomUUID();
|
|
1515
2982
|
const revision = currentRevision + 1;
|
|
2983
|
+
const reach = occurrenceSpan(occurrences);
|
|
2984
|
+
if ((event.transparency ?? 'opaque') === 'opaque' &&
|
|
2985
|
+
reach &&
|
|
2986
|
+
!(found && (await holdsEveryOccurrence(tx, found.id, params.resourceId, occurrences)))) {
|
|
2987
|
+
if (!(await recurrenceCoversWrite(tx, {
|
|
2988
|
+
tenantRef: params.tenantRef,
|
|
2989
|
+
resourceId: params.resourceId,
|
|
2990
|
+
...reach,
|
|
2991
|
+
...(found ? { exceptEventId: found.id } : {}),
|
|
2992
|
+
}))) {
|
|
2993
|
+
return { ok: false, code: 'recurrence_not_materialized' };
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
1516
2996
|
if (found) {
|
|
1517
2997
|
await tx `
|
|
1518
2998
|
UPDATE slotlock.calendar_events
|
|
@@ -1533,6 +3013,8 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1533
3013
|
materialized_starts_at = ${materializationWindow.start},
|
|
1534
3014
|
materialized_ends_at = ${materializationWindow.end},
|
|
1535
3015
|
recurrence_cursor = ${seriesCursorJson(tx, series.cursor)},
|
|
3016
|
+
recurrence_ends_by = ${recurrenceEndsBy}::text::timestamptz,
|
|
3017
|
+
roll_retry_at = NULL,
|
|
1536
3018
|
revision = ${revision},
|
|
1537
3019
|
source = ${params.source ?? null},
|
|
1538
3020
|
updated_at = now()
|
|
@@ -1546,7 +3028,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1546
3028
|
id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
1547
3029
|
summary, description, location, status, transparency, organizer, attendees,
|
|
1548
3030
|
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1549
|
-
materialized_ends_at, recurrence_cursor, revision, source
|
|
3031
|
+
materialized_ends_at, recurrence_cursor, recurrence_ends_by, revision, source
|
|
1550
3032
|
) VALUES (
|
|
1551
3033
|
${eventId}, ${params.tenantRef}, ${ownerRef}, ${params.externalRef},
|
|
1552
3034
|
${params.resourceId},
|
|
@@ -1559,8 +3041,8 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1559
3041
|
${event.recurrence?.rrule ?? null},
|
|
1560
3042
|
${tx.json(eventExceptionsForJson(event.recurrence?.exceptions))},
|
|
1561
3043
|
${materializationWindow.start}, ${materializationWindow.end},
|
|
1562
|
-
${seriesCursorJson(tx, series.cursor)}, ${
|
|
1563
|
-
${params.source ?? null}
|
|
3044
|
+
${seriesCursorJson(tx, series.cursor)}, ${recurrenceEndsBy}::text::timestamptz,
|
|
3045
|
+
${revision}, ${params.source ?? null}
|
|
1564
3046
|
)`;
|
|
1565
3047
|
}
|
|
1566
3048
|
// The normalized children are the queryable/canonical representation. The JSON
|
|
@@ -1603,7 +3085,11 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1603
3085
|
code !== SERIALIZATION_FAILURE) {
|
|
1604
3086
|
throw error;
|
|
1605
3087
|
}
|
|
1606
|
-
|
|
3088
|
+
const reach = occurrenceSpan(attempted);
|
|
3089
|
+
if (reach) {
|
|
3090
|
+
await rollSeriesBehind(sql, params.resourceId, reach.start, reach.end, storedEventId);
|
|
3091
|
+
}
|
|
3092
|
+
return findCalendarOverlap(sql, params.resourceId, attempted, storedEventId);
|
|
1607
3093
|
}
|
|
1608
3094
|
}
|
|
1609
3095
|
},
|
|
@@ -1631,7 +3117,31 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1631
3117
|
const children = await loadCalendarEventChildren(sql, [row.id]);
|
|
1632
3118
|
return toCalendarEvent(row, children.get(row.id));
|
|
1633
3119
|
},
|
|
3120
|
+
async getCalendarEventById(params) {
|
|
3121
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3122
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3123
|
+
}
|
|
3124
|
+
if (!isValidResourceId(params.eventId)) {
|
|
3125
|
+
throw invalidIdentityError('Slotlock event id must be a UUID');
|
|
3126
|
+
}
|
|
3127
|
+
const rows = await sql `
|
|
3128
|
+
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
3129
|
+
summary, description, location, status, transparency, organizer, attendees,
|
|
3130
|
+
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
3131
|
+
materialized_ends_at, revision, source, created_at, updated_at
|
|
3132
|
+
FROM slotlock.calendar_events
|
|
3133
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
|
|
3134
|
+
const row = rows[0];
|
|
3135
|
+
if (!row)
|
|
3136
|
+
return null;
|
|
3137
|
+
const children = await loadCalendarEventChildren(sql, [row.id]);
|
|
3138
|
+
return toCalendarEvent(row, children.get(row.id));
|
|
3139
|
+
},
|
|
1634
3140
|
async listCalendarEvents(params) {
|
|
3141
|
+
if (params.allOwners === true && params.ownerRef !== undefined) {
|
|
3142
|
+
throw invalidIdentityError('Slotlock lists one owner or every owner, not both');
|
|
3143
|
+
}
|
|
3144
|
+
const allOwners = params.allOwners === true;
|
|
1635
3145
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
1636
3146
|
if (!isValidIdentity(params.tenantRef) || !isValidEventOwnerRef(ownerRef)) {
|
|
1637
3147
|
throw Object.assign(new Error('Slotlock tenant reference must be 1-500 UTF-8 bytes'), {
|
|
@@ -1669,7 +3179,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1669
3179
|
event.source, event.created_at, event.updated_at
|
|
1670
3180
|
FROM slotlock.calendar_events event
|
|
1671
3181
|
WHERE event.tenant_ref = ${params.tenantRef}
|
|
1672
|
-
AND event.owner_ref = ${ownerRef}
|
|
3182
|
+
AND (${allOwners}::boolean OR event.owner_ref = ${ownerRef})
|
|
1673
3183
|
AND event.resource_id = ${params.resourceId}
|
|
1674
3184
|
AND EXISTS (
|
|
1675
3185
|
SELECT 1 FROM slotlock.calendar_event_occurrences occurrence
|
|
@@ -1696,7 +3206,7 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1696
3206
|
code: 'invalid_identity',
|
|
1697
3207
|
});
|
|
1698
3208
|
}
|
|
1699
|
-
const window =
|
|
3209
|
+
const window = seriesWindow(params.window);
|
|
1700
3210
|
const limit = params.limit ?? 100;
|
|
1701
3211
|
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
|
|
1702
3212
|
throw Object.assign(new Error('Slotlock horizon batch limit must be 1-1000'), {
|
|
@@ -1704,80 +3214,54 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1704
3214
|
});
|
|
1705
3215
|
}
|
|
1706
3216
|
const after = rollCursor(params.after);
|
|
3217
|
+
// Due: the masters rolledWindow rolls, which do not cover the window and end within the cap
|
|
3218
|
+
// of its start. Those it has never put back come first, then those it has, longest waiting
|
|
3219
|
+
// first: however many it cannot extend, they never stand in front of one it has not tried,
|
|
3220
|
+
// so every pass that is not capped by such new ones reaches the healthy series, and the ones
|
|
3221
|
+
// put back take turns.
|
|
1707
3222
|
const candidates = await sql `
|
|
1708
|
-
SELECT id, materialized_ends_at
|
|
3223
|
+
SELECT id, materialized_ends_at, roll_retry_at
|
|
1709
3224
|
FROM slotlock.calendar_events
|
|
1710
3225
|
WHERE tenant_ref = ${params.tenantRef}
|
|
1711
3226
|
AND recurrence_rule IS NOT NULL
|
|
1712
3227
|
AND (
|
|
1713
|
-
materialized_starts_at
|
|
3228
|
+
materialized_starts_at > ${window.start}
|
|
1714
3229
|
OR materialized_ends_at < ${window.end}
|
|
1715
3230
|
)
|
|
3231
|
+
AND materialized_ends_at <= ${new Date(window.start.getTime() + MAX_MATERIALIZATION_WINDOW_MS)}
|
|
3232
|
+
-- One it could not extend waits its turn behind the ones it can.
|
|
3233
|
+
AND (roll_retry_at IS NULL OR roll_retry_at <= now())
|
|
1716
3234
|
AND (
|
|
1717
3235
|
${after?.materializedEndsAt ?? null}::timestamptz IS NULL
|
|
1718
|
-
OR (materialized_ends_at, id) >
|
|
1719
|
-
(${after?.
|
|
3236
|
+
OR (COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id) >
|
|
3237
|
+
(COALESCE(${after?.rollRetryAt ?? null}::timestamptz, '-infinity'),
|
|
3238
|
+
${after?.materializedEndsAt ?? null}::timestamptz, ${after?.eventId ?? null}::uuid)
|
|
1720
3239
|
)
|
|
1721
|
-
ORDER BY materialized_ends_at, id
|
|
3240
|
+
ORDER BY COALESCE(roll_retry_at, '-infinity'), materialized_ends_at, id
|
|
1722
3241
|
LIMIT ${limit + 1}`;
|
|
1723
3242
|
const due = candidates.slice(0, limit);
|
|
1724
3243
|
let extended = 0;
|
|
1725
3244
|
let conflicts = 0;
|
|
1726
3245
|
let refused = 0;
|
|
1727
3246
|
const refusedEventIds = [];
|
|
3247
|
+
const conflictEventIds = [];
|
|
1728
3248
|
for (const candidate of due) {
|
|
1729
3249
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
1730
3250
|
try {
|
|
1731
|
-
const changed = await inTransaction(sql,
|
|
1732
|
-
const [row] = await tx `
|
|
1733
|
-
SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
|
|
1734
|
-
summary, description, location, status, transparency, organizer, attendees,
|
|
1735
|
-
reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
|
|
1736
|
-
materialized_ends_at, recurrence_cursor, revision, source, created_at,
|
|
1737
|
-
updated_at
|
|
1738
|
-
FROM slotlock.calendar_events
|
|
1739
|
-
WHERE id = ${candidate.id} AND tenant_ref = ${params.tenantRef}
|
|
1740
|
-
FOR UPDATE`;
|
|
1741
|
-
if (!row ||
|
|
1742
|
-
row.recurrence_rule === null ||
|
|
1743
|
-
(row.materialized_starts_at.getTime() === window.start.getTime() &&
|
|
1744
|
-
row.materialized_ends_at >= window.end)) {
|
|
1745
|
-
return false;
|
|
1746
|
-
}
|
|
1747
|
-
const childMap = await loadCalendarEventChildren(tx, [row.id]);
|
|
1748
|
-
const event = toCalendarEvent(row, childMap.get(row.id));
|
|
1749
|
-
// From where its last expansion stopped, when that can be trusted; else its start.
|
|
1750
|
-
const { occurrences, cursor } = expandCalendarEventSeries(event, window, {
|
|
1751
|
-
cursor: row.recurrence_cursor ?? null,
|
|
1752
|
-
stored: true,
|
|
1753
|
-
});
|
|
1754
|
-
await replaceCalendarEventOccurrences(tx, {
|
|
1755
|
-
tenantRef: params.tenantRef,
|
|
1756
|
-
eventId: row.id,
|
|
1757
|
-
resourceId: row.resource_id,
|
|
1758
|
-
event,
|
|
1759
|
-
occurrences,
|
|
1760
|
-
revision: Number(row.revision),
|
|
1761
|
-
});
|
|
1762
|
-
await tx `
|
|
1763
|
-
UPDATE slotlock.calendar_events
|
|
1764
|
-
SET materialized_starts_at = ${window.start},
|
|
1765
|
-
materialized_ends_at = ${window.end},
|
|
1766
|
-
recurrence_cursor = ${seriesCursorJson(tx, cursor)}
|
|
1767
|
-
WHERE id = ${row.id} AND tenant_ref = ${params.tenantRef}`;
|
|
1768
|
-
return true;
|
|
1769
|
-
});
|
|
3251
|
+
const changed = await inTransaction(sql, (tx) => rollStoredSeries(tx, params.tenantRef, candidate.id, window));
|
|
1770
3252
|
if (changed)
|
|
1771
3253
|
extended += 1;
|
|
1772
3254
|
break;
|
|
1773
3255
|
}
|
|
1774
3256
|
catch (error) {
|
|
1775
3257
|
if (error instanceof CalendarExpansionLimitError) {
|
|
1776
|
-
// It takes more than Slotlock expands: a rule that never occurs again,
|
|
1777
|
-
// cursor to resume from whose search from its start has outgrown the budget
|
|
1778
|
-
//
|
|
3258
|
+
// It takes more than Slotlock expands: a rule that never occurs again, one with no
|
|
3259
|
+
// cursor to resume from whose search from its start has outgrown the budget, or one an
|
|
3260
|
+
// earlier Slotlock stored that this one refuses. Nothing was written; its old coverage
|
|
3261
|
+
// stays, and the masters after it still roll.
|
|
1779
3262
|
refused += 1;
|
|
1780
3263
|
refusedEventIds.push(candidate.id);
|
|
3264
|
+
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
1781
3265
|
break;
|
|
1782
3266
|
}
|
|
1783
3267
|
const code = pgCode(error);
|
|
@@ -1790,9 +3274,17 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1790
3274
|
// The savepoint rolled every delete/insert back, so the previous materialized
|
|
1791
3275
|
// coverage remains truthful and callers will fail closed beyond it.
|
|
1792
3276
|
conflicts += 1;
|
|
3277
|
+
conflictEventIds.push(candidate.id);
|
|
3278
|
+
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
1793
3279
|
break;
|
|
1794
3280
|
}
|
|
1795
|
-
|
|
3281
|
+
// Anything else this one series meets (a row no Slotlock would write, a constraint it
|
|
3282
|
+
// trips) was rolled back with its savepoint too: it is put back like one over the
|
|
3283
|
+
// budget, so no single row stops the tenant's roll.
|
|
3284
|
+
refused += 1;
|
|
3285
|
+
refusedEventIds.push(candidate.id);
|
|
3286
|
+
await deferRoll(sql, params.tenantRef, candidate.id);
|
|
3287
|
+
break;
|
|
1796
3288
|
}
|
|
1797
3289
|
}
|
|
1798
3290
|
}
|
|
@@ -1802,14 +3294,24 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1802
3294
|
examined: due.length,
|
|
1803
3295
|
extended,
|
|
1804
3296
|
conflicts,
|
|
3297
|
+
conflictEventIds,
|
|
1805
3298
|
refused,
|
|
1806
3299
|
refusedEventIds,
|
|
1807
3300
|
hasMore,
|
|
1808
|
-
next: hasMore && last
|
|
3301
|
+
next: hasMore && last
|
|
3302
|
+
? {
|
|
3303
|
+
rollRetryAt: last.roll_retry_at,
|
|
3304
|
+
materializedEndsAt: last.materialized_ends_at,
|
|
3305
|
+
eventId: last.id,
|
|
3306
|
+
}
|
|
3307
|
+
: null,
|
|
1809
3308
|
};
|
|
1810
3309
|
},
|
|
1811
3310
|
async cancelCalendarEvent(params) {
|
|
1812
3311
|
const ownerRef = params.ownerRef ?? SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
3312
|
+
if (params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId)) {
|
|
3313
|
+
return { ok: false, code: 'invalid_identity' };
|
|
3314
|
+
}
|
|
1813
3315
|
if (!isValidIdentity(params.tenantRef) ||
|
|
1814
3316
|
!isValidEventOwnerRef(ownerRef) ||
|
|
1815
3317
|
!isValidIdentity(params.externalRef) ||
|
|
@@ -1874,12 +3376,16 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1874
3376
|
};
|
|
1875
3377
|
}
|
|
1876
3378
|
const events = await tx `
|
|
1877
|
-
SELECT id, revision FROM slotlock.calendar_events
|
|
3379
|
+
SELECT id, resource_id, revision FROM slotlock.calendar_events
|
|
1878
3380
|
WHERE tenant_ref = ${params.tenantRef}
|
|
1879
3381
|
AND owner_ref = ${ownerRef}
|
|
1880
3382
|
AND external_ref = ${params.externalRef}
|
|
1881
3383
|
FOR UPDATE`;
|
|
1882
3384
|
const found = events[0];
|
|
3385
|
+
if (params.expectedResourceId !== undefined &&
|
|
3386
|
+
found?.resource_id !== params.expectedResourceId) {
|
|
3387
|
+
return { ok: false, code: 'event_not_found' };
|
|
3388
|
+
}
|
|
1883
3389
|
const currentRevision = found ? Number(found.revision) : 0;
|
|
1884
3390
|
if (!found && ownerRef !== SLOTLOCK_INTERNAL_EVENT_OWNER_REF) {
|
|
1885
3391
|
return { ok: false, code: 'event_not_found' };
|
|
@@ -1887,41 +3393,183 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
1887
3393
|
if (params.expectedRevision !== currentRevision) {
|
|
1888
3394
|
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
1889
3395
|
}
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
3396
|
+
return finishCalendarCancel(tx, {
|
|
3397
|
+
tenantRef: params.tenantRef,
|
|
3398
|
+
ownerRef,
|
|
3399
|
+
externalRef: params.externalRef,
|
|
3400
|
+
eventId: found?.id ?? null,
|
|
3401
|
+
revision: currentRevision + 1,
|
|
3402
|
+
commandOwnerRef: ownerRef,
|
|
3403
|
+
idempotencyKey: params.idempotencyKey,
|
|
3404
|
+
payloadHash,
|
|
3405
|
+
});
|
|
3406
|
+
});
|
|
3407
|
+
},
|
|
3408
|
+
async cancelCalendarEventById(params) {
|
|
3409
|
+
if (!isValidIdentity(params.tenantRef) ||
|
|
3410
|
+
!isValidResourceId(params.eventId) ||
|
|
3411
|
+
!isValidIdentity(params.idempotencyKey) ||
|
|
3412
|
+
(params.expectedResourceId !== undefined && !isValidResourceId(params.expectedResourceId))) {
|
|
3413
|
+
return { ok: false, code: 'invalid_identity' };
|
|
3414
|
+
}
|
|
3415
|
+
if (!Number.isSafeInteger(params.expectedRevision) || params.expectedRevision < 0) {
|
|
3416
|
+
return { ok: false, code: 'invalid_revision' };
|
|
3417
|
+
}
|
|
3418
|
+
const commandOwnerRef = SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
|
|
3419
|
+
// Its own operation in the hash: an owner-scoped cancel under the same key never replays it.
|
|
3420
|
+
const payloadHash = calendarCommandHash({
|
|
3421
|
+
operation: 'cancel-by-id',
|
|
3422
|
+
eventId: params.eventId,
|
|
3423
|
+
idempotencyKey: params.idempotencyKey,
|
|
3424
|
+
expectedRevision: params.expectedRevision,
|
|
3425
|
+
expectedResourceId: params.expectedResourceId ?? null,
|
|
3426
|
+
});
|
|
3427
|
+
return inTransaction(sql, async (tx) => {
|
|
3428
|
+
await lockCalendarIdempotency(tx, params.tenantRef, commandOwnerRef, params.idempotencyKey);
|
|
3429
|
+
const commands = await tx `
|
|
3430
|
+
SELECT operation, payload_hash, event_id, external_ref,
|
|
3431
|
+
result_revision, occurrence_count
|
|
3432
|
+
FROM slotlock.calendar_event_commands
|
|
3433
|
+
WHERE tenant_ref = ${params.tenantRef}
|
|
3434
|
+
AND owner_ref = ${commandOwnerRef}
|
|
3435
|
+
AND idempotency_key = ${params.idempotencyKey}
|
|
3436
|
+
FOR UPDATE`;
|
|
3437
|
+
const priorCommand = commands[0];
|
|
3438
|
+
if (priorCommand) {
|
|
3439
|
+
if (priorCommand.operation !== 'cancel' || priorCommand.payload_hash !== payloadHash) {
|
|
3440
|
+
return { ok: false, code: 'idempotency_conflict' };
|
|
3441
|
+
}
|
|
3442
|
+
return {
|
|
3443
|
+
ok: true,
|
|
3444
|
+
eventId: priorCommand.event_id,
|
|
3445
|
+
externalRef: priorCommand.external_ref,
|
|
3446
|
+
revision: Number(priorCommand.result_revision),
|
|
3447
|
+
idempotent: true,
|
|
3448
|
+
};
|
|
3449
|
+
}
|
|
3450
|
+
// An event's owner and reference never change, so they are read before its identity lock,
|
|
3451
|
+
// which is taken before its row lock as every event write takes them.
|
|
3452
|
+
const [identity] = await tx `
|
|
3453
|
+
SELECT owner_ref, external_ref FROM slotlock.calendar_events
|
|
3454
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}`;
|
|
3455
|
+
if (!identity)
|
|
3456
|
+
return { ok: false, code: 'event_not_found' };
|
|
3457
|
+
await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
|
|
3458
|
+
const [found] = await tx `
|
|
3459
|
+
SELECT id, resource_id, revision FROM slotlock.calendar_events
|
|
3460
|
+
WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
|
|
3461
|
+
FOR UPDATE`;
|
|
3462
|
+
// Cancelled while this waited on its lock.
|
|
3463
|
+
if (!found)
|
|
3464
|
+
return { ok: false, code: 'event_not_found' };
|
|
3465
|
+
if (params.expectedResourceId !== undefined &&
|
|
3466
|
+
found.resource_id !== params.expectedResourceId) {
|
|
3467
|
+
return { ok: false, code: 'event_not_found' };
|
|
3468
|
+
}
|
|
3469
|
+
const currentRevision = Number(found.revision);
|
|
3470
|
+
if (params.expectedRevision !== currentRevision) {
|
|
3471
|
+
return { ok: false, code: 'revision_conflict', currentRevision };
|
|
3472
|
+
}
|
|
3473
|
+
return finishCalendarCancel(tx, {
|
|
3474
|
+
tenantRef: params.tenantRef,
|
|
3475
|
+
ownerRef: identity.owner_ref,
|
|
3476
|
+
externalRef: identity.external_ref,
|
|
3477
|
+
eventId: found.id,
|
|
3478
|
+
revision: currentRevision + 1,
|
|
3479
|
+
commandOwnerRef,
|
|
3480
|
+
idempotencyKey: params.idempotencyKey,
|
|
3481
|
+
payloadHash,
|
|
3482
|
+
});
|
|
3483
|
+
});
|
|
3484
|
+
},
|
|
3485
|
+
async pruneBookingRetention(params) {
|
|
3486
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3487
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3488
|
+
}
|
|
3489
|
+
const limit = params.limit ?? 100;
|
|
3490
|
+
if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_CALENDAR_LIST_LIMIT) {
|
|
3491
|
+
throw Object.assign(new Error('Slotlock retention batch limit must be 1-1000'), {
|
|
3492
|
+
code: 'invalid_limit',
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
const retentionDays = params.retentionDays ?? SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS;
|
|
3496
|
+
if (!Number.isSafeInteger(retentionDays) ||
|
|
3497
|
+
retentionDays < 1 ||
|
|
3498
|
+
retentionDays > MAX_EVENT_COMMAND_RETENTION_DAYS) {
|
|
3499
|
+
throw Object.assign(new Error('Slotlock retention window must be 1-3650 days'), {
|
|
3500
|
+
code: 'invalid_retention_window',
|
|
3501
|
+
});
|
|
3502
|
+
}
|
|
3503
|
+
return inTransaction(sql, async (tx) => {
|
|
3504
|
+
// A 0.2 server still serving after the migration wrote its confirmed holds (and
|
|
3505
|
+
// createReservation bookings) on the tenant's resources without the tenant or a reference,
|
|
3506
|
+
// so no cancel or move reaches them; the migration gives those it finds the tenant and
|
|
3507
|
+
// their own id, and this gives later ones the same. Each takes its reference's identity lock
|
|
3508
|
+
// first, as every booking write does; a tombstone of its id retired nothing and goes.
|
|
3509
|
+
const legacy = await tx `
|
|
3510
|
+
SELECT booking.id
|
|
3511
|
+
FROM slotlock.reservations booking
|
|
3512
|
+
JOIN slotlock.resources resource ON resource.id = booking.resource_id
|
|
3513
|
+
WHERE resource.tenant_ref = ${params.tenantRef}
|
|
3514
|
+
AND booking.tenant_ref IS NULL AND booking.status = 'confirmed'
|
|
3515
|
+
AND booking.calendar_event_id IS NULL
|
|
3516
|
+
AND NOT EXISTS (
|
|
3517
|
+
SELECT 1 FROM slotlock.reservations taken
|
|
3518
|
+
WHERE taken.tenant_ref = ${params.tenantRef}
|
|
3519
|
+
AND taken.external_ref = booking.id::text
|
|
3520
|
+
AND taken.calendar_event_id IS NULL
|
|
3521
|
+
)
|
|
3522
|
+
ORDER BY booking.id
|
|
3523
|
+
LIMIT ${limit}`;
|
|
3524
|
+
for (const { id } of legacy) {
|
|
3525
|
+
await lockExternalIdentity(tx, params.tenantRef, id);
|
|
3526
|
+
const [taken] = await tx `
|
|
3527
|
+
SELECT id FROM slotlock.reservations
|
|
3528
|
+
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}
|
|
3529
|
+
AND calendar_event_id IS NULL`;
|
|
3530
|
+
if (taken)
|
|
3531
|
+
continue;
|
|
1893
3532
|
await tx `
|
|
1894
|
-
DELETE FROM slotlock.
|
|
1895
|
-
WHERE
|
|
1896
|
-
SELECT reservation_id FROM slotlock.calendar_event_occurrences
|
|
1897
|
-
WHERE event_id = ${eventId} AND reservation_id IS NOT NULL
|
|
1898
|
-
)`;
|
|
3533
|
+
DELETE FROM slotlock.reservation_tombstones
|
|
3534
|
+
WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${id}`;
|
|
1899
3535
|
await tx `
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
3536
|
+
UPDATE slotlock.reservations
|
|
3537
|
+
SET tenant_ref = ${params.tenantRef}, external_ref = id::text,
|
|
3538
|
+
occupied_ends_at = ends_at + buffer_after_ms * interval '1 millisecond'
|
|
3539
|
+
WHERE id = ${id} AND tenant_ref IS NULL AND status = 'confirmed'`;
|
|
1904
3540
|
}
|
|
1905
|
-
await tx
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
3541
|
+
const expiredHoldsDeleted = await deleteExpiredHolds(tx, params.tenantRef, limit);
|
|
3542
|
+
const holdCommandsDeleted = await forgetExpiredHoldAnswers(tx, params.tenantRef, limit);
|
|
3543
|
+
// Only references retired with no booking: a cancelled booking's tombstone is kept.
|
|
3544
|
+
const [tombstones] = await tx `
|
|
3545
|
+
WITH candidates AS MATERIALIZED (
|
|
3546
|
+
SELECT tombstone.external_ref
|
|
3547
|
+
FROM slotlock.reservation_tombstones tombstone
|
|
3548
|
+
WHERE tombstone.tenant_ref = ${params.tenantRef}
|
|
3549
|
+
AND tombstone.stray
|
|
3550
|
+
AND tombstone.cancelled_at <
|
|
3551
|
+
clock_timestamp() - (${retentionDays}::int * interval '1 day')
|
|
3552
|
+
ORDER BY tombstone.cancelled_at, tombstone.external_ref
|
|
3553
|
+
LIMIT ${limit}
|
|
3554
|
+
FOR UPDATE OF tombstone SKIP LOCKED
|
|
3555
|
+
), deleted AS (
|
|
3556
|
+
DELETE FROM slotlock.reservation_tombstones tombstone
|
|
3557
|
+
USING candidates candidate
|
|
3558
|
+
WHERE tombstone.tenant_ref = ${params.tenantRef}
|
|
3559
|
+
AND tombstone.external_ref = candidate.external_ref
|
|
3560
|
+
AND tombstone.stray
|
|
3561
|
+
RETURNING 1
|
|
3562
|
+
)
|
|
3563
|
+
SELECT count(*)::int AS deleted FROM deleted`;
|
|
3564
|
+
const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
|
|
1919
3565
|
return {
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
3566
|
+
expiredHoldsDeleted,
|
|
3567
|
+
holdCommandsDeleted,
|
|
3568
|
+
tombstonesDeleted,
|
|
3569
|
+
hasMore: legacy.length === limit ||
|
|
3570
|
+
expiredHoldsDeleted === limit ||
|
|
3571
|
+
holdCommandsDeleted === limit ||
|
|
3572
|
+
tombstonesDeleted === limit,
|
|
1925
3573
|
};
|
|
1926
3574
|
});
|
|
1927
3575
|
},
|
|
@@ -2040,7 +3688,8 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2040
3688
|
}
|
|
2041
3689
|
return inTransaction(sql, async (tx) => {
|
|
2042
3690
|
await lockExternalIdentity(tx, params.tenantRef, `calendar-coverage:${params.resourceId}:${params.source}`);
|
|
2043
|
-
|
|
3691
|
+
// Coverage of an archived resource may still be recorded: it books nothing.
|
|
3692
|
+
if ((await tenantResourceState(tx, params.resourceId, params.tenantRef)) === 'missing') {
|
|
2044
3693
|
return { ok: false, code: 'resource_tenant_mismatch' };
|
|
2045
3694
|
}
|
|
2046
3695
|
const rows = await tx `
|
|
@@ -2075,6 +3724,9 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2075
3724
|
else {
|
|
2076
3725
|
if (params.revision !== 1)
|
|
2077
3726
|
return { ok: false, code: 'revision_conflict' };
|
|
3727
|
+
const limit = await tenantLimitReached(tx, params.tenantRef, 'coverage');
|
|
3728
|
+
if (limit !== null)
|
|
3729
|
+
return { ok: false, code: 'coverage_limit_reached', limit };
|
|
2078
3730
|
await tx `
|
|
2079
3731
|
INSERT INTO slotlock.calendar_coverage (
|
|
2080
3732
|
tenant_ref, resource_id, source, starts_at, ends_at, revision
|
|
@@ -2157,19 +3809,16 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2157
3809
|
AND event.transparency = 'opaque'
|
|
2158
3810
|
)
|
|
2159
3811
|
SELECT count(*)::int AS source_count,
|
|
3812
|
+
-- Complete when what is stored runs from where the series can first occur in the
|
|
3813
|
+
-- window (its start, if that is later) to the window's end: a series stored only
|
|
3814
|
+
-- from after its start leaves the occurrences before that unknown.
|
|
2160
3815
|
bool_and(
|
|
2161
3816
|
materialized_ends_at >= ${window.end}
|
|
2162
|
-
AND (
|
|
2163
|
-
earliest_possible_at >= ${window.start}
|
|
2164
|
-
OR materialized_starts_at <= ${window.start}
|
|
2165
|
-
)
|
|
3817
|
+
AND materialized_starts_at <= GREATEST(earliest_possible_at, ${window.start})
|
|
2166
3818
|
) AS complete,
|
|
2167
3819
|
bool_or(
|
|
2168
3820
|
materialized_ends_at > ${window.start}
|
|
2169
|
-
AND
|
|
2170
|
-
earliest_possible_at >= ${window.start}
|
|
2171
|
-
OR materialized_starts_at < ${window.end}
|
|
2172
|
-
)
|
|
3821
|
+
AND materialized_starts_at < ${window.end}
|
|
2173
3822
|
) AS overlaps
|
|
2174
3823
|
FROM recurring_sources
|
|
2175
3824
|
WHERE earliest_possible_at < ${window.end}`;
|
|
@@ -2220,16 +3869,148 @@ export function createSlotlockStore(sql, options = {}) {
|
|
|
2220
3869
|
};
|
|
2221
3870
|
},
|
|
2222
3871
|
async findNextAvailableFor(params) {
|
|
2223
|
-
|
|
3872
|
+
const window = params.searchWindow;
|
|
3873
|
+
if (!isFiniteWindow(window))
|
|
2224
3874
|
throw invalidWindowError();
|
|
3875
|
+
assertBoundedSearchWindow(window);
|
|
3876
|
+
const { tenantRef } = params;
|
|
3877
|
+
if (tenantRef !== null && !isValidIdentity(tenantRef)) {
|
|
3878
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3879
|
+
}
|
|
3880
|
+
const bufferAfterMs = params.bufferAfterMs ?? 0;
|
|
3881
|
+
if (!Number.isSafeInteger(bufferAfterMs) ||
|
|
3882
|
+
bufferAfterMs < 0 ||
|
|
3883
|
+
bufferAfterMs > MAX_BUFFER_AFTER_MS) {
|
|
3884
|
+
throw Object.assign(new Error('Slotlock bufferAfterMs must be a whole 0-30 days'), {
|
|
3885
|
+
code: 'invalid_buffer',
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
const notFound = () => Object.assign(new Error('Slotlock resource not found in this tenant'), {
|
|
3889
|
+
code: 'resource_not_found',
|
|
3890
|
+
});
|
|
3891
|
+
if (!isValidResourceId(params.resourceId))
|
|
3892
|
+
throw notFound();
|
|
3893
|
+
const [row] = await sql `
|
|
3894
|
+
SELECT id, external_ref, tenant_ref, timezone, availability_rules, visibility, archived_at
|
|
3895
|
+
FROM slotlock.resources
|
|
3896
|
+
WHERE id = ${params.resourceId} AND tenant_ref IS NOT DISTINCT FROM ${tenantRef}`;
|
|
3897
|
+
if (!row)
|
|
3898
|
+
throw notFound();
|
|
2225
3899
|
if (!Number.isFinite(params.durationMs) || params.durationMs <= 0)
|
|
2226
3900
|
return null;
|
|
2227
|
-
|
|
2228
|
-
const
|
|
2229
|
-
|
|
2230
|
-
const
|
|
2231
|
-
|
|
2232
|
-
|
|
3901
|
+
const resource = toResource(row);
|
|
3902
|
+
const rules = params.rules ?? resource.availabilityRules ?? [];
|
|
3903
|
+
// A slot's buffer may run past the window: the time it can reach must be proven free too.
|
|
3904
|
+
const reach = { start: window.start, end: new Date(window.end.getTime() + bufferAfterMs) };
|
|
3905
|
+
let busy;
|
|
3906
|
+
if (tenantRef === null) {
|
|
3907
|
+
// No event, coverage or series can belong to a resource with no tenant: only its rows.
|
|
3908
|
+
busy = await this.listBusy(params.resourceId, reach);
|
|
3909
|
+
}
|
|
3910
|
+
else {
|
|
3911
|
+
busy = [];
|
|
3912
|
+
const parts = bufferAfterMs > 0 ? [window, { start: window.end, end: reach.end }] : [window];
|
|
3913
|
+
for (const part of parts) {
|
|
3914
|
+
const freeBusy = await this.getFreeBusy({
|
|
3915
|
+
tenantRef,
|
|
3916
|
+
resourceId: params.resourceId,
|
|
3917
|
+
window: part,
|
|
3918
|
+
...(params.requiredSources !== undefined
|
|
3919
|
+
? { requiredSources: params.requiredSources }
|
|
3920
|
+
: {}),
|
|
3921
|
+
...(params.coverageMaxAgeMs !== undefined
|
|
3922
|
+
? { coverageMaxAgeMs: params.coverageMaxAgeMs }
|
|
3923
|
+
: {}),
|
|
3924
|
+
});
|
|
3925
|
+
// Absence is never treated as free time: what cannot be proven gives no slot.
|
|
3926
|
+
if (freeBusy.coverage.state !== 'complete')
|
|
3927
|
+
return null;
|
|
3928
|
+
busy.push(...freeBusy.busy);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
// A slot [s, s + d) with its buffer meets busy [b, e) exactly when [s, s + d) meets [b - buffer, e).
|
|
3932
|
+
return findNextAvailable({
|
|
3933
|
+
busy: busy.map((interval) => ({
|
|
3934
|
+
start: new Date(interval.start.getTime() - bufferAfterMs),
|
|
3935
|
+
end: interval.end,
|
|
3936
|
+
})),
|
|
3937
|
+
windows: expandRules(rules, window, resource.timezone),
|
|
3938
|
+
durationMs: params.durationMs,
|
|
3939
|
+
});
|
|
3940
|
+
},
|
|
3941
|
+
async suspendTenant(params) {
|
|
3942
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3943
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3944
|
+
}
|
|
3945
|
+
const reason = params.reason;
|
|
3946
|
+
if (reason !== undefined &&
|
|
3947
|
+
(typeof reason !== 'string' ||
|
|
3948
|
+
reason.length === 0 ||
|
|
3949
|
+
Array.from(reason).length > 500 ||
|
|
3950
|
+
Array.from(reason).some((character) => {
|
|
3951
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
3952
|
+
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
|
3953
|
+
}))) {
|
|
3954
|
+
throw Object.assign(new Error('Slotlock suspension reason must be 1-500 characters without control characters'), { code: 'invalid_suspension_reason' });
|
|
3955
|
+
}
|
|
3956
|
+
try {
|
|
3957
|
+
const [row] = await sql `
|
|
3958
|
+
SELECT slotlock.suspend_tenant(${params.tenantRef}::text, ${reason ?? null}::text) AS suspended`;
|
|
3959
|
+
return { suspended: row?.suspended === true };
|
|
3960
|
+
}
|
|
3961
|
+
catch (error) {
|
|
3962
|
+
throw suspensionRefusal(error);
|
|
3963
|
+
}
|
|
3964
|
+
},
|
|
3965
|
+
async unsuspendTenant(params) {
|
|
3966
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3967
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3968
|
+
}
|
|
3969
|
+
try {
|
|
3970
|
+
const [row] = await sql `
|
|
3971
|
+
SELECT slotlock.unsuspend_tenant(${params.tenantRef}::text) AS unsuspended`;
|
|
3972
|
+
return { unsuspended: row?.unsuspended === true };
|
|
3973
|
+
}
|
|
3974
|
+
catch (error) {
|
|
3975
|
+
throw suspensionRefusal(error);
|
|
3976
|
+
}
|
|
3977
|
+
},
|
|
3978
|
+
async isTenantSuspended(params) {
|
|
3979
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3980
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3981
|
+
}
|
|
3982
|
+
const [row] = await sql `
|
|
3983
|
+
SELECT slotlock.tenant_suspended(${params.tenantRef}::text) AS suspended`.catch((error) => {
|
|
3984
|
+
throw suspensionCheckRefusal(error);
|
|
3985
|
+
});
|
|
3986
|
+
return row?.suspended === true;
|
|
3987
|
+
},
|
|
3988
|
+
async eraseTenant(params) {
|
|
3989
|
+
if (!isValidIdentity(params.tenantRef)) {
|
|
3990
|
+
throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
|
|
3991
|
+
}
|
|
3992
|
+
const erased = await sql `
|
|
3993
|
+
SELECT erased_table, erased_rows FROM slotlock.erase_tenant(${params.tenantRef}::text)`;
|
|
3994
|
+
const rows = {};
|
|
3995
|
+
let total = 0;
|
|
3996
|
+
for (const row of erased) {
|
|
3997
|
+
const count = Number(row.erased_rows);
|
|
3998
|
+
rows[row.erased_table] = count;
|
|
3999
|
+
total += count;
|
|
4000
|
+
}
|
|
4001
|
+
return { rows, total };
|
|
2233
4002
|
},
|
|
2234
4003
|
};
|
|
4004
|
+
if (refusal === null)
|
|
4005
|
+
return store;
|
|
4006
|
+
const refuse = async () => {
|
|
4007
|
+
throw refusal();
|
|
4008
|
+
};
|
|
4009
|
+
return Object.assign(store, Object.fromEntries(SLOTLOCK_TENANT_WRITE_METHODS.map((name) => [name, refuse])));
|
|
4010
|
+
}
|
|
4011
|
+
/** The deployment role alone may suspend: another role is refused EXECUTE on the functions. */
|
|
4012
|
+
function suspensionRefusal(error) {
|
|
4013
|
+
if (pgCode(error) !== '42501')
|
|
4014
|
+
return error;
|
|
4015
|
+
return Object.assign(new Error('Slotlock suspends tenants and lifts suspensions only as the deployment role'), { code: 'tenant_suspension_not_permitted', cause: error });
|
|
2235
4016
|
}
|