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.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
@@ -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 { findNextAvailable } from './engine.js';
4
+ import { bookingSearch } from './booking-rules.js';
5
+ import { findNextAvailable, mergeIntervals } from './engine.js';
4
6
  import { expandRules } from './rules.js';
5
- import { calendarEventRollingHorizon } from './store.js';
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
- const value = options === undefined
15
- ? await store.withTenant(context.principal.tenantRef, callback)
16
- : await store.withTenant(context.principal.tenantRef, callback, options);
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
- function operationError(code) {
143
- if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found')) {
144
- return new SlotlockAgentOperationError('not_found', 404);
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 new SlotlockAgentOperationError(code, 400);
148
- if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded') {
149
- return new SlotlockAgentOperationError(code, 429);
150
- }
151
- return new SlotlockAgentOperationError(code, 409);
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
- function encodeEventCursor(event) {
185
- return Buffer.from(JSON.stringify({ start: event.start.toISOString(), id: event.id }), 'utf8').toString('base64url');
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({ tenantRef, id: resourceId });
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((resource) => ({
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.resource_ids;
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 requireResource(tenantStore, context.principal.tenantRef, resourceId);
647
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
246
648
  const sources = await requiredSources(options, context, resource);
247
- const result = await tenantStore.getFreeBusy({
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.resource_ids;
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 requireResource(tenantStore, context.principal.tenantRef, resourceId);
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.getFreeBusy({
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
- windows: expandRules(rules, window, resource.timezone),
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 requireResource(tenantStore, context.principal.tenantRef, input.resource_id);
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.code);
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.resource_ids;
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 requireResource(tenantStore, context.principal.tenantRef, resourceId);
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
- return withTenant(store, context, async (tenantStore) => {
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 requireResource(tenantStore, context.principal.tenantRef, resourceId);
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.code);
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
- return withTenant(store, context, async (tenantStore) => {
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.code);
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
- return withTenant(store, context, async (tenant) => ({
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 putReservation(context, input) {
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
- return withTenant(store, context, async (tenant) => {
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
- ...(await maintainSlotlockTenant(store, tenantOf(context), {
762
- batches,
763
- signal: context.signal,
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) {