slotlock 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
@@ -3,16 +3,78 @@ import { SlotlockAgentOperationError, } from './agent-server.js';
3
3
  import { findNextAvailable } from './engine.js';
4
4
  import { expandRules } from './rules.js';
5
5
  import { calendarEventRollingHorizon } from './store.js';
6
+ import { maintainSlotlockTenant } from './maintenance.js';
7
+ import { parseAvailabilityRules } from './weekly-hours.js';
8
+ /**
9
+ * A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
10
+ * `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
11
+ * and refuses it otherwise.
12
+ */
13
+ const WRITE = { isolation: 'read committed' };
6
14
  function assertActive(context) {
7
15
  if (context.signal.aborted)
8
16
  throw new SlotlockAgentOperationError('request_aborted', 503);
9
17
  }
10
- async function withTenant(store, context, callback) {
18
+ async function withTenant(store, context, callback, options) {
11
19
  assertActive(context);
12
- const value = await store.withTenant(context.principal.tenantRef, callback);
20
+ let value;
21
+ try {
22
+ value = await store.withTenant(context.principal.tenantRef, callback, {
23
+ ...options,
24
+ signal: context.signal,
25
+ });
26
+ }
27
+ catch (error) {
28
+ const code = error?.code;
29
+ // A suspended tenant may read; a write it asks for is forbidden, not a server failure.
30
+ if (code === 'tenant_suspended')
31
+ throw new SlotlockAgentOperationError('tenant_suspended', 403);
32
+ // Waited past the store's lock timeout behind the tenant's own writes: retry later.
33
+ if (code === 'lock_timeout')
34
+ throw new SlotlockAgentOperationError('server_busy', 503);
35
+ assertActive(context);
36
+ throw error;
37
+ }
13
38
  assertActive(context);
14
39
  return value;
15
40
  }
41
+ /**
42
+ * Busy intervals one free/busy read or search may gather across all the resources it names: as many
43
+ * as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
44
+ * one resource 100 times, did 100 times the work of one.
45
+ */
46
+ const MAX_REQUEST_BUSY_INTERVALS = 10_000;
47
+ /**
48
+ * Bookable windows one search may expand from its resources' availability rules in all: five
49
+ * resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
50
+ * resources over a year expanded 1.8 million windows and held the process for seven seconds.
51
+ */
52
+ const MAX_REQUEST_RULE_WINDOWS = 100_000;
53
+ /** The resources a request names, each once, in the order first named. */
54
+ function distinctResourceIds(input) {
55
+ return [...new Set(input.resource_ids)];
56
+ }
57
+ /** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
58
+ function busyBudget() {
59
+ let left = MAX_REQUEST_BUSY_INTERVALS;
60
+ return (intervals) => {
61
+ left -= intervals;
62
+ if (left < 0)
63
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
64
+ };
65
+ }
66
+ /** The store's free/busy read, its refusal to read past its limit answered as the caller's to narrow. */
67
+ async function readFreeBusy(tenantStore, params) {
68
+ try {
69
+ return await tenantStore.getFreeBusy(params);
70
+ }
71
+ catch (error) {
72
+ if (error?.code === 'calendar_work_limit') {
73
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
74
+ }
75
+ throw error;
76
+ }
77
+ }
16
78
  function interval(input) {
17
79
  return { start: new Date(input.start), end: new Date(input.end) };
18
80
  }
@@ -177,25 +239,41 @@ function parseEventCursor(value) {
177
239
  throw new SlotlockAgentOperationError('invalid_cursor', 400);
178
240
  }
179
241
  }
