slotlock 0.2.0 → 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 (51) hide show
  1. package/CHANGELOG.md +330 -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 +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
@@ -5,18 +5,76 @@ import { expandRules } from './rules.js';
5
5
  import { calendarEventRollingHorizon } from './store.js';
6
6
  import { maintainSlotlockTenant } from './maintenance.js';
7
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' };
8
14
  function assertActive(context) {
9
15
  if (context.signal.aborted)
10
16
  throw new SlotlockAgentOperationError('request_aborted', 503);
11
17
  }
12
18
  async function withTenant(store, context, callback, options) {
13
19
  assertActive(context);
14
- const value = options === undefined
15
- ? await store.withTenant(context.principal.tenantRef, callback)
16
- : await store.withTenant(context.principal.tenantRef, callback, options);
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
+ }
17
38
  assertActive(context);
18
39
  return value;
19
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
+ }
20
78
  function interval(input) {
21
79
  return { start: new Date(input.start), end: new Date(input.end) };
22
80
  }
@@ -181,25 +239,41 @@ function parseEventCursor(value) {
181
239
  throw new SlotlockAgentOperationError('invalid_cursor', 400);
182
240
  }
183
241
  }
184
- function encodeEventCursor(event) {
185
- 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 };
186
251
  }
187
252
  /**
188
253
  * The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
189
254
  * send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
190
255
  * issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
191
256
  * 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.
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.
193
259
  */
