@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,28 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { checkFilter, checkOrderBy, parseFilter, parseOrderBy } from "@protobase/schema";
3
+ //#region src/filter-input.ts
4
+ const filterProblem = (parameter, errors) => badRequest(parameter === "filter" ? "invalid-filter" : "invalid-order-by", `The ${parameter} parameter is invalid: ${errors[0].message}`, {
5
+ parameter,
6
+ errors
7
+ });
8
+ /** AIP-160 text to a checked filter; throws a 400 problem carrying the checker's errors with spans and hints. */
9
+ const checkedFilter = (model, source, maxLength) => {
10
+ if (!source?.trim()) return void 0;
11
+ const parsed = parseFilter(source, maxLength ? { maxLength } : {});
12
+ if (!parsed.ok) throw filterProblem("filter", parsed.errors);
13
+ if (!parsed.ast) return void 0;
14
+ const checked = checkFilter(model, parsed.ast);
15
+ if (!checked.ok) throw filterProblem("filter", checked.errors);
16
+ return checked.filter;
17
+ };
18
+ /** AIP-132 `order_by` text to a sort spec; throws a 400 problem on any error. */
19
+ const checkedSort = (model, source) => {
20
+ if (!source?.trim()) return void 0;
21
+ const parsed = parseOrderBy(source);
22
+ if (!parsed.ok) throw filterProblem("order_by", parsed.errors);
23
+ const checked = checkOrderBy(model, parsed.items);
24
+ if (!checked.ok) throw filterProblem("order_by", checked.errors);
25
+ return checked.sort;
26
+ };
27
+ //#endregion
28
+ export { checkedFilter, checkedSort };
@@ -0,0 +1,25 @@
1
+ //#region src/filter-shape.ts
2
+ /** The filter with its values blanked out: queries that differ only in values share a shape. */
3
+ const filterShape = (filter) => {
4
+ if (!filter) return "";
5
+ switch (filter.kind) {
6
+ case "and":
7
+ case "or": return `${filter.kind}(${filter.args.map(filterShape).join(",")})`;
8
+ case "not": return `not(${filterShape(filter.arg)})`;
9
+ case "compare": return `${filter.field.name}${filter.op}?`;
10
+ case "search": return "search";
11
+ default: return `${filter.kind}:${filter.field.name}`;
12
+ }
13
+ };
14
+ const filterFields = (filter) => {
15
+ if (!filter) return [];
16
+ switch (filter.kind) {
17
+ case "and":
18
+ case "or": return filter.args.flatMap(filterFields);
19
+ case "not": return filterFields(filter.arg);
20
+ case "search": return [];
21
+ default: return [filter.field];
22
+ }
23
+ };
24
+ //#endregion
25
+ export { filterFields, filterShape };
package/dist/hash.js ADDED
@@ -0,0 +1,10 @@
1
+ //#region src/hash.ts
2
+ /** Hex SHA-256 of a string, through Web Crypto so it runs on Node and Workers. */
3
+ const sha256Hex = async (text) => {
4
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
5
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
6
+ };
7
+ /** JSON with object keys sorted, so equal content always serializes equally. */
8
+ const stableJson = (value) => JSON.stringify(value, (_, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : v);
9
+ //#endregion
10
+ export { sha256Hex, stableJson };
@@ -0,0 +1,11 @@
1
+ import { AdminAuth, CreateAuthOptions, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
2
+ import { ResourceSource, configExports } from "./resource-source.js";
3
+ import { Admin, AdminOptions, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
4
+ import { CreateAdminInput, createAdmin } from "./create-admin.js";
5
+ import { consoleAuditQueue } from "./audit/console-queue.js";
6
+ import { JwtOptions, jwtAuthenticator } from "./auth/jwt.js";
7
+ import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
8
+ import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./better-auth/authenticator.js";
9
+ import { NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
10
+ import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
11
+ export { type Admin, type AdminAuth, type AdminOptions, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, type NewUser, type PipelineHook, type ResourceSource, type ScanGuardMode, type Session, type StoredUser, type TenantValue, type User, type ViewSource, type WriteEvent, type WriteOperation, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, createAdmin, createAuth, createUser, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, roleChoices, setUserBanned, setUserRole, unauthorized };
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ import { consoleAuditQueue } from "./audit/console-queue.js";
2
+ import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
3
+ import { createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
4
+ import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
5
+ import { createAdmin } from "./create-admin.js";
6
+ import { configExports } from "./resource-source.js";
7
+ import { jwtAuthenticator } from "./auth/jwt.js";
8
+ import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
9
+ import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
10
+ export { HttpProblem, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, createAdmin, createAuth, createUser, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, roleChoices, setUserBanned, setUserRole, unauthorized };
package/dist/key.js ADDED
@@ -0,0 +1,22 @@
1
+ import { badRequest } from "./problem.js";
2
+ import { decodeKey } from "@protobase/schema";
3
+ //#region src/key.ts
4
+ /** Decodes key text (`a,b` for composite keys, parts percent-encoded). decodeKey is a pure parser: every error it throws is a malformed key. */
5
+ const decodeKeyText = (entry, raw) => {
6
+ try {
7
+ const key = decodeKey(raw, entry.keyTypes);
8
+ return Array.isArray(key) ? key : [key];
9
+ } catch {
10
+ throw badRequest("invalid-key", `"${raw}" is not a valid ${entry.name} key`);
11
+ }
12
+ };
13
+ /**
14
+ * The key from the `:key` path segment, minus a custom method `suffix` such as `:undelete`. A single key is Hono's decoded
15
+ * param; composite parts stay percent-encoded so an encoded comma inside a part survives.
16
+ */
17
+ const keyFromPath = (c, entry, suffix = "") => {
18
+ const segment = entry.keyTypes.length === 1 ? c.req.param("key") : new URL(c.req.url).pathname.split("/").pop();
19
+ return decodeKeyText(entry, suffix && segment.endsWith(suffix) ? segment.slice(0, -suffix.length) : segment);
20
+ };
21
+ //#endregion
22
+ export { decodeKeyText, keyFromPath };
@@ -0,0 +1,96 @@
1
+ import { checkedFilter, checkedSort } from "./filter-input.js";
2
+ import { readScope, requestAccess, requireOperation } from "./request-access.js";
3
+ import { readTransaction } from "./transactions.js";
4
+ import { etagAlias } from "./sql-parts.js";
5
+ import { etagOf } from "./etag.js";
6
+ import { exposeRow, pickFields } from "./rows.js";
7
+ import { deletedRows, listParams, pageSizeOf, parseParams, seekParams, selectedFields } from "./params.js";
8
+ import { rowPermissions } from "./row-permissions.js";
9
+ import { estimateCount, exactCount, listQuery, seekToPosition } from "@protobase/query";
10
+ //#region src/list-service.ts
11
+ /**
12
+ * AIP-132 list, the `:search` variant and (with `jump`) `:seek`: both take the same parameters, from the query string or a JSON body.
13
+ * Everything the caller names (filter, order_by, fields, search) is checked against the model as this caller sees it,
14
+ * so a hidden field is refused exactly like an unknown one; the access row filter is applied by the query layer.
15
+ */
16
+ const listRecords = async (deps, entry, session, input, maxFilterLength, jump = false) => {
17
+ const params = jump ? parseParams(seekParams, input) : parseParams(listParams, input);
18
+ const access = await requestAccess(deps, entry, session);
19
+ requireOperation(access, "list");
20
+ const showDeleted = deletedRows(access.full, params.show_deleted) === "show";
21
+ const full = showDeleted ? {
22
+ ...access.full,
23
+ softDelete: void 0
24
+ } : access.full;
25
+ const model = showDeleted ? {
26
+ ...access.model,
27
+ softDelete: void 0
28
+ } : access.model;
29
+ const filter = checkedFilter(model, params.filter, maxFilterLength);
30
+ const sort = checkedSort(model, params.order_by);
31
+ const columns = selectedFields(model, params.fields);
32
+ const limit = pageSizeOf(params.page_size);
33
+ const scope = readScope(access, full);
34
+ const etagField = entry.etag && !model.fields[entry.etag.field.name] ? full.fields[entry.etag.field.name] : void 0;
35
+ const queryModel = etagField ? {
36
+ ...model,
37
+ fields: {
38
+ ...model.fields,
39
+ [etagField.name]: etagField
40
+ }
41
+ } : model;
42
+ const read = entry.etag && columns ? columns : Object.keys(model.fields);
43
+ const shape = {
44
+ ...filter && { filter },
45
+ ...sort && { sort },
46
+ limit
47
+ };
48
+ const warning = await deps.guard(entry, shape, {
49
+ model: queryModel,
50
+ scope
51
+ });
52
+ const position = jump ? params.position : void 0;
53
+ const result = await readTransaction(deps.db, deps.statementTimeoutMs, async (trx) => {
54
+ const extraColumns = entry.etag ? [{
55
+ field: entry.etag.field.name,
56
+ as: "text",
57
+ alias: etagAlias
58
+ }] : [];
59
+ const page = position === void 0 ? await listQuery(trx, queryModel, {
60
+ ...shape,
61
+ columns: read,
62
+ ...params.page_token && { cursor: params.page_token }
63
+ }, scope, { extraColumns }) : await seekToPosition(trx, queryModel, {
64
+ position,
65
+ orderBy: sort ?? [[entry.model.primaryKey[0], "asc"]],
66
+ ...filter && { filter },
67
+ columns: read,
68
+ limit
69
+ }, scope, { extraColumns });
70
+ const rows = page.rows.map((row) => exposeRow(model, row));
71
+ return {
72
+ page,
73
+ rows,
74
+ etags: await Promise.all(rows.map((row, i) => etagOf(entry, page.rows[i], row))),
75
+ permissions: deps.rowPermissions(entry.name) ? await rowPermissions(trx, access, rows) : void 0,
76
+ estimate: await estimateCount(trx, queryModel, filter, scope),
77
+ ...params.count === "exact" && { total: await exactCount(trx, queryModel, filter, scope) }
78
+ };
79
+ });
80
+ return {
81
+ warning,
82
+ body: {
83
+ items: result.rows.map((row, i) => ({
84
+ ...pickFields(row, columns),
85
+ etag: result.etags[i],
86
+ ...result.permissions && { permissions: result.permissions[i] }
87
+ })),
88
+ next_page_token: result.page.nextCursor ?? "",
89
+ ...position !== void 0 && { prev_page_token: result.page.prevCursor ?? "" },
90
+ total_size_estimate: result.estimate,
91
+ ...result.total !== void 0 && { total_size: result.total }
92
+ }
93
+ };
94
+ };
95
+ //#endregion
96
+ export { listRecords };
@@ -0,0 +1,26 @@
1
+ //#region src/mail/mail-settings.ts
2
+ const smtpUrlVariable = "PROTOBASE_SMTP_URL";
3
+ const mailFromVariable = "PROTOBASE_MAIL_FROM";
4
+ const address = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
5
+ /**
6
+ * The SMTP server and sender the platform passes in `PROTOBASE_SMTP_URL` and `PROTOBASE_MAIL_FROM`; `undefined` when
7
+ * neither is set, which turns mail (and so password reset) off. One without the other, or a value that cannot be used,
8
+ * is a configuration error, so the server stops at startup instead of failing on the first reset request.
9
+ */
10
+ const readMailSettings = (env) => {
11
+ const smtpUrl = env["PROTOBASE_SMTP_URL"] || void 0;
12
+ const from = env["PROTOBASE_MAIL_FROM"]?.trim() || void 0;
13
+ if (!smtpUrl && !from) return void 0;
14
+ if (!smtpUrl || !from) throw new Error(`${smtpUrlVariable} and ${mailFromVariable} go together: set both to send mail, or neither`);
15
+ if (!URL.canParse(smtpUrl)) throw new Error(`${smtpUrlVariable} is not a URL; use smtp://user:password@host:587 or smtps://...:465`);
16
+ const url = new URL(smtpUrl);
17
+ if (url.protocol !== "smtp:" && url.protocol !== "smtps:") throw new Error(`${smtpUrlVariable} must start with smtp:// or smtps://, not ${url.protocol}//`);
18
+ if (!url.hostname) throw new Error(`${smtpUrlVariable} has no host`);
19
+ if (!address.test(from)) throw new Error(`${mailFromVariable} must be a plain email address, like noreply@example.com`);
20
+ return {
21
+ smtpUrl,
22
+ from
23
+ };
24
+ };
25
+ //#endregion
26
+ export { mailFromVariable, readMailSettings, smtpUrlVariable };
@@ -0,0 +1,11 @@
1
+ //#region src/mail/smtp-mailer.d.ts
2
+ export type MailMessage = {
3
+ to: string;
4
+ subject: string;
5
+ text: string;
6
+ };
7
+ /** Sends one message; rejects when the server refuses it. */
8
+ export type Mailer = {
9
+ send: (message: MailMessage) => Promise<void>;
10
+ };
11
+ //#endregion
@@ -0,0 +1,17 @@
1
+ //#region src/mail/smtp-mailer.ts
2
+ /**
3
+ * Plain-text mail over SMTP with Nodemailer: the most used Node SMTP client, maintained, with no dependencies of its
4
+ * own. It is loaded on the first message, so `@protobase/server` itself stays free of Node's network modules.
5
+ */
6
+ const smtpMailer = (settings) => {
7
+ let transport;
8
+ return { send: async (message) => {
9
+ transport ??= import("nodemailer").then(({ createTransport }) => createTransport(settings.smtpUrl));
10
+ await (await transport).sendMail({
11
+ from: settings.from,
12
+ ...message
13
+ });
14
+ } };
15
+ };
16
+ //#endregion
17
+ export { smtpMailer };
package/dist/meta.js ADDED
@@ -0,0 +1,63 @@
1
+ import { sha256Hex, stableJson } from "./hash.js";
2
+ import { requestAccess } from "./request-access.js";
3
+ import { pruneView } from "./view-access.js";
4
+ import { prunePage } from "@protobase/layout";
5
+ import { pickView } from "@protobase/schema";
6
+ //#region src/meta.ts
7
+ const modelsHash = (deps) => sha256Hex(stableJson(deps.registry.entries.map((entry) => entry.model))).then((hash) => hash.slice(0, 32));
8
+ /**
9
+ * `/meta` as one caller sees it: the resources they can do anything with, each as the restricted model (hidden fields
10
+ * absent, `readOnly` unless writable), the view for their roles with hidden fields pruned, what they may do, and the
11
+ * user menu without the resources they cannot see.
12
+ * The ETag hashes all of it, so it differs per user and per roles.
13
+ */
14
+ const createMeta = (deps) => {
15
+ const base = modelsHash(deps);
16
+ const forCaller = async (session) => {
17
+ const visible = (await Promise.all(deps.registry.entries.map((entry) => requestAccess(deps, entry, session)))).filter((access) => Object.values(access.resolved.operations).some(Boolean));
18
+ const listed = Object.fromEntries(visible.map((access) => [access.entry.name, access.model]));
19
+ const views = visible.flatMap((access) => {
20
+ const view = pickView(deps.views, session.user.roles, access.entry.name);
21
+ return view ? [pruneView(view, access.readable, listed)] : [];
22
+ });
23
+ const callerAccess = {
24
+ models: listed,
25
+ permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
26
+ create: resolved.operations.create,
27
+ update: resolved.operations.update
28
+ }])),
29
+ actions: (resource) => new Set(views.find((view) => view.resource === resource)?.actions.map((action) => action.name) ?? [])
30
+ };
31
+ const pages = deps.pages.flatMap((page) => prunePage(page, session.user.roles, callerAccess) ?? []);
32
+ const shown = (item) => item.kind === "link" || (item.kind === "resource" ? visible.some((access) => access.entry.name === item.resource) : pages.some((page) => page.name === item.page));
33
+ const body = {
34
+ resources: visible.map((access) => access.readable),
35
+ views,
36
+ pages,
37
+ permissions: Object.fromEntries(visible.map(({ entry, resolved }) => [entry.name, {
38
+ read: resolved.operations.read || resolved.operations.list,
39
+ create: resolved.operations.create,
40
+ update: resolved.operations.update,
41
+ delete: resolved.operations.delete,
42
+ conditional: [
43
+ "create",
44
+ "update",
45
+ "delete"
46
+ ].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
47
+ }])),
48
+ ...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } }
49
+ };
50
+ return {
51
+ body,
52
+ etag: `"${(await sha256Hex(stableJson(body))).slice(0, 32)}"`
53
+ };
54
+ };
55
+ /** The X-Meta-Version header: the models, and the roles the access depends on, so a role change moves it. */
56
+ const version = async (roles) => (await sha256Hex(`${await base}|${[...roles].sort().join(",")}`)).slice(0, 32);
57
+ return {
58
+ forCaller,
59
+ version
60
+ };
61
+ };
62
+ //#endregion
63
+ export { createMeta };
@@ -0,0 +1,331 @@
1
+ //#region src/openapi/components.ts
2
+ const problemContent = { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } };
3
+ const problem = (description) => ({
4
+ description,
5
+ content: problemContent
6
+ });
7
+ const problemSchema = {
8
+ type: "object",
9
+ description: "RFC 9457 problem details",
10
+ required: [
11
+ "type",
12
+ "title",
13
+ "status",
14
+ "detail"
15
+ ],
16
+ properties: {
17
+ type: {
18
+ type: "string",
19
+ description: "A URN naming the kind of problem"
20
+ },
21
+ title: { type: "string" },
22
+ status: { type: "integer" },
23
+ detail: { type: "string" },
24
+ instance: {
25
+ type: "string",
26
+ description: "The request path"
27
+ },
28
+ parameter: {
29
+ type: "string",
30
+ description: "For filter and order_by errors, the offending parameter"
31
+ },
32
+ errors: {
33
+ type: "array",
34
+ description: "Filter and order_by errors (code, message, hint, span) or field errors (field, code, message)",
35
+ items: {
36
+ type: "object",
37
+ additionalProperties: true
38
+ }
39
+ }
40
+ },
41
+ additionalProperties: true
42
+ };
43
+ const errorResponses = {
44
+ BadRequest: problem("Invalid filter, order_by, parameter, key or body"),
45
+ Unauthorized: problem("Missing or invalid credentials"),
46
+ Forbidden: problem("The caller may not do this, or has no tenant"),
47
+ NotFound: problem("No such resource or record, or the record belongs to another tenant"),
48
+ PreconditionFailed: problem("If-Match no longer matches the record"),
49
+ PreconditionRequired: problem("If-Match is required"),
50
+ Conflict: problem("A unique or foreign key constraint rejected the write")
51
+ };
52
+ const parameters = {
53
+ filter: {
54
+ name: "filter",
55
+ in: "query",
56
+ description: [
57
+ "An [AIP-160](https://google.aip.dev/160) filter, for example `status = \"confirmed\" AND total > 100`.",
58
+ "Only filterable fields can be used.",
59
+ "Functions: `in(field, v1, v2, ...)`, `search(\"text\")` (also bare words), `similar(field, \"text\")`, `regex(field, \"pattern\")`, `isNull(field)`, `now()` with a duration offset such as `now() - 7d`.",
60
+ "Syntax errors come back as a problem with `errors` carrying spans and hints."
61
+ ].join(" "),
62
+ schema: { type: "string" }
63
+ },
64
+ orderBy: {
65
+ name: "order_by",
66
+ in: "query",
67
+ description: "Comma separated sortable fields, each with an optional `desc`, as in [AIP-132](https://google.aip.dev/132#ordering): `createdAt desc, number`. The primary key breaks ties.",
68
+ schema: { type: "string" }
69
+ },
70
+ pageSize: {
71
+ name: "page_size",
72
+ in: "query",
73
+ description: "As in [AIP-158](https://google.aip.dev/158): default 50, maximum 500 (larger values are coerced).",
74
+ schema: {
75
+ type: "integer",
76
+ minimum: 0,
77
+ maximum: 500
78
+ }
79
+ },
80
+ pageToken: {
81
+ name: "page_token",
82
+ in: "query",
83
+ description: "The `next_page_token` of the previous page",
84
+ schema: { type: "string" }
85
+ },
86
+ fields: {
87
+ name: "fields",
88
+ in: "query",
89
+ description: "Comma separated field names to return",
90
+ schema: { type: "string" }
91
+ },
92
+ count: {
93
+ name: "count",
94
+ in: "query",
95
+ description: "`exact` adds `total_size`, a real count(*)",
96
+ schema: {
97
+ type: "string",
98
+ enum: ["exact"]
99
+ }
100
+ },
101
+ key: {
102
+ name: "key",
103
+ in: "path",
104
+ required: true,
105
+ description: "The primary key. Composite keys are comma separated parts, each percent-encoded.",
106
+ schema: { type: "string" }
107
+ },
108
+ ifMatch: {
109
+ name: "If-Match",
110
+ in: "header",
111
+ required: true,
112
+ description: "The `etag` of the record, from a GET or a list item; `*` matches any version",
113
+ schema: { type: "string" }
114
+ },
115
+ ifMatchOptional: {
116
+ name: "If-Match",
117
+ in: "header",
118
+ description: "When sent, the operation only happens if it matches the current `etag` ([AIP-135](https://google.aip.dev/135)); `*` matches any version",
119
+ schema: { type: "string" }
120
+ },
121
+ showDeleted: {
122
+ name: "show_deleted",
123
+ in: "query",
124
+ description: "Also return soft-deleted records ([AIP-164](https://google.aip.dev/164)); only on resources that soft delete",
125
+ schema: { type: "boolean" }
126
+ },
127
+ prefer: {
128
+ name: "Prefer",
129
+ in: "header",
130
+ description: "`return=minimal` or `return=representation` (default)",
131
+ schema: { type: "string" }
132
+ }
133
+ };
134
+ const searchBody = {
135
+ type: "object",
136
+ description: "The list parameters as JSON, for filters too long for a URL",
137
+ properties: {
138
+ filter: {
139
+ type: "string",
140
+ description: parameters.filter.description
141
+ },
142
+ order_by: { type: "string" },
143
+ page_size: { type: "integer" },
144
+ page_token: { type: "string" },
145
+ fields: {
146
+ type: "array",
147
+ items: { type: "string" }
148
+ },
149
+ count: {
150
+ type: "string",
151
+ enum: ["exact"]
152
+ },
153
+ show_deleted: {
154
+ type: "boolean",
155
+ description: "Soft-delete resources only"
156
+ }
157
+ }
158
+ };
159
+ const batchSchemas = {
160
+ BatchRef: {
161
+ type: "object",
162
+ description: "Points at a record created earlier in the batch by its `ref` name: in a key it stands for that record's key, in `data` for its key (or, with `field`, that field's value)",
163
+ required: ["$ref"],
164
+ additionalProperties: { type: "string" }
165
+ },
166
+ BatchKey: {
167
+ description: "A record key: its text as in a URL (`a,b` when composite), a number, a list of parts, or a `$ref`",
168
+ anyOf: [
169
+ { type: "string" },
170
+ { type: "number" },
171
+ {
172
+ type: "array",
173
+ items: { anyOf: [
174
+ { type: "string" },
175
+ { type: "number" },
176
+ { $ref: "#/components/schemas/BatchRef" }
177
+ ] }
178
+ },
179
+ { $ref: "#/components/schemas/BatchRef" }
180
+ ]
181
+ },
182
+ BatchRequest: {
183
+ type: "object",
184
+ required: ["ops"],
185
+ properties: { ops: {
186
+ type: "array",
187
+ minItems: 1,
188
+ maxItems: 500,
189
+ description: "Run in order inside one transaction through the normal write pipeline; all or nothing",
190
+ items: { oneOf: [
191
+ {
192
+ type: "object",
193
+ required: [
194
+ "op",
195
+ "resource",
196
+ "data"
197
+ ],
198
+ properties: {
199
+ op: { const: "create" },
200
+ resource: { type: "string" },
201
+ data: {
202
+ type: "object",
203
+ additionalProperties: true
204
+ },
205
+ ref: {
206
+ type: "string",
207
+ description: "Names the created record for later `$ref`s"
208
+ }
209
+ },
210
+ additionalProperties: false
211
+ },
212
+ {
213
+ type: "object",
214
+ required: [
215
+ "op",
216
+ "resource",
217
+ "key",
218
+ "data"
219
+ ],
220
+ properties: {
221
+ op: { const: "update" },
222
+ resource: { type: "string" },
223
+ key: { $ref: "#/components/schemas/BatchKey" },
224
+ etag: {
225
+ type: "string",
226
+ description: "Required, as `If-Match` on a single update; `*` matches any version"
227
+ },
228
+ data: {
229
+ type: "object",
230
+ additionalProperties: true
231
+ }
232
+ },
233
+ additionalProperties: false
234
+ },
235
+ {
236
+ type: "object",
237
+ required: [
238
+ "op",
239
+ "resource",
240
+ "key"
241
+ ],
242
+ properties: {
243
+ op: { const: "delete" },
244
+ resource: { type: "string" },
245
+ key: { $ref: "#/components/schemas/BatchKey" },
246
+ etag: {
247
+ type: "string",
248
+ description: "Optional, as `If-Match` on a single delete"
249
+ }
250
+ },
251
+ additionalProperties: false
252
+ },
253
+ {
254
+ type: "object",
255
+ required: [
256
+ "op",
257
+ "resource",
258
+ "field",
259
+ "keys"
260
+ ],
261
+ properties: {
262
+ op: { const: "reorder" },
263
+ resource: { type: "string" },
264
+ field: {
265
+ type: "string",
266
+ description: "An integer field the caller may write, for example `position`"
267
+ },
268
+ keys: {
269
+ type: "array",
270
+ minItems: 1,
271
+ items: { $ref: "#/components/schemas/BatchKey" },
272
+ description: "The records in their final order: `field` becomes 1, 2, 3, ..."
273
+ },
274
+ etags: {
275
+ type: "object",
276
+ additionalProperties: { type: "string" },
277
+ description: "ETag per key (the key as text) for every record whose value changes"
278
+ }
279
+ },
280
+ additionalProperties: false
281
+ }
282
+ ] }
283
+ } }
284
+ },
285
+ BatchResponse: {
286
+ type: "object",
287
+ required: ["results"],
288
+ properties: { results: {
289
+ type: "array",
290
+ description: "One result per operation, in order: records come with their new `etag`",
291
+ items: {
292
+ type: "object",
293
+ required: ["op", "resource"],
294
+ properties: {
295
+ op: {
296
+ type: "string",
297
+ enum: [
298
+ "create",
299
+ "update",
300
+ "delete",
301
+ "reorder"
302
+ ]
303
+ },
304
+ resource: { type: "string" },
305
+ ref: { type: "string" },
306
+ record: {
307
+ type: "object",
308
+ additionalProperties: true
309
+ },
310
+ etag: { type: "string" },
311
+ deleted: { type: "boolean" },
312
+ records: {
313
+ type: "array",
314
+ items: {
315
+ type: "object",
316
+ properties: {
317
+ record: {
318
+ type: "object",
319
+ additionalProperties: true
320
+ },
321
+ etag: { type: "string" }
322
+ }
323
+ }
324
+ }
325
+ }
326
+ }
327
+ } }
328
+ }
329
+ };
330
+ //#endregion
331
+ export { batchSchemas, errorResponses, parameters, problemSchema, searchBody };