@voltro/protocol 0.51.0 → 0.53.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/dist/rest.d.ts CHANGED
@@ -74,6 +74,12 @@ declare interface AnyApprovalPolicy {
74
74
  /** A guard is either a scope check or a relationship check. */
75
75
  declare type AnyGuardSpec<Input = unknown> = GuardSpec<Input> | PolicyGuardSpec<Input>;
76
76
 
77
+ export declare const bytes: (stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>), options?: {
78
+ readonly contentType?: string;
79
+ readonly contentLength?: number;
80
+ readonly contentDisposition?: string;
81
+ }) => RestByteResponse;
82
+
77
83
  /**
78
84
  * Project every descriptor carrying a `publicApi` annotation into a REST
79
85
  * route, in stable (path) order. The boot layer feeds these straight into
@@ -224,6 +230,8 @@ declare interface IdempotencyStore {
224
230
  declare interface InsertTarget<Input = unknown, Row = unknown, Item = Record<string, unknown>> extends NestedTargetFields<Input> {
225
231
  readonly table: string;
226
232
  readonly op: 'insert';
233
+ /** Declared junction relations — see {@link TargetRelations}. */
234
+ readonly relations?: TargetRelations | undefined;
227
235
  readonly order?: 'prepend' | 'append' | undefined;
228
236
  /**
229
237
  * Build the optimistic row from the mutation input. The framework
@@ -248,6 +256,8 @@ declare interface InsertTarget<Input = unknown, Row = unknown, Item = Record<str
248
256
  readonly shapeItem?: ((input: Input, optimisticId: string) => Item) | undefined;
249
257
  }
250
258
 
259
+ export declare const isRestByteResponse: (v: unknown) => v is RestByteResponse;
260
+
251
261
  export declare const isRestStreamResponse: (v: unknown) => v is RestStreamResponse;
252
262
 
253
263
  /** Extract `:name` path params by aligning the route PATTERN with the request
@@ -349,12 +359,19 @@ declare interface OpenAccessSpec {
349
359
 
350
360
  /** A public raw-HTTP route a plugin serves on the framework listener. */