194
- async function requireResource(tenantStore, tenantRef, resourceId) {
260
+ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
195
261
  if (!STORE_ID_PATTERN.test(resourceId)) {
196
262
  throw new SlotlockAgentOperationError('resource_not_found', 404);
197
263
  }
198
- const resource = await tenantStore.getResource({ tenantRef, id: resourceId });
264
+ const resource = await tenantStore.getResource({
265
+ tenantRef,
266
+ id: resourceId,
267
+ ...(visibility !== undefined ? { visibility } : {}),
268
+ });
199
269
  if (!resource)
200
270
  throw new SlotlockAgentOperationError('resource_not_found', 404);
201
271
  return resource;
202
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
+ }
203
277
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
204
278
  export function createSlotlockStoreAgentBackend(store, options) {
205
279
  return {
@@ -210,6 +284,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
210
284
  const resource = await tenantStore.getResource({
211
285
  tenantRef: context.principal.tenantRef,
212
286
  id: resourceId,
287
+ visibility: 'shared',
213
288
  });
214
289
  return resource
215
290
  ? { name: resource.externalRef ?? null, timezone: resource.timezone }
@@ -222,6 +297,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
222
297
  const limit = input.limit;
223
298
  const resources = await tenantStore.listResources({
224
299
  tenantRef: context.principal.tenantRef,
300
+ visibility: 'shared',
225
301
  limit: limit + 1,
226
302
  ...(cursor !== undefined ? { after: cursor } : {}),
227
303
  });
@@ -238,13 +314,14 @@ export function createSlotlockStoreAgentBackend(store, options) {
238
314
  },
239
315
  async getFreeBusy(context, input) {
240
316
  const window = interval(input);
241
- const resourceIds = input.resource_ids;
317
+ const resourceIds = distinctResourceIds(input);
318
+ const charge = busyBudget();
242
319
  return withTenant(store, context, async (tenantStore) => {
243
320
  const resources = [];
244
321
  for (const resourceId of resourceIds) {
245
- const resource = await requireResource(tenantStore, context.principal.tenantRef, resourceId);
322
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
246
323
  const sources = await requiredSources(options, context, resource);
247
- const result = await tenantStore.getFreeBusy({
324
+ const result = await readFreeBusy(tenantStore, {
248
325
  tenantRef: context.principal.tenantRef,
249
326
  resourceId,
250
327
  window,
@@ -253,6 +330,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
253
330
  ? { coverageMaxAgeMs: options.coverageMaxAgeMs }
254
331
  : {}),
255
332
  });
333
+ charge(result.busy.length);
256
334
  resources.push({
257
335
  resource_id: resourceId,
258
336
  busy: result.busy.map((busy) => ({
@@ -267,15 +345,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
267
345
  },
268
346
  async findNextAvailable(context, input) {
269
347
  const window = interval(input);
270
- const resourceIds = input.resource_ids;
348
+ const resourceIds = distinctResourceIds(input);
271
349
  const durationMs = input.duration_minutes * 60_000;
350
+ const charge = busyBudget();
351
+ let windowsLeft = MAX_REQUEST_RULE_WINDOWS;
272
352
  return withTenant(store, context, async (tenantStore) => {
273
353
  let best = null;
274
354
  let incomplete = false;
275
355
  for (const resourceId of resourceIds) {
276
- const resource = await requireResource(tenantStore, context.principal.tenantRef, resourceId);
356
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
277
357
  const sources = await requiredSources(options, context, resource);
278
- const freeBusy = await tenantStore.getFreeBusy({
358
+ const freeBusy = await readFreeBusy(tenantStore, {
279
359
  tenantRef: context.principal.tenantRef,
280
360
  resourceId,
281
361
  window,
@@ -284,16 +364,17 @@ export function createSlotlockStoreAgentBackend(store, options) {
284
364
  ? { coverageMaxAgeMs: options.coverageMaxAgeMs }
285
365
  : {}),
286
366
  });
367
+ charge(freeBusy.busy.length);
287
368
  if (freeBusy.coverage.state !== 'complete') {
288
369
  incomplete = true;
289
370
  continue;
290
371
  }
291
372
  const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
292
- const candidate = findNextAvailable({
293
- busy: freeBusy.busy,
294
- windows: expandRules(rules, window, resource.timezone),
295
- durationMs,
296
- });
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 });
297
378
  if (candidate &&
298
379
  (!best ||
299
380
  candidate.start.getTime() < best.interval.start.getTime() ||
@@ -317,7 +398,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
317
398
  async createEvent(context, rawInput) {
318
399
  const input = rawInput;
319
400
  return withTenant(store, context, async (tenantStore) => {
320
- await requireResource(tenantStore, context.principal.tenantRef, input.resource_id);
401
+ await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
321
402
  const recurrence = recurrenceFromInput(input.recurrence_rule, input.recurrence_exceptions);
322
403
  const content = {
323
404
  start: new Date(input.starts_at),
@@ -361,7 +442,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
361
442
  if (!created)
362
443
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
363
444
  return { event: projectSlotlockCalendarEvent(created), replayed: result.idempotent };
364
- });
445
+ }, WRITE);
365
446
  },
366
447
  async getEvent(context, input) {
367
448
  return withTenant(store, context, async (tenantStore) => {
@@ -377,13 +458,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
377
458
  },
378
459
  async listEvents(context, input) {
379
460
  const window = interval(input);
380
- const resourceIds = input.resource_ids;
461
+ const resourceIds = distinctResourceIds(input);
381
462
  const limit = input.limit;
382
463
  const after = parseEventCursor(input.cursor);
383
464
  return withTenant(store, context, async (tenantStore) => {
384
465
  const combined = [];
385
466
  for (const resourceId of resourceIds) {
386
- await requireResource(tenantStore, context.principal.tenantRef, resourceId);
467
+ await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
387
468
  combined.push(...(await tenantStore.listCalendarEvents({
388
469
  tenantRef: context.principal.tenantRef,
389
470
  ownerRef: agentOwnerRef(context),
@@ -404,6 +485,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
404
485
  },
405
486
  async updateEvent(context, rawInput) {
406
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
+ }
407
493
  return withTenant(store, context, async (tenantStore) => {
408
494
  const current = await tenantStore.getCalendarEvent({
409
495
  tenantRef: context.principal.tenantRef,
@@ -414,7 +500,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
414
500
  throw new SlotlockAgentOperationError('event_not_found', 404);
415
501
  const resourceId = input.resource_id ?? current.resourceId;
416
502
  if (resourceId !== current.resourceId) {
417
- await requireResource(tenantStore, context.principal.tenantRef, resourceId);
503
+ await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
418
504
  }
419
505
  const recurrence = input.recurrence_rule === null
420
506
  ? undefined
@@ -473,6 +559,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
473
559
  tenantRef: context.principal.tenantRef,
474
560
  ownerRef: agentOwnerRef(context),
475
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,
476
565
  resourceId,
477
566
  expectedRevision: input.expected_revision,
478
567
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
@@ -490,7 +579,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
490
579
  if (!updated)
491
580
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
492
581
  return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
493
- });
582
+ }, WRITE);
494
583
  },
495
584
  async deleteEvent(context, input) {
496
585
  return withTenant(store, context, async (tenantStore) => {
@@ -506,6 +595,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
506
595
  tenantRef: context.principal.tenantRef,
507
596
  ownerRef: agentOwnerRef(context),
508
597
  externalRef,
598
+ expectedResourceId: current.resourceId,
509
599
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
510
600
  expectedRevision: input.expected_revision,
511
601
  });
@@ -517,7 +607,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
517
607
  deleted: true,
518
608
  replayed: result.idempotent,
519
609
  };
520
- });
610
+ }, WRITE);
521
611
  },
522
612
  };
523
613
  }
@@ -526,8 +616,9 @@ function storeRefusal(error) {
526
616
  const code = error?.code;
527
617
  if (typeof code !== 'string')
528
618
  return error;
529
- if (code === 'resource_limit_reached')
619
+ if (code === 'resource_limit_reached' || code === 'resource_visibility_conflict') {
530
620
  return new SlotlockAgentOperationError(code, 409);
621
+ }
531
622
  if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
532
623
  return new SlotlockAgentOperationError(code, 400);
533
624
  }
@@ -574,6 +665,17 @@ function resourceOutput(resource) {
574
665
  external_ref: resource.externalRef,
575
666
  timezone: resource.timezone,
576
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,
577
679
  };
578
680
  }
579
681
  function holdOutput(hold) {
@@ -599,6 +701,26 @@ function reservationOutput(reservation) {
599
701
  source: reservation.source,
600
702
  };
601
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;
602
724
  /**
603
725
  * Serve the application API (application-api.ts) from the production store, in the principal's
604
726
  * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
@@ -613,6 +735,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
613
735
  if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
614
736
  throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
615
737
  }
738
+ const tenantErasure = options.tenantErasure === true;
616
739
  const tenantOf = (context) => context.principal.tenantRef;
617
740
  return {
618
741
  async createResource(context, input) {
@@ -623,6 +746,9 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
623
746
  tenantRef: tenantOf(context),
624
747
  externalRef: input.external_ref,
625
748
  timezone: input.timezone,
749
+ ...(input.visibility !== undefined
750
+ ? { visibility: input.visibility }
751
+ : {}),
626
752
  ...(max !== undefined ? { maxTenantResources: max } : {}),
627
753
  });
628
754
  return { resource: resourceOutput(resource) };
@@ -637,6 +763,59 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
637
763
  resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
638
764
  }));
639
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
+ };
817
+ });
818
+ },
640
819
  async setResourceAvailability(context, input) {
641
820
  const rules = rulesInput(input.rules);
642
821
  const id = input.resource_id;
@@ -675,15 +854,51 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
675
854
  return withTenant(store, context, async (tenant) => {
676
855
  const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
677
856
  const result = await tenant.acquireHold({
857
+ tenantRef: tenantOf(context),
678
858
  resourceId: resource.id,
679
859
  start: new Date(input.start),
680
860
  end: new Date(input.end),
681
861
  ttlMs: input.ttl_seconds * 1_000,
682
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
+ : {}),
683
873
  });
684
874
  if (!result.ok)
685
875
  throw applicationRefusal(result.code);
686
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 };
687
902
  });
688
903
  },
689
904
  async confirmHold(context, input) {
@@ -695,7 +910,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
695
910
  if (!result.ok)
696
911
  throw applicationRefusal(result.code);
697
912
  return { reservation: reservationOutput(result.reservation) };
698
- });
913
+ }, WRITE);
699
914
  },
700
915
  async releaseHold(context, input) {
701
916
  const id = input.hold_id;
@@ -706,6 +921,32 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
706
921
  released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
707
922
  }));
708
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
+ },
709
950
  async putReservation(context, input) {
710
951
  return withTenant(store, context, async (tenant) => {
711
952
  const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
@@ -725,7 +966,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
725
966
  if (!result.ok)
726
967
  throw applicationRefusal(result.code);
727
968
  return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
728
- });
969
+ }, WRITE);
729
970
  },
730
971
  async cancelReservation(context, input) {
731
972
  return withTenant(store, context, async (tenant) => {
@@ -737,7 +978,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
737
978
  if (!result.ok)
738
979
  throw applicationRefusal(result.code);
739
980
  return { cancelled: result.cancelled, revision: result.revision };
740
- });
981
+ }, WRITE);
741
982
  },
742
983
  async recordCoverage(context, input) {
743
984
  return withTenant(store, context, async (tenant) => {
@@ -752,17 +993,95 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
752
993
  if (!result.ok)
753
994
  throw applicationRefusal(result.code);
754
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 };
755
1012
  });
756
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
+ },
757
1076
  async runMaintenance(context) {
758
1077
  assertActive(context);
759
1078
  try {
760
- return {
761
- ...(await maintainSlotlockTenant(store, tenantOf(context), {
762
- batches,
763
- signal: context.signal,
764
- })),
765
- };
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;
766
1085
  }
767
1086
  catch (error) {
768
1087
  if (context.signal.aborted)
package/api-keys.d.ts CHANGED
@@ -117,7 +117,10 @@ export declare function createSlotlockApiKeyStore(sql: SlotlockSql): SlotlockApi
117
117
  export interface SlotlockApiKeyAuthenticatorOptions {
118
118
  /** Key lookups that may run at once, default 2: at most this many connections serve them. */
119
119
  maxConcurrentLookups?: number;
120
- /** Lookups that may wait for one, default 256. A request beyond that is anonymous at once. */
120
+ /**
121
+ * Lookups that may wait for one, default 256, and as many again for keys that recently
122
+ * authenticated. A request beyond that is refused as busy at once.
123
+ */
121
124
  maxWaitingLookups?: number;
122
125
  }
123
126
  /**
@@ -126,7 +129,12 @@ export interface SlotlockApiKeyAuthenticatorOptions {
126
129
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
127
130
  *
128
131
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
129
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
130
- * the pool stays free for authenticated work. Rate-limit a public server in front of it as well.
132
+ * most `maxConcurrentLookups` connections, and past `maxWaitingLookups` a request is refused with a
133
+ * `SlotlockAgentOperationError` (`server_busy`, 503) rather than answered anonymous, which would
134
+ * tell a client holding a valid key that its key is bad. A key that authenticated in the last 15
135
+ * minutes waits in a queue of its own that is served first, so made-up keys, which never
136
+ * authenticate, cannot keep it waiting. Every request is still looked up, so a revoked key stops
137
+ * at once. Rate-limit a public server in front of it as well (`slotlock serve` does, per client
138
+ * address, before any lookup).
131
139
  */
132
140
  export declare function createSlotlockApiKeyAuthenticator(keys: Pick<SlotlockApiKeyStore, 'authenticate'>, options?: SlotlockApiKeyAuthenticatorOptions): (request: Request) => Promise<SlotlockAgentPrincipal | null>;