180
- function encodeEventCursor(event) {
181
- return Buffer.from(JSON.stringify({ start: event.start.toISOString(), id: event.id }), 'utf8').toString('base64url');
242
+ /** The cursor after one row of a page ordered by start then id: an event, a hold or a booking. */
243
+ function encodeEventCursor(row) {
244
+ return Buffer.from(JSON.stringify({ start: row.start.toISOString(), id: row.id }), 'utf8').toString('base64url');
245
+ }
246
+ /** One page of rows read with one more than `limit`, and the cursor to the next when there is one. */
247
+ function page(rows, limit) {
248
+ const shown = rows.slice(0, limit);
249
+ const last = shown.at(-1);
250
+ return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
182
251
  }
183
252
  /**
184
253
  * The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
185
254
  * send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
186
255
  * issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
187
256
  * invalid identity, so such an id names no resource: it is not found, like a well-formed id that
188
- * names nothing or another tenant's resource.
257
+ * names nothing or another tenant's resource. With `visibility`, a resource of the other is not
258
+ * found either: agents ask for `shared`, so a private resource is invisible to them.
189
259
  */
190
- async function requireResource(tenantStore, tenantRef, resourceId) {
260
+ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
191
261
  if (!STORE_ID_PATTERN.test(resourceId)) {
192
262
  throw new SlotlockAgentOperationError('resource_not_found', 404);
193
263
  }
194
- const resource = await tenantStore.getResource({ tenantRef, id: resourceId });
264
+ const resource = await tenantStore.getResource({
265
+ tenantRef,
266
+ id: resourceId,
267
+ ...(visibility !== undefined ? { visibility } : {}),
268
+ });
195
269
  if (!resource)
196
270
  throw new SlotlockAgentOperationError('resource_not_found', 404);
197
271
  return resource;
198
272
  }
273
+ /** The caller's shared resource with this id: what an agent may see and book. */
274
+ function requireAgentResource(tenantStore, tenantRef, resourceId) {
275
+ return requireResource(tenantStore, tenantRef, resourceId, 'shared');
276
+ }
199
277
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
200
278
  export function createSlotlockStoreAgentBackend(store, options) {
201
279
  return {
@@ -206,6 +284,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
206
284
  const resource = await tenantStore.getResource({
207
285
  tenantRef: context.principal.tenantRef,
208
286
  id: resourceId,
287
+ visibility: 'shared',
209
288
  });
210
289
  return resource
211
290
  ? { name: resource.externalRef ?? null, timezone: resource.timezone }
@@ -218,6 +297,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
218
297
  const limit = input.limit;
219
298
  const resources = await tenantStore.listResources({
220
299
  tenantRef: context.principal.tenantRef,
300
+ visibility: 'shared',
221
301
  limit: limit + 1,
222
302
  ...(cursor !== undefined ? { after: cursor } : {}),
223
303
  });
@@ -234,13 +314,14 @@ export function createSlotlockStoreAgentBackend(store, options) {
234
314
  },
235
315
  async getFreeBusy(context, input) {
236
316
  const window = interval(input);
237
- const resourceIds = input.resource_ids;
317
+ const resourceIds = distinctResourceIds(input);
318
+ const charge = busyBudget();
238
319
  return withTenant(store, context, async (tenantStore) => {
239
320
  const resources = [];
240
321
  for (const resourceId of resourceIds) {
241
- const resource = await requireResource(tenantStore, context.principal.tenantRef, resourceId);
322
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
242
323
  const sources = await requiredSources(options, context, resource);
243
- const result = await tenantStore.getFreeBusy({
324
+ const result = await readFreeBusy(tenantStore, {
244
325
  tenantRef: context.principal.tenantRef,
245
326
  resourceId,
246
327
  window,
@@ -249,6 +330,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
249
330
  ? { coverageMaxAgeMs: options.coverageMaxAgeMs }
250
331
  : {}),
251
332
  });
333
+ charge(result.busy.length);
252
334
  resources.push({
253
335
  resource_id: resourceId,
254
336
  busy: result.busy.map((busy) => ({
@@ -263,15 +345,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
263
345
  },
264
346
  async findNextAvailable(context, input) {
265
347
  const window = interval(input);
266
- const resourceIds = input.resource_ids;
348
+ const resourceIds = distinctResourceIds(input);
267
349
  const durationMs = input.duration_minutes * 60_000;
350
+ const charge = busyBudget();
351
+ let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
268
352
  return withTenant(store, context, async (tenantStore) => {
269
353
  let best = null;
270
354
  let incomplete = false;
271
355
  for (const resourceId of resourceIds) {
272
- const resource = await requireResource(tenantStore, context.principal.tenantRef, resourceId);
356
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
273
357
  const sources = await requiredSources(options, context, resource);
274
- const freeBusy = await tenantStore.getFreeBusy({
358
+ const freeBusy = await readFreeBusy(tenantStore, {
275
359
  tenantRef: context.principal.tenantRef,
276
360
  resourceId,
277
361
  window,
@@ -280,16 +364,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
280
364
  ? { coverageMaxAgeMs: options.coverageMaxAgeMs }
281
365
  : {}),
282
366
  });
367
+ charge(freeBusy.busy.length);
283
368
  if (freeBusy.coverage.state !== 'complete') {
284
369
  incomplete = true;
285
370
  continue;
286
371
  }
287
372
  const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
288
- const candidate = findNextAvailable({
289
- busy: freeBusy.busy,
290
- windows: expandRules(rules, window, resource.timezone),
291
- durationMs,
292
- });
373
+ const windows = expandRules(rules, window, resource.timezone);
374
+ windowsLeft -= windows.length;
375
+ if (windowsLeft < 0)
376
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
377
+ const candidate = findNextAvailable({ busy: freeBusy.busy, windows, durationMs });
293
378
  if (candidate &&
294
379
  (!best ||
295
380
  candidate.start.getTime() < best.interval.start.getTime() ||
@@ -313,7 +398,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
313
398
  async createEvent(context, rawInput) {
314
399
  const input = rawInput;
315
400
  return withTenant(store, context, async (tenantStore) => {
316
- await requireResource(tenantStore, context.principal.tenantRef, input.resource_id);
401
+ await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
317
402
  const recurrence = recurrenceFromInput(input.recurrence_rule, input.recurrence_exceptions);
318
403
  const content = {
319
404
  start: new Date(input.starts_at),
@@ -357,7 +442,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
357
442
  if (!created)
358
443
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
359
444
  return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
360
- });
445
+ }, WRITE);
361
446
  },
362
447
  async getEvent(context, input) {
363
448
  return withTenant(store, context, async (tenantStore) => {
@@ -373,13 +458,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
373
458
  },
374
459
  async listEvents(context, input) {
375
460
  const window = interval(input);
376
- const resourceIds = input.resource_ids;
461
+ const resourceIds = distinctResourceIds(input);
377
462
  const limit = input.limit;
378
463
  const after = parseEventCursor(input.cursor);
379
464
  return withTenant(store, context, async (tenantStore) => {
380
465
  const combined = [];
381
466
  for (const resourceId of resourceIds) {
382
- await requireResource(tenantStore, context.principal.tenantRef, resourceId);
467
+ await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
383
468
  combined.push(...(await tenantStore.listCalendarEvents({
384
469
  tenantRef: context.principal.tenantRef,
385
470
  ownerRef: agentOwnerRef(context),
@@ -400,6 +485,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
400
485
  },
401
486
  async updateEvent(context, rawInput) {
402
487
  const input = rawInput;
488
+ // No event is on a resource the store never issued (an external reference, say).
489
+ if (input.expected_resource_id !== undefined &&
490
+ !STORE_ID_PATTERN.test(input.expected_resource_id)) {
491
+ throw new SlotlockAgentOperationError('event_not_found', 404);
492
+ }
403
493
  return withTenant(store, context, async (tenantStore) => {
404
494
  const current = await tenantStore.getCalendarEvent({
405
495
  tenantRef: context.principal.tenantRef,
@@ -410,7 +500,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
410
500
  throw new SlotlockAgentOperationError('event_not_found', 404);
411
501
  const resourceId = input.resource_id ?? current.resourceId;
412
502
  if (resourceId !== current.resourceId) {
413
- await requireResource(tenantStore, context.principal.tenantRef, resourceId);
503
+ await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
414
504
  }
415
505
  const recurrence = input.recurrence_rule === null
416
506
  ? undefined
@@ -469,6 +559,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
469
559
  tenantRef: context.principal.tenantRef,
470
560
  ownerRef: agentOwnerRef(context),
471
561
  externalRef: current.externalRef,
562
+ // The caller's fence when it sent one, checked under the event's lock; otherwise the
563
+ // resource just read, so a move between that read and the write is not overwritten.
564
+ expectedResourceId: input.expected_resource_id ?? current.resourceId,
472
565
  resourceId,
473
566
  expectedRevision: input.expected_revision,
474
567
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
@@ -486,7 +579,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
486
579
  if (!updated)
487
580
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
488
581
  return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
489
- });
582
+ }, WRITE);
490
583
  },
491
584
  async deleteEvent(context, input) {
492
585
  return withTenant(store, context, async (tenantStore) => {
@@ -502,6 +595,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
502
595
  tenantRef: context.principal.tenantRef,
503
596
  ownerRef: agentOwnerRef(context),
504
597
  externalRef,
598
+ expectedResourceId: current.resourceId,
505
599
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
506
600
  expectedRevision: input.expected_revision,
507
601
  });
@@ -513,7 +607,487 @@ export function createSlotlockStoreAgentBackend(store, options) {
513
607
  deleted: true,
514
608
  replayed: result.idempotent,
515
609
  };
610
+ }, WRITE);
611
+ },
612
+ };
613
+ }
614
+ /** A store failure thrown with a `code` the caller may see, or the error itself to report. */
615
+ function storeRefusal(error) {
616
+ const code = error?.code;
617
+ if (typeof code !== 'string')
618
+ return error;
619
+ if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
620
+ return new SlotlockAgentOperationError(code, 409);
621
+ }
622
+ if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
623
+ return new SlotlockAgentOperationError(code, 400);
624
+ }
625
+ return error;
626
+ }
627
+ /** The status an application refusal answers with; the resource of another tenant is not found. */
628
+ function applicationRefusal(code) {
629
+ if (code === 'resource_tenant_mismatch') {
630
+ return new SlotlockAgentOperationError('resource_not_found', 404);
631
+ }
632
+ if (code.endsWith('_not_found'))
633
+ return new SlotlockAgentOperationError(code, 404);
634
+ if (code.startsWith('invalid_'))
635
+ return new SlotlockAgentOperationError(code, 400);
636
+ return new SlotlockAgentOperationError(code, 409);
637
+ }
638
+ function rulesOutput(rules) {
639
+ if (!rules)
640
+ return null;
641
+ return rules.map((rule) => ({
642
+ rrule: rule.rrule,
643
+ start_minutes: rule.startMinutes,
644
+ duration_minutes: rule.durationMinutes,
645
+ }));
646
+ }
647
+ function rulesInput(value) {
648
+ if (value === null)
649
+ return null;
650
+ const rules = value.map((rule) => ({
651
+ rrule: rule.rrule,
652
+ startMinutes: rule.start_minutes,
653
+ durationMinutes: rule.duration_minutes,
654
+ }));
655
+ try {
656
+ return parseAvailabilityRules(rules);
657
+ }
658
+ catch (error) {
659
+ throw storeRefusal(error);
660
+ }
661
+ }
662
+ function resourceOutput(resource) {
663
+ return {
664
+ id: resource.id,
665
+ external_ref: resource.externalRef,
666
+ timezone: resource.timezone,
667
+ availability: rulesOutput(resource.availabilityRules),
668
+ visibility: resource.visibility ?? 'shared',
669
+ archived_at: resource.archivedAt?.toISOString() ?? null,
670
+ };
671
+ }
672
+ /** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
673
+ function tenantEventOutput(event) {
674
+ return {
675
+ ...projectSlotlockCalendarEvent(event),
676
+ id: event.id,
677
+ event_ref: event.externalRef,
678
+ owner_ref: event.ownerRef,
679
+ };
680
+ }
681
+ function holdOutput(hold) {
682
+ return {
683
+ id: hold.id,
684
+ resource_id: hold.resourceId,
685
+ start: hold.start.toISOString(),
686
+ end: hold.end.toISOString(),
687
+ expires_at: hold.expiresAt.toISOString(),
688
+ source: hold.source,
689
+ };
690
+ }
691
+ function reservationOutput(reservation) {
692
+ return {
693
+ id: reservation.id,
694
+ resource_id: reservation.resourceId,
695
+ reservation_ref: reservation.externalRef,
696
+ start: reservation.start.toISOString(),
697
+ end: reservation.end.toISOString(),
698
+ // The store keeps milliseconds; a buffer set in seconds through the library still shows as busy.
699
+ buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
700
+ revision: reservation.revision,
701
+ source: reservation.source,
702
+ };
703
+ }
704
+ /**
705
+ * The namespace an application caller's idempotency keys live in: a digest of its tenant and
706
+ * subject, so the authenticated subject never lands in a durable row.
707
+ */
708
+ function applicationPrincipalRef(context) {
709
+ return `application:${createHash('sha256')
710
+ .update(`${context.principal.tenantRef}\0${context.principal.subject}`)
711
+ .digest('hex')}`;
712
+ }
713
+ /**
714
+ * An application caller's event command key: its tenant, subject and own key, digested, so one
715
+ * caller's key never replays another's command and neither the subject nor the raw key is stored.
716
+ */
717
+ function applicationCommandKey(context, idempotencyKey) {
718
+ return `application-command:${createHash('sha256')
719
+ .update(`${context.principal.tenantRef}\0${context.principal.subject}\0${idempotencyKey}`)
720
+ .digest('hex')}`;
721
+ }
722
+ /** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
723
+ const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
724
+ /**
725
+ * Serve the application API (application-api.ts) from the production store, in the principal's
726
+ * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
727
+ * reservations under the tenant's row-level security, so another tenant's hold is not found either.
728
+ */
729
+ export function createSlotlockStoreApplicationBackend(store, options = {}) {
730
+ const max = options.maxTenantResources;
731
+ if (max !== undefined && !(Number.isSafeInteger(max) && max >= 1 && max <= 100_000)) {
732
+ throw new RangeError('maxTenantResources must be a whole number from 1 to 100,000');
733
+ }
734
+ const batches = options.maintenanceBatches ?? 10;
735
+ if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
736
+ throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
737
+ }
738
+ const tenantErasure = options.tenantErasure === true;
739
+ const tenantOf = (context) => context.principal.tenantRef;
740
+ return {
741
+ async createResource(context, input) {
742
+ // A capped create counts under READ COMMITTED; withTenant opens it for the call.
743
+ return withTenant(store, context, async (tenant) => {
744
+ try {
745
+ const resource = await tenant.createResource({
746
+ tenantRef: tenantOf(context),
747
+ externalRef: input.external_ref,
748
+ timezone: input.timezone,
749
+ ...(input.visibility !== undefined
750
+ ? { visibility: input.visibility }
751
+ : {}),
752
+ ...(max !== undefined ? { maxTenantResources: max } : {}),
753
+ });
754
+ return { resource: resourceOutput(resource) };
755
+ }
756
+ catch (error) {
757
+ throw storeRefusal(error);
758
+ }
759
+ }, max !== undefined ? { isolation: 'read committed' } : undefined);
760
+ },
761
+ async getResource(context, input) {
762
+ return withTenant(store, context, async (tenant) => ({
763
+ resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
764
+ }));
765
+ },
766
+ async archiveResource(context, input) {
767
+ const id = input.resource_id;
768
+ if (!STORE_ID_PATTERN.test(id))
769
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
770
+ return withTenant(store, context, async (tenant) => {
771
+ const resource = await tenant.archiveResource({ tenantRef: tenantOf(context), id });
772
+ if (!resource)
773
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
774
+ return { resource: resourceOutput(resource) };
775
+ }, WRITE);
776
+ },
777
+ async restoreResource(context, input) {
778
+ const id = input.resource_id;
779
+ if (!STORE_ID_PATTERN.test(id))
780
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
781
+ return withTenant(store, context, async (tenant) => {
782
+ const resource = await tenant.restoreResource({ tenantRef: tenantOf(context), id });
783
+ if (!resource)
784
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
785
+ return { resource: resourceOutput(resource) };
786
+ }, WRITE);
787
+ },
788
+ async deleteResource(context, input) {
789
+ const id = input.resource_id;
790
+ // An id the store never issues names no resource: there is nothing to delete.
791
+ if (!STORE_ID_PATTERN.test(id))
792
+ return { deleted: false };
793
+ return withTenant(store, context, async (tenant) => {
794
+ const result = await tenant.deleteResource({ tenantRef: tenantOf(context), id });
795
+ if (!result.ok)
796
+ throw applicationRefusal(result.code);
797
+ return { deleted: result.deleted };
798
+ }, WRITE);
799
+ },
800
+ async listBusy(context, input) {
801
+ return withTenant(store, context, async (tenant) => {
802
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
803
+ // One row past the cap is enough to refuse: the rest is never read.
804
+ const busy = await tenant.listBusy(resource.id, interval(input), {
805
+ tenantRef: tenantOf(context),
806
+ limit: MAX_LISTED_BUSY + 1,
807
+ });
808
+ if (busy.length > MAX_LISTED_BUSY) {
809
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
810
+ }
811
+ return {
812
+ busy: busy.map((occupied) => ({
813
+ start: occupied.start.toISOString(),
814
+ end: occupied.end.toISOString(),
815
+ })),
816
+ };
516
817
  });
517
818
  },
819
+ async setResourceAvailability(context, input) {
820
+ const rules = rulesInput(input.rules);
821
+ const id = input.resource_id;
822
+ if (!STORE_ID_PATTERN.test(id))
823
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
824
+ return withTenant(store, context, async (tenant) => {
825
+ let resource;
826
+ try {
827
+ resource = await tenant.setResourceAvailability({ tenantRef: tenantOf(context), id, rules });
828
+ }
829
+ catch (error) {
830
+ throw storeRefusal(error);
831
+ }
832
+ if (!resource)
833
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
834
+ return { resource: resourceOutput(resource) };
835
+ });
836
+ },
837
+ async setTenantAvailability(context, input) {
838
+ const rules = rulesInput(input.rules);
839
+ return withTenant(store, context, async (tenant) => {
840
+ try {
841
+ return {
842
+ resources_updated: await tenant.setTenantAvailability({
843
+ tenantRef: tenantOf(context),
844
+ rules,
845
+ }),
846
+ };
847
+ }
848
+ catch (error) {
849
+ throw storeRefusal(error);
850
+ }
851
+ });
852
+ },
853
+ async acquireHold(context, input) {
854
+ return withTenant(store, context, async (tenant) => {
855
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
856
+ const result = await tenant.acquireHold({
857
+ tenantRef: tenantOf(context),
858
+ resourceId: resource.id,
859
+ start: new Date(input.start),
860
+ end: new Date(input.end),
861
+ ttlMs: input.ttl_seconds * 1_000,
862
+ ...(input.source !== undefined ? { source: input.source } : {}),
863
+ ...(input.buffer_after_minutes !== undefined
864
+ ? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
865
+ : {}),
866
+ // A retried request with the caller's key answers the hold the first one made.
867
+ ...(input.idempotency_key !== undefined
868
+ ? {
869
+ idempotencyKey: input.idempotency_key,
870
+ principalRef: applicationPrincipalRef(context),
871
+ }
872
+ : {}),
873
+ });
874
+ if (!result.ok)
875
+ throw applicationRefusal(result.code);
876
+ return { hold: holdOutput(result.hold) };
877
+ }, WRITE);
878
+ },
879
+ async getHold(context, input) {
880
+ const id = input.hold_id;
881
+ if (!STORE_ID_PATTERN.test(id))
882
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
883
+ return withTenant(store, context, async (tenant) => {
884
+ const hold = await tenant.getHold({ tenantRef: tenantOf(context), holdId: id });
885
+ if (!hold)
886
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
887
+ return { hold: holdOutput(hold) };
888
+ });
889
+ },
890
+ async listHolds(context, input) {
891
+ const limit = input.limit;
892
+ const after = parseEventCursor(input.cursor);
893
+ return withTenant(store, context, async (tenant) => {
894
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
895
+ const { shown, next } = page(await tenant.listHolds({
896
+ tenantRef: tenantOf(context),
897
+ resourceId: resource.id,
898
+ limit: limit + 1,
899
+ ...(after ? { after } : {}),
900
+ }), limit);
901
+ return { holds: shown.map(holdOutput), next_cursor: next };
902
+ });
903
+ },
904
+ async confirmHold(context, input) {
905
+ const id = input.hold_id;
906
+ if (!STORE_ID_PATTERN.test(id))
907
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
908
+ return withTenant(store, context, async (tenant) => {
909
+ const result = await tenant.confirmHold(id, { tenantRef: tenantOf(context) });
910
+ if (!result.ok)
911
+ throw applicationRefusal(result.code);
912
+ return { reservation: reservationOutput(result.reservation) };
913
+ }, WRITE);
914
+ },
915
+ async releaseHold(context, input) {
916
+ const id = input.hold_id;
917
+ // An id that is not one the store issues names no hold: there is nothing to release.
918
+ if (!STORE_ID_PATTERN.test(id))
919
+ return { released: false };
920
+ return withTenant(store, context, async (tenant) => ({
921
+ released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
922
+ }));
923
+ },
924
+ async getReservation(context, input) {
925
+ return withTenant(store, context, async (tenant) => {
926
+ const reservation = await tenant.getReservation({
927
+ tenantRef: tenantOf(context),
928
+ externalRef: input.reservation_ref,
929
+ });
930
+ if (!reservation)
931
+ throw new SlotlockAgentOperationError('reservation_not_found', 404);
932
+ return { reservation: reservationOutput(reservation) };
933
+ });
934
+ },
935
+ async listReservations(context, input) {
936
+ const limit = input.limit;
937
+ const after = parseEventCursor(input.cursor);
938
+ return withTenant(store, context, async (tenant) => {
939
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
940
+ const { shown, next } = page(await tenant.listReservations({
941
+ tenantRef: tenantOf(context),
942
+ resourceId: resource.id,
943
+ window: interval(input),
944
+ limit: limit + 1,
945
+ ...(after ? { after } : {}),
946
+ }), limit);
947
+ return { reservations: shown.map(reservationOutput), next_cursor: next };
948
+ });
949
+ },
950
+ async putReservation(context, input) {
951
+ return withTenant(store, context, async (tenant) => {
952
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
953
+ const command = {
954
+ tenantRef: tenantOf(context),
955
+ externalRef: input.reservation_ref,
956
+ resourceId: resource.id,
957
+ start: new Date(input.start),
958
+ end: new Date(input.end),
959
+ bufferAfterMs: input.buffer_after_minutes * 60_000,
960
+ revision: input.revision,
961
+ ...(input.source !== undefined ? { source: input.source } : {}),
962
+ };
963
+ const result = command.revision === 1
964
+ ? await tenant.createExternalReservation(command)
965
+ : await tenant.rescheduleExternalReservation(command);
966
+ if (!result.ok)
967
+ throw applicationRefusal(result.code);
968
+ return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
969
+ }, WRITE);
970
+ },
971
+ async cancelReservation(context, input) {
972
+ return withTenant(store, context, async (tenant) => {
973
+ const result = await tenant.cancelExternalReservation({
974
+ tenantRef: tenantOf(context),
975
+ externalRef: input.reservation_ref,
976
+ revision: input.revision,
977
+ });
978
+ if (!result.ok)
979
+ throw applicationRefusal(result.code);
980
+ return { cancelled: result.cancelled, revision: result.revision };
981
+ }, WRITE);
982
+ },
983
+ async recordCoverage(context, input) {
984
+ return withTenant(store, context, async (tenant) => {
985
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
986
+ const result = await tenant.recordCalendarCoverage({
987
+ tenantRef: tenantOf(context),
988
+ resourceId: resource.id,
989
+ source: input.source,
990
+ window: interval(input),
991
+ revision: input.revision,
992
+ });
993
+ if (!result.ok)
994
+ throw applicationRefusal(result.code);
995
+ return { revision: result.revision, replayed: result.idempotent };
996
+ }, WRITE);
997
+ },
998
+ async listTenantEvents(context, input) {
999
+ const limit = input.limit;
1000
+ const after = parseEventCursor(input.cursor);
1001
+ return withTenant(store, context, async (tenant) => {
1002
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
1003
+ const { shown, next } = page(await tenant.listCalendarEvents({
1004
+ tenantRef: tenantOf(context),
1005
+ allOwners: true,
1006
+ resourceId: resource.id,
1007
+ window: interval(input),
1008
+ limit: limit + 1,
1009
+ ...(after ? { after } : {}),
1010
+ }), limit);
1011
+ return { events: shown.map(tenantEventOutput), next_cursor: next };
1012
+ });
1013
+ },
1014
+ async getTenantEvent(context, input) {
1015
+ const id = input.event_id;
1016
+ if (!STORE_ID_PATTERN.test(id))
1017
+ throw new SlotlockAgentOperationError('event_not_found', 404);
1018
+ return withTenant(store, context, async (tenant) => {
1019
+ const event = await tenant.getCalendarEventById({ tenantRef: tenantOf(context), eventId: id });
1020
+ if (!event)
1021
+ throw new SlotlockAgentOperationError('event_not_found', 404);
1022
+ return { event: tenantEventOutput(event) };
1023
+ });
1024
+ },
1025
+ async cancelTenantEvent(context, input) {
1026
+ const id = input.event_id;
1027
+ const expectedResourceId = input.expected_resource_id;
1028
+ // An id the store never issues names no event, and no event is on a resource it never issued.
1029
+ if (!STORE_ID_PATTERN.test(id) ||
1030
+ (expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
1031
+ throw new SlotlockAgentOperationError('event_not_found', 404);
1032
+ }
1033
+ return withTenant(store, context, async (tenant) => {
1034
+ const result = await tenant.cancelCalendarEventById({
1035
+ tenantRef: tenantOf(context),
1036
+ eventId: id,
1037
+ idempotencyKey: applicationCommandKey(context, input.idempotency_key),
1038
+ expectedRevision: input.expected_revision,
1039
+ ...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
1040
+ });
1041
+ if (!result.ok)
1042
+ throw applicationRefusal(result.code);
1043
+ return {
1044
+ event_id: result.eventId,
1045
+ revision: result.revision,
1046
+ cancelled: true,
1047
+ replayed: result.idempotent,
1048
+ };
1049
+ }, WRITE);
1050
+ },
1051
+ async getTenant(context) {
1052
+ return withTenant(store, context, async (tenant) => ({
1053
+ tenant: {
1054
+ ref: tenantOf(context),
1055
+ suspended: await tenant.isTenantSuspended({ tenantRef: tenantOf(context) }),
1056
+ },
1057
+ }));
1058
+ },
1059
+ async eraseTenant(context, input) {
1060
+ if (!tenantErasure)
1061
+ throw new SlotlockAgentOperationError('tenant_erasure_disabled', 403);
1062
+ // The reference, not a yes: a key of another tenant than the caller believes cannot erase it.
1063
+ if (input.confirm_tenant_ref !== tenantOf(context)) {
1064
+ throw new SlotlockAgentOperationError('invalid_confirmation', 400);
1065
+ }
1066
+ return withTenant(store, context, async (tenant) => {
1067
+ // Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
1068
+ // kept for its operator, who erases it (slotlock tenant erase) when they decide to.
1069
+ if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
1070
+ throw new SlotlockAgentOperationError('tenant_suspended', 403);
1071
+ }
1072
+ const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) });
1073
+ return { total: erased.total, rows: erased.rows };
1074
+ }, WRITE);
1075
+ },
1076
+ async runMaintenance(context) {
1077
+ assertActive(context);
1078
+ try {
1079
+ // 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), {
1081
+ batches,
1082
+ signal: context.signal,
1083
+ });
1084
+ return result;
1085
+ }
1086
+ catch (error) {
1087
+ if (context.signal.aborted)
1088
+ throw new SlotlockAgentOperationError('request_aborted', 503);
1089
+ throw error;
1090
+ }
1091
+ },
518
1092
  };
519
1093
  }