@happyvertical/smrt-bookings 0.0.0-stage → 0.59.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.
package/AGENTS.md ADDED
@@ -0,0 +1,9 @@
1
+ # SMRT Bookings
2
+
3
+ This package owns service reservations and capacity. Application hosts supply
4
+ trusted actor and tenant context and verify their own customer, provider, project
5
+ and plan relationships. Commerce and payment state are separate.
6
+
7
+ Use service commands for writes; keep this package out of generated CRUD. Apply
8
+ its explicit PostgreSQL migration during deployment, outside request handling.
9
+ See [README.md](README.md) for the transaction and verification contracts.
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # s-m-r-t Bookings
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@happyvertical/smrt-bookings` reserves one elapsed developer hour or one
4
+ operator-declared local supervision window. It does not start agents, collect
5
+ payment, send notifications, or write Commerce records.
6
+
7
+ Apply `migrateBookingsV1` once through the host's deployment migration ledger
8
+ using a PostgreSQL database handle. Use an ordinary PostgreSQL connection or
9
+ pool for `BookingService`; the service owns its transaction and explicitly uses
10
+ READ COMMITTED. It does not create schema on demand. No ORM collection, CRUD
11
+ route, or generated CLI or MCP write is registered.
12
+
13
+ 1. An operator enables an offering and declares availability for a canonical
14
+ provider UUID. Use the same provider identity for all services that reserve
15
+ the same person. A declaration replaces that provider's configured windows.
16
+ Windows must not overlap and must have positive integer capacity. Mark at
17
+ most one exact supervision window per provider per local date. Changes that
18
+ invalidate any active reservation are refused.
19
+ 2. The named customer contact, or an operator acting for them, requests an
20
+ enabled offering. A supervision request requires a project. Disabling an
21
+ offering stops new requests; operators may finish pending requests.
22
+ 3. An operator proposes a provider, window, and terms revision with the exact
23
+ agreed wording. Supervision also needs an approved project plan ID and
24
+ version. Each proposal is immutable. The contact assents to its exact ID.
25
+ Replacing the proposal clears current assent. Requests, proposals, and
26
+ assent reserve no capacity.
27
+ 4. An operator confirms the exact assented proposal. The service locks the
28
+ booking and then the stable provider row, checks authority and current
29
+ declared availability, and checks every half-open segment. An exclusive
30
+ hour blocks all overlaps; supervision consumes one unit. Two active
31
+ supervision reservations for the same project and exact UTC interval are
32
+ refused even on different providers. An unsuccessful confirmation leaves
33
+ the proposal and assent available for a retry or revision.
34
+ 5. An operator can decline a pending request or cancel a pending or confirmed
35
+ booking with a reason. Cancelling a confirmed booking releases capacity
36
+ in the same transaction; it preserves the confirmed terms and history.
37
+ Booking and historical proposal reads are scoped to the named contact or
38
+ an operator. Booking audit history is operator-only.
39
+
40
+ Windows use an IANA zone and local timestamps of the form
41
+ `2027-11-07T01:30-04:00`. For example, use that offset with America/New_York. The offset must be valid for that instant in the zone.
42
+ During a DST fold the offsets identify different instants; a nonexistent wall
43
+ time is rejected. Stored proposals retain the local input and UTC instants.
44
+ Intervals include their start and exclude their end. A supervision window
45
+ starts and ends on one local date, or ends at midnight opening the next date;
46
+ it need not cover the entire day and may span a daylight-saving transition.
47
+
48
+ Use an authenticated tenant/actor context from the host, never from a request
49
+ body. The host's `BookingAuthority` supplies current operator permission and
50
+ the actor's named contact mapping, and must verify that external references
51
+ exist in that tenant and relate to each other. For a supervision proposal and
52
+ again at confirmation, it must verify that the exact plan version is approved
53
+ for the project. The confirmation check occurs after the provider lock; it is
54
+ evidence of approval at that point, not a distributed lock on a plan system.
55
+ Authority callbacks should be read-only and must not call a booking command.
56
+
57
+ Each write takes a retry key scoped to tenant and operation. A committed key
58
+ is bound to its actor and normalized command payload. An identical authorized
59
+ retry returns the original receipt; a different payload or actor is rejected.
60
+ A failed command rolls back both its changes and the key claim. Command receipts
61
+ and audit history commit with the booking, so a failure in caller-owned
62
+ communication does not undo the reservation. Clients can fetch it afterward
63
+ and coordinate manual invoicing separately.
64
+
65
+ Only real PostgreSQL is supported for writes. To run the package unit tests,
66
+ use `pnpm --filter @happyvertical/smrt-bookings test`. To run the separate
67
+ PostgreSQL suite, point `CI_POSTGRES_BASE_URL` at an isolated server with
68
+ database creation privileges and run `pnpm --filter @happyvertical/smrt-bookings
69
+ test:postgres`. The test wrapper provisions and removes a disposable database.
@@ -0,0 +1,6 @@
1
+ /** A service rejection suitable for mapping to a host response. */
2
+ export declare class BookingError extends Error {
3
+ readonly code: 'invalid' | 'forbidden' | 'not-found' | 'conflict' | 'unavailable' | 'duplicate-project-window';
4
+ constructor(code: 'invalid' | 'forbidden' | 'not-found' | 'conflict' | 'unavailable' | 'duplicate-project-window', message: string);
5
+ }
6
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,mEAAmE;AACnE,qBAAa,YAAa,SAAQ,KAAK;IAEnC,QAAQ,CAAC,IAAI,EACT,SAAS,GACT,WAAW,GACX,WAAW,GACX,UAAU,GACV,aAAa,GACb,0BAA0B;gBANrB,IAAI,EACT,SAAS,GACT,WAAW,GACX,WAAW,GACX,UAAU,GACV,aAAa,GACb,0BAA0B,EAC9B,OAAO,EAAE,MAAM;CAKlB"}
@@ -0,0 +1,6 @@
1
+ export { BookingError } from './errors.js';
2
+ export { migrateBookingsV1 } from './migration.js';
3
+ export { BookingService } from './service.js';
4
+ export type * from './types.js';
5
+ export { resolveBookingWindow } from './windows.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,mBAAmB,YAAY,CAAC;AAChC,OAAO,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,575 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ //#region src/errors.ts
3
+ var BookingError = class extends Error {
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "BookingError";
8
+ }
9
+ code;
10
+ };
11
+ //#endregion
12
+ //#region src/migration.ts
13
+ var statements = [
14
+ `CREATE TABLE smrt_booking_offerings (
15
+ tenant_id uuid NOT NULL,
16
+ id uuid NOT NULL,
17
+ kind text NOT NULL CHECK (kind IN ('developer-hour', 'supervision-day')),
18
+ enabled boolean NOT NULL,
19
+ PRIMARY KEY (tenant_id, id)
20
+ )`,
21
+ `CREATE TABLE smrt_booking_resources (
22
+ tenant_id uuid NOT NULL,
23
+ id uuid NOT NULL,
24
+ windows jsonb NOT NULL,
25
+ PRIMARY KEY (tenant_id, id)
26
+ )`,
27
+ `CREATE TABLE smrt_bookings (
28
+ tenant_id uuid NOT NULL,
29
+ id uuid NOT NULL,
30
+ offering_id uuid NOT NULL,
31
+ contact_id uuid NOT NULL,
32
+ project_id uuid,
33
+ kind text NOT NULL CHECK (kind IN ('developer-hour', 'supervision-day')),
34
+ status text NOT NULL CHECK (status IN ('requested', 'proposed', 'confirmed', 'declined', 'cancelled')),
35
+ proposal_id uuid,
36
+ assented_proposal_id uuid,
37
+ confirmed_proposal_id uuid,
38
+ PRIMARY KEY (tenant_id, id),
39
+ FOREIGN KEY (tenant_id, offering_id) REFERENCES smrt_booking_offerings (tenant_id, id),
40
+ CHECK (kind <> 'supervision-day' OR project_id IS NOT NULL),
41
+ CHECK (status <> 'confirmed' OR confirmed_proposal_id IS NOT NULL),
42
+ CHECK (assented_proposal_id IS NULL OR assented_proposal_id IS NOT DISTINCT FROM proposal_id),
43
+ CHECK (confirmed_proposal_id IS NULL OR confirmed_proposal_id IS NOT DISTINCT FROM assented_proposal_id)
44
+ )`,
45
+ `CREATE TABLE smrt_booking_proposals (
46
+ tenant_id uuid NOT NULL,
47
+ booking_id uuid NOT NULL,
48
+ id uuid NOT NULL,
49
+ provider_id uuid NOT NULL,
50
+ coverage jsonb NOT NULL,
51
+ terms jsonb NOT NULL,
52
+ approved_plan jsonb,
53
+ PRIMARY KEY (tenant_id, booking_id, id),
54
+ FOREIGN KEY (tenant_id, booking_id) REFERENCES smrt_bookings (tenant_id, id),
55
+ FOREIGN KEY (tenant_id, provider_id) REFERENCES smrt_booking_resources (tenant_id, id)
56
+ )`,
57
+ `ALTER TABLE smrt_bookings
58
+ ADD FOREIGN KEY (tenant_id, id, proposal_id) REFERENCES smrt_booking_proposals (tenant_id, booking_id, id)`,
59
+ `CREATE TABLE smrt_booking_allocations (
60
+ tenant_id uuid NOT NULL,
61
+ booking_id uuid NOT NULL,
62
+ proposal_id uuid NOT NULL,
63
+ provider_id uuid NOT NULL,
64
+ project_id uuid,
65
+ exclusive boolean NOT NULL,
66
+ start_at timestamptz NOT NULL,
67
+ end_at timestamptz NOT NULL CHECK (end_at > start_at),
68
+ released_at timestamptz,
69
+ PRIMARY KEY (tenant_id, booking_id),
70
+ FOREIGN KEY (tenant_id, booking_id, proposal_id)
71
+ REFERENCES smrt_booking_proposals (tenant_id, booking_id, id),
72
+ FOREIGN KEY (tenant_id, provider_id) REFERENCES smrt_booking_resources (tenant_id, id),
73
+ CHECK (exclusive = (project_id IS NULL))
74
+ )`,
75
+ `CREATE INDEX smrt_booking_active_provider ON smrt_booking_allocations (tenant_id, provider_id, start_at)
76
+ WHERE released_at IS NULL`,
77
+ `CREATE UNIQUE INDEX smrt_booking_active_project_window
78
+ ON smrt_booking_allocations (tenant_id, project_id, start_at, end_at)
79
+ WHERE released_at IS NULL AND project_id IS NOT NULL`,
80
+ `CREATE TABLE smrt_booking_commands (
81
+ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE,
82
+ tenant_id uuid NOT NULL,
83
+ operation text NOT NULL,
84
+ key_hash text NOT NULL,
85
+ actor_id text NOT NULL,
86
+ payload_hash text NOT NULL,
87
+ booking_id uuid,
88
+ result jsonb,
89
+ reason text,
90
+ at timestamptz NOT NULL DEFAULT clock_timestamp(),
91
+ PRIMARY KEY (tenant_id, operation, key_hash),
92
+ FOREIGN KEY (tenant_id, booking_id) REFERENCES smrt_bookings (tenant_id, id)
93
+ )`,
94
+ `CREATE INDEX smrt_booking_history ON smrt_booking_commands (tenant_id, booking_id, sequence)`
95
+ ];
96
+ async function migrateBookingsV1(db) {
97
+ if (!db.transaction) throw new TypeError("Bookings requires a PostgreSQL transaction");
98
+ await db.transaction(async (tx) => {
99
+ for (const statement of statements) await tx.query(statement);
100
+ });
101
+ }
102
+ //#endregion
103
+ //#region src/windows.ts
104
+ var WALL = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})([+-]\d{2}:\d{2})$/;
105
+ var MINUTE_MS = 6e4;
106
+ function formatter(zone) {
107
+ if (typeof zone !== "string" || !/^[A-Za-z][A-Za-z0-9_+/-]*$/.test(zone)) throw new BookingError("invalid", "An IANA time zone is required");
108
+ try {
109
+ return new Intl.DateTimeFormat("en-GB", {
110
+ timeZone: zone,
111
+ calendar: "iso8601",
112
+ numberingSystem: "latn",
113
+ year: "numeric",
114
+ month: "2-digit",
115
+ day: "2-digit",
116
+ hour: "2-digit",
117
+ minute: "2-digit",
118
+ second: "2-digit",
119
+ hourCycle: "h23"
120
+ });
121
+ } catch {
122
+ throw new BookingError("invalid", "Unknown IANA time zone");
123
+ }
124
+ }
125
+ function instant(value, format) {
126
+ const match = typeof value === "string" ? WALL.exec(value) : null;
127
+ if (!match || match[2] === "-00:00") throw new BookingError("invalid", "Expected local minute with explicit UTC offset");
128
+ const time = Date.parse(value);
129
+ if (!Number.isFinite(time)) throw new BookingError("invalid", "Invalid local timestamp");
130
+ const parts = Object.fromEntries(format.formatToParts(time).map(({ type, value: value2 }) => [type, value2]));
131
+ if (`${parts.year.padStart(4, "0")}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}` !== match[1] || parts.second !== "00") throw new BookingError("invalid", "Local timestamp and offset do not exist in this zone");
132
+ return new Date(time).toISOString();
133
+ }
134
+ function resolveBookingWindow(window) {
135
+ const format = formatter(window.zone);
136
+ const startUtc = instant(window.start, format);
137
+ const endUtc = instant(window.end, format);
138
+ if (startUtc >= endUtc) throw new BookingError("invalid", "A booking window must have positive duration");
139
+ return {
140
+ zone: format.resolvedOptions().timeZone,
141
+ start: window.start,
142
+ end: window.end,
143
+ startUtc,
144
+ endUtc
145
+ };
146
+ }
147
+ function elapsedHour(window) {
148
+ return Date.parse(window.endUtc) - Date.parse(window.startUtc) === 60 * MINUTE_MS;
149
+ }
150
+ function oneLocalDay(window) {
151
+ const startDate = window.start.slice(0, 10);
152
+ const endDate = window.end.slice(0, 10);
153
+ if (startDate === endDate) return true;
154
+ if (!window.end.slice(10).startsWith("T00:00")) return false;
155
+ const tomorrow = /* @__PURE__ */ new Date(`${startDate}T00:00:00Z`);
156
+ tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
157
+ return tomorrow.toISOString().slice(0, 10) === endDate;
158
+ }
159
+ function declareWindows(zone, windows) {
160
+ formatter(zone);
161
+ if (!Array.isArray(windows)) throw new BookingError("invalid", "Expected availability windows");
162
+ const days = /* @__PURE__ */ new Set();
163
+ const normalized = windows.map(({ start, end, capacity, supervision }) => {
164
+ if (!Number.isSafeInteger(capacity) || capacity < 1 || typeof supervision !== "boolean") throw new BookingError("invalid", "Availability requires positive integer capacity and a supervision flag");
165
+ const window = resolveBookingWindow({
166
+ start,
167
+ end,
168
+ zone
169
+ });
170
+ const day = start.slice(0, 10);
171
+ if (supervision) {
172
+ if (!oneLocalDay(window) || days.has(day)) throw new BookingError("invalid", "Declare at most one supervision window per local day");
173
+ days.add(day);
174
+ }
175
+ return {
176
+ ...window,
177
+ capacity,
178
+ supervision
179
+ };
180
+ }).sort((a, b) => a.startUtc.localeCompare(b.startUtc));
181
+ if (normalized.some((window, i) => i > 0 && normalized[i - 1].endUtc > window.startUtc)) throw new BookingError("invalid", "Availability windows must not overlap");
182
+ return normalized;
183
+ }
184
+ function supervisionDeclared(candidate, windows) {
185
+ return windows.some((window) => window.supervision && candidate.start === window.start && candidate.end === window.end && candidate.zone === window.zone);
186
+ }
187
+ function fitsCapacity(candidate, declared, occupied) {
188
+ const boundaries = /* @__PURE__ */ new Map();
189
+ const add = (at, key, delta) => {
190
+ const event = boundaries.get(at) ?? {
191
+ capacity: 0,
192
+ shared: 0,
193
+ exclusive: 0
194
+ };
195
+ event[key] += delta;
196
+ boundaries.set(at, event);
197
+ };
198
+ const sweep = (window, key, units) => {
199
+ if (window.startUtc >= candidate.endUtc || window.endUtc <= candidate.startUtc) return;
200
+ add(window.startUtc < candidate.startUtc ? candidate.startUtc : window.startUtc, key, units);
201
+ if (window.endUtc < candidate.endUtc) add(window.endUtc, key, -units);
202
+ };
203
+ add(candidate.startUtc, "capacity", 0);
204
+ for (const window of declared) sweep(window, "capacity", window.capacity);
205
+ for (const reservation of occupied) sweep(reservation, reservation.exclusive ? "exclusive" : "shared", 1);
206
+ let capacity = 0;
207
+ let shared = 0;
208
+ let exclusive = 0;
209
+ for (const at of [...boundaries.keys()].sort()) {
210
+ const delta = boundaries.get(at);
211
+ if (!delta) continue;
212
+ capacity += delta.capacity;
213
+ shared += delta.shared;
214
+ exclusive += delta.exclusive;
215
+ if (capacity < 1 || exclusive > 0 || (candidate.exclusive ? shared > 0 : shared >= capacity)) return false;
216
+ }
217
+ return true;
218
+ }
219
+ //#endregion
220
+ //#region src/service.ts
221
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
222
+ function id(value) {
223
+ if (typeof value !== "string" || !UUID.test(value)) throw new BookingError("invalid", "Expected a UUID reference");
224
+ return value.toLowerCase();
225
+ }
226
+ function required(value, max = 200) {
227
+ if (typeof value !== "string" || value.trim().length === 0 || value.length > max || value.includes("\0")) throw new BookingError("invalid", "Expected a nonempty text value within the length limit");
228
+ return value;
229
+ }
230
+ function identity(input) {
231
+ return Object.freeze({
232
+ tenantId: id(input.tenantId),
233
+ actorId: required(input.actorId)
234
+ });
235
+ }
236
+ function digest(value) {
237
+ return createHash("sha256").update(value).digest("hex");
238
+ }
239
+ function operator(access) {
240
+ if (!access.operator) throw new BookingError("forbidden", "Operator access required");
241
+ }
242
+ function contact(access, contactId) {
243
+ if (access.contactId !== contactId) throw new BookingError("forbidden", "Only the named customer contact may act for this booking");
244
+ }
245
+ function visible(access, row) {
246
+ if (!access.operator && access.contactId !== row.contact_id) throw new BookingError("not-found", "Booking does not exist");
247
+ }
248
+ function pending(row) {
249
+ if (row.status !== "requested" && row.status !== "proposed") throw new BookingError("conflict", "Booking is no longer pending");
250
+ }
251
+ function declared(kind, window, availability) {
252
+ if (kind === "supervision-day" && !supervisionDeclared(window, availability) || kind === "developer-hour" && !availability.some((entry) => entry.zone === window.zone) || !fitsCapacity({
253
+ ...window,
254
+ exclusive: kind === "developer-hour"
255
+ }, availability, [])) throw new BookingError("unavailable", "The proposed window is outside declared availability");
256
+ }
257
+ var BookingService = class {
258
+ #db;
259
+ #authority;
260
+ constructor(db, authority) {
261
+ this.#db = db;
262
+ this.#authority = authority;
263
+ }
264
+ async configureOffering(context, key, input) {
265
+ const offeringId = id(input.offeringId);
266
+ const { kind, enabled } = input;
267
+ if (kind !== "developer-hour" && kind !== "supervision-day" || typeof enabled !== "boolean") throw new BookingError("invalid", "Invalid offering kind or enabled flag");
268
+ return this.#write(context, "configure-offering", key, {
269
+ offeringId,
270
+ kind,
271
+ enabled
272
+ }, operator, async (tx, ctx) => {
273
+ if (!(await tx.query(`INSERT INTO smrt_booking_offerings (tenant_id, id, kind, enabled) VALUES (?, ?, ?, ?)
274
+ ON CONFLICT (tenant_id, id) DO UPDATE SET enabled = EXCLUDED.enabled
275
+ WHERE smrt_booking_offerings.kind = EXCLUDED.kind RETURNING id`, ctx.tenantId, offeringId, kind, enabled)).rows.length) throw new BookingError("conflict", "An offering kind cannot change");
276
+ return { id: offeringId };
277
+ });
278
+ }
279
+ async declareAvailability(context, key, input) {
280
+ const providerId = id(input.providerId);
281
+ const windows = declareWindows(input.zone, input.windows);
282
+ return this.#write(context, "declare-availability", key, {
283
+ providerId,
284
+ windows
285
+ }, operator, async (tx, ctx) => {
286
+ await tx.query(`INSERT INTO smrt_booking_resources (tenant_id, id, windows) VALUES (?, ?, '[]')
287
+ ON CONFLICT (tenant_id, id) DO NOTHING`, ctx.tenantId, providerId);
288
+ await this.#resource(tx, ctx, providerId, true);
289
+ operator(await this.#access(ctx, "declare-availability"));
290
+ await this.#verify(ctx, { providerId });
291
+ const allocated = await this.#allocations(tx, ctx, providerId);
292
+ if (allocated.some((item, i) => !item.exclusive && !supervisionDeclared(item.window, windows) || !fitsCapacity(item, windows, allocated.filter((_, j) => j !== i)))) throw new BookingError("unavailable", "Availability change would undercut confirmed reservations");
293
+ await tx.query("UPDATE smrt_booking_resources SET windows = ? WHERE tenant_id = ? AND id = ?", JSON.stringify(windows), ctx.tenantId, providerId);
294
+ return { id: providerId };
295
+ });
296
+ }
297
+ async request(context, key, input) {
298
+ const offeringId = id(input.offeringId);
299
+ const contactId = id(input.contactId);
300
+ const projectId = input.projectId === void 0 ? null : id(input.projectId);
301
+ return this.#write(context, "request", key, {
302
+ offeringId,
303
+ contactId,
304
+ projectId
305
+ }, (access) => {
306
+ if (!access.operator) contact(access, contactId);
307
+ }, async (tx, ctx) => {
308
+ const { rows } = await tx.query("SELECT kind, enabled FROM smrt_booking_offerings WHERE tenant_id = ? AND id = ? FOR SHARE", ctx.tenantId, offeringId);
309
+ if (!rows[0]?.enabled) throw new BookingError("not-found", "Offering is not available");
310
+ const kind = rows[0].kind;
311
+ if (kind === "supervision-day" && !projectId) throw new BookingError("invalid", "A supervision request requires a project");
312
+ await this.#verify(ctx, {
313
+ contactId,
314
+ projectId: projectId ?? void 0
315
+ });
316
+ const bookingId = randomUUID();
317
+ await tx.query(`INSERT INTO smrt_bookings (tenant_id, id, offering_id, contact_id, project_id, kind, status)
318
+ VALUES (?, ?, ?, ?, ?, ?, 'requested')`, ctx.tenantId, bookingId, offeringId, contactId, projectId, kind);
319
+ return { id: bookingId };
320
+ }, { createsBooking: true });
321
+ }
322
+ async propose(context, key, input) {
323
+ const bookingId = id(input.bookingId);
324
+ const providerId = id(input.providerId);
325
+ const window = resolveBookingWindow(input.window);
326
+ const terms = {
327
+ revision: required(input.terms.revision),
328
+ text: required(input.terms.text, 65536)
329
+ };
330
+ const plan = input.approvedPlan;
331
+ const approvedPlan = plan === void 0 ? null : {
332
+ id: id(plan.id),
333
+ version: required(plan.version)
334
+ };
335
+ return this.#write(context, "propose", key, {
336
+ bookingId,
337
+ providerId,
338
+ window,
339
+ terms,
340
+ approvedPlan
341
+ }, operator, async (tx, ctx) => {
342
+ const row = await this.#booking(tx, ctx, bookingId, true);
343
+ pending(row);
344
+ if (row.kind === "supervision-day" ? !approvedPlan : approvedPlan || !elapsedHour(window)) throw new BookingError("invalid", "Supervision requires a plan version; dedicated time requires one elapsed hour");
345
+ await this.#verify(ctx, {
346
+ ...this.#refs(row),
347
+ providerId,
348
+ approvedPlan: approvedPlan ?? void 0
349
+ });
350
+ declared(row.kind, window, await this.#resource(tx, ctx, providerId, false));
351
+ const proposalId = randomUUID();
352
+ await tx.query(`INSERT INTO smrt_booking_proposals (tenant_id, booking_id, id, provider_id, coverage, terms, approved_plan)
353
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, ctx.tenantId, bookingId, proposalId, providerId, JSON.stringify(window), JSON.stringify(terms), approvedPlan && JSON.stringify(approvedPlan));
354
+ await tx.query(`UPDATE smrt_bookings SET status = 'proposed', proposal_id = ?, assented_proposal_id = NULL
355
+ WHERE tenant_id = ? AND id = ?`, proposalId, ctx.tenantId, bookingId);
356
+ return {
357
+ id: bookingId,
358
+ proposalId
359
+ };
360
+ }, { bookingId });
361
+ }
362
+ async assent(context, key, input) {
363
+ const bookingId = id(input.bookingId);
364
+ const proposalId = id(input.proposalId);
365
+ return this.#write(context, "assent", key, {
366
+ bookingId,
367
+ proposalId
368
+ }, async (access, tx, ctx) => {
369
+ const row = await this.#booking(tx, ctx, bookingId, false);
370
+ visible(access, row);
371
+ contact(access, row.contact_id);
372
+ }, async (tx, ctx) => {
373
+ const row = await this.#booking(tx, ctx, bookingId, true);
374
+ contact(await this.#access(ctx, "assent"), row.contact_id);
375
+ this.#current(row, proposalId);
376
+ if (row.assented_proposal_id) throw new BookingError("conflict", "Customer assent is already recorded");
377
+ await this.#verify(ctx, this.#refs(row));
378
+ await tx.query("UPDATE smrt_bookings SET assented_proposal_id = ? WHERE tenant_id = ? AND id = ?", proposalId, ctx.tenantId, bookingId);
379
+ return {
380
+ id: bookingId,
381
+ proposalId
382
+ };
383
+ }, { bookingId });
384
+ }
385
+ async confirm(context, key, input) {
386
+ const bookingId = id(input.bookingId);
387
+ const proposalId = id(input.proposalId);
388
+ return this.#write(context, "confirm", key, {
389
+ bookingId,
390
+ proposalId
391
+ }, operator, async (tx, ctx) => {
392
+ const row = await this.#booking(tx, ctx, bookingId, true);
393
+ this.#current(row, proposalId);
394
+ if (row.assented_proposal_id !== proposalId) throw new BookingError("conflict", "Exact proposal does not have customer assent");
395
+ const proposal = await this.#proposal(tx, ctx, row, proposalId);
396
+ const availability = await this.#resource(tx, ctx, proposal.providerId, true);
397
+ operator(await this.#access(ctx, "confirm"));
398
+ await this.#verify(ctx, {
399
+ ...this.#refs(row),
400
+ providerId: proposal.providerId,
401
+ approvedPlan: proposal.approvedPlan ?? void 0
402
+ });
403
+ const exclusive = row.kind === "developer-hour";
404
+ const window = proposal.window;
405
+ declared(row.kind, window, availability);
406
+ if (!fitsCapacity({
407
+ ...window,
408
+ exclusive
409
+ }, availability, await this.#allocations(tx, ctx, proposal.providerId, window))) throw new BookingError("unavailable", "The proposed window has insufficient capacity");
410
+ if (!(await tx.query(`INSERT INTO smrt_booking_allocations
411
+ (tenant_id, booking_id, proposal_id, provider_id, project_id, exclusive, start_at, end_at)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
413
+ ON CONFLICT (tenant_id, project_id, start_at, end_at)
414
+ WHERE released_at IS NULL AND project_id IS NOT NULL DO NOTHING RETURNING booking_id`, ctx.tenantId, bookingId, proposalId, proposal.providerId, exclusive ? null : row.project_id, exclusive, window.startUtc, window.endUtc)).rows.length) throw new BookingError("duplicate-project-window", "Project already has a reservation for this window");
415
+ await tx.query(`UPDATE smrt_bookings SET status = 'confirmed', confirmed_proposal_id = ?
416
+ WHERE tenant_id = ? AND id = ?`, proposalId, ctx.tenantId, bookingId);
417
+ return {
418
+ id: bookingId,
419
+ proposalId
420
+ };
421
+ }, { bookingId });
422
+ }
423
+ decline(context, key, input) {
424
+ return this.#close(context, "decline", key, input);
425
+ }
426
+ cancel(context, key, input) {
427
+ return this.#close(context, "cancel", key, input);
428
+ }
429
+ async get(context, bookingId) {
430
+ const ctx = identity(context);
431
+ const access = await this.#access(ctx, "read");
432
+ const row = await this.#booking(this.#db, ctx, id(bookingId), false);
433
+ visible(access, row);
434
+ return {
435
+ id: row.id,
436
+ offeringId: row.offering_id,
437
+ contactId: row.contact_id,
438
+ projectId: row.project_id,
439
+ kind: row.kind,
440
+ status: row.status,
441
+ proposal: row.proposal_id ? await this.#proposal(this.#db, ctx, row, row.proposal_id) : null,
442
+ assentedProposalId: row.assented_proposal_id,
443
+ confirmedProposalId: row.confirmed_proposal_id
444
+ };
445
+ }
446
+ async getProposal(context, bookingId, proposalId) {
447
+ const ctx = identity(context);
448
+ const access = await this.#access(ctx, "read");
449
+ const row = await this.#booking(this.#db, ctx, id(bookingId), false);
450
+ visible(access, row);
451
+ return this.#proposal(this.#db, ctx, row, id(proposalId));
452
+ }
453
+ async history(context, bookingId) {
454
+ const ctx = identity(context);
455
+ operator(await this.#access(ctx, "history"));
456
+ await this.#booking(this.#db, ctx, id(bookingId), false);
457
+ const { rows } = await this.#db.query(`SELECT operation, actor_id, result->>'proposalId' AS proposal_id, reason, at
458
+ FROM smrt_booking_commands WHERE tenant_id = ? AND booking_id = ? ORDER BY at, sequence`, ctx.tenantId, id(bookingId));
459
+ return rows.map((row) => ({
460
+ operation: row.operation,
461
+ actorId: row.actor_id,
462
+ proposalId: row.proposal_id,
463
+ reason: row.reason,
464
+ at: new Date(row.at).toISOString()
465
+ }));
466
+ }
467
+ async #close(context, operation, key, input) {
468
+ const bookingId = id(input.bookingId);
469
+ const reason = required(input.reason, 2048);
470
+ return this.#write(context, operation, key, {
471
+ bookingId,
472
+ reason
473
+ }, operator, async (tx, ctx) => {
474
+ const row = await this.#booking(tx, ctx, bookingId, true);
475
+ if (operation === "cancel" && row.confirmed_proposal_id && row.status === "confirmed") {
476
+ const proposal = await this.#proposal(tx, ctx, row, row.confirmed_proposal_id);
477
+ await this.#resource(tx, ctx, proposal.providerId, true);
478
+ operator(await this.#access(ctx, "cancel"));
479
+ if (!(await tx.query(`UPDATE smrt_booking_allocations SET released_at = clock_timestamp()
480
+ WHERE tenant_id = ? AND booking_id = ? AND released_at IS NULL RETURNING booking_id`, ctx.tenantId, bookingId)).rows.length) throw new BookingError("conflict", "Confirmed booking has no active allocation");
481
+ } else pending(row);
482
+ await tx.query("UPDATE smrt_bookings SET status = ? WHERE tenant_id = ? AND id = ?", operation === "cancel" ? "cancelled" : "declined", ctx.tenantId, bookingId);
483
+ return {
484
+ id: bookingId,
485
+ ...row.proposal_id ? { proposalId: row.proposal_id } : {}
486
+ };
487
+ }, {
488
+ bookingId,
489
+ reason
490
+ });
491
+ }
492
+ async #access(context, operation) {
493
+ const access = await this.#authority.access(context, operation);
494
+ if (!access) throw new BookingError("forbidden", "Booking access denied");
495
+ return {
496
+ operator: access.operator === true,
497
+ contactId: access.contactId === void 0 ? void 0 : id(access.contactId)
498
+ };
499
+ }
500
+ async #verify(ctx, refs) {
501
+ if (await this.#authority.verifyReferences(ctx, structuredClone(refs)) !== true) throw new BookingError("forbidden", "Booking references or approval could not be verified");
502
+ }
503
+ #refs(row) {
504
+ return {
505
+ contactId: row.contact_id,
506
+ projectId: row.project_id ?? void 0
507
+ };
508
+ }
509
+ async #booking(tx, ctx, bookingId, lock) {
510
+ const { rows } = await tx.query(`SELECT * FROM smrt_bookings WHERE tenant_id = ? AND id = ?${lock ? " FOR UPDATE" : ""}`, ctx.tenantId, bookingId);
511
+ if (!rows[0]) throw new BookingError("not-found", "Booking does not exist");
512
+ return rows[0];
513
+ }
514
+ #current(row, proposalId) {
515
+ if (row.status !== "proposed" || row.proposal_id !== proposalId) throw new BookingError("conflict", "Booking does not have this current proposal");
516
+ }
517
+ async #proposal(tx, ctx, booking, proposalId) {
518
+ const { rows } = await tx.query(`SELECT provider_id, coverage::text, terms::text, approved_plan::text FROM smrt_booking_proposals
519
+ WHERE tenant_id = ? AND booking_id = ? AND id = ?`, ctx.tenantId, booking.id, proposalId);
520
+ const row = rows[0];
521
+ if (!row) throw new BookingError("not-found", "Proposal does not exist");
522
+ return {
523
+ id: proposalId,
524
+ providerId: row.provider_id,
525
+ window: JSON.parse(row.coverage),
526
+ terms: JSON.parse(row.terms),
527
+ approvedPlan: row.approved_plan === null ? null : JSON.parse(row.approved_plan)
528
+ };
529
+ }
530
+ async #resource(tx, ctx, providerId, lock) {
531
+ const { rows } = await tx.query(`SELECT windows::text FROM smrt_booking_resources WHERE tenant_id = ? AND id = ?${lock ? " FOR UPDATE" : ""}`, ctx.tenantId, providerId);
532
+ if (!rows[0]) throw new BookingError("not-found", "Provider availability is not configured");
533
+ return JSON.parse(rows[0].windows);
534
+ }
535
+ async #allocations(tx, ctx, providerId, window) {
536
+ const { rows } = await tx.query(`SELECT a.exclusive, p.coverage::text FROM smrt_booking_allocations a
537
+ JOIN smrt_booking_proposals p ON p.tenant_id = a.tenant_id AND p.booking_id = a.booking_id AND p.id = a.proposal_id
538
+ WHERE a.tenant_id = ? AND a.provider_id = ? AND a.released_at IS NULL${window ? " AND a.start_at < ? AND a.end_at > ?" : ""}`, ctx.tenantId, providerId, ...window ? [window.endUtc, window.startUtc] : []);
539
+ return rows.map((row) => {
540
+ const snapshot = JSON.parse(row.coverage);
541
+ return {
542
+ ...snapshot,
543
+ window: snapshot,
544
+ exclusive: row.exclusive
545
+ };
546
+ });
547
+ }
548
+ async #write(context, operation, key, canonical, permit, work, options = {}) {
549
+ const ctx = identity(context);
550
+ const keyHash = digest(required(key));
551
+ const payloadHash = digest(JSON.stringify(canonical));
552
+ if (!this.#db.transaction) throw new TypeError("Bookings requires a PostgreSQL transaction");
553
+ return this.#db.transaction(async (tx) => {
554
+ await tx.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
555
+ await permit(await this.#access(ctx, operation), tx, ctx);
556
+ if (!(await tx.query(`INSERT INTO smrt_booking_commands (tenant_id, operation, key_hash, actor_id, payload_hash)
557
+ VALUES (?, ?, ?, ?, ?) ON CONFLICT (tenant_id, operation, key_hash) DO NOTHING RETURNING key_hash`, ctx.tenantId, operation, keyHash, ctx.actorId, payloadHash)).rows.length) {
558
+ await permit(await this.#access(ctx, operation), tx, ctx);
559
+ const { rows } = await tx.query(`SELECT actor_id, payload_hash, result::text FROM smrt_booking_commands
560
+ WHERE tenant_id = ? AND operation = ? AND key_hash = ?`, ctx.tenantId, operation, keyHash);
561
+ const prior = rows[0];
562
+ if (!prior || prior.actor_id !== ctx.actorId || prior.payload_hash !== payloadHash || !prior.result) throw new BookingError("conflict", "Idempotency key is bound to a different command");
563
+ return JSON.parse(prior.result);
564
+ }
565
+ const result = await work(tx, ctx);
566
+ await tx.query(`UPDATE smrt_booking_commands SET booking_id = ?, result = ?, reason = ?, at = clock_timestamp()
567
+ WHERE tenant_id = ? AND operation = ? AND key_hash = ?`, options.bookingId ?? (options.createsBooking ? result.id : null), JSON.stringify(result), options.reason ?? null, ctx.tenantId, operation, keyHash);
568
+ return result;
569
+ });
570
+ }
571
+ };
572
+ //#endregion
573
+ export { BookingError, BookingService, migrateBookingsV1, resolveBookingWindow };
574
+
575
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["value"],"sources":["../src/errors.ts","../src/migration.ts","../src/windows.ts","../src/service.ts"],"sourcesContent":["/** A service rejection suitable for mapping to a host response. */\nexport class BookingError extends Error {\n constructor(\n readonly code:\n | 'invalid'\n | 'forbidden'\n | 'not-found'\n | 'conflict'\n | 'unavailable'\n | 'duplicate-project-window',\n message: string,\n ) {\n super(message);\n this.name = 'BookingError';\n }\n}\n","import type { DatabaseInterface } from '@happyvertical/sql';\n\nconst statements = [\n `CREATE TABLE smrt_booking_offerings (\n tenant_id uuid NOT NULL,\n id uuid NOT NULL,\n kind text NOT NULL CHECK (kind IN ('developer-hour', 'supervision-day')),\n enabled boolean NOT NULL,\n PRIMARY KEY (tenant_id, id)\n )`,\n `CREATE TABLE smrt_booking_resources (\n tenant_id uuid NOT NULL,\n id uuid NOT NULL,\n windows jsonb NOT NULL,\n PRIMARY KEY (tenant_id, id)\n )`,\n `CREATE TABLE smrt_bookings (\n tenant_id uuid NOT NULL,\n id uuid NOT NULL,\n offering_id uuid NOT NULL,\n contact_id uuid NOT NULL,\n project_id uuid,\n kind text NOT NULL CHECK (kind IN ('developer-hour', 'supervision-day')),\n status text NOT NULL CHECK (status IN ('requested', 'proposed', 'confirmed', 'declined', 'cancelled')),\n proposal_id uuid,\n assented_proposal_id uuid,\n confirmed_proposal_id uuid,\n PRIMARY KEY (tenant_id, id),\n FOREIGN KEY (tenant_id, offering_id) REFERENCES smrt_booking_offerings (tenant_id, id),\n CHECK (kind <> 'supervision-day' OR project_id IS NOT NULL),\n CHECK (status <> 'confirmed' OR confirmed_proposal_id IS NOT NULL),\n CHECK (assented_proposal_id IS NULL OR assented_proposal_id IS NOT DISTINCT FROM proposal_id),\n CHECK (confirmed_proposal_id IS NULL OR confirmed_proposal_id IS NOT DISTINCT FROM assented_proposal_id)\n )`,\n `CREATE TABLE smrt_booking_proposals (\n tenant_id uuid NOT NULL,\n booking_id uuid NOT NULL,\n id uuid NOT NULL,\n provider_id uuid NOT NULL,\n coverage jsonb NOT NULL,\n terms jsonb NOT NULL,\n approved_plan jsonb,\n PRIMARY KEY (tenant_id, booking_id, id),\n FOREIGN KEY (tenant_id, booking_id) REFERENCES smrt_bookings (tenant_id, id),\n FOREIGN KEY (tenant_id, provider_id) REFERENCES smrt_booking_resources (tenant_id, id)\n )`,\n `ALTER TABLE smrt_bookings\n ADD FOREIGN KEY (tenant_id, id, proposal_id) REFERENCES smrt_booking_proposals (tenant_id, booking_id, id)`,\n `CREATE TABLE smrt_booking_allocations (\n tenant_id uuid NOT NULL,\n booking_id uuid NOT NULL,\n proposal_id uuid NOT NULL,\n provider_id uuid NOT NULL,\n project_id uuid,\n exclusive boolean NOT NULL,\n start_at timestamptz NOT NULL,\n end_at timestamptz NOT NULL CHECK (end_at > start_at),\n released_at timestamptz,\n PRIMARY KEY (tenant_id, booking_id),\n FOREIGN KEY (tenant_id, booking_id, proposal_id)\n REFERENCES smrt_booking_proposals (tenant_id, booking_id, id),\n FOREIGN KEY (tenant_id, provider_id) REFERENCES smrt_booking_resources (tenant_id, id),\n CHECK (exclusive = (project_id IS NULL))\n )`,\n `CREATE INDEX smrt_booking_active_provider ON smrt_booking_allocations (tenant_id, provider_id, start_at)\n WHERE released_at IS NULL`,\n `CREATE UNIQUE INDEX smrt_booking_active_project_window\n ON smrt_booking_allocations (tenant_id, project_id, start_at, end_at)\n WHERE released_at IS NULL AND project_id IS NOT NULL`,\n `CREATE TABLE smrt_booking_commands (\n sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE,\n tenant_id uuid NOT NULL,\n operation text NOT NULL,\n key_hash text NOT NULL,\n actor_id text NOT NULL,\n payload_hash text NOT NULL,\n booking_id uuid,\n result jsonb,\n reason text,\n at timestamptz NOT NULL DEFAULT clock_timestamp(),\n PRIMARY KEY (tenant_id, operation, key_hash),\n FOREIGN KEY (tenant_id, booking_id) REFERENCES smrt_bookings (tenant_id, id)\n )`,\n `CREATE INDEX smrt_booking_history ON smrt_booking_commands (tenant_id, booking_id, sequence)`,\n];\n\n/** Initial PostgreSQL schema. Register once in the host deployment's migration ledger. */\nexport async function migrateBookingsV1(db: DatabaseInterface): Promise<void> {\n if (!db.transaction)\n throw new TypeError('Bookings requires a PostgreSQL transaction');\n await db.transaction(async (tx) => {\n for (const statement of statements) await tx.query(statement);\n });\n}\n","import { BookingError } from './errors.js';\nimport type {\n AvailabilityWindow,\n BookingWindow,\n ResolvedBookingWindow,\n} from './types.js';\n\nconst WALL = /^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2})([+-]\\d{2}:\\d{2})$/;\nconst MINUTE_MS = 60_000;\n\nfunction formatter(zone: string): Intl.DateTimeFormat {\n // Intl also accepts offsets as time zone names. These are not IANA zones.\n if (typeof zone !== 'string' || !/^[A-Za-z][A-Za-z0-9_+/-]*$/.test(zone)) {\n throw new BookingError('invalid', 'An IANA time zone is required');\n }\n try {\n return new Intl.DateTimeFormat('en-GB', {\n timeZone: zone,\n calendar: 'iso8601',\n numberingSystem: 'latn',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hourCycle: 'h23',\n });\n } catch {\n throw new BookingError('invalid', 'Unknown IANA time zone');\n }\n}\n\nfunction instant(value: string, format: Intl.DateTimeFormat): string {\n const match = typeof value === 'string' ? WALL.exec(value) : null;\n if (!match || match[2] === '-00:00') {\n throw new BookingError(\n 'invalid',\n 'Expected local minute with explicit UTC offset',\n );\n }\n const time = Date.parse(value);\n if (!Number.isFinite(time)) {\n throw new BookingError('invalid', 'Invalid local timestamp');\n }\n const parts = Object.fromEntries(\n format.formatToParts(time).map(({ type, value }) => [type, value]),\n );\n const local = `${parts.year.padStart(4, '0')}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;\n if (local !== match[1] || parts.second !== '00') {\n throw new BookingError(\n 'invalid',\n 'Local timestamp and offset do not exist in this zone',\n );\n }\n return new Date(time).toISOString();\n}\n\n/** Resolve half-open wall time; a DST fold must name the intended offset. */\nexport function resolveBookingWindow(\n window: BookingWindow,\n): ResolvedBookingWindow {\n const format = formatter(window.zone);\n const startUtc = instant(window.start, format);\n const endUtc = instant(window.end, format);\n if (startUtc >= endUtc) {\n throw new BookingError(\n 'invalid',\n 'A booking window must have positive duration',\n );\n }\n return {\n zone: format.resolvedOptions().timeZone,\n start: window.start,\n end: window.end,\n startUtc,\n endUtc,\n };\n}\n\nexport function elapsedHour(window: ResolvedBookingWindow): boolean {\n return (\n Date.parse(window.endUtc) - Date.parse(window.startUtc) === 60 * MINUTE_MS\n );\n}\n\nfunction oneLocalDay(window: ResolvedBookingWindow): boolean {\n const startDate = window.start.slice(0, 10);\n const endDate = window.end.slice(0, 10);\n if (startDate === endDate) return true;\n if (!window.end.slice(10).startsWith('T00:00')) return false;\n const tomorrow = new Date(`${startDate}T00:00:00Z`);\n tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);\n return tomorrow.toISOString().slice(0, 10) === endDate;\n}\n\nexport interface DeclaredWindow extends ResolvedBookingWindow {\n capacity: number;\n supervision: boolean;\n}\n\nexport function declareWindows(\n zone: string,\n windows: readonly AvailabilityWindow[],\n): DeclaredWindow[] {\n formatter(zone);\n if (!Array.isArray(windows))\n throw new BookingError('invalid', 'Expected availability windows');\n const days = new Set<string>();\n const normalized = windows\n .map(({ start, end, capacity, supervision }) => {\n if (\n !Number.isSafeInteger(capacity) ||\n capacity < 1 ||\n typeof supervision !== 'boolean'\n ) {\n throw new BookingError(\n 'invalid',\n 'Availability requires positive integer capacity and a supervision flag',\n );\n }\n const window = resolveBookingWindow({ start, end, zone });\n const day = start.slice(0, 10);\n if (supervision) {\n if (!oneLocalDay(window) || days.has(day)) {\n throw new BookingError(\n 'invalid',\n 'Declare at most one supervision window per local day',\n );\n }\n days.add(day);\n }\n return { ...window, capacity, supervision };\n })\n .sort((a, b) => a.startUtc.localeCompare(b.startUtc));\n if (\n normalized.some(\n (window, i) => i > 0 && normalized[i - 1].endUtc > window.startUtc,\n )\n ) {\n throw new BookingError('invalid', 'Availability windows must not overlap');\n }\n return normalized;\n}\n\nexport interface Reservation {\n startUtc: string;\n endUtc: string;\n exclusive: boolean;\n}\n\nexport function supervisionDeclared(\n candidate: ResolvedBookingWindow,\n windows: readonly DeclaredWindow[],\n): boolean {\n return windows.some(\n (window) =>\n window.supervision &&\n candidate.start === window.start &&\n candidate.end === window.end &&\n candidate.zone === window.zone,\n );\n}\n\n/** Check every segment; endpoints belonging to ending windows are excluded. */\nexport function fitsCapacity(\n candidate: Reservation,\n declared: readonly DeclaredWindow[],\n occupied: readonly Reservation[],\n): boolean {\n type Delta = { capacity: number; shared: number; exclusive: number };\n const boundaries = new Map<string, Delta>();\n const add = (at: string, key: keyof Delta, delta: number) => {\n const event = boundaries.get(at) ?? {\n capacity: 0,\n shared: 0,\n exclusive: 0,\n };\n event[key] += delta;\n boundaries.set(at, event);\n };\n const sweep = (\n window: Reservation | DeclaredWindow,\n key: keyof Delta,\n units: number,\n ) => {\n if (\n window.startUtc >= candidate.endUtc ||\n window.endUtc <= candidate.startUtc\n )\n return;\n add(\n window.startUtc < candidate.startUtc\n ? candidate.startUtc\n : window.startUtc,\n key,\n units,\n );\n if (window.endUtc < candidate.endUtc) add(window.endUtc, key, -units);\n };\n\n // Seed the first segment even when there is no declared coverage there.\n add(candidate.startUtc, 'capacity', 0);\n for (const window of declared) sweep(window, 'capacity', window.capacity);\n for (const reservation of occupied)\n sweep(reservation, reservation.exclusive ? 'exclusive' : 'shared', 1);\n\n let capacity = 0;\n let shared = 0;\n let exclusive = 0;\n for (const at of [...boundaries.keys()].sort()) {\n const delta = boundaries.get(at);\n if (!delta) continue;\n // Apply ends and starts together before checking the segment beginning here.\n capacity += delta.capacity;\n shared += delta.shared;\n exclusive += delta.exclusive;\n if (\n capacity < 1 ||\n exclusive > 0 ||\n (candidate.exclusive ? shared > 0 : shared >= capacity)\n )\n return false;\n }\n return true;\n}\n","import { createHash, randomUUID } from 'node:crypto';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { BookingError } from './errors.js';\nimport type {\n AvailabilityInput,\n BookingAccess,\n BookingAuthority,\n BookingContext,\n BookingHistoryEvent,\n BookingKind,\n BookingOperation,\n BookingProposal,\n BookingReceipt,\n BookingReferences,\n BookingStatus,\n BookingView,\n ProposeBookingInput,\n RequestBookingInput,\n ResolvedBookingWindow,\n} from './types.js';\nimport {\n type DeclaredWindow,\n declareWindows,\n elapsedHour,\n fitsCapacity,\n type Reservation,\n resolveBookingWindow,\n supervisionDeclared,\n} from './windows.js';\n\ntype WriteOperation = Exclude<BookingOperation, 'read' | 'history'>;\n\ninterface BookingRow {\n id: string;\n offering_id: string;\n contact_id: string;\n project_id: string | null;\n kind: BookingKind;\n status: BookingStatus;\n proposal_id: string | null;\n assented_proposal_id: string | null;\n confirmed_proposal_id: string | null;\n}\n\ninterface Allocation extends Reservation {\n window: ResolvedBookingWindow;\n}\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\nfunction id(value: string): string {\n if (typeof value !== 'string' || !UUID.test(value)) {\n throw new BookingError('invalid', 'Expected a UUID reference');\n }\n return value.toLowerCase();\n}\n\nfunction required(value: string, max = 200): string {\n if (\n typeof value !== 'string' ||\n value.trim().length === 0 ||\n value.length > max ||\n value.includes('\\0')\n ) {\n throw new BookingError(\n 'invalid',\n 'Expected a nonempty text value within the length limit',\n );\n }\n return value;\n}\n\nfunction identity(input: BookingContext): Readonly<BookingContext> {\n return Object.freeze({\n tenantId: id(input.tenantId),\n actorId: required(input.actorId),\n });\n}\n\nfunction digest(value: string): string {\n return createHash('sha256').update(value).digest('hex');\n}\n\nfunction operator(access: BookingAccess): void {\n if (!access.operator)\n throw new BookingError('forbidden', 'Operator access required');\n}\n\nfunction contact(access: BookingAccess, contactId: string): void {\n if (access.contactId !== contactId) {\n throw new BookingError(\n 'forbidden',\n 'Only the named customer contact may act for this booking',\n );\n }\n}\n\nfunction visible(access: BookingAccess, row: BookingRow): void {\n if (!access.operator && access.contactId !== row.contact_id) {\n throw new BookingError('not-found', 'Booking does not exist');\n }\n}\n\nfunction pending(row: BookingRow): void {\n if (row.status !== 'requested' && row.status !== 'proposed') {\n throw new BookingError('conflict', 'Booking is no longer pending');\n }\n}\n\nfunction declared(\n kind: BookingKind,\n window: ResolvedBookingWindow,\n availability: readonly DeclaredWindow[],\n): void {\n if (\n (kind === 'supervision-day' &&\n !supervisionDeclared(window, availability)) ||\n (kind === 'developer-hour' &&\n !availability.some((entry) => entry.zone === window.zone)) ||\n !fitsCapacity(\n { ...window, exclusive: kind === 'developer-hour' },\n availability,\n [],\n )\n ) {\n throw new BookingError(\n 'unavailable',\n 'The proposed window is outside declared availability',\n );\n }\n}\n\n/** Transactional PostgreSQL service. The application supplies trusted identity and authorization. */\nexport class BookingService {\n readonly #db: DatabaseInterface;\n readonly #authority: BookingAuthority;\n\n constructor(db: DatabaseInterface, authority: BookingAuthority) {\n this.#db = db;\n this.#authority = authority;\n }\n\n async configureOffering(\n context: BookingContext,\n key: string,\n input: { offeringId: string; kind: BookingKind; enabled: boolean },\n ): Promise<BookingReceipt> {\n const offeringId = id(input.offeringId);\n const { kind, enabled } = input;\n if (\n (kind !== 'developer-hour' && kind !== 'supervision-day') ||\n typeof enabled !== 'boolean'\n ) {\n throw new BookingError(\n 'invalid',\n 'Invalid offering kind or enabled flag',\n );\n }\n return this.#write(\n context,\n 'configure-offering',\n key,\n { offeringId, kind, enabled },\n operator,\n async (tx, ctx) => {\n const saved = await tx.query(\n `INSERT INTO smrt_booking_offerings (tenant_id, id, kind, enabled) VALUES (?, ?, ?, ?)\n ON CONFLICT (tenant_id, id) DO UPDATE SET enabled = EXCLUDED.enabled\n WHERE smrt_booking_offerings.kind = EXCLUDED.kind RETURNING id`,\n ctx.tenantId,\n offeringId,\n kind,\n enabled,\n );\n if (!saved.rows.length)\n throw new BookingError('conflict', 'An offering kind cannot change');\n return { id: offeringId };\n },\n );\n }\n\n async declareAvailability(\n context: BookingContext,\n key: string,\n input: AvailabilityInput,\n ): Promise<BookingReceipt> {\n const providerId = id(input.providerId);\n const windows = declareWindows(input.zone, input.windows);\n return this.#write(\n context,\n 'declare-availability',\n key,\n { providerId, windows },\n operator,\n async (tx, ctx) => {\n await tx.query(\n `INSERT INTO smrt_booking_resources (tenant_id, id, windows) VALUES (?, ?, '[]')\n ON CONFLICT (tenant_id, id) DO NOTHING`,\n ctx.tenantId,\n providerId,\n );\n await this.#resource(tx, ctx, providerId, true);\n operator(await this.#access(ctx, 'declare-availability'));\n await this.#verify(ctx, { providerId });\n const allocated = await this.#allocations(tx, ctx, providerId);\n if (\n allocated.some(\n (item, i) =>\n (!item.exclusive && !supervisionDeclared(item.window, windows)) ||\n !fitsCapacity(\n item,\n windows,\n allocated.filter((_, j) => j !== i),\n ),\n )\n ) {\n throw new BookingError(\n 'unavailable',\n 'Availability change would undercut confirmed reservations',\n );\n }\n await tx.query(\n 'UPDATE smrt_booking_resources SET windows = ? WHERE tenant_id = ? AND id = ?',\n JSON.stringify(windows),\n ctx.tenantId,\n providerId,\n );\n return { id: providerId };\n },\n );\n }\n\n async request(\n context: BookingContext,\n key: string,\n input: RequestBookingInput,\n ): Promise<BookingReceipt> {\n const offeringId = id(input.offeringId);\n const contactId = id(input.contactId);\n const projectId =\n input.projectId === undefined ? null : id(input.projectId);\n return this.#write(\n context,\n 'request',\n key,\n { offeringId, contactId, projectId },\n (access) => {\n if (!access.operator) contact(access, contactId);\n },\n async (tx, ctx) => {\n const { rows } = await tx.query(\n 'SELECT kind, enabled FROM smrt_booking_offerings WHERE tenant_id = ? AND id = ? FOR SHARE',\n ctx.tenantId,\n offeringId,\n );\n if (!rows[0]?.enabled)\n throw new BookingError('not-found', 'Offering is not available');\n const kind = rows[0].kind as BookingKind;\n if (kind === 'supervision-day' && !projectId) {\n throw new BookingError(\n 'invalid',\n 'A supervision request requires a project',\n );\n }\n await this.#verify(ctx, {\n contactId,\n projectId: projectId ?? undefined,\n });\n const bookingId = randomUUID();\n await tx.query(\n `INSERT INTO smrt_bookings (tenant_id, id, offering_id, contact_id, project_id, kind, status)\n VALUES (?, ?, ?, ?, ?, ?, 'requested')`,\n ctx.tenantId,\n bookingId,\n offeringId,\n contactId,\n projectId,\n kind,\n );\n return { id: bookingId };\n },\n { createsBooking: true },\n );\n }\n\n async propose(\n context: BookingContext,\n key: string,\n input: ProposeBookingInput,\n ): Promise<BookingReceipt> {\n const bookingId = id(input.bookingId);\n const providerId = id(input.providerId);\n const window = resolveBookingWindow(input.window);\n const terms = {\n revision: required(input.terms.revision),\n text: required(input.terms.text, 65_536),\n };\n const plan = input.approvedPlan;\n const approvedPlan =\n plan === undefined\n ? null\n : { id: id(plan.id), version: required(plan.version) };\n return this.#write(\n context,\n 'propose',\n key,\n { bookingId, providerId, window, terms, approvedPlan },\n operator,\n async (tx, ctx) => {\n const row = await this.#booking(tx, ctx, bookingId, true);\n pending(row);\n if (\n row.kind === 'supervision-day'\n ? !approvedPlan\n : approvedPlan || !elapsedHour(window)\n ) {\n throw new BookingError(\n 'invalid',\n 'Supervision requires a plan version; dedicated time requires one elapsed hour',\n );\n }\n await this.#verify(ctx, {\n ...this.#refs(row),\n providerId,\n approvedPlan: approvedPlan ?? undefined,\n });\n declared(\n row.kind,\n window,\n await this.#resource(tx, ctx, providerId, false),\n );\n const proposalId = randomUUID();\n await tx.query(\n `INSERT INTO smrt_booking_proposals (tenant_id, booking_id, id, provider_id, coverage, terms, approved_plan)\n VALUES (?, ?, ?, ?, ?, ?, ?)`,\n ctx.tenantId,\n bookingId,\n proposalId,\n providerId,\n JSON.stringify(window),\n JSON.stringify(terms),\n approvedPlan && JSON.stringify(approvedPlan),\n );\n await tx.query(\n `UPDATE smrt_bookings SET status = 'proposed', proposal_id = ?, assented_proposal_id = NULL\n WHERE tenant_id = ? AND id = ?`,\n proposalId,\n ctx.tenantId,\n bookingId,\n );\n return { id: bookingId, proposalId };\n },\n { bookingId },\n );\n }\n\n async assent(\n context: BookingContext,\n key: string,\n input: { bookingId: string; proposalId: string },\n ): Promise<BookingReceipt> {\n const bookingId = id(input.bookingId);\n const proposalId = id(input.proposalId);\n return this.#write(\n context,\n 'assent',\n key,\n { bookingId, proposalId },\n async (access, tx, ctx) => {\n // Check contact ownership before exposing the existence of a replay.\n const row = await this.#booking(tx, ctx, bookingId, false);\n visible(access, row);\n contact(access, row.contact_id);\n },\n async (tx, ctx) => {\n const row = await this.#booking(tx, ctx, bookingId, true);\n contact(await this.#access(ctx, 'assent'), row.contact_id);\n this.#current(row, proposalId);\n if (row.assented_proposal_id)\n throw new BookingError(\n 'conflict',\n 'Customer assent is already recorded',\n );\n await this.#verify(ctx, this.#refs(row));\n await tx.query(\n 'UPDATE smrt_bookings SET assented_proposal_id = ? WHERE tenant_id = ? AND id = ?',\n proposalId,\n ctx.tenantId,\n bookingId,\n );\n return { id: bookingId, proposalId };\n },\n { bookingId },\n );\n }\n\n async confirm(\n context: BookingContext,\n key: string,\n input: { bookingId: string; proposalId: string },\n ): Promise<BookingReceipt> {\n const bookingId = id(input.bookingId);\n const proposalId = id(input.proposalId);\n return this.#write(\n context,\n 'confirm',\n key,\n { bookingId, proposalId },\n operator,\n async (tx, ctx) => {\n const row = await this.#booking(tx, ctx, bookingId, true);\n this.#current(row, proposalId);\n if (row.assented_proposal_id !== proposalId) {\n throw new BookingError(\n 'conflict',\n 'Exact proposal does not have customer assent',\n );\n }\n const proposal = await this.#proposal(tx, ctx, row, proposalId);\n const availability = await this.#resource(\n tx,\n ctx,\n proposal.providerId,\n true,\n );\n // Refresh after waiting for contested capacity; this does not lock external authorities.\n operator(await this.#access(ctx, 'confirm'));\n await this.#verify(ctx, {\n ...this.#refs(row),\n providerId: proposal.providerId,\n approvedPlan: proposal.approvedPlan ?? undefined,\n });\n const exclusive = row.kind === 'developer-hour';\n const window = proposal.window;\n declared(row.kind, window, availability);\n if (\n !fitsCapacity(\n { ...window, exclusive },\n availability,\n await this.#allocations(tx, ctx, proposal.providerId, window),\n )\n ) {\n throw new BookingError(\n 'unavailable',\n 'The proposed window has insufficient capacity',\n );\n }\n const inserted = await tx.query(\n `INSERT INTO smrt_booking_allocations\n (tenant_id, booking_id, proposal_id, provider_id, project_id, exclusive, start_at, end_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (tenant_id, project_id, start_at, end_at)\n WHERE released_at IS NULL AND project_id IS NOT NULL DO NOTHING RETURNING booking_id`,\n ctx.tenantId,\n bookingId,\n proposalId,\n proposal.providerId,\n exclusive ? null : row.project_id,\n exclusive,\n window.startUtc,\n window.endUtc,\n );\n if (!inserted.rows.length) {\n throw new BookingError(\n 'duplicate-project-window',\n 'Project already has a reservation for this window',\n );\n }\n await tx.query(\n `UPDATE smrt_bookings SET status = 'confirmed', confirmed_proposal_id = ?\n WHERE tenant_id = ? AND id = ?`,\n proposalId,\n ctx.tenantId,\n bookingId,\n );\n return { id: bookingId, proposalId };\n },\n { bookingId },\n );\n }\n\n decline(\n context: BookingContext,\n key: string,\n input: { bookingId: string; reason: string },\n ): Promise<BookingReceipt> {\n return this.#close(context, 'decline', key, input);\n }\n\n cancel(\n context: BookingContext,\n key: string,\n input: { bookingId: string; reason: string },\n ): Promise<BookingReceipt> {\n return this.#close(context, 'cancel', key, input);\n }\n\n async get(context: BookingContext, bookingId: string): Promise<BookingView> {\n const ctx = identity(context);\n const access = await this.#access(ctx, 'read');\n const row = await this.#booking(this.#db, ctx, id(bookingId), false);\n visible(access, row);\n return {\n id: row.id,\n offeringId: row.offering_id,\n contactId: row.contact_id,\n projectId: row.project_id,\n kind: row.kind,\n status: row.status,\n proposal: row.proposal_id\n ? await this.#proposal(this.#db, ctx, row, row.proposal_id)\n : null,\n assentedProposalId: row.assented_proposal_id,\n confirmedProposalId: row.confirmed_proposal_id,\n };\n }\n\n async getProposal(\n context: BookingContext,\n bookingId: string,\n proposalId: string,\n ): Promise<BookingProposal> {\n const ctx = identity(context);\n const access = await this.#access(ctx, 'read');\n const row = await this.#booking(this.#db, ctx, id(bookingId), false);\n visible(access, row);\n return this.#proposal(this.#db, ctx, row, id(proposalId));\n }\n\n async history(\n context: BookingContext,\n bookingId: string,\n ): Promise<BookingHistoryEvent[]> {\n const ctx = identity(context);\n operator(await this.#access(ctx, 'history'));\n await this.#booking(this.#db, ctx, id(bookingId), false);\n const { rows } = await this.#db.query(\n `SELECT operation, actor_id, result->>'proposalId' AS proposal_id, reason, at\n FROM smrt_booking_commands WHERE tenant_id = ? AND booking_id = ? ORDER BY at, sequence`,\n ctx.tenantId,\n id(bookingId),\n );\n return rows.map((row) => ({\n operation: row.operation as BookingOperation,\n actorId: row.actor_id,\n proposalId: row.proposal_id,\n reason: row.reason,\n at: new Date(row.at).toISOString(),\n }));\n }\n\n async #close(\n context: BookingContext,\n operation: 'decline' | 'cancel',\n key: string,\n input: { bookingId: string; reason: string },\n ): Promise<BookingReceipt> {\n const bookingId = id(input.bookingId);\n const reason = required(input.reason, 2_048);\n return this.#write(\n context,\n operation,\n key,\n { bookingId, reason },\n operator,\n async (tx, ctx) => {\n const row = await this.#booking(tx, ctx, bookingId, true);\n if (\n operation === 'cancel' &&\n row.confirmed_proposal_id &&\n row.status === 'confirmed'\n ) {\n const proposal = await this.#proposal(\n tx,\n ctx,\n row,\n row.confirmed_proposal_id,\n );\n await this.#resource(tx, ctx, proposal.providerId, true);\n operator(await this.#access(ctx, 'cancel'));\n const released = await tx.query(\n `UPDATE smrt_booking_allocations SET released_at = clock_timestamp()\n WHERE tenant_id = ? AND booking_id = ? AND released_at IS NULL RETURNING booking_id`,\n ctx.tenantId,\n bookingId,\n );\n if (!released.rows.length)\n throw new BookingError(\n 'conflict',\n 'Confirmed booking has no active allocation',\n );\n } else {\n pending(row);\n }\n await tx.query(\n 'UPDATE smrt_bookings SET status = ? WHERE tenant_id = ? AND id = ?',\n operation === 'cancel' ? 'cancelled' : 'declined',\n ctx.tenantId,\n bookingId,\n );\n return {\n id: bookingId,\n ...(row.proposal_id ? { proposalId: row.proposal_id } : {}),\n };\n },\n { bookingId, reason },\n );\n }\n\n async #access(\n context: Readonly<BookingContext>,\n operation: BookingOperation,\n ): Promise<BookingAccess> {\n const access = await this.#authority.access(context, operation);\n if (!access) throw new BookingError('forbidden', 'Booking access denied');\n return {\n operator: access.operator === true,\n contactId:\n access.contactId === undefined ? undefined : id(access.contactId),\n };\n }\n\n async #verify(\n ctx: Readonly<BookingContext>,\n refs: BookingReferences,\n ): Promise<void> {\n // Use a detached copy so a host callback cannot change the version being verified and saved.\n if (\n (await this.#authority.verifyReferences(ctx, structuredClone(refs))) !==\n true\n ) {\n throw new BookingError(\n 'forbidden',\n 'Booking references or approval could not be verified',\n );\n }\n }\n\n #refs(row: BookingRow): BookingReferences {\n return {\n contactId: row.contact_id,\n projectId: row.project_id ?? undefined,\n };\n }\n\n async #booking(\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n bookingId: string,\n lock: boolean,\n ): Promise<BookingRow> {\n const { rows } = await tx.query(\n `SELECT * FROM smrt_bookings WHERE tenant_id = ? AND id = ?${lock ? ' FOR UPDATE' : ''}`,\n ctx.tenantId,\n bookingId,\n );\n if (!rows[0]) throw new BookingError('not-found', 'Booking does not exist');\n return rows[0] as BookingRow;\n }\n\n #current(row: BookingRow, proposalId: string): void {\n if (row.status !== 'proposed' || row.proposal_id !== proposalId) {\n throw new BookingError(\n 'conflict',\n 'Booking does not have this current proposal',\n );\n }\n }\n\n async #proposal(\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n booking: BookingRow,\n proposalId: string,\n ): Promise<BookingProposal> {\n const { rows } = await tx.query(\n `SELECT provider_id, coverage::text, terms::text, approved_plan::text FROM smrt_booking_proposals\n WHERE tenant_id = ? AND booking_id = ? AND id = ?`,\n ctx.tenantId,\n booking.id,\n proposalId,\n );\n const row = rows[0];\n if (!row) throw new BookingError('not-found', 'Proposal does not exist');\n return {\n id: proposalId,\n providerId: row.provider_id,\n window: JSON.parse(row.coverage),\n terms: JSON.parse(row.terms),\n approvedPlan:\n row.approved_plan === null ? null : JSON.parse(row.approved_plan),\n };\n }\n\n async #resource(\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n providerId: string,\n lock: boolean,\n ): Promise<DeclaredWindow[]> {\n const { rows } = await tx.query(\n `SELECT windows::text FROM smrt_booking_resources WHERE tenant_id = ? AND id = ?${lock ? ' FOR UPDATE' : ''}`,\n ctx.tenantId,\n providerId,\n );\n if (!rows[0])\n throw new BookingError(\n 'not-found',\n 'Provider availability is not configured',\n );\n return JSON.parse(rows[0].windows);\n }\n\n async #allocations(\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n providerId: string,\n window?: ResolvedBookingWindow,\n ): Promise<Allocation[]> {\n const { rows } = await tx.query(\n `SELECT a.exclusive, p.coverage::text FROM smrt_booking_allocations a\n JOIN smrt_booking_proposals p ON p.tenant_id = a.tenant_id AND p.booking_id = a.booking_id AND p.id = a.proposal_id\n WHERE a.tenant_id = ? AND a.provider_id = ? AND a.released_at IS NULL${window ? ' AND a.start_at < ? AND a.end_at > ?' : ''}`,\n ctx.tenantId,\n providerId,\n ...(window ? [window.endUtc, window.startUtc] : []),\n );\n return rows.map((row) => {\n const snapshot = JSON.parse(row.coverage) as ResolvedBookingWindow;\n return { ...snapshot, window: snapshot, exclusive: row.exclusive };\n });\n }\n\n async #write(\n context: BookingContext,\n operation: WriteOperation,\n key: string,\n canonical: object,\n permit: (\n access: BookingAccess,\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n ) => void | Promise<void>,\n work: (\n tx: DatabaseInterface,\n ctx: Readonly<BookingContext>,\n ) => Promise<BookingReceipt>,\n options: {\n bookingId?: string;\n reason?: string;\n createsBooking?: boolean;\n } = {},\n ): Promise<BookingReceipt> {\n const ctx = identity(context);\n const keyHash = digest(required(key));\n const payloadHash = digest(JSON.stringify(canonical));\n if (!this.#db.transaction)\n throw new TypeError('Bookings requires a PostgreSQL transaction');\n return this.#db.transaction(async (tx) => {\n await tx.query('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');\n await permit(await this.#access(ctx, operation), tx, ctx);\n const inserted = await tx.query(\n `INSERT INTO smrt_booking_commands (tenant_id, operation, key_hash, actor_id, payload_hash)\n VALUES (?, ?, ?, ?, ?) ON CONFLICT (tenant_id, operation, key_hash) DO NOTHING RETURNING key_hash`,\n ctx.tenantId,\n operation,\n keyHash,\n ctx.actorId,\n payloadHash,\n );\n if (!inserted.rows.length) {\n await permit(await this.#access(ctx, operation), tx, ctx);\n const { rows } = await tx.query(\n `SELECT actor_id, payload_hash, result::text FROM smrt_booking_commands\n WHERE tenant_id = ? AND operation = ? AND key_hash = ?`,\n ctx.tenantId,\n operation,\n keyHash,\n );\n const prior = rows[0];\n if (\n !prior ||\n prior.actor_id !== ctx.actorId ||\n prior.payload_hash !== payloadHash ||\n !prior.result\n ) {\n throw new BookingError(\n 'conflict',\n 'Idempotency key is bound to a different command',\n );\n }\n return JSON.parse(prior.result) as BookingReceipt;\n }\n const result = await work(tx, ctx);\n await tx.query(\n `UPDATE smrt_booking_commands SET booking_id = ?, result = ?, reason = ?, at = clock_timestamp()\n WHERE tenant_id = ? AND operation = ? AND key_hash = ?`,\n options.bookingId ?? (options.createsBooking ? result.id : null),\n JSON.stringify(result),\n options.reason ?? null,\n ctx.tenantId,\n operation,\n keyHash,\n );\n return result;\n });\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "timestamp": 0,
4
+ "packageName": "@happyvertical/smrt-bookings",
5
+ "packageVersion": "0.59.0",
6
+ "objects": {},
7
+ "moduleType": "smrt",
8
+ "stampsConstructors": true
9
+ }
@@ -0,0 +1,4 @@
1
+ import type { DatabaseInterface } from '@happyvertical/sql';
2
+ /** Initial PostgreSQL schema. Register once in the host deployment's migration ledger. */
3
+ export declare function migrateBookingsV1(db: DatabaseInterface): Promise<void>;
4
+ //# sourceMappingURL=migration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration.d.ts","sourceRoot":"","sources":["../src/migration.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAsF5D,0FAA0F;AAC1F,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAM5E"}
@@ -0,0 +1,35 @@
1
+ import type { DatabaseInterface } from '@happyvertical/sql';
2
+ import type { AvailabilityInput, BookingAuthority, BookingContext, BookingHistoryEvent, BookingKind, BookingProposal, BookingReceipt, BookingView, ProposeBookingInput, RequestBookingInput } from './types.js';
3
+ /** Transactional PostgreSQL service. The application supplies trusted identity and authorization. */
4
+ export declare class BookingService {
5
+ #private;
6
+ constructor(db: DatabaseInterface, authority: BookingAuthority);
7
+ configureOffering(context: BookingContext, key: string, input: {
8
+ offeringId: string;
9
+ kind: BookingKind;
10
+ enabled: boolean;
11
+ }): Promise<BookingReceipt>;
12
+ declareAvailability(context: BookingContext, key: string, input: AvailabilityInput): Promise<BookingReceipt>;
13
+ request(context: BookingContext, key: string, input: RequestBookingInput): Promise<BookingReceipt>;
14
+ propose(context: BookingContext, key: string, input: ProposeBookingInput): Promise<BookingReceipt>;
15
+ assent(context: BookingContext, key: string, input: {
16
+ bookingId: string;
17
+ proposalId: string;
18
+ }): Promise<BookingReceipt>;
19
+ confirm(context: BookingContext, key: string, input: {
20
+ bookingId: string;
21
+ proposalId: string;
22
+ }): Promise<BookingReceipt>;
23
+ decline(context: BookingContext, key: string, input: {
24
+ bookingId: string;
25
+ reason: string;
26
+ }): Promise<BookingReceipt>;
27
+ cancel(context: BookingContext, key: string, input: {
28
+ bookingId: string;
29
+ reason: string;
30
+ }): Promise<BookingReceipt>;
31
+ get(context: BookingContext, bookingId: string): Promise<BookingView>;
32
+ getProposal(context: BookingContext, bookingId: string, proposalId: string): Promise<BookingProposal>;
33
+ history(context: BookingContext, bookingId: string): Promise<BookingHistoryEvent[]>;
34
+ }
35
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,KAAK,EACV,iBAAiB,EAEjB,gBAAgB,EAChB,cAAc,EACd,mBAAmB,EACnB,WAAW,EAEX,eAAe,EACf,cAAc,EAGd,WAAW,EACX,mBAAmB,EACnB,mBAAmB,EAEpB,MAAM,YAAY,CAAC;AAiHpB,qGAAqG;AACrG,qBAAa,cAAc;;gBAIb,EAAE,EAAE,iBAAiB,EAAE,SAAS,EAAE,gBAAgB;IAKxD,iBAAiB,CACrB,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,WAAW,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,GACjE,OAAO,CAAC,cAAc,CAAC;IAmCpB,mBAAmB,CACvB,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAAC,cAAc,CAAC;IA+CpB,OAAO,CACX,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,cAAc,CAAC;IAiDpB,OAAO,CACX,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,cAAc,CAAC;IAmEpB,MAAM,CACV,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC/C,OAAO,CAAC,cAAc,CAAC;IAoCpB,OAAO,CACX,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAC/C,OAAO,CAAC,cAAc,CAAC;IAiF1B,OAAO,CACL,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAC3C,OAAO,CAAC,cAAc,CAAC;IAI1B,MAAM,CACJ,OAAO,EAAE,cAAc,EACvB,GAAG,EAAE,MAAM,EACX,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAC3C,OAAO,CAAC,cAAc,CAAC;IAIpB,GAAG,CAAC,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAoBrE,WAAW,CACf,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,eAAe,CAAC;IAQrB,OAAO,CACX,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,mBAAmB,EAAE,CAAC;CAmRlC"}
@@ -0,0 +1,57 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "sensitiveFieldsExcluded": true,
4
+ "generatedAt": "1970-01-01T00:00:00.000Z",
5
+ "packageName": "@happyvertical/smrt-bookings",
6
+ "packageVersion": "0.59.0",
7
+ "sourceManifestPath": "dist/manifest.json",
8
+ "agentDocPath": "AGENTS.md",
9
+ "sourceHashes": {
10
+ "manifest": "3ca5db6875d110c210b6b469c20884fee8660bc4f5da0f5b39f35a8a4be2f5a7",
11
+ "packageJson": "166411da48efbb098c83041c6b72e72658690ea49675ed9e02c8719edac1a3e3",
12
+ "agents": "01210ae016391e945e8ceac798120ebd5a8deeb807fe7c5cac6f5fc824e0ce02",
13
+ "moduleDoc:README.md": "a4b1fef604b682fdb60c1297427fb8a4cd56cba8a76e3e8eb113ddc5689cfff6"
14
+ },
15
+ "exports": [
16
+ "."
17
+ ],
18
+ "dependencies": {
19
+ "@happyvertical/sql": "catalog:",
20
+ "@happyvertical/smrt-vitest": "workspace:*",
21
+ "@types/node": "24.13.6",
22
+ "typescript": "6.0.3",
23
+ "vite": "8.1.6",
24
+ "vitest": "4.1.10"
25
+ },
26
+ "smrtDependencies": [
27
+ "@happyvertical/smrt-vitest"
28
+ ],
29
+ "sdkDependencies": [
30
+ "@happyvertical/sql"
31
+ ],
32
+ "tags": [
33
+ "cross-package"
34
+ ],
35
+ "risks": [
36
+ "sensitive-fields-excluded"
37
+ ],
38
+ "objects": [],
39
+ "surfaces": [],
40
+ "prompts": [],
41
+ "relationshipsV2": {
42
+ "foreignKeyFields": 0,
43
+ "crossPackageRefFields": 0,
44
+ "junctionCollections": 0,
45
+ "hierarchicalObjects": 0,
46
+ "polymorphicAssociations": 0,
47
+ "uuidColumns": 0
48
+ },
49
+ "agentDoc": "# SMRT Bookings\n\nThis package owns service reservations and capacity. Application hosts supply\ntrusted actor and tenant context and verify their own customer, provider, project\nand plan relationships. Commerce and payment state are separate.\n\nUse service commands for writes; keep this package out of generated CRUD. Apply\nits explicit PostgreSQL migration during deployment, outside request handling.\nSee [README.md](README.md) for the transaction and verification contracts.\n",
50
+ "moduleDocs": [
51
+ {
52
+ "path": "README.md",
53
+ "module": "README",
54
+ "content": "# s-m-r-t Bookings\n\n`@happyvertical/smrt-bookings` reserves one elapsed developer hour or one\noperator-declared local supervision window. It does not start agents, collect\npayment, send notifications, or write Commerce records.\n\nApply `migrateBookingsV1` once through the host's deployment migration ledger\nusing a PostgreSQL database handle. Use an ordinary PostgreSQL connection or\npool for `BookingService`; the service owns its transaction and explicitly uses\nREAD COMMITTED. It does not create schema on demand. No ORM collection, CRUD\nroute, or generated CLI or MCP write is registered.\n\n1. An operator enables an offering and declares availability for a canonical\n provider UUID. Use the same provider identity for all services that reserve\n the same person. A declaration replaces that provider's configured windows.\n Windows must not overlap and must have positive integer capacity. Mark at\n most one exact supervision window per provider per local date. Changes that\n invalidate any active reservation are refused.\n2. The named customer contact, or an operator acting for them, requests an\n enabled offering. A supervision request requires a project. Disabling an\n offering stops new requests; operators may finish pending requests.\n3. An operator proposes a provider, window, and terms revision with the exact\n agreed wording. Supervision also needs an approved project plan ID and\n version. Each proposal is immutable. The contact assents to its exact ID.\n Replacing the proposal clears current assent. Requests, proposals, and\n assent reserve no capacity.\n4. An operator confirms the exact assented proposal. The service locks the\n booking and then the stable provider row, checks authority and current\n declared availability, and checks every half-open segment. An exclusive\n hour blocks all overlaps; supervision consumes one unit. Two active\n supervision reservations for the same project and exact UTC interval are\n refused even on different providers. An unsuccessful confirmation leaves\n the proposal and assent available for a retry or revision.\n5. An operator can decline a pending request or cancel a pending or confirmed\n booking with a reason. Cancelling a confirmed booking releases capacity\n in the same transaction; it preserves the confirmed terms and history.\n Booking and historical proposal reads are scoped to the named contact or\n an operator. Booking audit history is operator-only.\n\nWindows use an IANA zone and local timestamps of the form\n`2027-11-07T01:30-04:00`. For example, use that offset with America/New_York. The offset must be valid for that instant in the zone.\nDuring a DST fold the offsets identify different instants; a nonexistent wall\ntime is rejected. Stored proposals retain the local input and UTC instants.\nIntervals include their start and exclude their end. A supervision window\nstarts and ends on one local date, or ends at midnight opening the next date;\nit need not cover the entire day and may span a daylight-saving transition.\n\nUse an authenticated tenant/actor context from the host, never from a request\nbody. The host's `BookingAuthority` supplies current operator permission and\nthe actor's named contact mapping, and must verify that external references\nexist in that tenant and relate to each other. For a supervision proposal and\nagain at confirmation, it must verify that the exact plan version is approved\nfor the project. The confirmation check occurs after the provider lock; it is\nevidence of approval at that point, not a distributed lock on a plan system.\nAuthority callbacks should be read-only and must not call a booking command.\n\nEach write takes a retry key scoped to tenant and operation. A committed key\nis bound to its actor and normalized command payload. An identical authorized\nretry returns the original receipt; a different payload or actor is rejected.\nA failed command rolls back both its changes and the key claim. Command receipts\nand audit history commit with the booking, so a failure in caller-owned\ncommunication does not undo the reservation. Clients can fetch it afterward\nand coordinate manual invoicing separately.\n\nOnly real PostgreSQL is supported for writes. To run the package unit tests,\nuse `pnpm --filter @happyvertical/smrt-bookings test`. To run the separate\nPostgreSQL suite, point `CI_POSTGRES_BASE_URL` at an isolated server with\ndatabase creation privileges and run `pnpm --filter @happyvertical/smrt-bookings\ntest:postgres`. The test wrapper provisions and removes a disposable database.\n"
55
+ }
56
+ ]
57
+ }
@@ -0,0 +1,100 @@
1
+ /** A host-authenticated identity. Never construct this directly from request data. */
2
+ export interface BookingContext {
3
+ tenantId: string;
4
+ actorId: string;
5
+ }
6
+ export type BookingOperation = 'configure-offering' | 'declare-availability' | 'request' | 'propose' | 'assent' | 'confirm' | 'decline' | 'cancel' | 'read' | 'history';
7
+ export interface BookingAccess {
8
+ operator: boolean;
9
+ /** Customer contact the host currently maps to this actor, if any. */
10
+ contactId?: string;
11
+ }
12
+ export interface ApprovedPlanRef {
13
+ id: string;
14
+ version: string;
15
+ }
16
+ export interface BookingReferences {
17
+ contactId?: string;
18
+ /** Canonical provider identity, shared by all offerings for this human. */
19
+ providerId?: string;
20
+ projectId?: string;
21
+ /** Only passed when the host must attest this exact version is approved. */
22
+ approvedPlan?: ApprovedPlanRef;
23
+ }
24
+ /** The host verifies tenant ownership, reference relationships and role access. */
25
+ export interface BookingAuthority {
26
+ access(context: Readonly<BookingContext>, operation: BookingOperation): Promise<BookingAccess | null>;
27
+ verifyReferences(context: Readonly<BookingContext>, references: Readonly<BookingReferences>): Promise<boolean>;
28
+ }
29
+ export type BookingKind = 'developer-hour' | 'supervision-day';
30
+ /** A minute-precision local timestamp with explicit UTC offset, e.g. YYYY-MM-DDTHH:mm-06:00. */
31
+ export interface WallWindow {
32
+ start: string;
33
+ end: string;
34
+ }
35
+ export interface BookingWindow extends WallWindow {
36
+ zone: string;
37
+ }
38
+ export interface ResolvedBookingWindow extends BookingWindow {
39
+ startUtc: string;
40
+ endUtc: string;
41
+ }
42
+ export interface AvailabilityWindow extends WallWindow {
43
+ capacity: number;
44
+ /** May this exact local coverage window be proposed as a supervision day? */
45
+ supervision: boolean;
46
+ }
47
+ export interface AvailabilityInput {
48
+ providerId: string;
49
+ zone: string;
50
+ windows: readonly AvailabilityWindow[];
51
+ }
52
+ /** Terms revision and verbatim accepted wording; does not encode capacity or payment state. */
53
+ export interface ProposalTerms {
54
+ revision: string;
55
+ text: string;
56
+ }
57
+ export interface BookingProposal {
58
+ id: string;
59
+ providerId: string;
60
+ window: ResolvedBookingWindow;
61
+ terms: ProposalTerms;
62
+ approvedPlan: ApprovedPlanRef | null;
63
+ }
64
+ export interface ProposeBookingInput {
65
+ bookingId: string;
66
+ providerId: string;
67
+ window: BookingWindow;
68
+ terms: ProposalTerms;
69
+ approvedPlan?: ApprovedPlanRef;
70
+ }
71
+ export interface RequestBookingInput {
72
+ offeringId: string;
73
+ contactId: string;
74
+ projectId?: string;
75
+ }
76
+ export type BookingStatus = 'requested' | 'proposed' | 'confirmed' | 'declined' | 'cancelled';
77
+ export interface BookingView {
78
+ id: string;
79
+ offeringId: string;
80
+ contactId: string;
81
+ projectId: string | null;
82
+ kind: BookingKind;
83
+ status: BookingStatus;
84
+ proposal: BookingProposal | null;
85
+ assentedProposalId: string | null;
86
+ confirmedProposalId: string | null;
87
+ }
88
+ /** Stable response persisted for retries, independent of subsequent changes. */
89
+ export interface BookingReceipt {
90
+ id: string;
91
+ proposalId?: string;
92
+ }
93
+ export interface BookingHistoryEvent {
94
+ operation: BookingOperation;
95
+ actorId: string;
96
+ proposalId: string | null;
97
+ reason: string | null;
98
+ at: string;
99
+ }
100
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
File without changes
@@ -0,0 +1,18 @@
1
+ import type { AvailabilityWindow, BookingWindow, ResolvedBookingWindow } from './types.js';
2
+ /** Resolve half-open wall time; a DST fold must name the intended offset. */
3
+ export declare function resolveBookingWindow(window: BookingWindow): ResolvedBookingWindow;
4
+ export declare function elapsedHour(window: ResolvedBookingWindow): boolean;
5
+ export interface DeclaredWindow extends ResolvedBookingWindow {
6
+ capacity: number;
7
+ supervision: boolean;
8
+ }
9
+ export declare function declareWindows(zone: string, windows: readonly AvailabilityWindow[]): DeclaredWindow[];
10
+ export interface Reservation {
11
+ startUtc: string;
12
+ endUtc: string;
13
+ exclusive: boolean;
14
+ }
15
+ export declare function supervisionDeclared(candidate: ResolvedBookingWindow, windows: readonly DeclaredWindow[]): boolean;
16
+ /** Check every segment; endpoints belonging to ending windows are excluded. */
17
+ export declare function fitsCapacity(candidate: Reservation, declared: readonly DeclaredWindow[], occupied: readonly Reservation[]): boolean;
18
+ //# sourceMappingURL=windows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"windows.d.ts","sourceRoot":"","sources":["../src/windows.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,kBAAkB,EAClB,aAAa,EACb,qBAAqB,EACtB,MAAM,YAAY,CAAC;AAqDpB,6EAA6E;AAC7E,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,aAAa,GACpB,qBAAqB,CAiBvB;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAIlE;AAYD,MAAM,WAAW,cAAe,SAAQ,qBAAqB;IAC3D,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,SAAS,kBAAkB,EAAE,GACrC,cAAc,EAAE,CAuClB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,qBAAqB,EAChC,OAAO,EAAE,SAAS,cAAc,EAAE,GACjC,OAAO,CAQT;AAED,+EAA+E;AAC/E,wBAAgB,YAAY,CAC1B,SAAS,EAAE,WAAW,EACtB,QAAQ,EAAE,SAAS,cAAc,EAAE,EACnC,QAAQ,EAAE,SAAS,WAAW,EAAE,GAC/B,OAAO,CAwDT"}
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-bookings",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.59.0",
4
+ "description": "Operator-managed service reservations for SMRT",
5
+ "type": "module",
6
+ "author": "HappyVertical",
7
+ "license": "MIT",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "AGENTS.md",
19
+ "CLAUDE.md"
20
+ ],
21
+ "dependencies": {
22
+ "@happyvertical/sql": "^0.111.3"
23
+ },
24
+ "devDependencies": {
25
+ "@happyvertical/smrt-vitest": "0.59.0",
26
+ "@types/node": "24.13.6",
27
+ "typescript": "6.0.3",
28
+ "vite": "8.1.6",
29
+ "vitest": "4.1.10"
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org",
33
+ "access": "public"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "https://github.com/happyvertical/smrt.git",
38
+ "directory": "packages/bookings"
39
+ },
40
+ "scripts": {
41
+ "build": "vite build --mode library",
42
+ "clean": "rm -rf dist",
43
+ "test": "vitest run",
44
+ "test:postgres": "node ../../scripts/run-with-ci-postgres.mjs -- pnpm exec vitest run postgres.optional.test.ts",
45
+ "typecheck": "tsc --noEmit",
46
+ "verify:pack": "node ../../scripts/verify-package-types-exports.js ."
47
+ }
6
48
  }