slotlock 0.1.1 → 0.2.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.
@@ -3,13 +3,17 @@ 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';
6
8
  function assertActive(context) {
7
9
  if (context.signal.aborted)
8
10
  throw new SlotlockAgentOperationError('request_aborted', 503);
9
11
  }
10
- async function withTenant(store, context, callback) {
12
+ async function withTenant(store, context, callback, options) {
11
13
  assertActive(context);
12
- const value = await store.withTenant(context.principal.tenantRef, callback);
14
+ const value = options === undefined
15
+ ? await store.withTenant(context.principal.tenantRef, callback)
16
+ : await store.withTenant(context.principal.tenantRef, callback, options);
13
17
  assertActive(context);
14
18
  return value;
15
19
  }
@@ -517,3 +521,254 @@ export function createSlotlockStoreAgentBackend(store, options) {
517
521
  },
518
522
  };
519
523
  }
524
+ /** A store failure thrown with a `code` the caller may see, or the error itself to report. */
525
+ function storeRefusal(error) {
526
+ const code = error?.code;
527
+ if (typeof code !== 'string')
528
+ return error;
529
+ if (code === 'resource_limit_reached')
530
+ return new SlotlockAgentOperationError(code, 409);
531
+ if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
532
+ return new SlotlockAgentOperationError(code, 400);
533
+ }
534
+ return error;
535
+ }
536
+ /** The status an application refusal answers with; the resource of another tenant is not found. */
537
+ function applicationRefusal(code) {
538
+ if (code === 'resource_tenant_mismatch') {
539
+ return new SlotlockAgentOperationError('resource_not_found', 404);
540
+ }
541
+ if (code.endsWith('_not_found'))
542
+ return new SlotlockAgentOperationError(code, 404);
543
+ if (code.startsWith('invalid_'))
544
+ return new SlotlockAgentOperationError(code, 400);
545
+ return new SlotlockAgentOperationError(code, 409);
546
+ }
547
+ function rulesOutput(rules) {
548
+ if (!rules)
549
+ return null;
550
+ return rules.map((rule) => ({
551
+ rrule: rule.rrule,
552
+ start_minutes: rule.startMinutes,
553
+ duration_minutes: rule.durationMinutes,
554
+ }));
555
+ }
556
+ function rulesInput(value) {
557
+ if (value === null)
558
+ return null;
559
+ const rules = value.map((rule) => ({
560
+ rrule: rule.rrule,
561
+ startMinutes: rule.start_minutes,
562
+ durationMinutes: rule.duration_minutes,
563
+ }));
564
+ try {
565
+ return parseAvailabilityRules(rules);
566
+ }
567
+ catch (error) {
568
+ throw storeRefusal(error);
569
+ }
570
+ }
571
+ function resourceOutput(resource) {
572
+ return {
573
+ id: resource.id,
574
+ external_ref: resource.externalRef,
575
+ timezone: resource.timezone,
576
+ availability: rulesOutput(resource.availabilityRules),
577
+ };
578
+ }
579
+ function holdOutput(hold) {
580
+ return {
581
+ id: hold.id,
582
+ resource_id: hold.resourceId,
583
+ start: hold.start.toISOString(),
584
+ end: hold.end.toISOString(),
585
+ expires_at: hold.expiresAt.toISOString(),
586
+ source: hold.source,
587
+ };
588
+ }
589
+ function reservationOutput(reservation) {
590
+ return {
591
+ id: reservation.id,
592
+ resource_id: reservation.resourceId,
593
+ reservation_ref: reservation.externalRef,
594
+ start: reservation.start.toISOString(),
595
+ end: reservation.end.toISOString(),
596
+ // The store keeps milliseconds; a buffer set in seconds through the library still shows as busy.
597
+ buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
598
+ revision: reservation.revision,
599
+ source: reservation.source,
600
+ };
601
+ }
602
+ /**
603
+ * Serve the application API (application-api.ts) from the production store, in the principal's
604
+ * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
605
+ * reservations under the tenant's row-level security, so another tenant's hold is not found either.
606
+ */
607
+ export function createSlotlockStoreApplicationBackend(store, options = {}) {
608
+ const max = options.maxTenantResources;
609
+ if (max !== undefined && !(Number.isSafeInteger(max) && max >= 1 && max <= 100_000)) {
610
+ throw new RangeError('maxTenantResources must be a whole number from 1 to 100,000');
611
+ }
612
+ const batches = options.maintenanceBatches ?? 10;
613
+ if (!(Number.isSafeInteger(batches) && batches >= 1 && batches <= 100)) {
614
+ throw new RangeError('maintenanceBatches must be a whole number from 1 to 100');
615
+ }
616
+ const tenantOf = (context) => context.principal.tenantRef;
617
+ return {
618
+ async createResource(context, input) {
619
+ // A capped create counts under READ COMMITTED; withTenant opens it for the call.
620
+ return withTenant(store, context, async (tenant) => {
621
+ try {
622
+ const resource = await tenant.createResource({
623
+ tenantRef: tenantOf(context),
624
+ externalRef: input.external_ref,
625
+ timezone: input.timezone,
626
+ ...(max !== undefined ? { maxTenantResources: max } : {}),
627
+ });
628
+ return { resource: resourceOutput(resource) };
629
+ }
630
+ catch (error) {
631
+ throw storeRefusal(error);
632
+ }
633
+ }, max !== undefined ? { isolation: 'read committed' } : undefined);
634
+ },
635
+ async getResource(context, input) {
636
+ return withTenant(store, context, async (tenant) => ({
637
+ resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
638
+ }));
639
+ },
640
+ async setResourceAvailability(context, input) {
641
+ const rules = rulesInput(input.rules);
642
+ const id = input.resource_id;
643
+ if (!STORE_ID_PATTERN.test(id))
644
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
645
+ return withTenant(store, context, async (tenant) => {
646
+ let resource;
647
+ try {
648
+ resource = await tenant.setResourceAvailability({ tenantRef: tenantOf(context), id, rules });
649
+ }
650
+ catch (error) {
651
+ throw storeRefusal(error);
652
+ }
653
+ if (!resource)
654
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
655
+ return { resource: resourceOutput(resource) };
656
+ });
657
+ },
658
+ async setTenantAvailability(context, input) {
659
+ const rules = rulesInput(input.rules);
660
+ return withTenant(store, context, async (tenant) => {
661
+ try {
662
+ return {
663
+ resources_updated: await tenant.setTenantAvailability({
664
+ tenantRef: tenantOf(context),
665
+ rules,
666
+ }),
667
+ };
668
+ }
669
+ catch (error) {
670
+ throw storeRefusal(error);
671
+ }
672
+ });
673
+ },
674
+ async acquireHold(context, input) {
675
+ return withTenant(store, context, async (tenant) => {
676
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
677
+ const result = await tenant.acquireHold({
678
+ resourceId: resource.id,
679
+ start: new Date(input.start),
680
+ end: new Date(input.end),
681
+ ttlMs: input.ttl_seconds * 1_000,
682
+ ...(input.source !== undefined ? { source: input.source } : {}),
683
+ });
684
+ if (!result.ok)
685
+ throw applicationRefusal(result.code);
686
+ return { hold: holdOutput(result.hold) };
687
+ });
688
+ },
689
+ async confirmHold(context, input) {
690
+ const id = input.hold_id;
691
+ if (!STORE_ID_PATTERN.test(id))
692
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
693
+ return withTenant(store, context, async (tenant) => {
694
+ const result = await tenant.confirmHold(id, { tenantRef: tenantOf(context) });
695
+ if (!result.ok)
696
+ throw applicationRefusal(result.code);
697
+ return { reservation: reservationOutput(result.reservation) };
698
+ });
699
+ },
700
+ async releaseHold(context, input) {
701
+ const id = input.hold_id;
702
+ // An id that is not one the store issues names no hold: there is nothing to release.
703
+ if (!STORE_ID_PATTERN.test(id))
704
+ return { released: false };
705
+ return withTenant(store, context, async (tenant) => ({
706
+ released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
707
+ }));
708
+ },
709
+ async putReservation(context, input) {
710
+ return withTenant(store, context, async (tenant) => {
711
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
712
+ const command = {
713
+ tenantRef: tenantOf(context),
714
+ externalRef: input.reservation_ref,
715
+ resourceId: resource.id,
716
+ start: new Date(input.start),
717
+ end: new Date(input.end),
718
+ bufferAfterMs: input.buffer_after_minutes * 60_000,
719
+ revision: input.revision,
720
+ ...(input.source !== undefined ? { source: input.source } : {}),
721
+ };
722
+ const result = command.revision === 1
723
+ ? await tenant.createExternalReservation(command)
724
+ : await tenant.rescheduleExternalReservation(command);
725
+ if (!result.ok)
726
+ throw applicationRefusal(result.code);
727
+ return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
728
+ });
729
+ },
730
+ async cancelReservation(context, input) {
731
+ return withTenant(store, context, async (tenant) => {
732
+ const result = await tenant.cancelExternalReservation({
733
+ tenantRef: tenantOf(context),
734
+ externalRef: input.reservation_ref,
735
+ revision: input.revision,
736
+ });
737
+ if (!result.ok)
738
+ throw applicationRefusal(result.code);
739
+ return { cancelled: result.cancelled, revision: result.revision };
740
+ });
741
+ },
742
+ async recordCoverage(context, input) {
743
+ return withTenant(store, context, async (tenant) => {
744
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
745
+ const result = await tenant.recordCalendarCoverage({
746
+ tenantRef: tenantOf(context),
747
+ resourceId: resource.id,
748
+ source: input.source,
749
+ window: interval(input),
750
+ revision: input.revision,
751
+ });
752
+ if (!result.ok)
753
+ throw applicationRefusal(result.code);
754
+ return { revision: result.revision, replayed: result.idempotent };
755
+ });
756
+ },
757
+ async runMaintenance(context) {
758
+ assertActive(context);
759
+ try {
760
+ return {
761
+ ...(await maintainSlotlockTenant(store, tenantOf(context), {
762
+ batches,
763
+ signal: context.signal,
764
+ })),
765
+ };
766
+ }
767
+ catch (error) {
768
+ if (context.signal.aborted)
769
+ throw new SlotlockAgentOperationError('request_aborted', 503);
770
+ throw error;
771
+ }
772
+ },
773
+ };
774
+ }
package/api-keys.d.ts CHANGED
@@ -1,10 +1,17 @@
1
- import { type SlotlockAgentPrincipal, type SlotlockAgentScope } from './agent-server.js';
1
+ import { type SlotlockAgentPrincipal } from './agent-server.js';
2
2
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
3
3
  import type { SlotlockSql } from './store.js';
