@protobase/server 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +202 -0
  2. package/dist/aggregate-service.js +135 -0
  3. package/dist/audit/console-queue.d.ts +5 -0
  4. package/dist/audit/console-queue.js +5 -0
  5. package/dist/auth/jwt.d.ts +21 -0
  6. package/dist/auth/jwt.js +49 -0
  7. package/dist/batch/refs.js +31 -0
  8. package/dist/batch/reorder.js +79 -0
  9. package/dist/batch/run-batch.js +105 -0
  10. package/dist/batch/schema.js +51 -0
  11. package/dist/better-auth/authenticator.d.ts +22 -0
  12. package/dist/better-auth/authenticator.js +21 -0
  13. package/dist/better-auth/create-auth.d.ts +1252 -0
  14. package/dist/better-auth/create-auth.js +104 -0
  15. package/dist/better-auth/jwks-cache.d.ts +9 -0
  16. package/dist/better-auth/jwks-cache.js +31 -0
  17. package/dist/better-auth/password-reset.js +32 -0
  18. package/dist/better-auth/status-route.js +19 -0
  19. package/dist/better-auth/tokens.d.ts +18 -0
  20. package/dist/better-auth/tokens.js +37 -0
  21. package/dist/better-auth/users.d.ts +50 -0
  22. package/dist/better-auth/users.js +111 -0
  23. package/dist/check-shell.js +17 -0
  24. package/dist/check-views.js +45 -0
  25. package/dist/create-admin.d.ts +23 -0
  26. package/dist/create-admin.js +85 -0
  27. package/dist/error-handler.js +44 -0
  28. package/dist/etag.js +22 -0
  29. package/dist/filter-input.js +28 -0
  30. package/dist/filter-shape.js +25 -0
  31. package/dist/hash.js +10 -0
  32. package/dist/index.d.ts +11 -0
  33. package/dist/index.js +10 -0
  34. package/dist/key.js +22 -0
  35. package/dist/list-service.js +96 -0
  36. package/dist/mail/mail-settings.js +26 -0
  37. package/dist/mail/smtp-mailer.d.ts +11 -0
  38. package/dist/mail/smtp-mailer.js +17 -0
  39. package/dist/meta.js +63 -0
  40. package/dist/openapi/components.js +331 -0
  41. package/dist/openapi/document.js +74 -0
  42. package/dist/openapi/naming.js +4 -0
  43. package/dist/openapi/resource-paths.js +417 -0
  44. package/dist/openapi/schemas.js +93 -0
  45. package/dist/params.js +48 -0
  46. package/dist/problem.d.ts +17 -0
  47. package/dist/problem.js +43 -0
  48. package/dist/read-service.js +65 -0
  49. package/dist/records.js +66 -0
  50. package/dist/registry.js +44 -0
  51. package/dist/request-access.js +97 -0
  52. package/dist/resource-source.d.ts +35 -0
  53. package/dist/resource-source.js +22 -0
  54. package/dist/reveal-service.js +43 -0
  55. package/dist/routes/batch.js +21 -0
  56. package/dist/routes/collection.js +63 -0
  57. package/dist/routes/docs.js +20 -0
  58. package/dist/routes/meta.js +22 -0
  59. package/dist/routes/preferences.js +12 -0
  60. package/dist/routes/record.js +97 -0
  61. package/dist/routes/target.js +18 -0
  62. package/dist/row-permissions.js +69 -0
  63. package/dist/rows.js +12 -0
  64. package/dist/scan-guard.js +72 -0
  65. package/dist/sql-parts.js +32 -0
  66. package/dist/tenant.js +10 -0
  67. package/dist/transactions.js +14 -0
  68. package/dist/types.d.ts +97 -0
  69. package/dist/validate-body.js +73 -0
  70. package/dist/view-access.js +54 -0
  71. package/dist/view-related.js +59 -0
  72. package/dist/write-pipeline.js +94 -0
  73. package/dist/write-shared.js +52 -0
  74. package/dist/write-update.js +45 -0
  75. package/package.json +39 -3
  76. package/README.md +0 -3
