slotlock 0.3.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 +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- 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/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/agent-store-backend.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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';
|
|
8
11
|
/**
|
|
@@ -11,6 +14,14 @@ import { parseAvailabilityRules } from './weekly-hours.js';
|
|
|
11
14
|
* and refuses it otherwise.
|
|
12
15
|
*/
|
|
13
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
|
+
}
|
|
14
25
|
function assertActive(context) {
|
|
15
26
|
if (context.signal.aborted)
|
|
16
27
|
throw new SlotlockAgentOperationError('request_aborted', 503);
|
|
@@ -19,9 +30,11 @@ async function withTenant(store, context, callback, options) {
|
|
|
19
30
|
assertActive(context);
|
|
20
31
|
let value;
|
|
21
32
|
try {
|
|
33
|
+
const actor = actorOf(context);
|
|
22
34
|
value = await store.withTenant(context.principal.tenantRef, callback, {
|
|
23
35
|
...options,
|
|
24
36
|
signal: context.signal,
|
|
37
|
+
...(actor !== undefined ? { actor } : {}),
|
|
25
38
|
});
|
|
26
39
|
}
|
|
27
40
|
catch (error) {
|
|
@@ -136,6 +149,12 @@ function agentPrincipalDigest(context, idempotencyKey) {
|
|
|
136
149
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
|
|
137
150
|
.digest('hex');
|
|
138
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
|
+
}
|
|
139
158
|
function agentOwnerRef(context) {
|
|
140
159
|
return `agent:${createHash('sha256')
|
|
141
160
|
.update(`${context.principal.tenantRef}\0${context.principal.subject}`)
|
|
@@ -197,16 +216,38 @@ export function projectSlotlockCalendarEvent(event) {
|
|
|
197
216
|
recurrence_id: null,
|
|
198
217
|
};
|
|
199
218
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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;
|
|
204
227
|
if (code.startsWith('invalid_'))
|
|
205
|
-
return
|
|
206
|
-
if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
|
|
207
|
-
return
|
|
208
|
-
|
|
209
|
-
|
|
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);
|
|
210
251
|
}
|
|
211
252
|
/** Resource and event ids: the UUIDs the store issues and validates. */
|
|
212
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;
|
|
@@ -277,6 +318,277 @@ function requireAgentResource(tenantStore, tenantRef, resourceId) {
|
|
|
277
318
|
/** Bind the protocol server to the production store without giving a request control of tenancy. */
|
|
278
319
|
export function createSlotlockStoreAgentBackend(store, options) {
|
|
279
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
|
+
},
|
|
280
592
|
async describeResource(context, resourceId) {
|
|
281
593
|
if (!STORE_ID_PATTERN.test(resourceId))
|
|
282
594
|
return null;
|
|
@@ -293,6 +605,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
293
605
|
},
|
|
294
606
|
async listResources(context, input) {
|
|
295
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
|
+
}
|
|
296
625
|
return withTenant(store, context, async (tenantStore) => {
|
|
297
626
|
const limit = input.limit;
|
|
298
627
|
const resources = await tenantStore.listResources({
|
|
@@ -303,11 +632,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
303
632
|
});
|
|
304
633
|
const page = resources.slice(0, limit);
|
|
305
634
|
return {
|
|
306
|
-
resources: page.map(
|
|
307
|
-
id: resource.id,
|
|
308
|
-
external_ref: resource.externalRef,
|
|
309
|
-
timezone: resource.timezone,
|
|
310
|
-
})),
|
|
635
|
+
resources: page.map(output),
|
|
311
636
|
next_cursor: resources.length > limit ? (page.at(-1)?.id ?? null) : null,
|
|
312
637
|
};
|
|
313
638
|
});
|
|
@@ -354,11 +679,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
354
679
|
let incomplete = false;
|
|
355
680
|
for (const resourceId of resourceIds) {
|
|
356
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
|
+
});
|
|
357
690
|
const sources = await requiredSources(options, context, resource);
|
|
358
691
|
const freeBusy = await readFreeBusy(tenantStore, {
|
|
359
692
|
tenantRef: context.principal.tenantRef,
|
|
360
693
|
resourceId,
|
|
361
|
-
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
|
+
},
|
|
362
699
|
requiredSources: [...sources],
|
|
363
700
|
...(options.coverageMaxAgeMs !== undefined
|
|
364
701
|
? { coverageMaxAgeMs: options.coverageMaxAgeMs }
|
|
@@ -369,12 +706,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
369
706
|
incomplete = true;
|
|
370
707
|
continue;
|
|
371
708
|
}
|
|
709
|
+
if (!search)
|
|
710
|
+
continue;
|
|
372
711
|
const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
|
|
373
|
-
const windows = expandRules(rules, window, resource.timezone);
|
|
712
|
+
const windows = expandRules(rules, search.window, resource.timezone);
|
|
374
713
|
windowsLeft -= windows.length;
|
|
375
714
|
if (windowsLeft < 0)
|
|
376
715
|
throw new SlotlockAgentOperationError('calendar_work_limit', 400);
|
|
377
|
-
|
|
716
|
+
// A slot [s, s + d) with its turnaround meets busy [b, e) when [s, s + d) meets [b - t, e).
|
|
717
|
+
const candidate = findNextAvailable({
|
|
718
|
+
busy: freeBusy.busy.map((busy) => ({
|
|
719
|
+
start: new Date(busy.start.getTime() - search.bufferAfterMs),
|
|
720
|
+
end: busy.end,
|
|
721
|
+
})),
|
|
722
|
+
windows,
|
|
723
|
+
durationMs,
|
|
724
|
+
...(search.align ? { align: search.align } : {}),
|
|
725
|
+
});
|
|
378
726
|
if (candidate &&
|
|
379
727
|
(!best ||
|
|
380
728
|
candidate.start.getTime() < best.interval.start.getTime() ||
|
|
@@ -431,9 +779,15 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
431
779
|
source: 'agent-server',
|
|
432
780
|
...content,
|
|
433
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
|
+
: {}),
|
|
434
788
|
});
|
|
435
789
|
if (!result.ok)
|
|
436
|
-
throw operationError(result
|
|
790
|
+
throw operationError(result);
|
|
437
791
|
const created = await tenantStore.getCalendarEvent({
|
|
438
792
|
tenantRef: context.principal.tenantRef,
|
|
439
793
|
ownerRef: agentOwnerRef(context),
|
|
@@ -490,7 +844,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
490
844
|
!STORE_ID_PATTERN.test(input.expected_resource_id)) {
|
|
491
845
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
492
846
|
}
|
|
493
|
-
|
|
847
|
+
const moved = await withTenant(store, context, async (tenantStore) => {
|
|
494
848
|
const current = await tenantStore.getCalendarEvent({
|
|
495
849
|
tenantRef: context.principal.tenantRef,
|
|
496
850
|
ownerRef: agentOwnerRef(context),
|
|
@@ -568,9 +922,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
568
922
|
source: current.source ?? 'agent-server',
|
|
569
923
|
...content,
|
|
570
924
|
materializationWindow: materializationWindow(content),
|
|
925
|
+
// An agent's event is a booking made now: the resource's booking rules apply.
|
|
926
|
+
enforceBookingRules: true,
|
|
571
927
|
});
|
|
572
928
|
if (!result.ok)
|
|
573
|
-
throw operationError(result
|
|
929
|
+
throw operationError(result);
|
|
574
930
|
const updated = await tenantStore.getCalendarEvent({
|
|
575
931
|
tenantRef: context.principal.tenantRef,
|
|
576
932
|
ownerRef: agentOwnerRef(context),
|
|
@@ -580,9 +936,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
580
936
|
throw new SlotlockAgentOperationError('store_inconsistent', 503);
|
|
581
937
|
return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
|
|
582
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;
|
|
583
943
|
},
|
|
584
944
|
async deleteEvent(context, input) {
|
|
585
|
-
|
|
945
|
+
const deleted = await withTenant(store, context, async (tenantStore) => {
|
|
586
946
|
const externalRef = input.event_id;
|
|
587
947
|
const current = await tenantStore.getCalendarEvent({
|
|
588
948
|
tenantRef: context.principal.tenantRef,
|
|
@@ -600,7 +960,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
600
960
|
expectedRevision: input.expected_revision,
|
|
601
961
|
});
|
|
602
962
|
if (!result.ok)
|
|
603
|
-
throw operationError(result
|
|
963
|
+
throw operationError(result);
|
|
604
964
|
return {
|
|
605
965
|
event_id: externalRef,
|
|
606
966
|
revision: result.revision,
|
|
@@ -608,6 +968,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
|
|
|
608
968
|
replayed: result.idempotent,
|
|
609
969
|
};
|
|
610
970
|
}, WRITE);
|
|
971
|
+
if (!deleted.replayed)
|
|
972
|
+
await offerFreedTime(store, context.principal.tenantRef);
|
|
973
|
+
return deleted;
|
|
611
974
|
},
|
|
612
975
|
};
|
|
613
976
|
}
|
|
@@ -635,6 +998,49 @@ function applicationRefusal(code) {
|
|
|
635
998
|
return new SlotlockAgentOperationError(code, 400);
|
|
636
999
|
return new SlotlockAgentOperationError(code, 409);
|
|
637
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
|
+
}
|
|
638
1044
|
function rulesOutput(rules) {
|
|
639
1045
|
if (!rules)
|
|
640
1046
|
return null;
|
|
@@ -659,14 +1065,51 @@ function rulesInput(value) {
|
|
|
659
1065
|
throw storeRefusal(error);
|
|
660
1066
|
}
|
|
661
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
|
+
}
|
|
662
1103
|
function resourceOutput(resource) {
|
|
663
1104
|
return {
|
|
664
1105
|
id: resource.id,
|
|
665
1106
|
external_ref: resource.externalRef,
|
|
666
1107
|
timezone: resource.timezone,
|
|
667
1108
|
availability: rulesOutput(resource.availabilityRules),
|
|
1109
|
+
booking_rules: bookingRulesOutput(resource.bookingRules),
|
|
668
1110
|
visibility: resource.visibility ?? 'shared',
|
|
669
1111
|
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
1112
|
+
pool: resource.pool ?? null,
|
|
670
1113
|
};
|
|
671
1114
|
}
|
|
672
1115
|
/** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
|
|
@@ -737,6 +1180,35 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
737
1180
|
}
|
|
738
1181
|
const tenantErasure = options.tenantErasure === true;
|
|
739
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
|
+
}
|
|
740
1212
|
return {
|
|
741
1213
|
async createResource(context, input) {
|
|
742
1214
|
// A capped create counts under READ COMMITTED; withTenant opens it for the call.
|
|
@@ -834,6 +1306,105 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
834
1306
|
return { resource: resourceOutput(resource) };
|
|
835
1307
|
});
|
|
836
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
|
+
},
|
|
837
1408
|
async setTenantAvailability(context, input) {
|
|
838
1409
|
const rules = rulesInput(input.rules);
|
|
839
1410
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -917,9 +1488,12 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
917
1488
|
// An id that is not one the store issues names no hold: there is nothing to release.
|
|
918
1489
|
if (!STORE_ID_PATTERN.test(id))
|
|
919
1490
|
return { released: false };
|
|
920
|
-
|
|
1491
|
+
const released = await withTenant(store, context, async (tenant) => ({
|
|
921
1492
|
released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
|
|
922
1493
|
}));
|
|
1494
|
+
if (released.released)
|
|
1495
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1496
|
+
return released;
|
|
923
1497
|
},
|
|
924
1498
|
async getReservation(context, input) {
|
|
925
1499
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -948,7 +1522,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
948
1522
|
});
|
|
949
1523
|
},
|
|
950
1524
|
async putReservation(context, input) {
|
|
951
|
-
|
|
1525
|
+
const put = await withTenant(store, context, async (tenant) => {
|
|
952
1526
|
const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
|
|
953
1527
|
const command = {
|
|
954
1528
|
tenantRef: tenantOf(context),
|
|
@@ -967,9 +1541,14 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
967
1541
|
throw applicationRefusal(result.code);
|
|
968
1542
|
return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
|
|
969
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;
|
|
970
1549
|
},
|
|
971
1550
|
async cancelReservation(context, input) {
|
|
972
|
-
|
|
1551
|
+
const cancelled = await withTenant(store, context, async (tenant) => {
|
|
973
1552
|
const result = await tenant.cancelExternalReservation({
|
|
974
1553
|
tenantRef: tenantOf(context),
|
|
975
1554
|
externalRef: input.reservation_ref,
|
|
@@ -979,6 +1558,101 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
979
1558
|
throw applicationRefusal(result.code);
|
|
980
1559
|
return { cancelled: result.cancelled, revision: result.revision };
|
|
981
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() };
|
|
1574
|
+
});
|
|
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);
|
|
982
1656
|
},
|
|
983
1657
|
async recordCoverage(context, input) {
|
|
984
1658
|
return withTenant(store, context, async (tenant) => {
|
|
@@ -1022,6 +1696,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1022
1696
|
return { event: tenantEventOutput(event) };
|
|
1023
1697
|
});
|
|
1024
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
|
+
},
|
|
1025
1710
|
async cancelTenantEvent(context, input) {
|
|
1026
1711
|
const id = input.event_id;
|
|
1027
1712
|
const expectedResourceId = input.expected_resource_id;
|
|
@@ -1030,7 +1715,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1030
1715
|
(expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
|
|
1031
1716
|
throw new SlotlockAgentOperationError('event_not_found', 404);
|
|
1032
1717
|
}
|
|
1033
|
-
|
|
1718
|
+
const cancelled = await withTenant(store, context, async (tenant) => {
|
|
1034
1719
|
const result = await tenant.cancelCalendarEventById({
|
|
1035
1720
|
tenantRef: tenantOf(context),
|
|
1036
1721
|
eventId: id,
|
|
@@ -1047,6 +1732,9 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1047
1732
|
replayed: result.idempotent,
|
|
1048
1733
|
};
|
|
1049
1734
|
}, WRITE);
|
|
1735
|
+
if (!cancelled.replayed)
|
|
1736
|
+
await offerFreedTime(store, tenantOf(context));
|
|
1737
|
+
return cancelled;
|
|
1050
1738
|
},
|
|
1051
1739
|
async getTenant(context) {
|
|
1052
1740
|
return withTenant(store, context, async (tenant) => ({
|
|
@@ -1073,15 +1761,68 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
|
|
|
1073
1761
|
return { total: erased.total, rows: erased.rows };
|
|
1074
1762
|
}, WRITE);
|
|
1075
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
|
+
},
|
|
1076
1811
|
async runMaintenance(context) {
|
|
1077
1812
|
assertActive(context);
|
|
1078
1813
|
try {
|
|
1079
1814
|
// A caller goes round again while `capped`; when to do so at once is the scheduler's.
|
|
1080
|
-
const { rollPending: _, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1815
|
+
const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
|
|
1081
1816
|
batches,
|
|
1082
1817
|
signal: context.signal,
|
|
1083
1818
|
});
|
|
1084
|
-
return
|
|
1819
|
+
return {
|
|
1820
|
+
...result,
|
|
1821
|
+
zone_shifts: zoneShifts,
|
|
1822
|
+
stale_sources: staleSources,
|
|
1823
|
+
no_shows: noShows,
|
|
1824
|
+
waitlist_offers: waitlistOffers,
|
|
1825
|
+
};
|
|
1085
1826
|
}
|
|
1086
1827
|
catch (error) {
|
|
1087
1828
|
if (context.signal.aborted)
|