4
4
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
5
- /** `read` covers the operations that only look; `write` the ones that change a calendar. */
6
- export declare const SLOTLOCK_API_KEY_SCOPES: readonly ["read", "write"];
7
- export type SlotlockApiKeyScope = SlotlockAgentScope;
5
+ /**
6
+ * `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
7
+ * the application API (application-api.ts: resources, bookable hours, holds, reservations, coverage
8
+ * and maintenance), for an application's backend rather than an agent. A key's scopes are stored in
9
+ * this order.
10
+ */
11
+ export declare const SLOTLOCK_API_KEY_SCOPES: readonly ["read", "write", "admin"];
12
+ /** What a key gets unless it asks for more: what an agent needs. `admin` is never a default. */
13
+ export declare const SLOTLOCK_API_KEY_DEFAULT_SCOPES: readonly ["read", "write"];
14
+ export type SlotlockApiKeyScope = (typeof SLOTLOCK_API_KEY_SCOPES)[number];
8
15
  /** Every key starts with this, so a person or a secret scanner can tell what it is. */
9
16
  export declare const SLOTLOCK_API_KEY_PREFIX = "slk_";
10
17
  /** The longest a key may live: ten years. A key without an expiry lives until it is revoked. */
package/api-keys.js CHANGED
@@ -10,8 +10,15 @@
10
10
  import { createHash, randomBytes } from 'node:crypto';