@@ -0,0 +1,74 @@
1
+ import { batchSchemas, errorResponses, parameters, problemSchema, searchBody } from "./components.js";
2
+ import { resourcePaths } from "./resource-paths.js";
3
+ import { resourceSchemas } from "./schemas.js";
4
+ //#region src/openapi/document.ts
5
+ /** The OpenAPI 3.1 document for one caller, generated from the resource models they may see and their zod record schemas. */
6
+ const openApiDocument = (accesses, basePath, systemPath) => ({
7
+ openapi: "3.1.0",
8
+ info: {
9
+ title: "Protobase API",
10
+ version: "1.0.0",
11
+ description: "Admin API for an existing Postgres database. Follows Google's API design conventions ([AIP](https://google.aip.dev)) and reports errors as RFC 9457 `application/problem+json`."
12
+ },
13
+ servers: [{ url: basePath }],
14
+ security: [{ bearer: [] }],
15
+ tags: accesses.map(({ entry }) => ({ name: entry.name })),
16
+ paths: {
17
+ "/meta": {
18
+ servers: [{ url: systemPath || "/" }],
19
+ get: {
20
+ operationId: "get_meta",
21
+ summary: "Resource and view models",
22
+ description: "ETag from a content hash; send If-None-Match for a 304.",
23
+ responses: {
24
+ 200: { description: "The models" },
25
+ 304: { description: "Not modified" },
26
+ 401: { $ref: "#/components/responses/Unauthorized" }
27
+ }
28
+ }
29
+ },
30
+ [`/${basePath.slice(basePath.lastIndexOf("/") + 1)}:batchWrite`]: {
31
+ servers: [{ url: basePath.slice(0, basePath.lastIndexOf("/")) || "/" }],
32
+ post: {
33
+ operationId: "batch_write",
34
+ summary: "Write many records in one transaction",
35
+ description: "Ordered create, update, delete and reorder operations, at most 500, across resources. Each runs through the normal write pipeline (validation, access with row filters and record rules, tenant, ETags, hooks); the first failure rolls everything back and the problem names it in `operation` (its index), `op` and `resource`. A stale ETag is a 412 for the whole batch. `reorder` moves only the records that change, first out of the way (above the highest value in the column, or below the lowest when a check constraint refuses that), so a unique index such as (invoice_id, position) is never violated half way. `{ \"$ref\": name }` points at a record created earlier by `ref`.",
36
+ requestBody: {
37
+ required: true,
38
+ content: { "application/json": { schema: { $ref: "#/components/schemas/BatchRequest" } } }
39
+ },
40
+ responses: {
41
+ 200: {
42
+ description: "Every result, in order",
43
+ content: { "application/json": { schema: { $ref: "#/components/schemas/BatchResponse" } } }
44
+ },
45
+ 400: { $ref: "#/components/responses/BadRequest" },
46
+ 401: { $ref: "#/components/responses/Unauthorized" },
47
+ 403: { $ref: "#/components/responses/Forbidden" },
48
+ 404: { $ref: "#/components/responses/NotFound" },
49
+ 409: { $ref: "#/components/responses/Conflict" },
50
+ 412: { $ref: "#/components/responses/PreconditionFailed" },
51
+ 428: { $ref: "#/components/responses/PreconditionRequired" }
52
+ }
53
+ }
54
+ },
55
+ ...Object.assign({}, ...accesses.map(resourcePaths))
56
+ },
57
+ components: {
58
+ securitySchemes: { bearer: {
59
+ type: "http",
60
+ scheme: "bearer",
61
+ bearerFormat: "JWT"
62
+ } },
63
+ parameters,
64
+ responses: errorResponses,
65
+ schemas: {
66
+ Problem: problemSchema,
67
+ SearchBody: searchBody,
68
+ ...batchSchemas,
69
+ ...Object.assign({}, ...accesses.map(resourceSchemas))
70
+ }
71
+ }
72
+ });
73
+ //#endregion
74
+ export { openApiDocument };
@@ -0,0 +1,4 @@
1
+ //#region src/openapi/naming.ts
2
+ const pascal = (name) => name.charAt(0).toUpperCase() + name.slice(1);
3
+ //#endregion
4
+ export { pascal };
@@ -0,0 +1,417 @@
1
+ import { pascal } from "./naming.js";
2
+ //#region src/openapi/resource-paths.ts
3
+ const param = (name) => ({ $ref: `#/components/parameters/${name}` });
4
+ const error = (name) => ({ $ref: `#/components/responses/${name}` });
5
+ const json = (schema) => ({ "application/json": { schema } });
6
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
7
+ const listParameters = (entry) => [
8
+ "filter",
9
+ "orderBy",
10
+ "pageSize",
11
+ "pageToken",
12
+ "fields",
13
+ "count",
14
+ ...entry.model.softDelete ? ["showDeleted"] : []
15
+ ].map(param);
16
+ const field = (description) => ({
17
+ name: "field",
18
+ in: "query",
19
+ required: true,
20
+ description,
21
+ schema: { type: "string" }
22
+ });
23
+ const query = (name, description, schema = { type: "string" }, required = false) => ({
24
+ name,
25
+ in: "query",
26
+ required,
27
+ description,
28
+ schema
29
+ });
30
+ const record = (name, entry) => ({
31
+ description: `The ${entry.name} record. The ETag header is its version.`,
32
+ headers: { ETag: { schema: { type: "string" } } },
33
+ content: json(ref(name))
34
+ });
35
+ const page = (name, seek = false) => json({
36
+ type: "object",
37
+ required: [
38
+ "items",
39
+ "next_page_token",
40
+ "total_size_estimate"
41
+ ],
42
+ properties: {
43
+ items: {
44
+ type: "array",
45
+ items: ref(name)
46
+ },
47
+ next_page_token: {
48
+ type: "string",
49
+ description: "Empty on the last page"
50
+ },
51
+ ...seek && { prev_page_token: {
52
+ type: "string",
53
+ description: "Empty on the first page"
54
+ } },
55
+ total_size_estimate: {
56
+ type: "integer",
57
+ description: "The planner's estimate"
58
+ },
59
+ total_size: {
60
+ type: "integer",
61
+ description: "Only with count=exact"
62
+ }
63
+ }
64
+ });
65
+ const needs = {
66
+ list: "list",
67
+ search: "list",
68
+ facets: "list",
69
+ series: "list",
70
+ histogram: "list",
71
+ seek: "list",
72
+ get: "read",
73
+ reveal: "read",
74
+ create: "create",
75
+ update: "update",
76
+ undelete: "update",
77
+ delete: "delete"
78
+ };
79
+ const withoutDenied = (paths, access) => Object.fromEntries(Object.entries(paths).flatMap(([path, methods]) => {
80
+ const allowed = Object.entries(methods).filter(([, operation]) => access.resolved.operations[needs[String(operation.operationId).split("_")[0]]]);
81
+ return allowed.length > 0 ? [[path, Object.fromEntries(allowed)]] : [];
82
+ }));
83
+ const auth = { 401: error("Unauthorized") };
84
+ /** The paths of one resource: AIP-132 list, AIP-131 get, AIP-133 create, AIP-134 update, AIP-135 delete, and the `:` helpers (AIP-136). */
85
+ const resourcePaths = (access) => {
86
+ const { entry } = access;
87
+ const name = pascal(entry.name);
88
+ const tag = [entry.name];
89
+ const tenantNote = entry.model.tenant ? " Rows of other tenants are never visible." : "";
90
+ const softNote = entry.model.softDelete ? " Soft-deleted rows are hidden." : "";
91
+ const keyErrors = {
92
+ 400: error("BadRequest"),
93
+ ...auth,
94
+ 404: error("NotFound")
95
+ };
96
+ const sensitive = Object.values(access.readable.fields).filter((field) => field.sensitive).map((field) => field.name);
97
+ const paths = {
98
+ [`/${entry.name}`]: {
99
+ get: {
100
+ operationId: `list_${entry.name}`,
101
+ tags: tag,
102
+ summary: `List ${entry.name}`,
103
+ description: `Keyset paginated list.${tenantNote}${softNote}`,
104
+ parameters: listParameters(entry),
105
+ responses: {
106
+ 200: {
107
+ description: "A page",
108
+ content: page(name)
109
+ },
110
+ 400: error("BadRequest"),
111
+ ...auth
112
+ }
113
+ },
114
+ post: {
115
+ operationId: `create_${entry.name}`,
116
+ tags: tag,
117
+ summary: `Create a ${entry.name} record`,
118
+ parameters: [param("prefer")],
119
+ requestBody: {
120
+ required: true,
121
+ content: json(ref(`${name}Create`))
122
+ },
123
+ responses: {
124
+ 201: {
125
+ description: "Created; Location and ETag headers",
126
+ content: json(ref(name))
127
+ },
128
+ 400: error("BadRequest"),
129
+ ...auth,
130
+ 403: error("Forbidden"),
131
+ 409: error("Conflict")
132
+ }
133
+ }
134
+ },
135
+ [`/${entry.name}:search`]: { post: {
136
+ operationId: `search_${entry.name}`,
137
+ tags: tag,
138
+ summary: `List ${entry.name} with a JSON body`,
139
+ requestBody: {
140
+ required: true,
141
+ content: json({ $ref: "#/components/schemas/SearchBody" })
142
+ },
143
+ responses: {
144
+ 200: {
145
+ description: "A page",
146
+ content: page(name)
147
+ },
148
+ 400: error("BadRequest"),
149
+ ...auth
150
+ }
151
+ } },
152
+ [`/${entry.name}/{key}`]: {
153
+ get: {
154
+ operationId: `get_${entry.name}`,
155
+ tags: tag,
156
+ summary: `Get a ${entry.name} record`,
157
+ parameters: [
158
+ param("key"),
159
+ param("fields"),
160
+ ...entry.model.softDelete ? [param("showDeleted")] : []
161
+ ],
162
+ responses: {
163
+ 200: record(name, entry),
164
+ ...keyErrors
165
+ }
166
+ },
167
+ patch: {
168
+ operationId: `update_${entry.name}`,
169
+ tags: tag,
170
+ summary: `Update a ${entry.name} record`,
171
+ description: "Only the fields in the body change. Needs If-Match; a stale ETag is a 412.",
172
+ parameters: [
173
+ param("key"),
174
+ param("ifMatch"),
175
+ param("prefer")
176
+ ],
177
+ requestBody: {
178
+ required: true,
179
+ content: json(ref(`${name}Update`))
180
+ },
181
+ responses: {
182
+ 200: record(name, entry),
183
+ 204: { description: "With Prefer: return=minimal" },
184
+ ...keyErrors,
185
+ 403: error("Forbidden"),
186
+ 409: error("Conflict"),
187
+ 412: error("PreconditionFailed"),
188
+ 428: error("PreconditionRequired")
189
+ }
190
+ },
191
+ delete: {
192
+ operationId: `delete_${entry.name}`,
193
+ tags: tag,
194
+ summary: `Delete a ${entry.name} record`,
195
+ description: entry.model.softDelete ? "Soft delete: the record is hidden and can be restored." : "Removes the row.",
196
+ parameters: [
197
+ param("key"),
198
+ param("ifMatchOptional"),
199
+ param("prefer")
200
+ ],
201
+ responses: {
202
+ 204: { description: "Deleted" },
203
+ ...keyErrors,
204
+ 403: error("Forbidden"),
205
+ 409: error("Conflict"),
206
+ 412: error("PreconditionFailed")
207
+ }
208
+ }
209
+ },
210
+ ...entry.model.softDelete && { [`/${entry.name}/{key}:undelete`]: { post: {
211
+ operationId: `undelete_${entry.name}`,
212
+ tags: tag,
213
+ summary: `Restore a deleted ${entry.name} record`,
214
+ description: "[AIP-164](https://google.aip.dev/164): restores a deleted record. A record that is not deleted is a 409.",
215
+ parameters: [
216
+ param("key"),
217
+ param("ifMatchOptional"),
218
+ param("prefer")
219
+ ],
220
+ responses: {
221
+ 200: record(name, entry),
222
+ 204: { description: "With Prefer: return=minimal" },
223
+ ...keyErrors,
224
+ 403: error("Forbidden"),
225
+ 409: error("Conflict"),
226
+ 412: error("PreconditionFailed")
227
+ }
228
+ } } },
229
+ ...sensitive.length > 0 && { [`/${entry.name}/{key}:reveal`]: { post: {
230
+ operationId: `reveal_${entry.name}`,
231
+ tags: tag,
232
+ summary: `Reveal a sensitive field of a ${entry.name} record`,
233
+ description: "A sensitive field never comes with the record. This returns its value for one record, with the same access as a get, and publishes an audit event first.",
234
+ parameters: [param("key")],
235
+ requestBody: {
236
+ required: true,
237
+ content: json({
238
+ type: "object",
239
+ required: ["field"],
240
+ properties: { field: {
241
+ type: "string",
242
+ enum: sensitive
243
+ } },
244
+ additionalProperties: false
245
+ })
246
+ },
247
+ responses: {
248
+ 200: {
249
+ description: "The value; never cached",
250
+ content: json({
251
+ type: "object",
252
+ required: ["field", "value"],
253
+ properties: {
254
+ field: { type: "string" },
255
+ value: {}
256
+ }
257
+ })
258
+ },
259
+ ...keyErrors
260
+ }
261
+ } } },
262
+ [`/${entry.name}:facets`]: { get: {
263
+ operationId: `facets_${entry.name}`,
264
+ tags: tag,
265
+ summary: `Value counts of a ${entry.name} field`,
266
+ description: "Applies the filter except its own terms on the field.",
267
+ parameters: [
268
+ field("A filterable field"),
269
+ param("filter"),
270
+ query("limit", "Most values to return", { type: "integer" })
271
+ ],
272
+ responses: {
273
+ 200: {
274
+ description: "Counts, most frequent first",
275
+ content: json({
276
+ type: "object",
277
+ properties: {
278
+ field: { type: "string" },
279
+ facets: {
280
+ type: "array",
281
+ items: {
282
+ type: "object",
283
+ properties: {
284
+ value: {},
285
+ count: { type: "integer" }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ })
291
+ },
292
+ 400: error("BadRequest"),
293
+ ...auth
294
+ }
295
+ } },
296
+ [`/${entry.name}:series`]: { get: {
297
+ operationId: `series_${entry.name}`,
298
+ tags: tag,
299
+ summary: `Counts per time bucket of a ${entry.name} date field`,
300
+ parameters: [
301
+ field("A filterable date or timestamp field"),
302
+ param("filter"),
303
+ query("range", "Relative range ending now (default 30d); `all` starts at the earliest row", {
304
+ type: "string",
305
+ enum: [
306
+ "7d",
307
+ "30d",
308
+ "90d",
309
+ "1y",
310
+ "all"
311
+ ]
312
+ }),
313
+ query("from", "Explicit start (ISO 8601); with `to`, replaces range"),
314
+ query("to", "Exclusive end (ISO 8601), default now"),
315
+ query("granularity", "Default day", {
316
+ type: "string",
317
+ enum: [
318
+ "hour",
319
+ "day",
320
+ "week",
321
+ "month"
322
+ ]
323
+ }),
324
+ query("time_zone", "IANA time zone for the buckets, default UTC")
325
+ ],
326
+ responses: {
327
+ 200: {
328
+ description: "Buckets in local ISO time, empty ones included",
329
+ content: json({
330
+ type: "object",
331
+ properties: {
332
+ field: { type: "string" },
333
+ granularity: { type: "string" },
334
+ time_zone: { type: "string" },
335
+ points: {
336
+ type: "array",
337
+ items: {
338
+ type: "object",
339
+ properties: {
340
+ bucket: { type: "string" },
341
+ count: { type: "integer" }
342
+ }
343
+ }
344
+ }
345
+ }
346
+ })
347
+ },
348
+ 400: error("BadRequest"),
349
+ ...auth
350
+ }
351
+ } },
352
+ [`/${entry.name}:histogram`]: { get: {
353
+ operationId: `histogram_${entry.name}`,
354
+ tags: tag,
355
+ summary: `Equal-width buckets of a numeric ${entry.name} field`,
356
+ parameters: [
357
+ field("A filterable numeric field"),
358
+ param("filter"),
359
+ query("buckets", "Default 20, at most 200", { type: "integer" })
360
+ ],
361
+ responses: {
362
+ 200: {
363
+ description: "Buckets between the filtered minimum and maximum",
364
+ content: json({
365
+ type: "object",
366
+ properties: {
367
+ field: { type: "string" },
368
+ min: { type: ["number", "null"] },
369
+ max: { type: ["number", "null"] },
370
+ buckets: {
371
+ type: "array",
372
+ items: {
373
+ type: "object",
374
+ properties: {
375
+ from: { type: "number" },
376
+ to: { type: "number" },
377
+ count: { type: "integer" }
378
+ }
379
+ }
380
+ }
381
+ }
382
+ })
383
+ },
384
+ 400: error("BadRequest"),
385
+ ...auth
386
+ }
387
+ } },
388
+ [`/${entry.name}:seek`]: { get: {
389
+ operationId: `seek_${entry.name}`,
390
+ tags: tag,
391
+ summary: `A page of ${entry.name} at a row position`,
392
+ description: "For scrollbar jumps: resolves a 0-based row position, within the rows the caller can see and in the given order, to a page of rows, with no OFFSET. The estimate behind it is scaled to the caller's own rows; no column statistics or boundary values are returned, only the page and its tokens.",
393
+ parameters: [
394
+ query("position", "0-based row position to jump to", {
395
+ type: "integer",
396
+ minimum: 0
397
+ }, true),
398
+ param("orderBy"),
399
+ param("filter"),
400
+ param("pageSize"),
401
+ param("fields"),
402
+ param("count")
403
+ ],
404
+ responses: {
405
+ 200: {
406
+ description: "The page at that position; continue from either token",
407
+ content: page(name, true)
408
+ },
409
+ 400: error("BadRequest"),
410
+ ...auth
411
+ }
412
+ } }
413
+ };
414
+ return withoutDenied(paths, access);
415
+ };
416
+ //#endregion
417
+ export { resourcePaths };
@@ -0,0 +1,93 @@
1
+ import { writableFields } from "../request-access.js";
2
+ import { pascal } from "./naming.js";
3
+ import { z } from "zod";
4
+ //#region src/openapi/schemas.ts
5
+ const convert = (entry, io) => {
6
+ const schema = z.toJSONSchema(entry.source.recordSchema(), {
7
+ io,
8
+ unrepresentable: "any",
9
+ override: ({ jsonSchema }) => {
10
+ if (jsonSchema.format) delete jsonSchema.pattern;
11
+ }
12
+ });
13
+ delete schema.$schema;
14
+ return schema;
15
+ };
16
+ const hoist = (entry, schema) => {
17
+ const { $defs = {}, ...rest } = schema;
18
+ const rename = (key) => `${pascal(entry.name)}${key.replace(/^__/, "_")}`;
19
+ let text = JSON.stringify(rest);
20
+ for (const key of Object.keys($defs)) text = text.replaceAll(`"#/$defs/${key}"`, `"#/components/schemas/${rename(key)}"`);
21
+ const hoisted = Object.fromEntries(Object.entries($defs).map(([key, value]) => [rename(key), JSON.parse(JSON.stringify(value).replaceAll(`"#/$defs/${key}"`, `"#/components/schemas/${rename(key)}"`))]));
22
+ return {
23
+ schema: JSON.parse(text),
24
+ hoisted
25
+ };
26
+ };
27
+ const only = (schema, names) => ({
28
+ ...schema,
29
+ properties: Object.fromEntries(names.filter((name) => schema.properties?.[name]).map((name) => [name, schema.properties[name]])),
30
+ ...schema.required && { required: schema.required.filter((name) => names.includes(name)) }
31
+ });
32
+ /**
33
+ * Component schemas of a resource for one caller: the record with the fields they may read, and the create and update
34
+ * bodies with the fields they may write. Hidden fields appear nowhere.
35
+ */
36
+ const resourceSchemas = (access) => {
37
+ const { entry, model, resolved } = access;
38
+ const name = pascal(entry.name);
39
+ const readable = Object.keys(model.fields);
40
+ const output = hoist(entry, convert(entry, "output"));
41
+ const input = hoist(entry, convert(entry, "input"));
42
+ const record = only(output.schema, readable);
43
+ for (const field of Object.values(model.fields)) if (field.readOnly && record.properties?.[field.name]) record.properties[field.name].readOnly = true;
44
+ record.properties.etag = {
45
+ type: "string",
46
+ readOnly: true,
47
+ description: "The record version, as in the ETag header; send it in If-Match when updating or deleting"
48
+ };
49
+ record.properties.permissions = {
50
+ type: "object",
51
+ readOnly: true,
52
+ description: "What the caller may do with this record: update and delete, and on a get also `fields`, the mode (`edit` or `read`) of each field",
53
+ properties: {
54
+ update: { type: "boolean" },
55
+ delete: { type: "boolean" },
56
+ fields: {
57
+ type: "object",
58
+ additionalProperties: {
59
+ type: "string",
60
+ enum: ["edit", "read"]
61
+ }
62
+ }
63
+ }
64
+ };
65
+ record.required = [...record.required ?? [], "etag"];
66
+ const writable = access.readable;
67
+ const body = (names) => {
68
+ const properties = only(input.schema, names).properties;
69
+ const optional = new Set(names.filter((field) => writable.fields[field]?.nullable || writable.fields[field]?.default && "db" in writable.fields[field].default));
70
+ for (const field of names) {
71
+ const fieldModel = writable.fields[field];
72
+ if (fieldModel?.default && "db" in fieldModel.default && properties[field]) properties[field].description = "Defaults to a value the database generates";
73
+ }
74
+ const required = input.schema.required?.filter((field) => names.includes(field) && !optional.has(field)) ?? [];
75
+ return {
76
+ type: "object",
77
+ properties,
78
+ ...required.length > 0 && { required },
79
+ additionalProperties: false
80
+ };
81
+ };
82
+ const create = body(writableFields(access, "create"));
83
+ const update = { ...body(writableFields(access, "update")) };
84
+ delete update.required;
85
+ return {
86
+ [name]: record,
87
+ ...resolved.operations.create && { [`${name}Create`]: create },
88
+ ...resolved.operations.update && { [`${name}Update`]: update },
89
+ ...output.hoisted
90
+ };
91
+ };
92
+ //#endregion
93
+ export { resourceSchemas };
package/dist/params.js ADDED
@@ -0,0 +1,48 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { z } from "zod";
3
+ //#region src/params.ts
4
+ /** Parses query or body parameters; a failure is a 400 problem listing each offending parameter. */
5
+ const parseParams = (schema, input) => {
6
+ const parsed = schema.safeParse(input);
7
+ if (parsed.success) return parsed.data;
8
+ const errors = parsed.error.issues.map((issue) => ({
9
+ parameter: issue.path.join("."),
10
+ message: issue.message
11
+ }));
12
+ throw badRequest("invalid-parameter", `Invalid parameter ${errors[0].parameter}: ${errors[0].message}`, { errors });
13
+ };
14
+ const listParams = z.object({
15
+ filter: z.string().optional(),
16
+ order_by: z.string().optional(),
17
+ page_size: z.coerce.number().int().min(0).optional(),
18
+ page_token: z.string().optional(),
19
+ fields: z.union([z.string(), z.array(z.string())]).optional(),
20
+ count: z.literal("exact").optional(),
21
+ show_deleted: z.union([z.boolean(), z.enum(["true", "false"]).transform((value) => value === "true")]).optional()
22
+ });
23
+ /** The list parameters plus the row position to jump to (`:seek`). */
24
+ const seekParams = listParams.extend({ position: z.coerce.number().int().min(0) });
25
+ /** AIP-158: 0 or absent means the default, anything above the maximum is coerced down. */
26
+ const pageSizeOf = (requested) => Math.min(requested || 50, 500);
27
+ /** AIP-157 style column selection: comma separated field names, or an array in a body. */
28
+ const selectedFields = (model, fields) => {
29
+ if (fields === void 0) return void 0;
30
+ const names = (Array.isArray(fields) ? fields : fields.split(",")).map((name) => name.trim()).filter(Boolean);
31
+ const unknown = names.filter((name) => !Object.hasOwn(model.fields, name));
32
+ if (unknown.length > 0) throw badRequest("invalid-parameter", `Unknown fields: ${unknown.join(", ")}`, { errors: unknown.map((name) => ({
33
+ parameter: "fields",
34
+ message: `Unknown field "${name}"`
35
+ })) });
36
+ return names.length > 0 ? names : void 0;
37
+ };
38
+ /** AIP-164 `show_deleted`: only soft-delete resources have deleted rows to show. */
39
+ const deletedRows = (model, showDeleted) => {
40
+ if (!showDeleted) return "hide";
41
+ if (!model.softDelete) throw badRequest("invalid-parameter", `Resource "${model.name}" does not soft delete, so show_deleted does not apply`, { errors: [{
42
+ parameter: "show_deleted",
43
+ message: "Not supported"
44
+ }] });
45
+ return "show";
46
+ };
47
+ //#endregion
48
+ export { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields };
@@ -0,0 +1,17 @@
1
+ //#region src/problem.d.ts
2
+ export type ProblemExtras = Record<string, unknown>;
3
+ /** An RFC 9457 problem. Throw it anywhere below a route; the central error handler renders it. */
4
+ export declare class HttpProblem extends Error {
5
+ readonly status: number;
6
+ readonly slug: string;
7
+ readonly title: string;
8
+ readonly extras: ProblemExtras;
9
+ readonly headers: Record<string, string>;
10
+ constructor(status: number, slug: string, title: string, detail: string, extras?: ProblemExtras, headers?: Record<string, string>);
11
+ get detail(): string;
12
+ }
13
+ export declare const badRequest: (slug: string, detail: string, extras?: ProblemExtras) => HttpProblem;
14
+ export declare const unauthorized: (detail: string) => HttpProblem;
15
+ export declare const forbidden: (slug: string, detail: string) => HttpProblem;
16
+ export declare const notFound: (detail: string) => HttpProblem;
17
+ //#endregion