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.
@@ -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 { 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';
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
- function operationError(code) {
201
- if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found')) {
202
- return new SlotlockAgentOperationError('not_found', 404);
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 new SlotlockAgentOperationError(code, 400);
206
- if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded') {
207
- return new SlotlockAgentOperationError(code, 429);
208
- }
209
- 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);
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((resource) => ({
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
- const candidate = findNextAvailable({ busy: freeBusy.busy, windows, durationMs });
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.code);
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
- return withTenant(store, context, async (tenantStore) => {
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.code);
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
- return withTenant(store, context, async (tenantStore) => {
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.code);
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
- return withTenant(store, context, async (tenant) => ({
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
- return withTenant(store, context, async (tenant) => {
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
- return withTenant(store, context, async (tenant) => {
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
- return withTenant(store, context, async (tenant) => {
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 result;
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)