slotlock 0.2.0 → 0.5.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 +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -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 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/agent-store-backend.js
CHANGED
|
@@ -1,22 +1,93 @@
|
|
|
1
|
+
import { slotlockCloudEvent } from './webhooks.js';
|
|
1
2
|
import { createHash } from 'node:crypto';
|
|
2
3
|
import { SlotlockAgentOperationError, } from './agent-server.js';
|
|
3
|
-
import {
|
|
4
|
+
import { bookingSearch } from './booking-rules.js';
|
|
5
|
+
import { findNextAvailable, mergeIntervals } from './engine.js';
|
|
4
6
|
import { expandRules } from './rules.js';
|
|
5
|
-
import {
|
|
7
|
+
import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
|
|
8
|
+
import { calendarEventRollingHorizon, } from './store.js';
|
|
6
9
|
import { maintainSlotlockTenant } from './maintenance.js';
|
|
7
10
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
11
|
+
/**
|
|
12
|
+
* A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
|
|
13
|
+
* `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
|
|
14
|
+
* and refuses it otherwise.
|
|
15
|
+
*/
|
|
16
|
+
const WRITE = { isolation: 'read committed' };
|
|
17
|
+
/** Who a call's changes are recorded as in the history: the principal's subject, kept short. */
|
|
18
|
+
function actorOf(context) {
|
|
19
|
+
const subject = context.principal.subject;
|
|
20
|
+
if (typeof subject !== 'string' || subject.length === 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
const clean = subject.replace(/[\u0000-\u001f\u007f]/g, '');
|
|
23
|
+
return clean.length === 0 ? undefined : clean.slice(0, 200);
|
|
24
|
+
}
|
|
8
25
|
function assertActive(context) {
|
|
9
26
|
if (context.signal.aborted)
|
|
10
27
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
11
28
|
}
|
|
12
29
|
async function withTenant(store, context, callback, options) {
|
|
13
30
|
assertActive(context);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
const actor = actorOf(context);
|
|
34
|
+
value = await store.withTenant(context.principal.tenantRef, callback, {
|
|
35
|
+
...options,
|
|
36
|
+
signal: context.signal,
|
|
37
|
+
...(actor !== undefined ? { actor } : {}),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const code = error?.code;
|
|
42
|
+
// A suspended tenant may read; a write it asks for is forbidden, not a server failure.
|
|
43
|
+
if (code === 'tenant_suspended')
|
|
44
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
45
|
+
// Waited past the store's lock timeout behind the tenant's own writes: retry later.
|
|
46
|
+
if (code === 'lock_timeout')
|
|
47
|
+
throw new SlotlockAgentOperationError('server_busy', 503);
|
|
48
|
+
assertActive(context);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
17
51
|
assertActive(context);
|
|
18
52
|
return value;
|
|
19
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Busy intervals one free/busy read or search may gather across all the resources it names: as many
|
|
56
|
+
* as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
|
|
57
|
+
* one resource 100 times, did 100 times the work of one.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_REQUEST_BUSY_INTERVALS = 10_000;
|
|
60
|
+
/**
|
|
61
|
+
* Bookable windows one search may expand from its resources' availability rules in all: five
|
|
62
|
+
* resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
|
|
63
|
+
* resources over a year expanded 1.8 million windows and held the process for seven seconds.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_REQUEST_RULE_WINDOWS = 100_000;
|
|
66
|
+
/** The resources a request names, each once, in the order first named. */
|
|
67
|
+
function distinctResourceIds(input) {
|
|
68
|
+
return [...new Set(input.resource_ids)];
|
|
69
|
+
}
|
|
70
|
+
/** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
|
|
71
|
+
function busyBudget() {
|
|
72
|
+
let left = MAX_REQUEST_BUSY_INTERVALS;
|
|
73
|
+
return (intervals) => {
|
|
74
|
+
left -= intervals;
|
|
75
|
+
if (left < 0)
|
|
76
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** The store's free/busy read, its refusal to read past its limit answered as the caller's to narrow. */
|
|
80
|
+
async function readFreeBusy(tenantStore, params) {
|
|
81
|
+
try {
|
|
82
|
+
return await tenantStore.getFreeBusy(params);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (error?.code === 'calendar_work_limit') {
|
|
86
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
20
91
|
function interval(input) {
|
|
21
92
|
return { start: new Date(input.start), end: new Date(input.end) };
|
|
22
93
|
}
|
|
@@ -78,6 +149,12 @@ function agentPrincipalDigest(context, idempotencyKey) {
|
|
|
78
149
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
|
|
79
150
|
.digest('hex');
|
|
80
151
|
}
|
|
152
|
+
/** A version-4-shaped UUID from a digest: a stable id for a value that names one thing. */
|
|
153
|
+
function uuidFromDigest(value) {
|
|
154
|
+
const hex = createHash('sha256').update(value).digest('hex');
|
|
155
|
+
const variant = ((Number.parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
|
|
156
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
157
|
+
}
|
|
81
158
|
function agentOwnerRef(context) {
|
|
82
159
|
return `agent:${createHash('sha256')
|
|
83
160
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
@@ -139,16 +216,38 @@ export function projectSlotlockCalendarEvent(event) {
|
|
|
139
216
|
recurrence_id: null,
|
|
140
217
|
};
|
|
141
218
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
219
|
+
/**
|
|
220
|
+
* The HTTP status a store refusal answers with on every agent surface: a missing or foreign
|
|
221
|
+
* record 404, a malformed request 400, a full agent quota 429, and any other conflict with what is
|
|
222
|
+
* stored 409. rest-api.ts declares the 404 and 409 each route can answer from this.
|
|
223
|
+
*/
|
|
224
|
+
export function slotlockAgentErrorStatus(code) {
|
|
225
|
+
if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found'))
|
|
226
|
+
return 404;
|
|
146
227
|
if (code.startsWith('invalid_'))
|
|
147
|
-
return
|
|
148
|
-
if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
|
|
149
|
-
return
|
|
150
|
-
|
|
151
|
-
|
|
228
|
+
return 400;
|
|
229
|
+
if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
|
|
230
|
+
return 429;
|
|
231
|
+
return 409;
|
|
232
|
+
}
|
|
233
|
+
/** The code an agent's error carries for each booking rule a write breaks. */
|
|
234
|
+
const BOOKING_RULE_CODES = Object.freeze({
|
|
235
|
+
min_notice: 'booking_rule_min_notice',
|
|
236
|
+
max_advance: 'booking_rule_max_advance',
|
|
237
|
+
slot_step: 'booking_rule_slot_step',
|
|
238
|
+
outside_hours: 'booking_rule_outside_hours',
|
|
239
|
+
uncertain: 'booking_rule_uncertain',
|
|
240
|
+
});
|
|
241
|
+
function operationError(refusal) {
|
|
242
|
+
// A broken booking rule is named in the code, which is all an agent's error carries.
|
|
243
|
+
const code = typeof refusal === 'string'
|
|
244
|
+
? refusal
|
|
245
|
+
: refusal.code === 'booking_rule_violated' && refusal.rule
|
|
246
|
+
? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
|
|
247
|
+
: refusal.code;
|
|
248
|
+
const status = slotlockAgentErrorStatus(code);
|
|
249
|
+
// A gone hold is named, so the agent holds again; it covers expired and another holder's alike.
|
|
250
|
+
return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status);
|
|
152
251
|
}
|
|
153
252
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
154
253
|
const STORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
@@ -181,28 +280,315 @@ function parseEventCursor(value) {
|
|
|
181
280
|
throw new SlotlockAgentOperationError('invalid_cursor', 400);
|
|
182
281
|
}
|
|
183
282
|
}
|
|
184
|
-
|
|
185
|
-
|
|
283
|
+
/** The cursor after one row of a page ordered by start then id: an event, a hold or a booking. */
|
|
284
|
+
function encodeEventCursor(row) {
|
|
285
|
+
return Buffer.from(JSON.stringify({ start: row.start.toISOString(), id: row.id }), 'utf8').toString('base64url');
|
|
286
|
+
}
|
|
287
|
+
/** One page of rows read with one more than `limit`, and the cursor to the next when there is one. */
|
|
288
|
+
function page(rows, limit) {
|
|
289
|
+
const shown = rows.slice(0, limit);
|
|
290
|
+
const last = shown.at(-1);
|
|
291
|
+
return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
|
|
186
292
|
}
|
|
187
293
|
/**
|
|
188
294
|
* The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
|
|
189
295
|
* send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
|
|
190
296
|
* issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
|
|
191
297
|
* invalid identity, so such an id names no resource: it is not found, like a well-formed id that
|
|
192
|
-
* names nothing or another tenant's resource.
|
|
298
|
+
* names nothing or another tenant's resource. With `visibility`, a resource of the other is not
|
|
299
|
+
* found either: agents ask for `shared`, so a private resource is invisible to them.
|
|
193
300
|
*/
|
|
194
|
-
async function requireResource(tenantStore, tenantRef, resourceId) {
|
|
301
|
+
async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
|
|
195
302
|
if (!STORE_ID_PATTERN.test(resourceId)) {
|
|
196
303
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
197
304
|
}
|
|
198
|
-
const resource = await tenantStore.getResource({
|
|
305
|
+
const resource = await tenantStore.getResource({
|
|
306
|
+
tenantRef,
|
|
307
|
+
id: resourceId,
|
|
308
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
309
|
+
});
|
|
199
310
|
if (!resource)
|
|
200
311
|
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
201
312
|
return resource;
|
|
202
313
|
}
|
|
314
|
+
/** The caller's shared resource with this id: what an agent may see and book. */
|
|
315
|
+
function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
316
|
+
return requireResource(tenantStore, tenantRef, resourceId, 'shared');
|
|
317
|
+
}
|
|
203
318
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
204
319
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
205
320
|
return {
|
|
321
|
+
async holdSlots(context, input) {
|
|
322
|
+
const slots = input.slots;
|
|
323
|
+
const key = input.idempotency_key;
|
|
324
|
+
const holderRef = agentOwnerRef(context);
|
|
325
|
+
// One group per agent and key, so a retry answers with the same holds and group.
|
|
326
|
+
const groupId = uuidFromDigest(`slotlock-hold-group\0${holderRef}\0${key}`);
|
|
327
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
328
|
+
const holds = [];
|
|
329
|
+
const refused = [];
|
|
330
|
+
for (const [index, slot] of slots.entries()) {
|
|
331
|
+
try {
|
|
332
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, slot.resource_id);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (!(error instanceof SlotlockAgentOperationError))
|
|
336
|
+
throw error;
|
|
337
|
+
refused.push({ index, code: error.code });
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const result = await tenantStore.acquireHold({
|
|
341
|
+
tenantRef: context.principal.tenantRef,
|
|
342
|
+
principalRef: holderRef,
|
|
343
|
+
holderRef,
|
|
344
|
+
idempotencyKey: agentCommandIdempotencyKey(context, `${key}\0${index}`),
|
|
345
|
+
groupId,
|
|
346
|
+
resourceId: slot.resource_id,
|
|
347
|
+
start: new Date(slot.start),
|
|
348
|
+
end: new Date(slot.end),
|
|
349
|
+
ttlMs: input.ttl_seconds * 1_000,
|
|
350
|
+
source: 'agent-server',
|
|
351
|
+
});
|
|
352
|
+
if (result.ok) {
|
|
353
|
+
holds.push({
|
|
354
|
+
hold_id: result.hold.id,
|
|
355
|
+
resource_id: result.hold.resourceId,
|
|
356
|
+
start: result.hold.start.toISOString(),
|
|
357
|
+
end: result.hold.end.toISOString(),
|
|
358
|
+
expires_at: result.hold.expiresAt.toISOString(),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
refused.push({ index, code: operationError(result).code });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { group_id: groupId, holds, refused };
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
async bookPool(context, rawInput) {
|
|
369
|
+
const input = rawInput;
|
|
370
|
+
const content = {
|
|
371
|
+
start: new Date(input.starts_at),
|
|
372
|
+
end: new Date(input.ends_at),
|
|
373
|
+
timezone: input.timezone,
|
|
374
|
+
summary: eventSummary(input.title),
|
|
375
|
+
status: 'confirmed',
|
|
376
|
+
transparency: 'opaque',
|
|
377
|
+
attendees: [],
|
|
378
|
+
reminders: [],
|
|
379
|
+
};
|
|
380
|
+
const tenantRef = context.principal.tenantRef;
|
|
381
|
+
const ownerRef = agentOwnerRef(context);
|
|
382
|
+
const externalRef = agentExternalRef(context, input.idempotency_key);
|
|
383
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
384
|
+
const put = (resourceId) => tenantStore.putCalendarEvent({
|
|
385
|
+
tenantRef,
|
|
386
|
+
ownerRef,
|
|
387
|
+
externalRef,
|
|
388
|
+
resourceId,
|
|
389
|
+
expectedRevision: 0,
|
|
390
|
+
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
391
|
+
source: 'agent-server',
|
|
392
|
+
...content,
|
|
393
|
+
materializationWindow: materializationWindow(content),
|
|
394
|
+
enforceBookingRules: true,
|
|
395
|
+
});
|
|
396
|
+
const answer = async (result) => {
|
|
397
|
+
const created = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: result.externalRef });
|
|
398
|
+
if (!created)
|
|
399
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
400
|
+
return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
|
|
401
|
+
};
|
|
402
|
+
// A retry answers the member the first call booked, whichever it was.
|
|
403
|
+
const earlier = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef });
|
|
404
|
+
if (earlier) {
|
|
405
|
+
const replay = await put(earlier.resourceId);
|
|
406
|
+
if (!replay.ok)
|
|
407
|
+
throw operationError(replay);
|
|
408
|
+
return answer(replay);
|
|
409
|
+
}
|
|
410
|
+
const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
|
|
411
|
+
if (members.length === 0)
|
|
412
|
+
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
413
|
+
// Each member in turn, each try its own savepoint: a member that is taken or that the
|
|
414
|
+
// booking's rules refuse is passed over; the exclusion constraint still decides each one.
|
|
415
|
+
for (const member of members) {
|
|
416
|
+
const result = await put(member.id);
|
|
417
|
+
if (result.ok)
|
|
418
|
+
return answer(result);
|
|
419
|
+
if (result.code !== 'overlap' && result.code !== 'booking_rule_violated') {
|
|
420
|
+
throw operationError(result);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
throw new SlotlockAgentOperationError('pool_full', 409);
|
|
424
|
+
}, WRITE);
|
|
425
|
+
},
|
|
426
|
+
async getPoolAvailability(context, input) {
|
|
427
|
+
const window = interval(input);
|
|
428
|
+
const pool = input.pool;
|
|
429
|
+
const charge = busyBudget();
|
|
430
|
+
let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
|
|
431
|
+
const now = new Date();
|
|
432
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
433
|
+
const tenantRef = context.principal.tenantRef;
|
|
434
|
+
const members = await tenantStore.listPoolMembers({ tenantRef, pool, visibility: 'shared' });
|
|
435
|
+
if (members.length === 0)
|
|
436
|
+
throw new SlotlockAgentOperationError('pool_not_found', 404);
|
|
437
|
+
// Each member's busy time, as edges: a count of busy members that rises and falls.
|
|
438
|
+
const edges = new Map([
|
|
439
|
+
[window.start.getTime(), 0],
|
|
440
|
+
[window.end.getTime(), 0],
|
|
441
|
+
]);
|
|
442
|
+
let complete = true;
|
|
443
|
+
for (const member of members) {
|
|
444
|
+
const sources = await requiredSources(options, context, member);
|
|
445
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
446
|
+
tenantRef,
|
|
447
|
+
resourceId: member.id,
|
|
448
|
+
window,
|
|
449
|
+
requiredSources: [...sources],
|
|
450
|
+
...(options.coverageMaxAgeMs !== undefined
|
|
451
|
+
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
452
|
+
: {}),
|
|
453
|
+
});
|
|
454
|
+
charge(freeBusy.busy.length);
|
|
455
|
+
if (freeBusy.coverage.state !== 'complete')
|
|
456
|
+
complete = false;
|
|
457
|
+
// Not free either where its booking rules refuse a booking: before its notice, past how
|
|
458
|
+
// far ahead it books, and, when it enforces them, outside its bookable hours. Its slot
|
|
459
|
+
// step is not counted: it moves when a booking may start, not whether the member is free.
|
|
460
|
+
const rules = member.bookingRules;
|
|
461
|
+
const search = bookingSearch({ rules, timezone: member.timezone, window, now });
|
|
462
|
+
const latestStart = rules?.maxAdvanceDays !== undefined
|
|
463
|
+
? now.getTime() + rules.maxAdvanceDays * 86_400_000
|
|
464
|
+
: window.end.getTime();
|
|
465
|
+
let open = search
|
|
466
|
+
? [{ start: search.window.start, end: new Date(Math.min(search.window.end.getTime(), latestStart)) }]
|
|
467
|
+
: [];
|
|
468
|
+
if (rules?.enforceHours === true && open.length > 0) {
|
|
469
|
+
const hours = member.availabilityRules ?? (await options.availabilityRules(context, member));
|
|
470
|
+
const windows = expandRules(hours, window, member.timezone);
|
|
471
|
+
windowsLeft -= windows.length;
|
|
472
|
+
if (windowsLeft < 0)
|
|
473
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
474
|
+
open = windows.flatMap((hoursWindow) => {
|
|
475
|
+
const start = Math.max(hoursWindow.start.getTime(), open[0].start.getTime());
|
|
476
|
+
const end = Math.min(hoursWindow.end.getTime(), open[0].end.getTime());
|
|
477
|
+
return end > start ? [{ start: new Date(start), end: new Date(end) }] : [];
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
const closed = [];
|
|
481
|
+
let from = window.start.getTime();
|
|
482
|
+
for (const part of mergeIntervals(open.filter((part) => part.end > part.start))) {
|
|
483
|
+
if (part.start.getTime() > from)
|
|
484
|
+
closed.push({ start: new Date(from), end: part.start });
|
|
485
|
+
from = Math.max(from, part.end.getTime());
|
|
486
|
+
}
|
|
487
|
+
if (from < window.end.getTime())
|
|
488
|
+
closed.push({ start: new Date(from), end: window.end });
|
|
489
|
+
for (const taken of mergeIntervals([...freeBusy.busy, ...closed])) {
|
|
490
|
+
edges.set(taken.start.getTime(), (edges.get(taken.start.getTime()) ?? 0) + 1);
|
|
491
|
+
edges.set(taken.end.getTime(), (edges.get(taken.end.getTime()) ?? 0) - 1);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const times = [...edges.keys()].sort((a, b) => a - b);
|
|
495
|
+
const intervals = [];
|
|
496
|
+
let busy = 0;
|
|
497
|
+
for (const [index, at] of times.entries()) {
|
|
498
|
+
busy += edges.get(at) ?? 0;
|
|
499
|
+
const next = times[index + 1];
|
|
500
|
+
if (next === undefined || at >= window.end.getTime())
|
|
501
|
+
break;
|
|
502
|
+
const free = members.length - busy;
|
|
503
|
+
const last = intervals.at(-1);
|
|
504
|
+
// Parts with the same count are one.
|
|
505
|
+
if (last && last.free === free)
|
|
506
|
+
last.end = new Date(next).toISOString();
|
|
507
|
+
else
|
|
508
|
+
intervals.push({ start: new Date(at).toISOString(), end: new Date(next).toISOString(), free });
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
pool,
|
|
512
|
+
members: members.length,
|
|
513
|
+
intervals,
|
|
514
|
+
coverage: coverageProjection(window, complete),
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
async bookTogether(context, rawInput) {
|
|
519
|
+
const input = rawInput;
|
|
520
|
+
// In one order for every bundle, so two over the same resources wait on each other in turn
|
|
521
|
+
// and never deadlock.
|
|
522
|
+
const resourceIds = [...new Set(input.resource_ids)].sort();
|
|
523
|
+
if (resourceIds.length < 2)
|
|
524
|
+
throw new SlotlockAgentOperationError('invalid_arguments', 400);
|
|
525
|
+
const content = {
|
|
526
|
+
start: new Date(input.starts_at),
|
|
527
|
+
end: new Date(input.ends_at),
|
|
528
|
+
timezone: input.timezone,
|
|
529
|
+
summary: eventSummary(input.title),
|
|
530
|
+
status: 'confirmed',
|
|
531
|
+
transparency: 'opaque',
|
|
532
|
+
attendees: [],
|
|
533
|
+
reminders: [],
|
|
534
|
+
};
|
|
535
|
+
return withTenant(store, context, async (tenantStore) => {
|
|
536
|
+
for (const resourceId of resourceIds) {
|
|
537
|
+
try {
|
|
538
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (!(error instanceof SlotlockAgentOperationError))
|
|
542
|
+
throw error;
|
|
543
|
+
throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const events = [];
|
|
547
|
+
let replayed = true;
|
|
548
|
+
for (const resourceId of resourceIds) {
|
|
549
|
+
const key = `${input.idempotency_key}\0${resourceId}`;
|
|
550
|
+
const result = await tenantStore.putCalendarEvent({
|
|
551
|
+
tenantRef: context.principal.tenantRef,
|
|
552
|
+
ownerRef: agentOwnerRef(context),
|
|
553
|
+
externalRef: agentExternalRef(context, key),
|
|
554
|
+
resourceId,
|
|
555
|
+
expectedRevision: 0,
|
|
556
|
+
idempotencyKey: agentCommandIdempotencyKey(context, key),
|
|
557
|
+
source: 'agent-server',
|
|
558
|
+
...content,
|
|
559
|
+
materializationWindow: materializationWindow(content),
|
|
560
|
+
enforceBookingRules: true,
|
|
561
|
+
});
|
|
562
|
+
// Thrown, so the whole transaction rolls back: the events already put go with it.
|
|
563
|
+
if (!result.ok) {
|
|
564
|
+
const refusal = operationError(result);
|
|
565
|
+
throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
|
|
566
|
+
}
|
|
567
|
+
const created = await tenantStore.getCalendarEvent({
|
|
568
|
+
tenantRef: context.principal.tenantRef,
|
|
569
|
+
ownerRef: agentOwnerRef(context),
|
|
570
|
+
externalRef: result.externalRef,
|
|
571
|
+
});
|
|
572
|
+
if (!created)
|
|
573
|
+
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
574
|
+
events.push(projectSlotlockCalendarEvent(created));
|
|
575
|
+
replayed &&= result.idempotent;
|
|
576
|
+
}
|
|
577
|
+
return { events, replayed };
|
|
578
|
+
}, WRITE);
|
|
579
|
+
},
|
|
580
|
+
async releaseSlots(context, input) {
|
|
581
|
+
const id = input.hold_id;
|
|
582
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
583
|
+
return { released: false };
|
|
584
|
+
const result = await withTenant(store, context, async (tenantStore) => tenantStore.releaseHold(id, {
|
|
585
|
+
tenantRef: context.principal.tenantRef,
|
|
586
|
+
holderRef: agentOwnerRef(context),
|
|
587
|
+
}).then(({ released }) => ({ released })));
|
|
588
|
+
if (result.released)
|
|
589
|
+
await offerFreedTime(store, context.principal.tenantRef);
|
|
590
|
+
return result;
|
|
591
|
+
},
|
|
206
592
|
async describeResource(context, resourceId) {
|
|
207
593
|
if (!STORE_ID_PATTERN.test(resourceId))
|
|
208
594
|
return null;
|
|
@@ -210,6 +596,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
210
596
|
const resource = await tenantStore.getResource({
|
|
211
597
|
tenantRef: context.principal.tenantRef,
|
|
212
598
|
id: resourceId,
|
|
599
|
+
visibility: 'shared',
|
|
213
600
|
});
|
|
214
601
|
return resource
|
|
215
602
|
? { name: resource.externalRef ?? null, timezone: resource.timezone }
|
|
@@ -218,33 +605,48 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
218
605
|
},
|
|
219
606
|
async listResources(context, input) {
|
|
220
607
|
const cursor = parseResourceCursor(input.cursor);
|
|
608
|
+
const output = (resource) => ({
|
|
609
|
+
id: resource.id,
|
|
610
|
+
external_ref: resource.externalRef,
|
|
611
|
+
timezone: resource.timezone,
|
|
612
|
+
...(resource.pool !== undefined ? { pool: resource.pool } : {}),
|
|
613
|
+
});
|
|
614
|
+
if (typeof input.pool === 'string') {
|
|
615
|
+
// A pool's members, in the order a pool booking tries them; at most 100, one page.
|
|
616
|
+
return withTenant(store, context, async (tenantStore) => ({
|
|
617
|
+
resources: (await tenantStore.listPoolMembers({
|
|
618
|
+
tenantRef: context.principal.tenantRef,
|
|
619
|
+
pool: input.pool,
|
|
620
|
+
visibility: 'shared',
|
|
621
|
+
})).map(output),
|
|
622
|
+
next_cursor: null,
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
221
625
|
return withTenant(store, context, async (tenantStore) => {
|
|
222
626
|
const limit = input.limit;
|
|
223
627
|
const resources = await tenantStore.listResources({
|
|
224
628
|
tenantRef: context.principal.tenantRef,
|
|
629
|
+
visibility: 'shared',
|
|
225
630
|
limit: limit + 1,
|
|
226
631
|
...(cursor !== undefined ? { after: cursor } : {}),
|
|
227
632
|
});
|
|
228
633
|
const page = resources.slice(0, limit);
|
|
229
634
|
return {
|
|
230
|
-
resources: page.map(
|
|
231
|
-
id: resource.id,
|
|
232
|
-
external_ref: resource.externalRef,
|
|
233
|
-
timezone: resource.timezone,
|
|
234
|
-
})),
|
|
635
|
+
resources: page.map(output),
|
|
235
636
|
next_cursor: resources.length > limit ? (page.at(-1)?.id ?? null) : null,
|
|
236
637
|
};
|
|
237
638
|
});
|
|
238
639
|
},
|
|
239
640
|
async getFreeBusy(context, input) {
|
|
240
641
|
const window = interval(input);
|
|
241
|
-
const resourceIds = input
|
|
642
|
+
const resourceIds = distinctResourceIds(input);
|
|
643
|
+
const charge = busyBudget();
|
|
242
644
|
return withTenant(store, context, async (tenantStore) => {
|
|
243
645
|
const resources = [];
|
|
244
646
|
for (const resourceId of resourceIds) {
|
|
245
|
-
const resource = await
|
|
647
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
246
648
|
const sources = await requiredSources(options, context, resource);
|
|
247
|
-
const result = await tenantStore
|
|
649
|
+
const result = await readFreeBusy(tenantStore, {
|
|
248
650
|
tenantRef: context.principal.tenantRef,
|
|
249
651
|
resourceId,
|
|
250
652
|
window,
|
|
@@ -253,6 +655,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
253
655
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
254
656
|
: {}),
|
|
255
657
|
});
|
|
658
|
+
charge(result.busy.length);
|
|
256
659
|
resources.push({
|
|
257
660
|
resource_id: resourceId,
|
|
258
661
|
busy: result.busy.map((busy) => ({
|
|
@@ -267,32 +670,58 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
267
670
|
},
|
|
268
671
|
async findNextAvailable(context, input) {
|
|
269
672
|
const window = interval(input);
|
|
270
|
-
const resourceIds = input
|
|
673
|
+
const resourceIds = distinctResourceIds(input);
|
|
271
674
|
const durationMs = input.duration_minutes * 60_000;
|
|
675
|
+
const charge = busyBudget();
|
|
676
|
+
let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
|
|
272
677
|
return withTenant(store, context, async (tenantStore) => {
|
|
273
678
|
let best = null;
|
|
274
679
|
let incomplete = false;
|
|
275
680
|
for (const resourceId of resourceIds) {
|
|
276
|
-
const resource = await
|
|
681
|
+
const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
682
|
+
// Only what a booking made now may take: the resource's booking rules bound the search,
|
|
683
|
+
// as they bound the write that follows it.
|
|
684
|
+
const search = bookingSearch({
|
|
685
|
+
rules: resource.bookingRules,
|
|
686
|
+
timezone: resource.timezone,
|
|
687
|
+
window,
|
|
688
|
+
now: new Date(),
|
|
689
|
+
});
|
|
277
690
|
const sources = await requiredSources(options, context, resource);
|
|
278
|
-
const freeBusy = await tenantStore
|
|
691
|
+
const freeBusy = await readFreeBusy(tenantStore, {
|
|
279
692
|
tenantRef: context.principal.tenantRef,
|
|
280
693
|
resourceId,
|
|
281
|
-
window
|
|
694
|
+
// A slot's turnaround may run past the window: what it reaches must be free too.
|
|
695
|
+
window: {
|
|
696
|
+
start: window.start,
|
|
697
|
+
end: new Date(window.end.getTime() + (search?.bufferAfterMs ?? 0)),
|
|
698
|
+
},
|
|
282
699
|
requiredSources: [...sources],
|
|
283
700
|
...(options.coverageMaxAgeMs !== undefined
|
|
284
701
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
285
702
|
: {}),
|
|
286
703
|
});
|
|
704
|
+
charge(freeBusy.busy.length);
|
|
287
705
|
if (freeBusy.coverage.state !== 'complete') {
|
|
288
706
|
incomplete = true;
|
|
289
707
|
continue;
|
|
290
708
|
}
|
|
709
|
+
if (!search)
|
|
710
|
+
continue;
|
|
291
711
|
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
712
|
+
const windows = expandRules(rules, search.window, resource.timezone);
|
|
713
|
+
windowsLeft -= windows.length;
|
|
714
|
+
if (windowsLeft < 0)
|
|
715
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
716
|
+
// A slot [s, s + d) with its turnaround meets busy [b, e) when [s, s + d) meets [b - t, e).
|
|
292
717
|
const candidate = findNextAvailable({
|
|
293
|
-
busy: freeBusy.busy
|
|
294
|
-
|
|
718
|
+
busy: freeBusy.busy.map((busy) => ({
|
|
719
|
+
start: new Date(busy.start.getTime() - search.bufferAfterMs),
|
|
720
|
+
end: busy.end,
|
|
721
|
+
})),
|
|
722
|
+
windows,
|
|
295
723
|
durationMs,
|
|
724
|
+
...(search.align ? { align: search.align } : {}),
|
|
296
725
|
});
|
|
297
726
|
if (candidate &&
|
|
298
727
|
(!best ||
|
|
@@ -317,7 +746,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
317
746
|
async createEvent(context, rawInput) {
|
|
318
747
|
const input = rawInput;
|
|
319
748
|
return withTenant(store, context, async (tenantStore) => {
|
|
320
|
-
await
|
|
749
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
|
|
321
750
|
const recurrence = recurrenceFromInput(input.recurrence_rule, input.recurrence_exceptions);
|
|
322
751
|
const content = {
|
|
323
752
|
start: new Date(input.starts_at),
|
|
@@ -350,9 +779,15 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
350
779
|
source: 'agent-server',
|
|
351
780
|
...content,
|
|
352
781
|
materializationWindow: materializationWindow(content),
|
|
782
|
+
// An agent's event is a booking made now: the resource's booking rules apply.
|
|
783
|
+
enforceBookingRules: true,
|
|
784
|
+
// Booking a held time: only this agent's hold, which gives way in the same transaction.
|
|
785
|
+
...(typeof input.hold_id === 'string'
|
|
786
|
+
? { fromHold: { holdId: input.hold_id, holderRef: agentOwnerRef(context) } }
|
|
787
|
+
: {}),
|
|
353
788
|
});
|
|
354
789
|
if (!result.ok)
|
|
355
|
-
throw operationError(result
|
|
790
|
+
throw operationError(result);
|
|
356
791
|
const created = await tenantStore.getCalendarEvent({
|
|
357
792
|
tenantRef: context.principal.tenantRef,
|
|
358
793
|
ownerRef: agentOwnerRef(context),
|
|
@@ -361,7 +796,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
361
796
|
if (!created)
|
|
362
797
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
363
798
|
return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
|
|
364
|
-
});
|
|
799
|
+
}, WRITE);
|
|
365
800
|
},
|
|
366
801
|
async getEvent(context, input) {
|
|
367
802
|
return withTenant(store, context, async (tenantStore) => {
|
|
@@ -377,13 +812,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
377
812
|
},
|
|
378
813
|
async listEvents(context, input) {
|
|
379
814
|
const window = interval(input);
|
|
380
|
-
const resourceIds = input
|
|
815
|
+
const resourceIds = distinctResourceIds(input);
|
|
381
816
|
const limit = input.limit;
|
|
382
817
|
const after = parseEventCursor(input.cursor);
|
|
383
818
|
return withTenant(store, context, async (tenantStore) => {
|
|
384
819
|
const combined = [];
|
|
385
820
|
for (const resourceId of resourceIds) {
|
|
386
|
-
await
|
|
821
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
387
822
|
combined.push(...(await tenantStore.listCalendarEvents({
|
|
388
823
|
tenantRef: context.principal.tenantRef,
|
|
389
824
|
ownerRef: agentOwnerRef(context),
|
|
@@ -404,7 +839,12 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
404
839
|
},
|
|
405
840
|
async updateEvent(context, rawInput) {
|
|
406
841
|
const input = rawInput;
|
|
407
|
-
|
|
842
|
+
// No event is on a resource the store never issued (an external reference, say).
|
|
843
|
+
if (input.expected_resource_id !== undefined &&
|
|
844
|
+
!STORE_ID_PATTERN.test(input.expected_resource_id)) {
|
|
845
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
846
|
+
}
|
|
847
|
+
const moved = await withTenant(store, context, async (tenantStore) => {
|
|
408
848
|
const current = await tenantStore.getCalendarEvent({
|
|
409
849
|
tenantRef: context.principal.tenantRef,
|
|
410
850
|
ownerRef: agentOwnerRef(context),
|
|
@@ -414,7 +854,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
414
854
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
415
855
|
const resourceId = input.resource_id ?? current.resourceId;
|
|
416
856
|
if (resourceId !== current.resourceId) {
|
|
417
|
-
await
|
|
857
|
+
await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
|
|
418
858
|
}
|
|
419
859
|
const recurrence = input.recurrence_rule === null
|
|
420
860
|
? undefined
|
|
@@ -473,15 +913,20 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
473
913
|
tenantRef: context.principal.tenantRef,
|
|
474
914
|
ownerRef: agentOwnerRef(context),
|
|
475
915
|
externalRef: current.externalRef,
|
|
916
|
+
// The caller's fence when it sent one, checked under the event's lock; otherwise the
|
|
917
|
+
// resource just read, so a move between that read and the write is not overwritten.
|
|
918
|
+
expectedResourceId: input.expected_resource_id ?? current.resourceId,
|
|
476
919
|
resourceId,
|
|
477
920
|
expectedRevision: input.expected_revision,
|
|
478
921
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
479
922
|
source: current.source ?? 'agent-server',
|
|
480
923
|
...content,
|
|
481
924
|
materializationWindow: materializationWindow(content),
|
|
925
|
+
// An agent's event is a booking made now: the resource's booking rules apply.
|
|
926
|
+
enforceBookingRules: true,
|
|
482
927
|
});
|
|
483
928
|
if (!result.ok)
|
|
484
|
-
throw operationError(result
|
|
929
|
+
throw operationError(result);
|
|
485
930
|
const updated = await tenantStore.getCalendarEvent({
|
|
486
931
|
tenantRef: context.principal.tenantRef,
|
|
487
932
|
ownerRef: agentOwnerRef(context),
|
|
@@ -490,10 +935,14 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
490
935
|
if (!updated)
|
|
491
936
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
492
937
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
493
|
-
});
|
|
938
|
+
}, WRITE);
|
|
939
|
+
// A moved or shortened event may have freed the time it held.
|
|
940
|
+
if (!moved.replayed)
|
|
941
|
+
await offerFreedTime(store, context.principal.tenantRef);
|
|
942
|
+
return moved;
|
|
494
943
|
},
|
|
495
944
|
async deleteEvent(context, input) {
|
|
496
|
-
|
|
945
|
+
const deleted = await withTenant(store, context, async (tenantStore) => {
|
|
497
946
|
const externalRef = input.event_id;
|
|
498
947
|
const current = await tenantStore.getCalendarEvent({
|
|
499
948
|
tenantRef: context.principal.tenantRef,
|
|
@@ -506,18 +955,22 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
506
955
|
tenantRef: context.principal.tenantRef,
|
|
507
956
|
ownerRef: agentOwnerRef(context),
|
|
508
957
|
externalRef,
|
|
958
|
+
expectedResourceId: current.resourceId,
|
|
509
959
|
idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
|
|
510
960
|
expectedRevision: input.expected_revision,
|
|
511
961
|
});
|
|
512
962
|
if (!result.ok)
|
|
513
|
-
throw operationError(result
|
|
963
|
+
throw operationError(result);
|
|
514
964
|
return {
|
|
515
965
|
event_id: externalRef,
|
|
516
966
|
revision: result.revision,
|
|
517
967
|
deleted: true,
|
|
518
968
|
replayed: result.idempotent,
|
|
519
969
|
};
|
|
520
|
-
});
|
|
970
|
+
}, WRITE);
|
|
971
|
+
if (!deleted.replayed)
|
|
972
|
+
await offerFreedTime(store, context.principal.tenantRef);
|
|
973
|
+
return deleted;
|
|
521
974
|
},
|
|
522
975
|
};
|
|
523
976
|
}
|
|
@@ -526,8 +979,9 @@ function storeRefusal(error) {
|
|
|
526
979
|
const code = error?.code;
|
|
527
980
|
if (typeof code !== 'string')
|
|
528
981
|
return error;
|
|
529
|
-
if (code === 'resource_limit_reached')
|
|
982
|
+
if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
|
|
530
983
|
return new SlotlockAgentOperationError(code, 409);
|
|
984
|
+
}
|
|
531
985
|
if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
|
|
532
986
|
return new SlotlockAgentOperationError(code, 400);
|
|
533
987
|
}
|
|
@@ -544,6 +998,49 @@ function applicationRefusal(code) {
|
|
|
544
998
|
return new SlotlockAgentOperationError(code, 400);
|
|
545
999
|
return new SlotlockAgentOperationError(code, 409);
|
|
546
1000
|
}
|
|
1001
|
+
function waitlistOutput(entry) {
|
|
1002
|
+
return {
|
|
1003
|
+
id: entry.id,
|
|
1004
|
+
resource_id: entry.resourceId,
|
|
1005
|
+
pool: entry.pool,
|
|
1006
|
+
start: entry.start.toISOString(),
|
|
1007
|
+
end: entry.end.toISOString(),
|
|
1008
|
+
holder_ref: entry.holderRef,
|
|
1009
|
+
offer_ttl_seconds: Math.round(entry.offerTtlMs / 1000),
|
|
1010
|
+
status: entry.status,
|
|
1011
|
+
hold_id: entry.holdId,
|
|
1012
|
+
offered_resource_id: entry.offeredResourceId,
|
|
1013
|
+
offered_at: entry.offeredAt?.toISOString() ?? null,
|
|
1014
|
+
created_at: entry.createdAt.toISOString(),
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Offer what a cancel or release just freed to the tenant's waitlist now, rather than at the next
|
|
1019
|
+
* maintenance pass. Best effort, after the freeing transaction committed: maintenance offers
|
|
1020
|
+
* whatever this misses.
|
|
1021
|
+
*/
|
|
1022
|
+
async function offerFreedTime(store, tenantRef) {
|
|
1023
|
+
try {
|
|
1024
|
+
await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }));
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Left to maintenance.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
function webhookOutput(endpoint) {
|
|
1031
|
+
return {
|
|
1032
|
+
id: endpoint.id,
|
|
1033
|
+
url: endpoint.url,
|
|
1034
|
+
event_types: endpoint.eventTypes,
|
|
1035
|
+
created_at: endpoint.createdAt.toISOString(),
|
|
1036
|
+
attempts: endpoint.attempts,
|
|
1037
|
+
failing_since: endpoint.failingSince?.toISOString() ?? null,
|
|
1038
|
+
last_status: endpoint.lastStatus,
|
|
1039
|
+
last_error: endpoint.lastError,
|
|
1040
|
+
disabled_at: endpoint.disabledAt?.toISOString() ?? null,
|
|
1041
|
+
disabled_reason: endpoint.disabledReason,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
547
1044
|
function rulesOutput(rules) {
|
|
548
1045
|
if (!rules)
|
|
549
1046
|
return null;
|
|
@@ -568,12 +1065,60 @@ function rulesInput(value) {
|
|
|
568
1065
|
throw storeRefusal(error);
|
|
569
1066
|
}
|
|
570
1067
|
}
|
|
1068
|
+
/** Booking rules as the application API writes them, snake_case; `null` for none. */
|
|
1069
|
+
function bookingRulesOutput(rules) {
|
|
1070
|
+
if (!rules)
|
|
1071
|
+
return null;
|
|
1072
|
+
return {
|
|
1073
|
+
...(rules.minNoticeMinutes !== undefined ? { min_notice_minutes: rules.minNoticeMinutes } : {}),
|
|
1074
|
+
...(rules.maxAdvanceDays !== undefined ? { max_advance_days: rules.maxAdvanceDays } : {}),
|
|
1075
|
+
...(rules.slotStepMinutes !== undefined ? { slot_step_minutes: rules.slotStepMinutes } : {}),
|
|
1076
|
+
...(rules.turnaroundMinutes !== undefined ? { turnaround_minutes: rules.turnaroundMinutes } : {}),
|
|
1077
|
+
...(rules.enforceHours !== undefined ? { enforce_hours: rules.enforceHours } : {}),
|
|
1078
|
+
...(rules.checkInMinutes !== undefined ? { check_in_minutes: rules.checkInMinutes } : {}),
|
|
1079
|
+
...(rules.requireCertain !== undefined ? { require_certain: rules.requireCertain } : {}),
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
function bookingRulesInput(value) {
|
|
1083
|
+
if (value === null || value === undefined)
|
|
1084
|
+
return null;
|
|
1085
|
+
const input = value;
|
|
1086
|
+
const rules = {};
|
|
1087
|
+
if (input.min_notice_minutes !== undefined)
|
|
1088
|
+
rules.minNoticeMinutes = input.min_notice_minutes;
|
|
1089
|
+
if (input.max_advance_days !== undefined)
|
|
1090
|
+
rules.maxAdvanceDays = input.max_advance_days;
|
|
1091
|
+
if (input.slot_step_minutes !== undefined)
|
|
1092
|
+
rules.slotStepMinutes = input.slot_step_minutes;
|
|
1093
|
+
if (input.turnaround_minutes !== undefined)
|
|
1094
|
+
rules.turnaroundMinutes = input.turnaround_minutes;
|
|
1095
|
+
if (input.enforce_hours !== undefined)
|
|
1096
|
+
rules.enforceHours = input.enforce_hours;
|
|
1097
|
+
if (input.check_in_minutes !== undefined)
|
|
1098
|
+
rules.checkInMinutes = input.check_in_minutes;
|
|
1099
|
+
if (input.require_certain !== undefined)
|
|
1100
|
+
rules.requireCertain = input.require_certain;
|
|
1101
|
+
return rules;
|
|
1102
|
+
}
|
|
571
1103
|
function resourceOutput(resource) {
|
|
572
1104
|
return {
|
|
573
1105
|
id: resource.id,
|
|
574
1106
|
external_ref: resource.externalRef,
|
|
575
1107
|
timezone: resource.timezone,
|
|
576
1108
|
availability: rulesOutput(resource.availabilityRules),
|
|
1109
|
+
booking_rules: bookingRulesOutput(resource.bookingRules),
|
|
1110
|
+
visibility: resource.visibility ?? 'shared',
|
|
1111
|
+
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
1112
|
+
pool: resource.pool ?? null,
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
1116
|
+
function tenantEventOutput(event) {
|
|
1117
|
+
return {
|
|
1118
|
+
...projectSlotlockCalendarEvent(event),
|
|
1119
|
+
id: event.id,
|
|
1120
|
+
event_ref: event.externalRef,
|
|
1121
|
+
owner_ref: event.ownerRef,
|
|
577
1122
|
};
|
|
578
1123
|
}
|
|
579
1124
|
function holdOutput(hold) {
|
|
@@ -599,6 +1144,26 @@ function reservationOutput(reservation) {
|
|
|
599
1144
|
source: reservation.source,
|
|
600
1145
|
};
|
|
601
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* The namespace an application caller's idempotency keys live in: a digest of its tenant and
|
|
1149
|
+
* subject, so the authenticated subject never lands in a durable row.
|
|
1150
|
+
*/
|
|
1151
|
+
function applicationPrincipalRef(context) {
|
|
1152
|
+
return `application:${createHash('sha256')
|
|
1153
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
1154
|
+
.digest('hex')}`;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* An application caller's event command key: its tenant, subject and own key, digested, so one
|
|
1158
|
+
* caller's key never replays another's command and neither the subject nor the raw key is stored.
|
|
1159
|
+
*/
|
|
1160
|
+
function applicationCommandKey(context, idempotencyKey) {
|
|
1161
|
+
return `application-command:${createHash('sha256')
|
|
1162
|
+
.update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
|
|
1163
|
+
.digest('hex')}`;
|
|
1164
|
+
}
|
|
1165
|
+
/** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
|
|
1166
|
+
const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
|
|
602
1167
|
/**
|
|
603
1168
|
* Serve the application API (application-api.ts) from the production store, in the principal's
|
|
604
1169
|
* tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
|
|
@@ -613,7 +1178,37 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
613
1178
|
if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
|
|
614
1179
|
throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
|
|
615
1180
|
}
|
|
1181
|
+
const tenantErasure = options.tenantErasure === true;
|
|
616
1182
|
const tenantOf = (context) => context.principal.tenantRef;
|
|
1183
|
+
async function history(context, input) {
|
|
1184
|
+
const page = {
|
|
1185
|
+
tenantRef: tenantOf(context),
|
|
1186
|
+
limit: input.limit,
|
|
1187
|
+
...(input.before !== undefined ? { before: input.before } : {}),
|
|
1188
|
+
};
|
|
1189
|
+
return withTenant(store, context, async (tenant) => {
|
|
1190
|
+
let entries;
|
|
1191
|
+
if (typeof input.resource_id === 'string') {
|
|
1192
|
+
await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1193
|
+
entries = await tenant.listHistory({ ...page, resourceId: input.resource_id });
|
|
1194
|
+
}
|
|
1195
|
+
else {
|
|
1196
|
+
const subject = (input.reservation_ref ?? input.event_id);
|
|
1197
|
+
entries = await tenant.listHistory({ ...page, subject });
|
|
1198
|
+
}
|
|
1199
|
+
const limit = input.limit;
|
|
1200
|
+
return {
|
|
1201
|
+
history: entries.map((entry) => ({
|
|
1202
|
+
id: entry.id,
|
|
1203
|
+
type: entry.type,
|
|
1204
|
+
time: entry.occurredAt.toISOString(),
|
|
1205
|
+
actor: entry.actor,
|
|
1206
|
+
data: entry.data,
|
|
1207
|
+
})),
|
|
1208
|
+
next_before: entries.length === limit ? (entries.at(-1)?.id ?? null) : null,
|
|
1209
|
+
};
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
617
1212
|
return {
|
|
618
1213
|
async createResource(context, input) {
|
|
619
1214
|
// A capped create counts under READ COMMITTED; withTenant opens it for the call.
|
|
@@ -623,6 +1218,9 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
623
1218
|
tenantRef: tenantOf(context),
|
|
624
1219
|
externalRef: input.external_ref,
|
|
625
1220
|
timezone: input.timezone,
|
|
1221
|
+
...(input.visibility !== undefined
|
|
1222
|
+
? { visibility: input.visibility }
|
|
1223
|
+
: {}),
|
|
626
1224
|
...(max !== undefined ? { maxTenantResources: max } : {}),
|
|
627
1225
|
});
|
|
628
1226
|
return { resource: resourceOutput(resource) };
|
|
@@ -637,6 +1235,59 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
637
1235
|
resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
|
|
638
1236
|
}));
|
|
639
1237
|
},
|
|
1238
|
+
async archiveResource(context, input) {
|
|
1239
|
+
const id = input.resource_id;
|
|
1240
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1241
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1242
|
+
return withTenant(store, context, async (tenant) => {
|
|
1243
|
+
const resource = await tenant.archiveResource({ tenantRef: tenantOf(context), id });
|
|
1244
|
+
if (!resource)
|
|
1245
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1246
|
+
return { resource: resourceOutput(resource) };
|
|
1247
|
+
}, WRITE);
|
|
1248
|
+
},
|
|
1249
|
+
async restoreResource(context, input) {
|
|
1250
|
+
const id = input.resource_id;
|
|
1251
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1252
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1253
|
+
return withTenant(store, context, async (tenant) => {
|
|
1254
|
+
const resource = await tenant.restoreResource({ tenantRef: tenantOf(context), id });
|
|
1255
|
+
if (!resource)
|
|
1256
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1257
|
+
return { resource: resourceOutput(resource) };
|
|
1258
|
+
}, WRITE);
|
|
1259
|
+
},
|
|
1260
|
+
async deleteResource(context, input) {
|
|
1261
|
+
const id = input.resource_id;
|
|
1262
|
+
// An id the store never issues names no resource: there is nothing to delete.
|
|
1263
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1264
|
+
return { deleted: false };
|
|
1265
|
+
return withTenant(store, context, async (tenant) => {
|
|
1266
|
+
const result = await tenant.deleteResource({ tenantRef: tenantOf(context), id });
|
|
1267
|
+
if (!result.ok)
|
|
1268
|
+
throw applicationRefusal(result.code);
|
|
1269
|
+
return { deleted: result.deleted };
|
|
1270
|
+
}, WRITE);
|
|
1271
|
+
},
|
|
1272
|
+
async listBusy(context, input) {
|
|
1273
|
+
return withTenant(store, context, async (tenant) => {
|
|
1274
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1275
|
+
// One row past the cap is enough to refuse: the rest is never read.
|
|
1276
|
+
const busy = await tenant.listBusy(resource.id, interval(input), {
|
|
1277
|
+
tenantRef: tenantOf(context),
|
|
1278
|
+
limit: MAX_LISTED_BUSY + 1,
|
|
1279
|
+
});
|
|
1280
|
+
if (busy.length > MAX_LISTED_BUSY) {
|
|
1281
|
+
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
busy: busy.map((occupied) => ({
|
|
1285
|
+
start: occupied.start.toISOString(),
|
|
1286
|
+
end: occupied.end.toISOString(),
|
|
1287
|
+
})),
|
|
1288
|
+
};
|
|
1289
|
+
});
|
|
1290
|
+
},
|
|
640
1291
|
async setResourceAvailability(context, input) {
|
|
641
1292
|
const rules = rulesInput(input.rules);
|
|
642
1293
|
const id = input.resource_id;
|
|
@@ -655,6 +1306,105 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
655
1306
|
return { resource: resourceOutput(resource) };
|
|
656
1307
|
});
|
|
657
1308
|
},
|
|
1309
|
+
async exportResourceAvailability(context, input) {
|
|
1310
|
+
return withTenant(store, context, async (tenant) => {
|
|
1311
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1312
|
+
if (resource.availabilityRules === undefined)
|
|
1313
|
+
return { icalendar: null };
|
|
1314
|
+
return {
|
|
1315
|
+
icalendar: emitVAvailability({
|
|
1316
|
+
rules: resource.availabilityRules,
|
|
1317
|
+
timezone: resource.timezone,
|
|
1318
|
+
uid: `${resource.id}-hours`,
|
|
1319
|
+
}),
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
},
|
|
1323
|
+
async importResourceAvailability(context, input) {
|
|
1324
|
+
let parsed;
|
|
1325
|
+
try {
|
|
1326
|
+
parsed = parseVAvailability(input.icalendar);
|
|
1327
|
+
}
|
|
1328
|
+
catch (error) {
|
|
1329
|
+
throw storeRefusal(error);
|
|
1330
|
+
}
|
|
1331
|
+
const id = input.resource_id;
|
|
1332
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1333
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1334
|
+
return withTenant(store, context, async (tenant) => {
|
|
1335
|
+
const current = await requireResource(tenant, tenantOf(context), id);
|
|
1336
|
+
// Rules are read in the resource's zone: times written in another would land elsewhere.
|
|
1337
|
+
if (parsed.timezone !== null && parsed.timezone !== current.timezone) {
|
|
1338
|
+
throw new SlotlockAgentOperationError('invalid_availability', 400, {
|
|
1339
|
+
timezone: parsed.timezone,
|
|
1340
|
+
resource_timezone: current.timezone,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
let resource;
|
|
1344
|
+
try {
|
|
1345
|
+
resource = await tenant.setResourceAvailability({
|
|
1346
|
+
tenantRef: tenantOf(context),
|
|
1347
|
+
id,
|
|
1348
|
+
rules: parsed.rules,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
catch (error) {
|
|
1352
|
+
throw storeRefusal(error);
|
|
1353
|
+
}
|
|
1354
|
+
if (!resource)
|
|
1355
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1356
|
+
return { resource: resourceOutput(resource) };
|
|
1357
|
+
});
|
|
1358
|
+
},
|
|
1359
|
+
async setResourceBookingRules(context, input) {
|
|
1360
|
+
const rules = bookingRulesInput(input.rules);
|
|
1361
|
+
const id = input.resource_id;
|
|
1362
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1363
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1364
|
+
return withTenant(store, context, async (tenant) => {
|
|
1365
|
+
let resource;
|
|
1366
|
+
try {
|
|
1367
|
+
resource = await tenant.setResourceBookingRules({ tenantRef: tenantOf(context), id, rules });
|
|
1368
|
+
}
|
|
1369
|
+
catch (error) {
|
|
1370
|
+
throw storeRefusal(error);
|
|
1371
|
+
}
|
|
1372
|
+
if (!resource)
|
|
1373
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1374
|
+
return { resource: resourceOutput(resource) };
|
|
1375
|
+
});
|
|
1376
|
+
},
|
|
1377
|
+
async setResourcePool(context, input) {
|
|
1378
|
+
const id = input.resource_id;
|
|
1379
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1380
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1381
|
+
return withTenant(store, context, async (tenant) => {
|
|
1382
|
+
let resource;
|
|
1383
|
+
try {
|
|
1384
|
+
resource = await tenant.setResourcePool({ tenantRef: tenantOf(context), id, pool: input.pool });
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
throw storeRefusal(error);
|
|
1388
|
+
}
|
|
1389
|
+
if (!resource)
|
|
1390
|
+
throw new SlotlockAgentOperationError('resource_not_found', 404);
|
|
1391
|
+
return { resource: resourceOutput(resource) };
|
|
1392
|
+
}, WRITE);
|
|
1393
|
+
},
|
|
1394
|
+
async getResourceHistory(context, input) {
|
|
1395
|
+
return history(context, input);
|
|
1396
|
+
},
|
|
1397
|
+
async getReservationHistory(context, input) {
|
|
1398
|
+
return history(context, input);
|
|
1399
|
+
},
|
|
1400
|
+
async getTenantEventHistory(context, input) {
|
|
1401
|
+
return history(context, input);
|
|
1402
|
+
},
|
|
1403
|
+
async listPools(context) {
|
|
1404
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1405
|
+
pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
|
|
1406
|
+
}));
|
|
1407
|
+
},
|
|
658
1408
|
async setTenantAvailability(context, input) {
|
|
659
1409
|
const rules = rulesInput(input.rules);
|
|
660
1410
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -675,15 +1425,51 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
675
1425
|
return withTenant(store, context, async (tenant) => {
|
|
676
1426
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
677
1427
|
const result = await tenant.acquireHold({
|
|
1428
|
+
tenantRef: tenantOf(context),
|
|
678
1429
|
resourceId: resource.id,
|
|
679
1430
|
start: new Date(input.start),
|
|
680
1431
|
end: new Date(input.end),
|
|
681
1432
|
ttlMs: input.ttl_seconds * 1_000,
|
|
682
1433
|
...(input.source !== undefined ? { source: input.source } : {}),
|
|
1434
|
+
...(input.buffer_after_minutes !== undefined
|
|
1435
|
+
? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
|
|
1436
|
+
: {}),
|
|
1437
|
+
// A retried request with the caller's key answers the hold the first one made.
|
|
1438
|
+
...(input.idempotency_key !== undefined
|
|
1439
|
+
? {
|
|
1440
|
+
idempotencyKey: input.idempotency_key,
|
|
1441
|
+
principalRef: applicationPrincipalRef(context),
|
|
1442
|
+
}
|
|
1443
|
+
: {}),
|
|
683
1444
|
});
|
|
684
1445
|
if (!result.ok)
|
|
685
1446
|
throw applicationRefusal(result.code);
|
|
686
1447
|
return { hold: holdOutput(result.hold) };
|
|
1448
|
+
}, WRITE);
|
|
1449
|
+
},
|
|
1450
|
+
async getHold(context, input) {
|
|
1451
|
+
const id = input.hold_id;
|
|
1452
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1453
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
1454
|
+
return withTenant(store, context, async (tenant) => {
|
|
1455
|
+
const hold = await tenant.getHold({ tenantRef: tenantOf(context), holdId: id });
|
|
1456
|
+
if (!hold)
|
|
1457
|
+
throw new SlotlockAgentOperationError('hold_not_found', 404);
|
|
1458
|
+
return { hold: holdOutput(hold) };
|
|
1459
|
+
});
|
|
1460
|
+
},
|
|
1461
|
+
async listHolds(context, input) {
|
|
1462
|
+
const limit = input.limit;
|
|
1463
|
+
const after = parseEventCursor(input.cursor);
|
|
1464
|
+
return withTenant(store, context, async (tenant) => {
|
|
1465
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1466
|
+
const { shown, next } = page(await tenant.listHolds({
|
|
1467
|
+
tenantRef: tenantOf(context),
|
|
1468
|
+
resourceId: resource.id,
|
|
1469
|
+
limit: limit + 1,
|
|
1470
|
+
...(after ? { after } : {}),
|
|
1471
|
+
}), limit);
|
|
1472
|
+
return { holds: shown.map(holdOutput), next_cursor: next };
|
|
687
1473
|
});
|
|
688
1474
|
},
|
|
689
1475
|
async confirmHold(context, input) {
|
|
@@ -695,19 +1481,48 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
695
1481
|
if (!result.ok)
|
|
696
1482
|
throw applicationRefusal(result.code);
|
|
697
1483
|
return { reservation: reservationOutput(result.reservation) };
|
|
698
|
-
});
|
|
1484
|
+
}, WRITE);
|
|
699
1485
|
},
|
|
700
1486
|
async releaseHold(context, input) {
|
|
701
1487
|
const id = input.hold_id;
|
|
702
1488
|
// An id that is not one the store issues names no hold: there is nothing to release.
|
|
703
1489
|
if (!STORE_ID_PATTERN.test(id))
|
|
704
1490
|
return { released: false };
|
|
705
|
-
|
|
1491
|
+
const released = await withTenant(store, context, async (tenant) => ({
|
|
706
1492
|
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
707
1493
|
}));
|
|
1494
|
+
if (released.released)
|
|
1495
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1496
|
+
return released;
|
|
708
1497
|
},
|
|
709
|
-
async
|
|
1498
|
+
async getReservation(context, input) {
|
|
710
1499
|
return withTenant(store, context, async (tenant) => {
|
|
1500
|
+
const reservation = await tenant.getReservation({
|
|
1501
|
+
tenantRef: tenantOf(context),
|
|
1502
|
+
externalRef: input.reservation_ref,
|
|
1503
|
+
});
|
|
1504
|
+
if (!reservation)
|
|
1505
|
+
throw new SlotlockAgentOperationError('reservation_not_found', 404);
|
|
1506
|
+
return { reservation: reservationOutput(reservation) };
|
|
1507
|
+
});
|
|
1508
|
+
},
|
|
1509
|
+
async listReservations(context, input) {
|
|
1510
|
+
const limit = input.limit;
|
|
1511
|
+
const after = parseEventCursor(input.cursor);
|
|
1512
|
+
return withTenant(store, context, async (tenant) => {
|
|
1513
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1514
|
+
const { shown, next } = page(await tenant.listReservations({
|
|
1515
|
+
tenantRef: tenantOf(context),
|
|
1516
|
+
resourceId: resource.id,
|
|
1517
|
+
window: interval(input),
|
|
1518
|
+
limit: limit + 1,
|
|
1519
|
+
...(after ? { after } : {}),
|
|
1520
|
+
}), limit);
|
|
1521
|
+
return { reservations: shown.map(reservationOutput), next_cursor: next };
|
|
1522
|
+
});
|
|
1523
|
+
},
|
|
1524
|
+
async putReservation(context, input) {
|
|
1525
|
+
const put = await withTenant(store, context, async (tenant) => {
|
|
711
1526
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
712
1527
|
const command = {
|
|
713
1528
|
tenantRef: tenantOf(context),
|
|
@@ -725,10 +1540,15 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
725
1540
|
if (!result.ok)
|
|
726
1541
|
throw applicationRefusal(result.code);
|
|
727
1542
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
728
|
-
});
|
|
1543
|
+
}, WRITE);
|
|
1544
|
+
// A move frees the time the booking held.
|
|
1545
|
+
if (input.revision > 1 && !put.replayed) {
|
|
1546
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1547
|
+
}
|
|
1548
|
+
return put;
|
|
729
1549
|
},
|
|
730
1550
|
async cancelReservation(context, input) {
|
|
731
|
-
|
|
1551
|
+
const cancelled = await withTenant(store, context, async (tenant) => {
|
|
732
1552
|
const result = await tenant.cancelExternalReservation({
|
|
733
1553
|
tenantRef: tenantOf(context),
|
|
734
1554
|
externalRef: input.reservation_ref,
|
|
@@ -737,8 +1557,103 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
737
1557
|
if (!result.ok)
|
|
738
1558
|
throw applicationRefusal(result.code);
|
|
739
1559
|
return { cancelled: result.cancelled, revision: result.revision };
|
|
1560
|
+
}, WRITE);
|
|
1561
|
+
if (cancelled.cancelled)
|
|
1562
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1563
|
+
return cancelled;
|
|
1564
|
+
},
|
|
1565
|
+
async checkInReservation(context, input) {
|
|
1566
|
+
return withTenant(store, context, async (tenant) => {
|
|
1567
|
+
const result = await tenant.checkInReservation({
|
|
1568
|
+
tenantRef: tenantOf(context),
|
|
1569
|
+
externalRef: input.reservation_ref,
|
|
1570
|
+
});
|
|
1571
|
+
if (!result.ok)
|
|
1572
|
+
throw applicationRefusal(result.code);
|
|
1573
|
+
return { reference: result.reference, checked_in_at: result.checkedInAt.toISOString() };
|
|
740
1574
|
});
|
|
741
1575
|
},
|
|
1576
|
+
async joinWaitlist(context, input) {
|
|
1577
|
+
const ttl = input.offer_ttl_seconds;
|
|
1578
|
+
return withTenant(store, context, async (tenant) => {
|
|
1579
|
+
const result = await tenant.joinWaitlist({
|
|
1580
|
+
tenantRef: tenantOf(context),
|
|
1581
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
1582
|
+
...(input.pool !== undefined ? { pool: input.pool } : {}),
|
|
1583
|
+
start: new Date(input.start),
|
|
1584
|
+
end: new Date(input.end),
|
|
1585
|
+
holderRef: input.holder_ref,
|
|
1586
|
+
...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
|
|
1587
|
+
});
|
|
1588
|
+
if (!result.ok)
|
|
1589
|
+
throw applicationRefusal(result.code);
|
|
1590
|
+
return { entry: waitlistOutput(result.entry) };
|
|
1591
|
+
});
|
|
1592
|
+
},
|
|
1593
|
+
async listWaitlist(context, input) {
|
|
1594
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1595
|
+
entries: (await tenant.listWaitlist({
|
|
1596
|
+
tenantRef: tenantOf(context),
|
|
1597
|
+
limit: input.limit,
|
|
1598
|
+
...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
|
|
1599
|
+
...(input.pool !== undefined ? { pool: input.pool } : {}),
|
|
1600
|
+
...(input.holder_ref !== undefined ? { holderRef: input.holder_ref } : {}),
|
|
1601
|
+
})).map(waitlistOutput),
|
|
1602
|
+
}));
|
|
1603
|
+
},
|
|
1604
|
+
async leaveWaitlist(context, input) {
|
|
1605
|
+
const left = await withTenant(store, context, async (tenant) => ({
|
|
1606
|
+
left: await tenant.leaveWaitlist({ tenantRef: tenantOf(context), id: input.entry_id }),
|
|
1607
|
+
}));
|
|
1608
|
+
if (left.left)
|
|
1609
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1610
|
+
return left;
|
|
1611
|
+
},
|
|
1612
|
+
async getCoverage(context, input) {
|
|
1613
|
+
return withTenant(store, context, async (tenant) => {
|
|
1614
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1615
|
+
const sources = await tenant.listCoverage({ tenantRef: tenantOf(context), resourceId: resource.id });
|
|
1616
|
+
return {
|
|
1617
|
+
sources: sources.map((source) => ({
|
|
1618
|
+
source: source.source,
|
|
1619
|
+
start: source.start.toISOString(),
|
|
1620
|
+
end: source.end.toISOString(),
|
|
1621
|
+
revision: source.revision,
|
|
1622
|
+
observed_at: source.observedAt.toISOString(),
|
|
1623
|
+
last_error: source.lastError,
|
|
1624
|
+
last_error_at: source.lastErrorAt?.toISOString() ?? null,
|
|
1625
|
+
stale: source.stale,
|
|
1626
|
+
})),
|
|
1627
|
+
};
|
|
1628
|
+
});
|
|
1629
|
+
},
|
|
1630
|
+
async recordCoverageFailure(context, input) {
|
|
1631
|
+
return withTenant(store, context, async (tenant) => {
|
|
1632
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1633
|
+
const result = await tenant.recordCoverageFailure({
|
|
1634
|
+
tenantRef: tenantOf(context),
|
|
1635
|
+
resourceId: resource.id,
|
|
1636
|
+
source: input.source,
|
|
1637
|
+
error: input.error,
|
|
1638
|
+
});
|
|
1639
|
+
if (!result.ok)
|
|
1640
|
+
throw applicationRefusal(result.code);
|
|
1641
|
+
return { recorded: true };
|
|
1642
|
+
}, WRITE);
|
|
1643
|
+
},
|
|
1644
|
+
async deleteCoverage(context, input) {
|
|
1645
|
+
return withTenant(store, context, async (tenant) => {
|
|
1646
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1647
|
+
const deleted = await tenant.deleteCoverage({
|
|
1648
|
+
tenantRef: tenantOf(context),
|
|
1649
|
+
resourceId: resource.id,
|
|
1650
|
+
source: input.source,
|
|
1651
|
+
});
|
|
1652
|
+
if (!deleted)
|
|
1653
|
+
throw applicationRefusal('coverage_not_found');
|
|
1654
|
+
return { deleted: true };
|
|
1655
|
+
}, WRITE);
|
|
1656
|
+
},
|
|
742
1657
|
async recordCoverage(context, input) {
|
|
743
1658
|
return withTenant(store, context, async (tenant) => {
|
|
744
1659
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
@@ -752,16 +1667,161 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
752
1667
|
if (!result.ok)
|
|
753
1668
|
throw applicationRefusal(result.code);
|
|
754
1669
|
return { revision: result.revision, replayed: result.idempotent };
|
|
1670
|
+
}, WRITE);
|
|
1671
|
+
},
|
|
1672
|
+
async listTenantEvents(context, input) {
|
|
1673
|
+
const limit = input.limit;
|
|
1674
|
+
const after = parseEventCursor(input.cursor);
|
|
1675
|
+
return withTenant(store, context, async (tenant) => {
|
|
1676
|
+
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
1677
|
+
const { shown, next } = page(await tenant.listCalendarEvents({
|
|
1678
|
+
tenantRef: tenantOf(context),
|
|
1679
|
+
allOwners: true,
|
|
1680
|
+
resourceId: resource.id,
|
|
1681
|
+
window: interval(input),
|
|
1682
|
+
limit: limit + 1,
|
|
1683
|
+
...(after ? { after } : {}),
|
|
1684
|
+
}), limit);
|
|
1685
|
+
return { events: shown.map(tenantEventOutput), next_cursor: next };
|
|
755
1686
|
});
|
|
756
1687
|
},
|
|
1688
|
+
async getTenantEvent(context, input) {
|
|
1689
|
+
const id = input.event_id;
|
|
1690
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1691
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1692
|
+
return withTenant(store, context, async (tenant) => {
|
|
1693
|
+
const event = await tenant.getCalendarEventById({ tenantRef: tenantOf(context), eventId: id });
|
|
1694
|
+
if (!event)
|
|
1695
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1696
|
+
return { event: tenantEventOutput(event) };
|
|
1697
|
+
});
|
|
1698
|
+
},
|
|
1699
|
+
async exportTenantEvent(context, input) {
|
|
1700
|
+
const id = input.event_id;
|
|
1701
|
+
if (!STORE_ID_PATTERN.test(id))
|
|
1702
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1703
|
+
return withTenant(store, context, async (tenant) => {
|
|
1704
|
+
const event = await tenant.getCalendarEventById({ tenantRef: tenantOf(context), eventId: id });
|
|
1705
|
+
if (!event)
|
|
1706
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1707
|
+
return { event: toJSCalendarEvent(event) };
|
|
1708
|
+
});
|
|
1709
|
+
},
|
|
1710
|
+
async cancelTenantEvent(context, input) {
|
|
1711
|
+
const id = input.event_id;
|
|
1712
|
+
const expectedResourceId = input.expected_resource_id;
|
|
1713
|
+
// An id the store never issues names no event, and no event is on a resource it never issued.
|
|
1714
|
+
if (!STORE_ID_PATTERN.test(id) ||
|
|
1715
|
+
(expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
|
|
1716
|
+
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1717
|
+
}
|
|
1718
|
+
const cancelled = await withTenant(store, context, async (tenant) => {
|
|
1719
|
+
const result = await tenant.cancelCalendarEventById({
|
|
1720
|
+
tenantRef: tenantOf(context),
|
|
1721
|
+
eventId: id,
|
|
1722
|
+
idempotencyKey: applicationCommandKey(context, input.idempotency_key),
|
|
1723
|
+
expectedRevision: input.expected_revision,
|
|
1724
|
+
...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
|
|
1725
|
+
});
|
|
1726
|
+
if (!result.ok)
|
|
1727
|
+
throw applicationRefusal(result.code);
|
|
1728
|
+
return {
|
|
1729
|
+
event_id: result.eventId,
|
|
1730
|
+
revision: result.revision,
|
|
1731
|
+
cancelled: true,
|
|
1732
|
+
replayed: result.idempotent,
|
|
1733
|
+
};
|
|
1734
|
+
}, WRITE);
|
|
1735
|
+
if (!cancelled.replayed)
|
|
1736
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1737
|
+
return cancelled;
|
|
1738
|
+
},
|
|
1739
|
+
async getTenant(context) {
|
|
1740
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1741
|
+
tenant: {
|
|
1742
|
+
ref: tenantOf(context),
|
|
1743
|
+
suspended: await tenant.isTenantSuspended({ tenantRef: tenantOf(context) }),
|
|
1744
|
+
},
|
|
1745
|
+
}));
|
|
1746
|
+
},
|
|
1747
|
+
async eraseTenant(context, input) {
|
|
1748
|
+
if (!tenantErasure)
|
|
1749
|
+
throw new SlotlockAgentOperationError('tenant_erasure_disabled', 403);
|
|
1750
|
+
// The reference, not a yes: a key of another tenant than the caller believes cannot erase it.
|
|
1751
|
+
if (input.confirm_tenant_ref !== tenantOf(context)) {
|
|
1752
|
+
throw new SlotlockAgentOperationError('invalid_confirmation', 400);
|
|
1753
|
+
}
|
|
1754
|
+
return withTenant(store, context, async (tenant) => {
|
|
1755
|
+
// Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
|
|
1756
|
+
// kept for its operator, who erases it (slotlock tenant erase) when they decide to.
|
|
1757
|
+
if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
|
|
1758
|
+
throw new SlotlockAgentOperationError('tenant_suspended', 403);
|
|
1759
|
+
}
|
|
1760
|
+
const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) });
|
|
1761
|
+
return { total: erased.total, rows: erased.rows };
|
|
1762
|
+
}, WRITE);
|
|
1763
|
+
},
|
|
1764
|
+
async listChanges(context, input) {
|
|
1765
|
+
return withTenant(store, context, async (tenant) => {
|
|
1766
|
+
const page = await tenant.listChanges({
|
|
1767
|
+
tenantRef: tenantOf(context),
|
|
1768
|
+
limit: input.limit,
|
|
1769
|
+
...(input.cursor !== undefined ? { after: input.cursor } : {}),
|
|
1770
|
+
});
|
|
1771
|
+
if (!page.ok) {
|
|
1772
|
+
throw new SlotlockAgentOperationError(page.code, page.code === 'cursor_expired' ? 410 : 400);
|
|
1773
|
+
}
|
|
1774
|
+
return { changes: page.changes.map(slotlockCloudEvent), next_cursor: page.next };
|
|
1775
|
+
});
|
|
1776
|
+
},
|
|
1777
|
+
async createWebhook(context, input) {
|
|
1778
|
+
return withTenant(store, context, async (tenant) => {
|
|
1779
|
+
const created = await tenant.createWebhookEndpoint({
|
|
1780
|
+
tenantRef: tenantOf(context),
|
|
1781
|
+
url: input.url,
|
|
1782
|
+
...(input.event_types !== undefined ? { eventTypes: input.event_types } : {}),
|
|
1783
|
+
});
|
|
1784
|
+
if (!created.ok) {
|
|
1785
|
+
throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' ? 409 : 400);
|
|
1786
|
+
}
|
|
1787
|
+
return { webhook: webhookOutput(created.endpoint), secret: created.secret };
|
|
1788
|
+
}, WRITE);
|
|
1789
|
+
},
|
|
1790
|
+
async listWebhooks(context) {
|
|
1791
|
+
return withTenant(store, context, async (tenant) => ({
|
|
1792
|
+
webhooks: (await tenant.listWebhookEndpoints({ tenantRef: tenantOf(context) })).map(webhookOutput),
|
|
1793
|
+
}));
|
|
1794
|
+
},
|
|
1795
|
+
async deleteWebhook(context, input) {
|
|
1796
|
+
return withTenant(store, context, async (tenant) => tenant.deleteWebhookEndpoint({ tenantRef: tenantOf(context), id: input.webhook_id }), WRITE);
|
|
1797
|
+
},
|
|
1798
|
+
async enableWebhook(context, input) {
|
|
1799
|
+
return withTenant(store, context, async (tenant) => {
|
|
1800
|
+
const enabled = await tenant.enableWebhookEndpoint({
|
|
1801
|
+
tenantRef: tenantOf(context),
|
|
1802
|
+
id: input.webhook_id,
|
|
1803
|
+
});
|
|
1804
|
+
if (!enabled)
|
|
1805
|
+
throw new SlotlockAgentOperationError('webhook_not_found', 404);
|
|
1806
|
+
if ('code' in enabled)
|
|
1807
|
+
throw new SlotlockAgentOperationError(enabled.code, 409);
|
|
1808
|
+
return { webhook: webhookOutput(enabled) };
|
|
1809
|
+
}, WRITE);
|
|
1810
|
+
},
|
|
757
1811
|
async runMaintenance(context) {
|
|
758
1812
|
assertActive(context);
|
|
759
1813
|
try {
|
|
1814
|
+
// A caller goes round again while `capped`; when to do so at once is the scheduler's.
|
|
1815
|
+
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1816
|
+
batches,
|
|
1817
|
+
signal: context.signal,
|
|
1818
|
+
});
|
|
760
1819
|
return {
|
|
761
|
-
...
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1820
|
+
...result,
|
|
1821
|
+
zone_shifts: zoneShifts,
|
|
1822
|
+
stale_sources: staleSources,
|
|
1823
|
+
no_shows: noShows,
|
|
1824
|
+
waitlist_offers: waitlistOffers,
|
|
765
1825
|
};
|
|
766
1826
|
}
|
|
767
1827
|
catch (error) {
|