11
11
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
12
12
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
13
- /** `read` covers the operations that only look; `write` the ones that change a calendar. */
14
- export const SLOTLOCK_API_KEY_SCOPES = Object.freeze(['read', 'write']);
13
+ /**
14
+ * `read` covers the agent operations that only look; `write` the ones that change a calendar; `admin`
15
+ * the application API (application-api.ts: resources, bookable hours, holds, reservations, coverage
16
+ * and maintenance), for an application's backend rather than an agent. A key's scopes are stored in
17
+ * this order.
18
+ */
19
+ export const SLOTLOCK_API_KEY_SCOPES = Object.freeze(['read', 'write', 'admin']);
20
+ /** What a key gets unless it asks for more: what an agent needs. `admin` is never a default. */
21
+ export const SLOTLOCK_API_KEY_DEFAULT_SCOPES = Object.freeze(['read', 'write']);
15
22
  /** Every key starts with this, so a person or a secret scanner can tell what it is. */
16
23
  export const SLOTLOCK_API_KEY_PREFIX = 'slk_';
17
24
  /** The longest a key may live: ten years. A key without an expiry lives until it is revoked. */
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import type { SlotlockAgentPrincipal } from './agent-server.js';
3
+ import type { SlotlockTraceContext } from './mcp-modern.js';
4
+ export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_acquire_hold' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_record_coverage' | 'slotlock_run_maintenance';
5
+ /** The scope an application operation needs, whichever operation it is. */
6
+ export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
7
+ export interface SlotlockApplicationInvocationContext {
8
+ principal: SlotlockAgentPrincipal;
9
+ operation: SlotlockApplicationOperation;
10
+ signal: AbortSignal;
11
+ /** W3C trace context the caller sent, already validated. */
12
+ trace?: SlotlockTraceContext;
13
+ }
14
+ type Handler = (context: SlotlockApplicationInvocationContext, input: Record<string, unknown>) => Promise<Record<string, unknown>>;
15
+ /**
16
+ * What serves the application operations. Every method receives the input its operation's schema
17
+ * accepted, and returns what its output schema describes; a refusal is a thrown
18
+ * `SlotlockAgentOperationError`. The tenant is the principal's, never an argument.
19
+ */
20
+ export interface SlotlockApplicationBackend {
21
+ createResource: Handler;
22
+ getResource: Handler;
23
+ setResourceAvailability: Handler;
24
+ setTenantAvailability: Handler;
25
+ acquireHold: Handler;
26
+ confirmHold: Handler;
27
+ releaseHold: Handler;
28
+ putReservation: Handler;
29
+ cancelReservation: Handler;
30
+ recordCoverage: Handler;
31
+ runMaintenance: Handler;
32
+ }
33
+ export interface SlotlockApplicationOperationDefinition {
34
+ name: SlotlockApplicationOperation;
35
+ title: string;
36
+ description: string;
37
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
38
+ /** Relative to the server's base URL; `{name}` is one path segment, an input of that name. */
39
+ path: string;
40
+ input: z.ZodType<Record<string, unknown>>;
41
+ output: z.ZodType<Record<string, unknown>>;
42
+ risk: {
43
+ readOnly: boolean;
44
+ destructive: boolean;
45
+ idempotent: boolean;
46
+ };
47
+ invoke: keyof SlotlockApplicationBackend;
48
+ /** Valid input, path parameters included (a test runs each against the schema). */
49
+ example: Record<string, unknown>;
50
+ }
51
+ export declare const SLOTLOCK_APPLICATION_OPERATIONS: readonly SlotlockApplicationOperationDefinition[];
52
+ export declare function isSlotlockApplicationOperation(name: unknown): name is SlotlockApplicationOperation;
53
+ export declare function slotlockApplicationOperation(name: SlotlockApplicationOperation): SlotlockApplicationOperationDefinition;
54
+ export {};
@@ -0,0 +1,290 @@
1
+ // The application API: what an application, rather than an agent, does over HTTP that the TypeScript
2
+ // store does in process. It creates resources and sets their bookable hours, holds and reserves time,
3
+ // records how much of an external calendar it has read, and runs maintenance. Any language can call it.
4
+ //
5
+ // The agent server serves these operations as REST under /v1, beside the agent tools, when it is
6
+ // given an `application` backend and `rest` is on, and describes them in the same OpenAPI document.
7
+ // They need the `admin` scope. They are never MCP tools or A2A skills: no agent is offered them.
8
+ //
9
+ // This module holds only the contract (names, schemas, routes); it imports nothing from the agent
10
+ // server at runtime. The store-backed implementation is createSlotlockStoreApplicationBackend.
11
+ import { z } from 'zod';
12
+ /** The scope an application operation needs, whichever operation it is. */
13
+ export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
14
+ const DATE_TIME = z.string().datetime({ offset: true });
15
+ const RESOURCE_ID = z.string().trim().min(1).max(200);
16
+ const HOLD_ID = z.string().trim().min(1).max(200);
17
+ /** Matches the store's identities: not blank, at most 500 UTF-8 bytes, no control characters. */
18
+ const IDENTITY = z
19
+ .string()
20
+ .min(1)
21
+ .max(500)
22
+ .refine((value) => value.trim().length > 0 &&
23
+ Buffer.byteLength(value, 'utf8') <= 500 &&
24
+ !/[\u0000-\u001f\u007f]/.test(value), { message: 'must be 1-500 UTF-8 bytes, not blank, without control characters' });
25
+ const SOURCE = z
26
+ .string()
27
+ .min(1)
28
+ .max(200)
29
+ .refine((value) => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/.test(value), {
30
+ message: 'must not be blank or hold control characters',
31
+ });
32
+ const TIMEZONE = z.string().min(1).max(100);
33
+ const REVISION = z.number().int().positive();
34
+ /** The longest span one hold or reservation may cover: the longest event Slotlock stores. */
35
+ const MAX_SPAN_MS = 3_660 * 24 * 60 * 60 * 1_000;
36
+ const boundedSpan = (value) => {
37
+ const span = Date.parse(value.end) - Date.parse(value.start);
38
+ return span > 0 && span <= MAX_SPAN_MS;
39
+ };
40
+ const SPAN_MESSAGE = { message: 'end must be after start, by at most 3,660 days' };
41
+ const AVAILABILITY_RULE = z
42
+ .object({
43
+ rrule: z.string().min(1).max(500),
44
+ start_minutes: z.number().int().min(0).max(1_439),
45
+ duration_minutes: z.number().int().min(1).max(10_080),
46
+ })
47
+ .strict();
48
+ const AVAILABILITY_RULES = z.array(AVAILABILITY_RULE).max(50);
49
+ const RESOURCE = z
50
+ .object({
51
+ id: RESOURCE_ID,
52
+ external_ref: z.string().max(500).nullable(),
53
+ timezone: TIMEZONE,
54
+ /** The resource's own weekly hours; `null` when it uses the default. */
55
+ availability: AVAILABILITY_RULES.nullable(),
56
+ })
57
+ .strict();
58
+ const HOLD = z
59
+ .object({
60
+ id: HOLD_ID,
61
+ resource_id: RESOURCE_ID,
62
+ start: DATE_TIME,
63
+ end: DATE_TIME,
64
+ expires_at: DATE_TIME,
65
+ source: z.string().max(200).nullable(),
66
+ })
67
+ .strict();
68
+ const RESERVATION = z
69
+ .object({
70
+ id: z.string().max(200),
71
+ resource_id: RESOURCE_ID,
72
+ reservation_ref: z.string().max(500).nullable(),
73
+ start: DATE_TIME,
74
+ end: DATE_TIME,
75
+ buffer_after_minutes: z.number().int().min(0),
76
+ revision: REVISION,
77
+ source: z.string().max(200).nullable(),
78
+ })
79
+ .strict();
80
+ const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
81
+ const EXAMPLE_HOLD_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
82
+ const EXAMPLE_RULES = [
83
+ { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', start_minutes: 540, duration_minutes: 480 },
84
+ ];
85
+ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
86
+ {
87
+ name: 'slotlock_create_resource',
88
+ title: 'Create or update a resource',
89
+ description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more.',
90
+ method: 'POST',
91
+ path: '/v1/resources',
92
+ input: z.object({ external_ref: IDENTITY, timezone: TIMEZONE.default('UTC') }).strict(),
93
+ output: z.object({ resource: RESOURCE }).strict(),
94
+ risk: { readOnly: false, destructive: false, idempotent: true },
95
+ invoke: 'createResource',
96
+ example: { external_ref: 'vehicle-42', timezone: 'Europe/London' },
97
+ },
98
+ {
99
+ name: 'slotlock_get_resource',
100
+ title: 'Get a resource',
101
+ description: 'One resource of the tenant, with its own weekly bookable hours.',
102
+ method: 'GET',
103
+ path: '/v1/resources/{resource_id}',
104
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
105
+ output: z.object({ resource: RESOURCE }).strict(),
106
+ risk: { readOnly: true, destructive: false, idempotent: true },
107
+ invoke: 'getResource',
108
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
109
+ },
110
+ {
111
+ name: 'slotlock_set_resource_availability',
112
+ title: "Set a resource's bookable hours",
113
+ description: "Give one resource its own weekly bookable hours. `null` makes it use the server's default; `[]` makes it never bookable. Each rule is weekly with BYDAY, in the resource's time zone: start_minutes after midnight, lasting duration_minutes.",
114
+ method: 'PUT',
115
+ path: '/v1/resources/{resource_id}/availability',
116
+ input: z.object({ resource_id: RESOURCE_ID, rules: AVAILABILITY_RULES.nullable() }).strict(),
117
+ output: z.object({ resource: RESOURCE }).strict(),
118
+ risk: { readOnly: false, destructive: false, idempotent: true },
119
+ invoke: 'setResourceAvailability',
120
+ example: { resource_id: EXAMPLE_RESOURCE_ID, rules: EXAMPLE_RULES },
121
+ },
122
+ {
123
+ name: 'slotlock_set_tenant_availability',
124
+ title: "Set every resource's bookable hours",
125
+ description: "Set the weekly bookable hours of every resource the tenant has, as slotlock_set_resource_availability does for one. `null` makes them all use the server's default. Returns how many resources changed.",
126
+ method: 'PUT',
127
+ path: '/v1/availability',
128
+ input: z.object({ rules: AVAILABILITY_RULES.nullable() }).strict(),
129
+ output: z.object({ resources_updated: z.number().int().min(0) }).strict(),
130
+ risk: { readOnly: false, destructive: false, idempotent: true },
131
+ invoke: 'setTenantAvailability',
132
+ example: { rules: EXAMPLE_RULES },
133
+ },
134
+ {
135
+ name: 'slotlock_acquire_hold',
136
+ title: 'Hold time',
137
+ description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. A taken window answers 409 overlap.',
138
+ method: 'POST',
139
+ path: '/v1/holds',
140
+ input: z
141
+ .object({
142
+ resource_id: RESOURCE_ID,
143
+ start: DATE_TIME,
144
+ end: DATE_TIME,
145
+ ttl_seconds: z
146
+ .number()
147
+ .int()
148
+ .min(1)
149
+ .max(7 * 24 * 60 * 60),
150
+ source: SOURCE.optional(),
151
+ })
152
+ .strict()
153
+ .refine(boundedSpan, SPAN_MESSAGE),
154
+ output: z.object({ hold: HOLD }).strict(),
155
+ risk: { readOnly: false, destructive: false, idempotent: false },
156
+ invoke: 'acquireHold',
157
+ example: {
158
+ resource_id: EXAMPLE_RESOURCE_ID,
159
+ start: '2027-03-29T09:00:00Z',
160
+ end: '2027-03-29T10:00:00Z',
161
+ ttl_seconds: 600,
162
+ source: 'checkout',
163
+ },
164
+ },
165
+ {
166
+ name: 'slotlock_confirm_hold',
167
+ title: 'Confirm a hold',
168
+ description: 'Turn an unexpired hold into a confirmed reservation. Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found.',
169
+ method: 'POST',
170
+ path: '/v1/holds/{hold_id}/confirm',
171
+ input: z.object({ hold_id: HOLD_ID }).strict(),
172
+ output: z.object({ reservation: RESERVATION }).strict(),
173
+ risk: { readOnly: false, destructive: false, idempotent: false },
174
+ invoke: 'confirmHold',
175
+ example: { hold_id: EXAMPLE_HOLD_ID },
176
+ },
177
+ {
178
+ name: 'slotlock_release_hold',
179
+ title: 'Release a hold',
180
+ description: 'Free a held window. released is false when the hold was already gone: expired, confirmed or released.',
181
+ method: 'DELETE',
182
+ path: '/v1/holds/{hold_id}',
183
+ input: z.object({ hold_id: HOLD_ID }).strict(),
184
+ output: z.object({ released: z.boolean() }).strict(),
185
+ risk: { readOnly: false, destructive: true, idempotent: true },
186
+ invoke: 'releaseHold',
187
+ example: { hold_id: EXAMPLE_HOLD_ID },
188
+ },
189
+ {
190
+ name: 'slotlock_put_reservation',
191
+ title: 'Book or move a reservation',
192
+ description: 'Book the reservation with this reference (revision 1), or move it (the next revision: the current one plus 1). Sending the same reservation at its current revision again changes nothing (replayed: true). A taken window answers 409 overlap and leaves a moved reservation where it was. buffer_after_minutes keeps the time after it busy as well.',
193
+ method: 'PUT',
194
+ path: '/v1/reservations/{reservation_ref}',
195
+ input: z
196
+ .object({
197
+ reservation_ref: IDENTITY,
198
+ resource_id: RESOURCE_ID,
199
+ start: DATE_TIME,
200
+ end: DATE_TIME,
201
+ buffer_after_minutes: z
202
+ .number()
203
+ .int()
204
+ .min(0)
205
+ .max(30 * 24 * 60)
206
+ .default(0),
207
+ revision: REVISION,
208
+ source: SOURCE.optional(),
209
+ })
210
+ .strict()
211
+ .refine(boundedSpan, SPAN_MESSAGE),
212
+ output: z.object({ reservation: RESERVATION, replayed: z.boolean() }).strict(),
213
+ risk: { readOnly: false, destructive: false, idempotent: true },
214
+ invoke: 'putReservation',
215
+ example: {
216
+ reservation_ref: 'booking-BK-42',
217
+ resource_id: EXAMPLE_RESOURCE_ID,
218
+ start: '2027-03-29T09:00:00Z',
219
+ end: '2027-03-31T09:00:00Z',
220
+ buffer_after_minutes: 60,
221
+ revision: 1,
222
+ },
223
+ },
224
+ {
225
+ name: 'slotlock_cancel_reservation',
226
+ title: 'Cancel a reservation',
227
+ description: 'Cancel the reservation with this reference at the next revision (the current one plus 1). Cancelling leaves a tombstone, so a delayed booking cannot bring it back; repeating the cancel answers cancelled: false. Use a new reference for a new reservation.',
228
+ method: 'DELETE',
229
+ path: '/v1/reservations/{reservation_ref}',
230
+ input: z.object({ reservation_ref: IDENTITY, revision: REVISION }).strict(),
231
+ output: z.object({ cancelled: z.boolean(), revision: REVISION }).strict(),
232
+ risk: { readOnly: false, destructive: true, idempotent: true },
233
+ invoke: 'cancelReservation',
234
+ example: { reservation_ref: 'booking-BK-42', revision: 2 },
235
+ },
236
+ {
237
+ name: 'slotlock_record_coverage',
238
+ title: 'Record calendar coverage',
239
+ description: "Record that the events of an external calendar (source) are complete for a window of a resource, at that source's revision. Free/busy calls time unproven, not free, until every source a resource requires covers it. Revisions only go forward; repeating one changes nothing.",
240
+ method: 'PUT',
241
+ path: '/v1/resources/{resource_id}/coverage/{source}',
242
+ input: z
243
+ .object({
244
+ resource_id: RESOURCE_ID,
245
+ source: SOURCE,
246
+ start: DATE_TIME,
247
+ end: DATE_TIME,
248
+ revision: REVISION,
249
+ })
250
+ .strict()
251
+ .refine((value) => Date.parse(value.end) > Date.parse(value.start), SPAN_MESSAGE),
252
+ output: z.object({ revision: REVISION, replayed: z.boolean() }).strict(),
253
+ risk: { readOnly: false, destructive: false, idempotent: true },
254
+ invoke: 'recordCoverage',
255
+ example: {
256
+ resource_id: EXAMPLE_RESOURCE_ID,
257
+ source: 'google:fleet@example.com',
258
+ start: '2027-03-01T00:00:00Z',
259
+ end: '2027-06-01T00:00:00Z',
260
+ revision: 7,
261
+ },
262
+ },
263
+ {
264
+ name: 'slotlock_run_maintenance',
265
+ title: 'Run maintenance',
266
+ description: "Extend recurring events to the rolling horizon and prune what the retention window no longer needs, for the caller's tenant. Run it at least daily (slotlock serve does). capped is true when there was more than one call does: call again.",
267
+ method: 'POST',
268
+ path: '/v1/maintenance',
269
+ input: z.object({}).strict(),
270
+ output: z
271
+ .object({
272
+ extended: z.number().int().min(0),
273
+ conflicts: z.number().int().min(0),
274
+ refused: z.number().int().min(0),
275
+ pruned: z.number().int().min(0),
276
+ capped: z.boolean(),
277
+ })
278
+ .strict(),
279
+ risk: { readOnly: false, destructive: false, idempotent: true },
280
+ invoke: 'runMaintenance',
281
+ example: {},
282
+ },
283
+ ]);
284
+ const BY_NAME = new Map(SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => [operation.name, operation]));
285
+ export function isSlotlockApplicationOperation(name) {
286
+ return typeof name === 'string' && BY_NAME.has(name);
287
+ }
288
+ export function slotlockApplicationOperation(name) {
289
+ return BY_NAME.get(name);
290
+ }
package/dashboard.d.ts CHANGED
@@ -76,7 +76,10 @@ export interface SlotlockDashboardOptions {
76
76
  availability?: readonly WeeklyAvailabilityRule[];
77
77
  fetch?: typeof fetch;
78
78
  now?: () => number;
79
- /** Called with whatever a request handler throws; the person sees a generic error page. */
79
+ /**
80
+ * Called with whatever a request handler throws; the person sees a generic error page. Not
81
+ * awaited; what it throws, or its promise rejects with, is ignored.
82
+ */
80
83
  onError?: (error: unknown) => void;
81
84
  }
82
85
  export interface SlotlockDashboard {