351
361
  declare interface PluginHttpRoute {
352
- /** HTTP method, or `'*'` for any (the handler decides). */
353
- readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'DELETE';
362
+ /** HTTP method, or `'*'` for any (the handler decides). PATCH/HEAD/OPTIONS
363
+ * are first-class the REST desugar used to mount `'*'` partly BECAUSE
364
+ * this union lacked PATCH; that reason is gone (the `'*'` mount remains
365
+ * for its other job: one dispatcher per shared path + a precise 405). */
366
+ readonly method: '*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
354
367
  /** Absolute path prefix, e.g. `/_voltro/storage`. Matches the path AND
355
368
  * any sub-path (`/_voltro/storage/abc123`). */
356
369
  readonly path: string;
357
370
  readonly handle: (req: PluginHttpRouteRequest) => Promise<PluginHttpRouteResult>;
371
+ /** Per-route body cap override (bytes) — wins over the listener's shared
372
+ * `maxBodyBytes`. NOTE: routes SHARING a path share one body read, so the
373
+ * widest override on the path's group applies to the whole group. */
374
+ readonly maxBodyBytes?: number;
358
375
  /**
359
376
  * Opt this route's path OUT of the listener's cross-site origin check.
360
377
  *
@@ -393,6 +410,21 @@ declare interface PluginHttpRoute {
393
410
  readonly originGuard?: 'exempt';
394
411
  }
395
412
 
413
+ /**
414
+ * A binary streaming body — the download/export shape. The serve layer pipes
415
+ * the Web ReadableStream to the socket without buffering, so a response
416
+ * larger than the heap is fine; the LAZY thunk form defers opening the
417
+ * source (a provider connection, a file handle) until the response actually
418
+ * streams.
419
+ */
420
+ declare interface PluginHttpRouteByteStream {
421
+ readonly stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
422
+ /** Declared up front when known — lets the client render progress. */
423
+ readonly contentLength?: number;
424
+ /** e.g. `attachment; filename="export.zip"`. */
425
+ readonly contentDisposition?: string;
426
+ }
427
+
396
428
  declare interface PluginHttpRouteRequest {
397
429
  readonly method: string;
398
430
  /** Path WITHOUT query string. */
@@ -504,6 +536,12 @@ declare interface PluginHttpRouteResult {
504
536
  /** Stream the response (SSE) instead of sending `body`. See
505
537
  * {@link PluginHttpRouteStream}. */
506
538
  readonly stream?: PluginHttpRouteStream;
539
+ /** Stream a BINARY response (a download, an export) instead of sending
540
+ * `body` — see {@link PluginHttpRouteByteStream}. Never buffered by the
541
+ * serve layer; never compressed (flush timing + Content-Length are the
542
+ * contract). Takes precedence over `body`; do not set both `stream` and
543
+ * `byteStream`. */
544
+ readonly byteStream?: PluginHttpRouteByteStream;
507
545
  }
508
546
 
509
547
  /**
@@ -739,6 +777,19 @@ export declare const requireAnyScope: (scopes: ReadonlyArray<string>) => RestGua
739
777
  */
740
778
  export declare const requireScope: (scope: string) => RestGuard;
741
779
 
780
+ /** A BINARY streaming response (download/export). The serve layer pipes the
781
+ * ReadableStream without buffering — a body larger than the heap is fine.
782
+ * The LAZY thunk form defers opening the source until the response streams. */
783
+ export declare interface RestByteResponse {
784
+ readonly __voltroRestBytes: true;
785
+ readonly byteStream: {
786
+ readonly stream: ReadableStream<Uint8Array> | (() => ReadableStream<Uint8Array>);
787
+ readonly contentLength?: number;
788
+ readonly contentDisposition?: string;
789
+ };
790
+ readonly contentType?: string;
791
+ }
792
+
742
793
  export declare type RestGuard = (ctx: RestRouteContext) => RestGuardRejection | undefined | Promise<RestGuardRejection | undefined>;
743
794
 
744
795
  /**
@@ -751,7 +802,7 @@ export declare interface RestGuardRejection {
751
802
  readonly message: string;
752
803
  }
753
804
 
754
- export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
805
+ export declare type RestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
755
806
 
756
807
  /**
757
808
  * Per-call context handed to a REST route handler. Resolved by the serve
@@ -791,6 +842,26 @@ export declare interface RestRouteDescriptor<I, O> {
791
842
  /** ISO date. Sets a `Sunset:` header; past the date the route returns
792
843
  * `410 Gone` with a replacement pointer. */
793
844
  readonly sunset?: string;
845
+ /**
846
+ * Opt-in API version. `version: 'v2'` + `path: '/customers'` mounts the route
847
+ * at `/v2/customers` — the same `/vN/` convention the `publicApi:` projection
848
+ * has always used (`derivePublicPath`) and the built-in `/v1/api-keys`
849
+ * surface follows.
850
+ *
851
+ * OPT-IN on purpose: a route without `version` keeps its literal `path`
852
+ * untouched. An automatic prefix would silently move every deployed route —
853
+ * a second breaking change hiding inside a naming feature.
854
+ *
855
+ * Two versions of one resource are TWO descriptors: the old version is
856
+ * ordinary code — visible, testable, deletable — carrying `deprecated` (the
857
+ * replacement pointer) and `sunset` (the date it starts answering `410`,
858
+ * whose body then also names this `version`). There is no transformation
859
+ * DSL, and the rpc SOCKET is deliberately outside this: the generated client
860
+ * is versioned with the server it was generated from (a stale browser tab
861
+ * runs the previous client until reload — that skew window exists and is
862
+ * documented, it is not solved by URL versioning).
863
+ */
864
+ readonly version?: `v${number}`;
794
865
  readonly guards?: ReadonlyArray<RestGuard>;
795
866
  /**
796
867
  * This route STREAMS (Server-Sent Events) rather than resolving one value — its
@@ -801,6 +872,13 @@ export declare interface RestRouteDescriptor<I, O> {
801
872
  * no spec, because clients are generated from it.
802
873
  */
803
874
  readonly streaming?: boolean;
875
+ /** Per-route body cap override (bytes) — see PluginHttpRoute.maxBodyBytes. */
876
+ readonly maxBodyBytes?: number;
877
+ /** GET only: derive a weak ETag from the encoded response and answer a
878
+ * matching `If-None-Match` with 304. The tag is content-derived (an md5
879
+ * of the JSON), so it is correct across content-encodings — the
880
+ * transport's compression varies the bytes, not the representation. */
881
+ readonly etag?: boolean;
804
882
  }
805
883
 
806
884
  export declare interface RestRouteExample {
@@ -922,11 +1000,29 @@ declare type Subject = typeof Subject.Type;
922
1000
 
923
1001
  declare type Target<Input = unknown, Row = unknown> = TargetSpec<Input, Row> | ReadonlyArray<TargetSpec<Input, Row>>;
924
1002
 
1003
+ /**
1004
+ * Declared many-to-many RELATIONS of a write target: `{ inputField:
1005
+ * junctionTable }`. After the executor succeeds — inside the SAME
1006
+ * transaction — the framework reconciles the junction's links for the
1007
+ * written row against `input[inputField]` (an array of target ids) via the
1008
+ * diff-based `store.relationLinks`, so a form's multi-reference field saves
1009
+ * in one mutation with no hand-written junction code. The anchor column is
1010
+ * derived from the junction's `reference()` targets (a self-junction is
1011
+ * refused, never guessed).
1012
+ *
1013
+ * An ABSENT input field leaves the links untouched (absent ≠ empty — an
1014
+ * empty array is the explicit "clear them all"). The row id is the
1015
+ * executor's `output.id`, falling back to `input.id`.
1016
+ */
1017
+ declare type TargetRelations = Readonly<Record<string, string>>;
1018
+
925
1019
  declare type TargetSpec<Input = unknown, Row = unknown> = InsertTarget<Input, Row> | UpdateTarget<Input, Row> | DeleteTarget<Input>;
926
1020
 
927
1021
  declare interface UpdateTarget<Input = unknown, Row = unknown, Item = Record<string, unknown>> extends NestedTargetFields<Input> {
928
1022
  readonly table: string;
929
1023
  readonly op: 'update';
1024
+ /** Declared junction relations — see {@link TargetRelations}. */
1025
+ readonly relations?: TargetRelations | undefined;
930
1026
  /** Identify the row(s) to patch. Default: `input.id`. Return an ARRAY to patch
931
1027
  * MANY rows/items in one mutation (a bulk edit — where the per-item
932
1028
  * parallel-write race lived). */
package/dist/rest.js CHANGED
@@ -33,26 +33,45 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
33
33
  status: 403,
34
34
  message: `Missing required scope: one of ${e.join(", ")}`
35
35
  };
36
- }, m = (e) => e, h = (e, t = {}) => ({
36
+ }, m = (e) => {
37
+ if (e.version === void 0) return e;
38
+ if (!/^v\d+$/.test(e.version)) throw Error(`defineRestRoute(${e.method} ${e.path}): version "${e.version}" must be 'v' followed by digits — 'v1', 'v2', …`);
39
+ if ((/* @__PURE__ */ RegExp("^/v\\d+(/|$)")).test(e.path)) throw Error(`defineRestRoute(${e.method} ${e.path}): the path already starts with a version segment AND declares version: '${e.version}'. Pick one spelling — either bake the version into the path, or declare version: and keep the path bare ('/customers'). Both would mount '/${e.version}${e.path}', which is never what the author meant.`);
40
+ return {
41
+ ...e,
42
+ path: `/${e.version}${e.path}`
43
+ };
44
+ }, h = (e, t = {}) => ({
37
45
  __voltroRestStream: !0,
38
46
  stream: {
39
47
  subscribe: e,
40
48
  ...t.keepAliveMs === void 0 ? {} : { keepAliveMs: t.keepAliveMs }
41
49
  }
42
- }), g = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, _ = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, v = (e, t, n) => ({
50
+ }), g = (e, t) => `event: ${e}\n${(typeof t == "string" ? t : JSON.stringify(t)).split("\n").map((e) => `data: ${e}`).join("\n")}\n\n`, _ = (e) => typeof e == "object" && !!e && e.__voltroRestStream === !0, v = (e, t = {}) => ({
51
+ __voltroRestBytes: !0,
52
+ byteStream: {
53
+ stream: e,
54
+ ...t.contentLength === void 0 ? {} : { contentLength: t.contentLength },
55
+ ...t.contentDisposition === void 0 ? {} : { contentDisposition: t.contentDisposition }
56
+ },
57
+ ...t.contentType === void 0 ? {} : { contentType: t.contentType }
58
+ }), y = (e) => typeof e == "object" && !!e && e.__voltroRestBytes === !0, b = async (e) => {
59
+ let t = await globalThis.crypto.subtle.digest("SHA-1", new TextEncoder().encode(e));
60
+ return [...new Uint8Array(t)].map((e) => e.toString(16).padStart(2, "0")).join("");
61
+ }, x = (e, t, n) => ({
43
62
  status: e,
44
63
  contentType: "application/json; charset=utf-8",
45
64
  body: JSON.stringify(t),
46
65
  ...n ? { headers: n } : {}
47
- }), y = (e) => {
66
+ }), S = (e) => {
48
67
  if (e.length === 0) return;
49
68
  let t = new TextDecoder().decode(e);
50
69
  if (t.trim() !== "") return JSON.parse(t);
51
- }, b = (e) => {
70
+ }, C = (e) => {
52
71
  let t = {};
53
72
  for (let [n, r] of new URLSearchParams(e)) t[n] = r;
54
73
  return t;
55
- }, x = (e, t) => {
74
+ }, w = (e, t) => {
56
75
  let n = e.split("/").filter((e) => e.length > 0), r = t.split("/").filter((e) => e.length > 0), i = {};
57
76
  for (let e = 0; e < n.length; e++) {
58
77
  let t = n[e];
@@ -66,22 +85,24 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
66
85
  }
67
86
  }
68
87
  return i;
69
- }, S = (e, t) => ({
70
- query: b(e.query),
88
+ }, T = (e, t) => ({
89
+ query: C(e.query),
71
90
  params: t,
72
- body: y(e.rawBody)
73
- }), C = /* @__PURE__ */ new Set([
91
+ body: S(e.rawBody)
92
+ }), E = /* @__PURE__ */ new Set([
74
93
  "POST",
75
94
  "PUT",
76
95
  "PATCH",
77
96
  "DELETE"
78
- ]), w = (c, l) => {
97
+ ]), D = (c, l) => {
79
98
  let u = c.input ? s.decodeUnknown(c.input) : void 0, d = s.encode(c.output), f = {};
80
99
  return c.deprecated !== void 0 && (f.Deprecation = "true"), c.sunset !== void 0 && (f.Sunset = c.sunset), {
81
100
  method: "*",
82
101
  path: c.path,
102
+ ...c.maxBodyBytes === void 0 ? {} : { maxBodyBytes: c.maxBodyBytes },
83
103
  handle: async (s) => {
84
- if (s.method.toUpperCase() !== c.method) return v(405, {
104
+ let p = s.method.toUpperCase();
105
+ if (!(p === c.method || p === "HEAD" && c.method === "GET")) return x(405, {
85
106
  error: "Method Not Allowed",
86
107
  allow: c.method
87
108
  }, {
@@ -90,74 +111,98 @@ var c = (e, t) => t.method ?? (e === "query" ? "GET" : "POST"), l = (e, t) => t.
90
111
  });
91
112
  if (c.sunset !== void 0) {
92
113
  let e = Date.parse(c.sunset);
93
- if (!Number.isNaN(e) && Date.now() >= e) return v(410, {
114
+ if (!Number.isNaN(e) && Date.now() >= e) return x(410, {
94
115
  error: "Gone",
116
+ ...c.version === void 0 ? {} : { version: c.version },
95
117
  ...c.deprecated === void 0 ? {} : { replacement: c.deprecated }
96
118
  }, f);
97
119
  }
98
- let p;
120
+ let m;
99
121
  if (u) {
100
- let e = S(s, x(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
122
+ let e = T(s, w(c.path, s.path)), t = await o.runPromise(u(e).pipe(o.map((e) => ({
101
123
  ok: !0,
102
124
  value: e
103
125
  })), o.catchAll((e) => o.succeed({
104
126
  ok: !1,
105
127
  cause: e
106
128
  }))));
107
- if (!t.ok) return v(400, {
129
+ if (!t.ok) return x(400, {
108
130
  error: "Invalid request",
109
131
  detail: String(t.cause)
110
132
  }, f);
111
- p = t.value;
133
+ m = t.value;
112
134
  }
113
- let m = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), h = {
114
- subject: m,
135
+ let h = l.resolveSubject ? await l.resolveSubject(s.headers) : e(s.headers["x-tenant"] ?? null), g = {
136
+ subject: h,
115
137
  headers: s.headers,
116
138
  store: l.store
117
139
  };
118
140
  if (c.guards) for (let e of c.guards) {
119
- let t = await e(h);
120
- if (t) return v(t.status, { error: t.message }, f);
141
+ let t = await e(g);
142
+ if (t) return x(t.status, { error: t.message }, f);
121
143
  }
122
- let g = l.idempotency, y = g && C.has(c.method) ? s.headers[g.header.toLowerCase()] : void 0, b = g && y ? r(m.tenantId, c.method, c.path) : "";
123
- if (g && y) {
124
- let e = await i(g.store, b, y, g.ttlMs, Date.now());
125
- if (e.kind === "replay") return v(e.response.status, e.response.body, {
144
+ let v = l.idempotency, S = v && E.has(c.method) ? s.headers[v.header.toLowerCase()] : void 0, C = v && S ? r(h.tenantId, c.method, c.path) : "";
145
+ if (v && S) {
146
+ let e = await i(v.store, C, S, v.ttlMs, Date.now());
147
+ if (e.kind === "replay") return x(e.response.status, e.response.body, {
126
148
  ...f,
127
149
  "Idempotency-Replayed": "true"
128
150
  });
129
- if (e.kind === "conflict") return v(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
151
+ if (e.kind === "conflict") return x(409, { error: "A request with this Idempotency-Key is already being processed" }, f);
130
152
  }
131
153
  try {
132
- let e = await c.handler(p, h);
133
- if (_(e)) return {
154
+ let e = await c.handler(m, g);
155
+ if (_(e) || y(e)) return v && S && await n(v.store, C, S), y(e) ? {
156
+ status: 200,
157
+ headers: f,
158
+ byteStream: e.byteStream,
159
+ ...e.contentType === void 0 ? {} : { contentType: e.contentType }
160
+ } : {
134
161
  status: 200,
135
162
  headers: f,
136
163
  stream: e.stream
137
164
  };
138
- let n = await o.runPromise(d(e));
139
- return g && y && await t(g.store, b, y, {
165
+ let r = await o.runPromise(d(e));
166
+ if (v && S && await t(v.store, C, S, {
140
167
  status: 200,
141
- body: n
142
- }, Date.now()), v(200, n, f);
168
+ body: r
169
+ }, Date.now()), c.etag === !0 && c.method === "GET") {
170
+ let e = `W/"${await b(JSON.stringify(r))}"`, t = s.headers["if-none-match"];
171
+ return t !== void 0 && t.split(",").some((t) => t.trim() === e) ? {
172
+ status: 304,
173
+ headers: {
174
+ ...f,
175
+ ETag: e
176
+ }
177
+ } : x(200, r, {
178
+ ...f,
179
+ ETag: e
180
+ });
181
+ }
182
+ return x(200, r, f);
143
183
  } catch (e) {
144
- if (g && y && await n(g.store, b, y), typeof e == "object" && e && typeof e.status == "number") {
184
+ if (v && S && await n(v.store, C, S), typeof e == "object" && e && typeof e.status == "number") {
145
185
  let t = e;
146
- return v(t.status, { error: t.message ?? "Error" }, f);
186
+ return x(t.status, { error: t.message ?? "Error" }, f);
147
187
  }
148
188
  return a({
149
189
  error: e,
150
190
  source: "rest",
151
191
  name: `${c.method} ${c.path}`,
152
192
  fields: { status: 500 }
153
- }), v(500, { error: "Internal Server Error" }, f);
193
+ }), x(500, { error: "Internal Server Error" }, f);
154
194
  }
155
195
  }
156
196
  };
157
- }, T = (e, t = {}) => e.map((e) => w(e, t)), E = async (e, t) => {
197
+ }, O = (e, t = {}) => {
198
+ if (t.idempotency !== void 0) {
199
+ for (let t of e) if (t.streaming === !0 && E.has(t.method)) throw Error(`REST route ${t.method} ${t.path} declares \`streaming: true\` on a method the idempotency binding claims (${[...E].join("/")}). A stream cannot complete an idempotency claim — there is no replayable body to cache — so a retried request would 409 until the claim's TTL. Either serve the stream on GET, or scope the idempotency binding away from this app's streaming routes.`);
200
+ }
201
+ return e.map((e) => D(e, t));
202
+ }, k = async (e, t) => {
158
203
  let n = await e[0].handle(t);
159
204
  for (let r = 1; r < e.length && n.status === 405; r++) n = await e[r].handle(t);
160
205
  return n;
161
206
  };
162
207
  //#endregion
163
- export { d as collectPublicApiRoutes, m as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, E as dispatchSharedPath, _ as isRestStreamResponse, x as matchPathParams, u as publicApiRoute, p as requireAnyScope, f as requireScope, T as restRoutesToHttpRoutes, h as sse, g as sseFrame };
208
+ export { v as bytes, d as collectPublicApiRoutes, m as defineRestRoute, c as derivePublicMethod, l as derivePublicPath, k as dispatchSharedPath, y as isRestByteResponse, _ as isRestStreamResponse, w as matchPathParams, u as publicApiRoute, p as requireAnyScope, f as requireScope, O as restRoutesToHttpRoutes, h as sse, g as sseFrame };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/protocol",
3
- "version": "0.51.0",
3
+ "version": "0.53.0",
4
4
  "description": "The Voltro wire + plugin contract — defineQuery/Mutation/Action/Stream, definePlugin, sessions / JWT / API-keys, and the RPC protocol.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -54,8 +54,8 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@effect/sql": "^0.52.0",
57
- "@voltro/database": "0.51.0",
58
- "@voltro/logger": "0.51.0",
57
+ "@voltro/database": "0.53.0",
58
+ "@voltro/logger": "0.53.0",
59
59
  "jose": "^6.2.8"
60
60
  },
61
61
  "peerDependencies": {