@kedge/server 0.0.0-stage → 0.10.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.
@@ -0,0 +1,440 @@
1
+ // P0.4's routes (p0.4-dated-capacity.md §6, dated-capacity.md §7): store
2
+ // availability, and the admin's capacity commands and views. Every command
3
+ // that adds a fact takes an Idempotency-Key, scoped to the caller and the
4
+ // operation (docs/06); the store replays a retry's stored result.
5
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
6
+ import { actorOf, KedgeError, parseLocalDate } from "@kedge/core";
7
+ import type { Context } from "hono";
8
+ import type { AdminPermissions, KedgeEnv } from "./access";
9
+ import { jsonBody, ok } from "./http";
10
+ import type { AppDeps } from "./types";
11
+
12
+ const ID = z.string().min(1).max(64);
13
+ const Instant = z.iso.datetime({ offset: true }).transform((v) => Date.parse(v));
14
+ const ORDER_ID = /^ord_[0-9A-HJKMNP-TV-Z]{26}$/;
15
+
16
+ export function capacityRoutes(app: OpenAPIHono<KedgeEnv>, deps: AppDeps, perms: AdminPermissions): void {
17
+ const { capacity, availability } = deps;
18
+ const clock = deps.clock;
19
+ /** A command's retry scope is its caller (p0.5 §8): the staff member or key the request resolved. */
20
+ const who = (c: Context<KedgeEnv>) => ({
21
+ principal: actorOf(c.var.access.ctx.principal),
22
+ key: c.req.header("idempotency-key") ?? null,
23
+ });
24
+
25
+ if (availability) {
26
+ app.openapi(
27
+ createRoute({
28
+ method: "get",
29
+ path: "/store/availability",
30
+ request: {
31
+ query: z.object({
32
+ variant: ID,
33
+ kind: z.string().min(1).max(64).optional(),
34
+ from: Instant,
35
+ to: Instant.optional(),
36
+ days: z.coerce.number().int().min(1).max(62).optional(),
37
+ location: ID.optional(),
38
+ }),
39
+ },
40
+ responses: ok("Units bookable over a period, or per day"),
41
+ }),
42
+ async (c) => {
43
+ const q = c.req.valid("query");
44
+ if ((q.to === undefined) === (q.days === undefined))
45
+ throw new KedgeError("BAD_REQUEST", "Give either to or days");
46
+ const periods =
47
+ q.days !== undefined ? availability.days(q.from, q.days) : [{ start: q.from, end: q.to as number }];
48
+ const out = await availability.available({
49
+ variantId: q.variant,
50
+ ...(q.kind ? { kind: q.kind } : {}),
51
+ periods,
52
+ ...(q.location ? { locationId: q.location } : {}),
53
+ now: clock.now(),
54
+ });
55
+ return c.json(
56
+ {
57
+ variant_id: q.variant,
58
+ kind: out.kind,
59
+ location_id: q.location ?? null,
60
+ periods: out.periods.map((p) => ({
61
+ start: new Date(p.start).toISOString(),
62
+ end: new Date(p.end).toISOString(),
63
+ available: p.available,
64
+ })),
65
+ },
66
+ 200,
67
+ );
68
+ },
69
+ );
70
+ }
71
+
72
+ if (!capacity) return;
73
+ const PoolParam = z.object({ id: ID });
74
+
75
+ app.openapi(
76
+ perms.declare(
77
+ "capacity:read",
78
+ createRoute({
79
+ method: "get",
80
+ path: "/admin/capacity/pools",
81
+ request: { query: z.object({ inventory_item_id: ID.optional(), kind: z.string().max(64).optional() }) },
82
+ responses: ok("Pools"),
83
+ }),
84
+ ),
85
+ async (c) => {
86
+ const q = c.req.valid("query");
87
+ return c.json(
88
+ {
89
+ pools: await capacity.pools({
90
+ ...(q.inventory_item_id ? { inventoryItemId: q.inventory_item_id } : {}),
91
+ ...(q.kind ? { kind: q.kind } : {}),
92
+ }),
93
+ },
94
+ 200,
95
+ );
96
+ },
97
+ );
98
+ app.openapi(
99
+ perms.declare(
100
+ { permission: "capacity:write", own: true },
101
+ createRoute({
102
+ method: "post",
103
+ path: "/admin/capacity/pools",
104
+ request: jsonBody(
105
+ z.object({
106
+ kind: z.string().min(1).max(64),
107
+ inventory_item_id: ID.nullable().optional(),
108
+ location_id: ID.nullable().optional(),
109
+ bucket_minutes: z.number().int(),
110
+ max_span: z.number().int(),
111
+ initial_base: z.number().int(),
112
+ }),
113
+ ),
114
+ responses: ok("The new pool"),
115
+ }),
116
+ ),
117
+ async (c) => {
118
+ const b = c.req.valid("json");
119
+ return c.json(
120
+ await capacity.createPool(who(c), {
121
+ kind: b.kind,
122
+ inventoryItemId: b.inventory_item_id ?? null,
123
+ locationId: b.location_id ?? null,
124
+ bucketMinutes: b.bucket_minutes,
125
+ maxSpan: b.max_span,
126
+ initialBase: b.initial_base,
127
+ }),
128
+ 200,
129
+ );
130
+ },
131
+ );
132
+ app.openapi(
133
+ perms.declare(
134
+ "capacity:read",
135
+ createRoute({
136
+ method: "get",
137
+ path: "/admin/capacity/pools/{id}/availability",
138
+ request: { params: PoolParam, query: z.object({ from: z.coerce.number().int(), to: z.coerce.number().int() }) },
139
+ responses: ok("Capacity, held and free per bucket, with the active holds"),
140
+ }),
141
+ ),
142
+ async (c) => {
143
+ const { id } = c.req.valid("param");
144
+ const { from, to } = c.req.valid("query");
145
+ const view = await capacity.poolAvailability(id, from, to);
146
+ return c.json({ ...(view as object), holds: await capacity.holdsOn(id, from, to) }, 200);
147
+ },
148
+ );
149
+ app.openapi(
150
+ perms.declare(
151
+ { permission: "capacity:write", own: true },
152
+ createRoute({
153
+ method: "post",
154
+ path: "/admin/capacity/pools/{id}/base-changes",
155
+ request: {
156
+ params: PoolParam,
157
+ ...jsonBody(
158
+ z.object({
159
+ delta: z.number().int(),
160
+ origin: z.enum(["physical", "scheduled"]),
161
+ date: z.string().optional(),
162
+ reason: z.string().min(1).max(500),
163
+ }),
164
+ ),
165
+ },
166
+ responses: ok("The base change and the bucket it takes effect from"),
167
+ }),
168
+ ),
169
+ async (c) => {
170
+ const b = c.req.valid("json");
171
+ if (b.origin === "scheduled" && !b.date) throw new KedgeError("BAD_REQUEST", "A scheduled change needs a date");
172
+ return c.json(
173
+ await capacity.changeBase(who(c), c.req.valid("param").id, {
174
+ delta: b.delta,
175
+ origin: b.origin,
176
+ ...(b.date ? { date: parseLocalDate(b.date) } : {}),
177
+ reason: b.reason,
178
+ }),
179
+ 200,
180
+ );
181
+ },
182
+ );
183
+ app.openapi(
184
+ perms.declare(
185
+ "capacity:write",
186
+ createRoute({
187
+ method: "post",
188
+ path: "/admin/capacity/base-changes/{id}/cancel",
189
+ request: { params: PoolParam },
190
+ responses: ok("Canceled"),
191
+ }),
192
+ ),
193
+ async (c) => {
194
+ await capacity.cancelBaseChange(c.req.valid("param").id);
195
+ return c.json({ canceled: true }, 200);
196
+ },
197
+ );
198
+ app.openapi(
199
+ perms.declare(
200
+ { permission: "capacity:write", own: true },
201
+ createRoute({
202
+ method: "post",
203
+ path: "/admin/capacity/pools/{id}/adjustments",
204
+ request: {
205
+ params: PoolParam,
206
+ ...jsonBody(
207
+ z.object({ start: Instant, end: Instant, delta: z.number().int(), reason: z.string().min(1).max(500) }),
208
+ ),
209
+ },
210
+ responses: ok("The adjustment"),
211
+ }),
212
+ ),
213
+ async (c) => c.json(await capacity.adjust(who(c), c.req.valid("param").id, c.req.valid("json")), 200),
214
+ );
215
+ app.openapi(
216
+ perms.declare(
217
+ "capacity:write",
218
+ createRoute({
219
+ method: "post",
220
+ path: "/admin/capacity/adjustments/{id}/cancel",
221
+ request: { params: PoolParam },
222
+ responses: ok("Canceled"),
223
+ }),
224
+ ),
225
+ async (c) => {
226
+ await capacity.cancelAdjustment(c.req.valid("param").id);
227
+ return c.json({ canceled: true }, 200);
228
+ },
229
+ );
230
+ app.openapi(
231
+ perms.declare(
232
+ { permission: "capacity:write", own: true },
233
+ createRoute({
234
+ method: "put",
235
+ path: "/admin/capacity/pools/{id}/ceilings/{slot}",
236
+ request: {
237
+ params: z.object({ id: ID, slot: z.coerce.number().int().min(0) }),
238
+ ...jsonBody(z.object({ ceiling: z.number().int().nullable() })),
239
+ },
240
+ responses: ok("The weekly ceiling"),
241
+ }),
242
+ ),
243
+ async (c) => {
244
+ const { id, slot } = c.req.valid("param");
245
+ return c.json(
246
+ await capacity.setCeiling(who(c), id, { slotOfWeek: slot, ceiling: c.req.valid("json").ceiling }),
247
+ 200,
248
+ );
249
+ },
250
+ );
251
+ app.openapi(
252
+ perms.declare(
253
+ "capacity:write",
254
+ createRoute({
255
+ method: "put",
256
+ path: "/admin/capacity/buffers",
257
+ request: jsonBody(
258
+ z.object({
259
+ kind: z.string().min(1).max(64),
260
+ pool_kind: z.string().min(1).max(64),
261
+ before: z.number().int(),
262
+ after: z.number().int(),
263
+ }),
264
+ ),
265
+ responses: ok("The buffer"),
266
+ }),
267
+ ),
268
+ async (c) => {
269
+ const b = c.req.valid("json");
270
+ await capacity.setBuffer({ kind: b.kind, poolKind: b.pool_kind, before: b.before, after: b.after });
271
+ return c.json(b, 200);
272
+ },
273
+ );
274
+ app.openapi(
275
+ perms.declare(
276
+ { permission: "capacity:write", own: true },
277
+ createRoute({
278
+ method: "post",
279
+ path: "/admin/capacity/pools/{id}/holds",
280
+ request: {
281
+ params: PoolParam,
282
+ ...jsonBody(
283
+ z.object({
284
+ kind: z.enum(["block", "maintenance", "import"]),
285
+ start: Instant,
286
+ end: Instant,
287
+ qty: z.number().int(),
288
+ }),
289
+ ),
290
+ },
291
+ responses: ok("The hold"),
292
+ }),
293
+ ),
294
+ async (c) => c.json(await capacity.hold(who(c), c.req.valid("param").id, c.req.valid("json")), 200),
295
+ );
296
+ app.openapi(
297
+ perms.declare(
298
+ "capacity:write",
299
+ createRoute({
300
+ method: "post",
301
+ path: "/admin/capacity/holds/{id}/release",
302
+ request: { params: PoolParam },
303
+ responses: ok("Released"),
304
+ }),
305
+ ),
306
+ async (c) => {
307
+ await capacity.releaseHold(c.req.valid("param").id);
308
+ return c.json({ released: true }, 200);
309
+ },
310
+ );
311
+ const Mapping = z.object({ mapping: z.string().min(1).max(64), key: z.string().min(1).max(255), pool_id: ID });
312
+ app.openapi(
313
+ perms.declare(
314
+ { permission: "capacity:write", own: true },
315
+ createRoute({
316
+ method: "post",
317
+ path: "/admin/capacity/scope-mappings",
318
+ request: jsonBody(Mapping),
319
+ responses: ok("Mapped"),
320
+ }),
321
+ ),
322
+ async (c) => {
323
+ const b = c.req.valid("json");
324
+ return c.json(await capacity.mapScope(who(c), { mapping: b.mapping, key: b.key, poolId: b.pool_id }), 200);
325
+ },
326
+ );
327
+ app.openapi(
328
+ perms.declare(
329
+ "capacity:write",
330
+ createRoute({
331
+ method: "delete",
332
+ path: "/admin/capacity/scope-mappings",
333
+ request: jsonBody(Mapping),
334
+ responses: ok("Unmapped"),
335
+ }),
336
+ ),
337
+ async (c) => {
338
+ const b = c.req.valid("json");
339
+ await capacity.unmapScope({ mapping: b.mapping, key: b.key, poolId: b.pool_id });
340
+ return c.json({ unmapped: true }, 200);
341
+ },
342
+ );
343
+ app.openapi(
344
+ perms.declare(
345
+ { permission: "capacity:convert", own: true },
346
+ createRoute({
347
+ method: "post",
348
+ path: "/admin/inventory-items/{id}/conversion",
349
+ request: {
350
+ params: PoolParam,
351
+ ...jsonBody(
352
+ z.object({
353
+ to: z.enum(["count", "dated", "none"]),
354
+ bucket_minutes: z.number().int().optional(),
355
+ max_span: z.number().int().optional(),
356
+ reason: z.string().min(1).max(500),
357
+ }),
358
+ ),
359
+ },
360
+ responses: ok("The converted item and its new pools"),
361
+ }),
362
+ ),
363
+ async (c) => {
364
+ const b = c.req.valid("json");
365
+ return c.json(
366
+ await capacity.convert(who(c), c.req.valid("param").id, {
367
+ to: b.to,
368
+ ...(b.bucket_minutes !== undefined ? { bucketMinutes: b.bucket_minutes } : {}),
369
+ ...(b.max_span !== undefined ? { maxSpan: b.max_span } : {}),
370
+ reason: b.reason,
371
+ }),
372
+ 200,
373
+ );
374
+ },
375
+ );
376
+ app.openapi(
377
+ perms.declare(
378
+ { permission: "capacity:write", own: true },
379
+ createRoute({
380
+ method: "post",
381
+ path: "/admin/capacity/pools/{id}/replace",
382
+ request: {
383
+ params: PoolParam,
384
+ ...jsonBody(
385
+ z.object({
386
+ bucket_minutes: z.number().int(),
387
+ max_span: z.number().int(),
388
+ reason: z.string().min(1).max(500),
389
+ }),
390
+ ),
391
+ },
392
+ responses: ok("The replacement pool"),
393
+ }),
394
+ ),
395
+ async (c) => {
396
+ const b = c.req.valid("json");
397
+ return c.json(
398
+ await capacity.replacePool(who(c), c.req.valid("param").id, {
399
+ bucketMinutes: b.bucket_minutes,
400
+ maxSpan: b.max_span,
401
+ reason: b.reason,
402
+ }),
403
+ 200,
404
+ );
405
+ },
406
+ );
407
+ app.openapi(
408
+ perms.declare(
409
+ "capacity:read",
410
+ createRoute({ method: "get", path: "/admin/capacity/shortages", responses: ok("Shortages now") }),
411
+ ),
412
+ async (c) => c.json(await capacity.shortages(), 200),
413
+ );
414
+ app.openapi(
415
+ perms.declare(
416
+ "capacity:read",
417
+ createRoute({
418
+ method: "get",
419
+ path: "/admin/capacity/negative-bases",
420
+ responses: ok("Negative bases to reconcile"),
421
+ }),
422
+ ),
423
+ async (c) => c.json({ steps: await capacity.negativeBases() }, 200),
424
+ );
425
+ app.openapi(
426
+ perms.declare(
427
+ "capacity:write",
428
+ createRoute({
429
+ method: "post",
430
+ path: "/admin/orders/{id}/not-reopenable",
431
+ request: { params: z.object({ id: z.string().regex(ORDER_ID) }) },
432
+ responses: ok("The order can no longer be placed"),
433
+ }),
434
+ ),
435
+ async (c) => {
436
+ await capacity.markNotReopenable(c.req.valid("param").id);
437
+ return c.json({ reopenable: false }, 200);
438
+ },
439
+ );
440
+ }