@databricks/appkit 0.72.0 → 0.74.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.
Files changed (156) hide show
  1. package/CLAUDE.md +24 -0
  2. package/NOTICE.md +1 -0
  3. package/dist/appkit/package.js +1 -1
  4. package/dist/beta.d.ts +11 -8
  5. package/dist/beta.js +7 -6
  6. package/dist/cli/commands/agent/eval.js +85 -16
  7. package/dist/cli/commands/agent/eval.js.map +1 -1
  8. package/dist/connectors/index.js +1 -1
  9. package/dist/connectors/mlflow/auth.d.ts +11 -1
  10. package/dist/connectors/mlflow/auth.d.ts.map +1 -1
  11. package/dist/connectors/mlflow/auth.js +22 -2
  12. package/dist/connectors/mlflow/auth.js.map +1 -1
  13. package/dist/connectors/mlflow/index.d.ts +2 -0
  14. package/dist/database/errors.js +15 -5
  15. package/dist/database/errors.js.map +1 -1
  16. package/dist/database/runtime/data-path.d.ts +7 -0
  17. package/dist/database/runtime/data-path.d.ts.map +1 -0
  18. package/dist/database/runtime/data-path.js.map +1 -1
  19. package/dist/database/runtime/engine/drizzle-data-path.js +7 -5
  20. package/dist/database/runtime/engine/drizzle-data-path.js.map +1 -1
  21. package/dist/database/schema-builder/define-schema.d.ts +1 -1
  22. package/dist/database/schema-builder/define-schema.js +1 -1
  23. package/dist/database/schema-builder/define-schema.js.map +1 -1
  24. package/dist/errors/database-validation.d.ts +23 -0
  25. package/dist/errors/database-validation.d.ts.map +1 -0
  26. package/dist/errors/database-validation.js +24 -0
  27. package/dist/errors/database-validation.js.map +1 -0
  28. package/dist/errors/index.js +1 -0
  29. package/dist/evals/dataset.d.ts +49 -0
  30. package/dist/evals/dataset.d.ts.map +1 -0
  31. package/dist/evals/dataset.js +51 -0
  32. package/dist/evals/dataset.js.map +1 -0
  33. package/dist/evals/define-eval.d.ts +4 -2
  34. package/dist/evals/define-eval.d.ts.map +1 -1
  35. package/dist/evals/define-eval.js +5 -1
  36. package/dist/evals/define-eval.js.map +1 -1
  37. package/dist/evals/discover.d.ts +15 -1
  38. package/dist/evals/discover.d.ts.map +1 -1
  39. package/dist/evals/discover.js +26 -2
  40. package/dist/evals/discover.js.map +1 -1
  41. package/dist/evals/http-driver.d.ts.map +1 -1
  42. package/dist/evals/http-driver.js +82 -55
  43. package/dist/evals/http-driver.js.map +1 -1
  44. package/dist/evals/index.d.ts +14 -0
  45. package/dist/evals/index.js +6 -5
  46. package/dist/evals/judge.d.ts +1 -0
  47. package/dist/evals/judge.d.ts.map +1 -1
  48. package/dist/evals/mlflow-report.d.ts +1 -0
  49. package/dist/evals/mlflow-report.d.ts.map +1 -1
  50. package/dist/evals/mlflow-run.d.ts +2 -0
  51. package/dist/evals/mlflow-run.d.ts.map +1 -1
  52. package/dist/evals/report.d.ts +16 -1
  53. package/dist/evals/report.d.ts.map +1 -1
  54. package/dist/evals/report.js +64 -2
  55. package/dist/evals/report.js.map +1 -1
  56. package/dist/evals/run-eval.d.ts +8 -0
  57. package/dist/evals/run-eval.d.ts.map +1 -1
  58. package/dist/evals/run-eval.js +54 -5
  59. package/dist/evals/run-eval.js.map +1 -1
  60. package/dist/evals/run-evals.d.ts +41 -3
  61. package/dist/evals/run-evals.d.ts.map +1 -1
  62. package/dist/evals/run-evals.js +215 -34
  63. package/dist/evals/run-evals.js.map +1 -1
  64. package/dist/evals/types.d.ts +80 -6
  65. package/dist/evals/types.d.ts.map +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.js +2 -1
  68. package/dist/plugin/plugin.d.ts.map +1 -1
  69. package/dist/plugin/plugin.js +1 -1
  70. package/dist/plugin/plugin.js.map +1 -1
  71. package/dist/plugins/database/crud/contract.js +17 -8
  72. package/dist/plugins/database/crud/contract.js.map +1 -1
  73. package/dist/plugins/database/crud/exposure.js +63 -22
  74. package/dist/plugins/database/crud/exposure.js.map +1 -1
  75. package/dist/plugins/database/crud/request.js +50 -0
  76. package/dist/plugins/database/crud/request.js.map +1 -0
  77. package/dist/plugins/database/crud/response.js +77 -0
  78. package/dist/plugins/database/crud/response.js.map +1 -0
  79. package/dist/plugins/database/crud/routes.js +71 -52
  80. package/dist/plugins/database/crud/routes.js.map +1 -1
  81. package/dist/plugins/database/database.d.ts +6 -4
  82. package/dist/plugins/database/database.d.ts.map +1 -1
  83. package/dist/plugins/database/database.js +46 -16
  84. package/dist/plugins/database/database.js.map +1 -1
  85. package/dist/plugins/database/defaults.js +5 -1
  86. package/dist/plugins/database/defaults.js.map +1 -1
  87. package/dist/plugins/database/entity-client.js +143 -10
  88. package/dist/plugins/database/entity-client.js.map +1 -1
  89. package/dist/plugins/database/entity-types.d.ts +1 -1
  90. package/dist/plugins/database/hooks.d.ts +38 -0
  91. package/dist/plugins/database/hooks.d.ts.map +1 -0
  92. package/dist/plugins/database/index.d.ts +3 -2
  93. package/dist/plugins/database/lifecycle.js +67 -28
  94. package/dist/plugins/database/lifecycle.js.map +1 -1
  95. package/dist/plugins/database/scope.js +58 -0
  96. package/dist/plugins/database/scope.js.map +1 -0
  97. package/dist/plugins/database/types.d.ts +40 -12
  98. package/dist/plugins/database/types.d.ts.map +1 -1
  99. package/dist/plugins/server/index.js +2 -2
  100. package/dist/plugins/server/index.js.map +1 -1
  101. package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js +3 -3
  102. package/dist/plugins/server/remote-tunnel/remote-tunnel-manager.js.map +1 -1
  103. package/dist/plugins/server/static-server.js +3 -3
  104. package/dist/plugins/server/static-server.js.map +1 -1
  105. package/dist/plugins/server/utils.js +3 -3
  106. package/dist/plugins/server/utils.js.map +1 -1
  107. package/dist/plugins/server/vite-dev-server.js +4 -4
  108. package/dist/plugins/server/vite-dev-server.js.map +1 -1
  109. package/dist/shared/src/schemas/manifest.d.ts +87 -87
  110. package/dist/type-generator/database/generate.js +3 -3
  111. package/dist/type-generator/database/generate.js.map +1 -1
  112. package/dist/type-generator/migration.js +2 -2
  113. package/dist/type-generator/migration.js.map +1 -1
  114. package/dist/type-generator/serving/server-file-extractor.js +3 -3
  115. package/dist/type-generator/serving/server-file-extractor.js.map +1 -1
  116. package/docs/api/appkit/Class.AppKitError.md +1 -0
  117. package/docs/api/appkit/Class.DatabaseValidationError.md +191 -0
  118. package/docs/api/appkit/Function.defineEvalConfig.md +18 -0
  119. package/docs/api/appkit/Function.defineSchema.md +1 -1
  120. package/docs/api/appkit/Function.discoverEvalConfigs.md +18 -0
  121. package/docs/api/appkit/Function.formatResultsJUnit.md +18 -0
  122. package/docs/api/appkit/Function.formatResultsJson.md +18 -0
  123. package/docs/api/appkit/Function.readEvalDataset.md +21 -0
  124. package/docs/api/appkit/Function.resolveWorkspaceClient.md +18 -0
  125. package/docs/api/appkit/Function.runWithRetries.md +28 -0
  126. package/docs/api/appkit/Function.userTurns.md +20 -0
  127. package/docs/api/appkit/Interface.AssertionHandle.md +1 -1
  128. package/docs/api/appkit/Interface.DatabaseValidationIssue.md +21 -0
  129. package/docs/api/appkit/Interface.DatasetRow.md +21 -0
  130. package/docs/api/appkit/Interface.DiscoveredEvalConfig.md +25 -0
  131. package/docs/api/appkit/Interface.DriveResult.md +28 -0
  132. package/docs/api/appkit/Interface.EntityMutationHooks.md +173 -0
  133. package/docs/api/appkit/Interface.EvalDefinition.md +50 -0
  134. package/docs/api/appkit/Interface.EvalDriver.md +26 -5
  135. package/docs/api/appkit/Interface.EvalResult.md +11 -0
  136. package/docs/api/appkit/Interface.EvalSummary.md +11 -0
  137. package/docs/api/appkit/Interface.HookApp.md +12 -0
  138. package/docs/api/appkit/Interface.HookContext.md +21 -0
  139. package/docs/api/appkit/Interface.ReadEvalDatasetOptions.md +34 -0
  140. package/docs/api/appkit/Interface.ReadSerializerContext.md +21 -0
  141. package/docs/api/appkit/Interface.RunEvalOptions.md +22 -0
  142. package/docs/api/appkit/Interface.RunEvalsOptions.md +45 -1
  143. package/docs/api/appkit/Interface.TestContext.md +67 -8
  144. package/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +53 -0
  145. package/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md +8 -0
  146. package/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +49 -0
  147. package/docs/api/appkit/TypeAlias.DatabaseExports.md +3 -3
  148. package/docs/api/appkit/TypeAlias.EntityHooks.md +25 -0
  149. package/docs/api/appkit/TypeAlias.IDatabaseConfig.md +16 -5
  150. package/docs/api/appkit/TypeAlias.ReadSerializer.md +19 -0
  151. package/docs/api/appkit/TypeAlias.TransactionClient.md +19 -0
  152. package/docs/api/appkit.md +142 -119
  153. package/docs/plugins/database.md +144 -0
  154. package/llms.txt +24 -0
  155. package/package.json +2 -2
  156. package/sbom.cdx.json +1 -1
@@ -3,38 +3,79 @@ import { databaseSetupFailed } from "../../../database/errors.js";
3
3
  //#region src/plugins/database/crud/exposure.ts
4
4
  /** A table name also becomes a URL path segment, so keep it unambiguous. */
5
5
  const ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
6
+ const WRITE_OPERATIONS = [
7
+ "create",
8
+ "update",
9
+ "delete"
10
+ ];
6
11
  /** Refuse names that cannot address exactly one table over HTTP. */
7
12
  function assertRoutable(names) {
8
- const lowercased = /* @__PURE__ */ new Set();
13
+ const lowercased = /* @__PURE__ */ new Map();
9
14
  for (const name of names) {
10
- if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) throw databaseSetupFailed();
11
- lowercased.add(name.toLowerCase());
15
+ if (!ROUTABLE_TABLE.test(name)) throw databaseSetupFailed(`Table ${JSON.stringify(name)} cannot be exposed through api. Route names must start with a letter, contain only letters, digits, "_", or "-", and be at most 64 characters. Rename the table, exclude it with api.tables, or set api: false.`);
16
+ const previous = lowercased.get(name.toLowerCase());
17
+ if (previous !== void 0) throw databaseSetupFailed(`Tables ${JSON.stringify(previous)} and ${JSON.stringify(name)} conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false.`);
18
+ lowercased.set(name.toLowerCase(), name);
12
19
  }
13
20
  }
14
- /**
15
- * Resolve the tables whose generated reads are explicitly turned on. The
16
- * exposure value arrives untyped because a finalized schema widens its table
17
- * names to `string`, so every name is re-checked against the declared schema.
18
- */
19
- function resolveExposedTables(exposure, declared) {
20
- if (exposure === void 0 || exposure === false) return [];
21
- if (exposure === true) {
22
- assertRoutable(declared);
23
- return [...declared];
24
- }
25
- if (typeof exposure !== "object" || exposure === null) throw databaseSetupFailed();
26
- const requested = exposure.tables;
27
- if (!Array.isArray(requested)) throw databaseSetupFailed();
21
+ /** Validate a unique list drawn from an allowlist. */
22
+ function requestedNames(value, allowed, path) {
23
+ if (!Array.isArray(value)) throw databaseSetupFailed(`${path} must be an array of names.`);
28
24
  const names = [];
29
- for (const name of requested) {
30
- if (typeof name !== "string" || !declared.includes(name)) throw databaseSetupFailed();
31
- if (names.includes(name)) throw databaseSetupFailed();
25
+ for (const name of value) {
26
+ if (typeof name !== "string") throw databaseSetupFailed(`${path} must contain only string names.`);
27
+ if (!allowed.includes(name)) throw databaseSetupFailed(`${path} contains unsupported name ${JSON.stringify(name)}. Allowed names: ${allowed.map((entry) => JSON.stringify(entry)).join(", ") || "none"}.`);
28
+ if (names.includes(name)) throw databaseSetupFailed(`${path} contains duplicate name ${JSON.stringify(name)}. List each name once.`);
32
29
  names.push(name);
33
30
  }
34
- assertRoutable(names);
35
31
  return names;
36
32
  }
33
+ /** Reject misspelled restrictions instead of silently enabling all routes. */
34
+ function configuration(value, allowedKeys, path) {
35
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw databaseSetupFailed(`${path} must be true, false, or a configuration object.`);
36
+ const prototype = Object.getPrototypeOf(value);
37
+ if (prototype !== Object.prototype && prototype !== null) throw databaseSetupFailed(`${path} must be a plain configuration object.`);
38
+ for (const key of Object.keys(value)) if (!allowedKeys.includes(key)) throw databaseSetupFailed(`Unknown option ${JSON.stringify(`${path}.${key}`)}. Allowed options: ${allowedKeys.join(", ")}.`);
39
+ return value;
40
+ }
41
+ /** Enable CRUD by default, applying only the restrictions the caller supplies. */
42
+ function resolveCrudExposure(exposure, declared) {
43
+ if (exposure === false) return {
44
+ tables: [],
45
+ writes: /* @__PURE__ */ new Map()
46
+ };
47
+ let tables;
48
+ let writeConfig;
49
+ if (exposure === void 0 || exposure === true) tables = [...declared];
50
+ else {
51
+ const configured = configuration(exposure, ["tables", "writes"], "api");
52
+ tables = configured.tables === void 0 ? [...declared] : requestedNames(configured.tables, declared, "api.tables");
53
+ writeConfig = configured.writes;
54
+ }
55
+ assertRoutable(tables);
56
+ const writes = /* @__PURE__ */ new Map();
57
+ if (writeConfig === false) return {
58
+ tables,
59
+ writes
60
+ };
61
+ let writeTables;
62
+ let operations;
63
+ if (writeConfig === void 0 || writeConfig === true) {
64
+ writeTables = tables;
65
+ operations = [...WRITE_OPERATIONS];
66
+ } else {
67
+ const configured = configuration(writeConfig, ["tables", "operations"], "api.writes");
68
+ writeTables = configured.tables === void 0 ? tables : requestedNames(configured.tables, tables, "api.writes.tables");
69
+ operations = configured.operations === void 0 ? [...WRITE_OPERATIONS] : requestedNames(configured.operations, WRITE_OPERATIONS, "api.writes.operations");
70
+ }
71
+ const enabled = new Set(operations);
72
+ for (const table of writeTables) writes.set(table, enabled);
73
+ return {
74
+ tables,
75
+ writes
76
+ };
77
+ }
37
78
 
38
79
  //#endregion
39
- export { resolveExposedTables };
80
+ export { resolveCrudExposure };
40
81
  //# sourceMappingURL=exposure.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"exposure.js","names":[],"sources":["../../../../src/plugins/database/crud/exposure.ts"],"sourcesContent":["import { databaseSetupFailed } from \"../../../database/errors\";\n\n/** A table name also becomes a URL path segment, so keep it unambiguous. */\nconst ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;\n\n/** Refuse names that cannot address exactly one table over HTTP. */\nfunction assertRoutable(names: readonly string[]): void {\n const lowercased = new Set<string>();\n for (const name of names) {\n // Express matches paths case-insensitively, so near-duplicates would alias.\n if (!ROUTABLE_TABLE.test(name) || lowercased.has(name.toLowerCase())) {\n throw databaseSetupFailed();\n }\n lowercased.add(name.toLowerCase());\n }\n}\n\n/**\n * Resolve the tables whose generated reads are explicitly turned on. The\n * exposure value arrives untyped because a finalized schema widens its table\n * names to `string`, so every name is re-checked against the declared schema.\n */\nexport function resolveExposedTables(\n exposure: unknown,\n declared: readonly string[],\n): string[] {\n if (exposure === undefined || exposure === false) return [];\n if (exposure === true) {\n assertRoutable(declared);\n return [...declared];\n }\n if (typeof exposure !== \"object\" || exposure === null) {\n throw databaseSetupFailed();\n }\n const requested = (exposure as { tables?: unknown }).tables;\n if (!Array.isArray(requested)) throw databaseSetupFailed();\n\n const names: string[] = [];\n for (const name of requested) {\n // Unknown and duplicate names are configuration bugs, not empty routes.\n if (typeof name !== \"string\" || !declared.includes(name)) {\n throw databaseSetupFailed();\n }\n if (names.includes(name)) throw databaseSetupFailed();\n names.push(name);\n }\n assertRoutable(names);\n return names;\n}\n"],"mappings":";;;;AAGA,MAAM,iBAAiB;;AAGvB,SAAS,eAAe,OAAgC;CACtD,MAAM,6BAAa,IAAI,KAAa;AACpC,MAAK,MAAM,QAAQ,OAAO;AAExB,MAAI,CAAC,eAAe,KAAK,KAAK,IAAI,WAAW,IAAI,KAAK,aAAa,CAAC,CAClE,OAAM,qBAAqB;AAE7B,aAAW,IAAI,KAAK,aAAa,CAAC;;;;;;;;AAStC,SAAgB,qBACd,UACA,UACU;AACV,KAAI,aAAa,UAAa,aAAa,MAAO,QAAO,EAAE;AAC3D,KAAI,aAAa,MAAM;AACrB,iBAAe,SAAS;AACxB,SAAO,CAAC,GAAG,SAAS;;AAEtB,KAAI,OAAO,aAAa,YAAY,aAAa,KAC/C,OAAM,qBAAqB;CAE7B,MAAM,YAAa,SAAkC;AACrD,KAAI,CAAC,MAAM,QAAQ,UAAU,CAAE,OAAM,qBAAqB;CAE1D,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,QAAQ,WAAW;AAE5B,MAAI,OAAO,SAAS,YAAY,CAAC,SAAS,SAAS,KAAK,CACtD,OAAM,qBAAqB;AAE7B,MAAI,MAAM,SAAS,KAAK,CAAE,OAAM,qBAAqB;AACrD,QAAM,KAAK,KAAK;;AAElB,gBAAe,MAAM;AACrB,QAAO"}
1
+ {"version":3,"file":"exposure.js","names":[],"sources":["../../../../src/plugins/database/crud/exposure.ts"],"sourcesContent":["import { databaseSetupFailed } from \"../../../database/errors\";\nimport type { DatabaseApiWriteOperation } from \"../types\";\n\n/** A table name also becomes a URL path segment, so keep it unambiguous. */\nconst ROUTABLE_TABLE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;\nconst WRITE_OPERATIONS: readonly DatabaseApiWriteOperation[] = [\n \"create\",\n \"update\",\n \"delete\",\n];\n\n/** Resolved generated routes for one plugin instance. */\nexport interface CrudExposure {\n readonly tables: readonly string[];\n readonly writes: ReadonlyMap<string, ReadonlySet<DatabaseApiWriteOperation>>;\n}\n\n/** Refuse names that cannot address exactly one table over HTTP. */\nfunction assertRoutable(names: readonly string[]): void {\n const lowercased = new Map<string, string>();\n for (const name of names) {\n if (!ROUTABLE_TABLE.test(name)) {\n throw databaseSetupFailed(\n `Table ${JSON.stringify(name)} cannot be exposed through api. Route names must start with a letter, contain only letters, digits, \"_\", or \"-\", and be at most 64 characters. Rename the table, exclude it with api.tables, or set api: false.`,\n );\n }\n // Express matches paths case-insensitively, so near-duplicates would alias.\n const previous = lowercased.get(name.toLowerCase());\n if (previous !== undefined) {\n throw databaseSetupFailed(\n `Tables ${JSON.stringify(previous)} and ${JSON.stringify(name)} conflict in api because routes are case-insensitive. Rename a table, select only one with api.tables, or set api: false.`,\n );\n }\n lowercased.set(name.toLowerCase(), name);\n }\n}\n\n/** Validate a unique list drawn from an allowlist. */\nfunction requestedNames(\n value: unknown,\n allowed: readonly string[],\n path: string,\n): string[] {\n if (!Array.isArray(value)) {\n throw databaseSetupFailed(`${path} must be an array of names.`);\n }\n const names: string[] = [];\n for (const name of value) {\n if (typeof name !== \"string\") {\n throw databaseSetupFailed(`${path} must contain only string names.`);\n }\n if (!allowed.includes(name)) {\n throw databaseSetupFailed(\n `${path} contains unsupported name ${JSON.stringify(name)}. Allowed names: ${allowed.map((entry) => JSON.stringify(entry)).join(\", \") || \"none\"}.`,\n );\n }\n if (names.includes(name)) {\n throw databaseSetupFailed(\n `${path} contains duplicate name ${JSON.stringify(name)}. List each name once.`,\n );\n }\n names.push(name);\n }\n return names;\n}\n\n/** Reject misspelled restrictions instead of silently enabling all routes. */\nfunction configuration(\n value: unknown,\n allowedKeys: readonly string[],\n path: string,\n): Record<string, unknown> {\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n throw databaseSetupFailed(\n `${path} must be true, false, or a configuration object.`,\n );\n }\n const prototype = Object.getPrototypeOf(value);\n if (prototype !== Object.prototype && prototype !== null) {\n throw databaseSetupFailed(`${path} must be a plain configuration object.`);\n }\n for (const key of Object.keys(value)) {\n if (!allowedKeys.includes(key)) {\n throw databaseSetupFailed(\n `Unknown option ${JSON.stringify(`${path}.${key}`)}. Allowed options: ${allowedKeys.join(\", \")}.`,\n );\n }\n }\n return value as Record<string, unknown>;\n}\n\n/** Enable CRUD by default, applying only the restrictions the caller supplies. */\nexport function resolveCrudExposure(\n exposure: unknown,\n declared: readonly string[],\n): CrudExposure {\n if (exposure === false) {\n return { tables: [], writes: new Map() };\n }\n\n let tables: string[];\n let writeConfig: unknown;\n if (exposure === undefined || exposure === true) {\n tables = [...declared];\n } else {\n const configured = configuration(exposure, [\"tables\", \"writes\"], \"api\");\n tables =\n configured.tables === undefined\n ? [...declared]\n : requestedNames(configured.tables, declared, \"api.tables\");\n writeConfig = configured.writes;\n }\n assertRoutable(tables);\n\n const writes = new Map<string, ReadonlySet<DatabaseApiWriteOperation>>();\n if (writeConfig === false) return { tables, writes };\n\n let writeTables: string[];\n let operations: DatabaseApiWriteOperation[];\n if (writeConfig === undefined || writeConfig === true) {\n writeTables = tables;\n operations = [...WRITE_OPERATIONS];\n } else {\n const configured = configuration(\n writeConfig,\n [\"tables\", \"operations\"],\n \"api.writes\",\n );\n writeTables =\n configured.tables === undefined\n ? tables\n : requestedNames(configured.tables, tables, \"api.writes.tables\");\n operations =\n configured.operations === undefined\n ? [...WRITE_OPERATIONS]\n : (requestedNames(\n configured.operations,\n WRITE_OPERATIONS,\n \"api.writes.operations\",\n ) as DatabaseApiWriteOperation[]);\n }\n\n const enabled = new Set(operations);\n for (const table of writeTables) writes.set(table, enabled);\n return { tables, writes };\n}\n"],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ import { DatabasePluginError, invalidDatabaseInput } from "../../../database/errors.js";
2
+ import { boundedJson, isPlainObject } from "./contract.js";
3
+
4
+ //#region src/plugins/database/crud/request.ts
5
+ /**
6
+ * Decode a path identifier against the declared key type. A keyless table gets
7
+ * no `/:id` route, so arriving here without a key is a wiring fault, not input.
8
+ */
9
+ function decodeId(table, raw) {
10
+ const { primaryKey } = table;
11
+ if (!primaryKey) throw new DatabasePluginError("INTERNAL", "read");
12
+ const value = primaryKey.decode(raw);
13
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "bigint") throw invalidDatabaseInput(["id"], "Not a valid identifier");
14
+ return value;
15
+ }
16
+ /** Map one body value onto its column; `undefined` when it does not fit. */
17
+ function decodeWriteValue(column, raw) {
18
+ if (raw === null) return column.meta.notNull ? void 0 : null;
19
+ if (column.meta.kind !== "json") return column.decode(raw);
20
+ try {
21
+ return boundedJson(raw);
22
+ } catch {
23
+ return;
24
+ }
25
+ }
26
+ /** Decode one untrusted body against the columns this operation may set. */
27
+ function decodeBody(table, writable, raw) {
28
+ if (!isPlainObject(raw)) throw invalidDatabaseInput(["body"], "Expected a JSON object");
29
+ const values = {};
30
+ for (const [key, value] of Object.entries(raw)) {
31
+ const column = writable.has(key) ? table.columns.get(key) : void 0;
32
+ if (!column) throw invalidDatabaseInput(table.selectable.has(key) ? [key] : ["body"], "Unknown or read-only field");
33
+ const decoded = decodeWriteValue(column, value);
34
+ if (decoded === void 0) throw invalidDatabaseInput([key], "Does not match the column type");
35
+ values[key] = decoded;
36
+ }
37
+ return values;
38
+ }
39
+ /** Decode the body of `POST /:table`, which may carry a caller-chosen key. */
40
+ function decodeCreateBody(table, raw) {
41
+ return decodeBody(table, table.creatable, raw);
42
+ }
43
+ /** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */
44
+ function decodeUpdateBody(table, raw) {
45
+ return decodeBody(table, table.updatable, raw);
46
+ }
47
+
48
+ //#endregion
49
+ export { decodeCreateBody, decodeId, decodeUpdateBody };
50
+ //# sourceMappingURL=request.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.js","names":[],"sources":["../../../../src/plugins/database/crud/request.ts"],"sourcesContent":["import {\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type { IdValue, Row, ScalarValue } from \"../../../database/runtime\";\nimport type { CompiledColumn, JsonValue } from \"./codecs\";\nimport { boundedJson, type CrudTable, isPlainObject } from \"./contract\";\n\n/**\n * Decode a path identifier against the declared key type. A keyless table gets\n * no `/:id` route, so arriving here without a key is a wiring fault, not input.\n */\nexport function decodeId(table: CrudTable, raw: string): IdValue {\n const { primaryKey } = table;\n if (!primaryKey) throw new DatabasePluginError(\"INTERNAL\", \"read\");\n const value = primaryKey.decode(raw);\n if (\n typeof value !== \"string\" &&\n typeof value !== \"number\" &&\n typeof value !== \"bigint\"\n ) {\n throw invalidDatabaseInput([\"id\"], \"Not a valid identifier\");\n }\n return value;\n}\n\n/** Map one body value onto its column; `undefined` when it does not fit. */\nfunction decodeWriteValue(\n column: CompiledColumn,\n raw: unknown,\n): ScalarValue | JsonValue | undefined {\n if (raw === null) return column.meta.notNull ? undefined : null;\n if (column.meta.kind !== \"json\") return column.decode(raw);\n try {\n // JSON columns accept any JSON the response budget can carry back.\n return boundedJson(raw);\n } catch {\n return undefined;\n }\n}\n\n/** Decode one untrusted body against the columns this operation may set. */\nfunction decodeBody(\n table: CrudTable,\n writable: ReadonlySet<string>,\n raw: unknown,\n): Row {\n if (!isPlainObject(raw)) {\n throw invalidDatabaseInput([\"body\"], \"Expected a JSON object\");\n }\n const values: Row = {};\n for (const [key, value] of Object.entries(raw)) {\n // Private, server-generated, and unknown fields are refused, not dropped.\n const column = writable.has(key) ? table.columns.get(key) : undefined;\n if (!column) {\n // Naming the field echoes caller input, so only a public name is named.\n throw invalidDatabaseInput(\n table.selectable.has(key) ? [key] : [\"body\"],\n \"Unknown or read-only field\",\n );\n }\n const decoded = decodeWriteValue(column, value);\n if (decoded === undefined) {\n throw invalidDatabaseInput([key], \"Does not match the column type\");\n }\n values[key] = decoded;\n }\n return values;\n}\n\n/** Decode the body of `POST /:table`, which may carry a caller-chosen key. */\nexport function decodeCreateBody(table: CrudTable, raw: unknown): Row {\n return decodeBody(table, table.creatable, raw);\n}\n\n/** Decode the body of `PATCH /:table/:id`, which may not carry a key or stamp. */\nexport function decodeUpdateBody(table: CrudTable, raw: unknown): Row {\n return decodeBody(table, table.updatable, raw);\n}\n"],"mappings":";;;;;;;;AAYA,SAAgB,SAAS,OAAkB,KAAsB;CAC/D,MAAM,EAAE,eAAe;AACvB,KAAI,CAAC,WAAY,OAAM,IAAI,oBAAoB,YAAY,OAAO;CAClE,MAAM,QAAQ,WAAW,OAAO,IAAI;AACpC,KACE,OAAO,UAAU,YACjB,OAAO,UAAU,YACjB,OAAO,UAAU,SAEjB,OAAM,qBAAqB,CAAC,KAAK,EAAE,yBAAyB;AAE9D,QAAO;;;AAIT,SAAS,iBACP,QACA,KACqC;AACrC,KAAI,QAAQ,KAAM,QAAO,OAAO,KAAK,UAAU,SAAY;AAC3D,KAAI,OAAO,KAAK,SAAS,OAAQ,QAAO,OAAO,OAAO,IAAI;AAC1D,KAAI;AAEF,SAAO,YAAY,IAAI;SACjB;AACN;;;;AAKJ,SAAS,WACP,OACA,UACA,KACK;AACL,KAAI,CAAC,cAAc,IAAI,CACrB,OAAM,qBAAqB,CAAC,OAAO,EAAE,yBAAyB;CAEhE,MAAM,SAAc,EAAE;AACtB,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,EAAE;EAE9C,MAAM,SAAS,SAAS,IAAI,IAAI,GAAG,MAAM,QAAQ,IAAI,IAAI,GAAG;AAC5D,MAAI,CAAC,OAEH,OAAM,qBACJ,MAAM,WAAW,IAAI,IAAI,GAAG,CAAC,IAAI,GAAG,CAAC,OAAO,EAC5C,6BACD;EAEH,MAAM,UAAU,iBAAiB,QAAQ,MAAM;AAC/C,MAAI,YAAY,OACd,OAAM,qBAAqB,CAAC,IAAI,EAAE,iCAAiC;AAErE,SAAO,OAAO;;AAEhB,QAAO;;;AAIT,SAAgB,iBAAiB,OAAkB,KAAmB;AACpE,QAAO,WAAW,OAAO,MAAM,WAAW,IAAI;;;AAIhD,SAAgB,iBAAiB,OAAkB,KAAmB;AACpE,QAAO,WAAW,OAAO,MAAM,WAAW,IAAI"}
@@ -0,0 +1,77 @@
1
+ import { DatabaseValidationError } from "../../../errors/database-validation.js";
2
+ import "../../../errors/index.js";
3
+ import { DatabasePluginError, classifyDatabaseError } from "../../../database/errors.js";
4
+ import { MAX_RESPONSE_BYTES } from "../defaults.js";
5
+
6
+ //#region src/plugins/database/crud/response.ts
7
+ /** Low-cardinality span outcome for one failed generated route. */
8
+ function routeOutcome(error) {
9
+ const statusCode = error instanceof DatabaseValidationError ? error.statusCode : classifyDatabaseError(error, "read").statusCode;
10
+ if (statusCode === 404) return "not_found";
11
+ return statusCode < 500 ? "rejected" : "failed";
12
+ }
13
+ /**
14
+ * Row data is never cacheable by a shared proxy or a browser: the same URL can
15
+ * answer differently once the underlying table or the caller's rights change.
16
+ */
17
+ function writeJson(res, status, payload) {
18
+ res.status(status);
19
+ res.type("application/json");
20
+ res.setHeader("Cache-Control", "no-store");
21
+ res.send(payload);
22
+ }
23
+ /** Measure the encoded body before sending so no partial response escapes. */
24
+ function sendJson(res, status, body) {
25
+ const payload = JSON.stringify(body);
26
+ if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
27
+ writeJson(res, status, payload);
28
+ }
29
+ /**
30
+ * Encode the list envelope one row at a time, charging each encoded row
31
+ * against the byte budget before the next row is shaped, so one response
32
+ * never costs more than the budget in memory.
33
+ */
34
+ function sendListPage(res, rows, encodeRow, limit, offset) {
35
+ const prefix = "{\"items\":[";
36
+ const suffix = `],"limit":${limit},"offset":${offset}}`;
37
+ let bytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
38
+ const items = [];
39
+ for (const row of rows) {
40
+ const encoded = JSON.stringify(encodeRow(row));
41
+ bytes += Buffer.byteLength(encoded, "utf8") + (items.length > 0 ? 1 : 0);
42
+ if (bytes > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
43
+ items.push(encoded);
44
+ }
45
+ writeJson(res, 200, prefix + items.join(",") + suffix);
46
+ }
47
+ /** A `204` carries no body but owes the same cache promise as one that does. */
48
+ function sendEmpty(res, status) {
49
+ res.status(status);
50
+ res.setHeader("Cache-Control", "no-store");
51
+ res.send();
52
+ }
53
+ /**
54
+ * Convert a failure into its safe category. A hook's deliberate validation
55
+ * error is the one signal that reaches the caller, and only through the issues
56
+ * naming a public column of this table.
57
+ */
58
+ function safeError(table, phase, error) {
59
+ if (!(error instanceof DatabaseValidationError)) return classifyDatabaseError(error, phase);
60
+ return new DatabasePluginError("VALIDATION_FAILED", phase, void 0, error.issues.filter((issue) => issue.path.length > 0 && table.selectable.has(issue.path[0])).map((issue) => ({
61
+ path: [...issue.path],
62
+ message: issue.message
63
+ })));
64
+ }
65
+ /** Answer with the failure's safe category and the field it concerns. */
66
+ function sendError(res, table, phase, error) {
67
+ if (res.headersSent) return;
68
+ const safe = safeError(table, phase, error);
69
+ const body = { error: safe.clientMessage };
70
+ if (safe.details && safe.details.length > 0) body.details = safe.details;
71
+ const payload = JSON.stringify(body);
72
+ writeJson(res, safe.statusCode, Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES ? JSON.stringify({ error: safe.clientMessage }) : payload);
73
+ }
74
+
75
+ //#endregion
76
+ export { routeOutcome, sendEmpty, sendError, sendJson, sendListPage };
77
+ //# sourceMappingURL=response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.js","names":[],"sources":["../../../../src/plugins/database/crud/response.ts"],"sourcesContent":["import type { Response } from \"express\";\n\nimport {\n classifyDatabaseError,\n type DatabaseErrorDetail,\n DatabasePluginError,\n} from \"../../../database/errors\";\nimport type { Row } from \"../../../database/runtime\";\nimport { DatabaseValidationError } from \"../../../errors\";\nimport { MAX_RESPONSE_BYTES } from \"../defaults\";\nimport type { JsonValue } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\n\n/** Low-cardinality span outcome for one failed generated route. */\nexport function routeOutcome(\n error: unknown,\n): \"not_found\" | \"rejected\" | \"failed\" {\n const statusCode =\n error instanceof DatabaseValidationError\n ? error.statusCode\n : classifyDatabaseError(error, \"read\").statusCode;\n if (statusCode === 404) return \"not_found\";\n return statusCode < 500 ? \"rejected\" : \"failed\";\n}\n\n/**\n * Row data is never cacheable by a shared proxy or a browser: the same URL can\n * answer differently once the underlying table or the caller's rights change.\n */\nfunction writeJson(res: Response, status: number, payload: string): void {\n res.status(status);\n res.type(\"application/json\");\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send(payload);\n}\n\n/** Measure the encoded body before sending so no partial response escapes. */\nexport function sendJson(res: Response, status: number, body: JsonValue): void {\n const payload = JSON.stringify(body);\n if (Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n writeJson(res, status, payload);\n}\n\n/**\n * Encode the list envelope one row at a time, charging each encoded row\n * against the byte budget before the next row is shaped, so one response\n * never costs more than the budget in memory.\n */\nexport function sendListPage(\n res: Response,\n rows: readonly Row[],\n encodeRow: (row: Row) => JsonValue,\n limit: number,\n offset: number,\n): void {\n const prefix = '{\"items\":[';\n const suffix = `],\"limit\":${limit},\"offset\":${offset}}`;\n let bytes =\n Buffer.byteLength(prefix, \"utf8\") + Buffer.byteLength(suffix, \"utf8\");\n const items: string[] = [];\n for (const row of rows) {\n const encoded = JSON.stringify(encodeRow(row));\n bytes += Buffer.byteLength(encoded, \"utf8\") + (items.length > 0 ? 1 : 0);\n if (bytes > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n items.push(encoded);\n }\n writeJson(res, 200, prefix + items.join(\",\") + suffix);\n}\n\n/** A `204` carries no body but owes the same cache promise as one that does. */\nexport function sendEmpty(res: Response, status: number): void {\n res.status(status);\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send();\n}\n\n/**\n * Convert a failure into its safe category. A hook's deliberate validation\n * error is the one signal that reaches the caller, and only through the issues\n * naming a public column of this table.\n */\nfunction safeError(\n table: CrudTable,\n phase: \"read\" | \"write\",\n error: unknown,\n): DatabasePluginError {\n if (!(error instanceof DatabaseValidationError)) {\n return classifyDatabaseError(error, phase);\n }\n const details = error.issues\n .filter(\n (issue) => issue.path.length > 0 && table.selectable.has(issue.path[0]),\n )\n .map((issue) => ({ path: [...issue.path], message: issue.message }));\n return new DatabasePluginError(\n \"VALIDATION_FAILED\",\n phase,\n undefined,\n details,\n );\n}\n\n/** Answer with the failure's safe category and the field it concerns. */\nexport function sendError(\n res: Response,\n table: CrudTable,\n phase: \"read\" | \"write\",\n error: unknown,\n): void {\n if (res.headersSent) return;\n const safe = safeError(table, phase, error);\n const body: { error: string; details?: readonly DatabaseErrorDetail[] } = {\n error: safe.clientMessage,\n };\n if (safe.details && safe.details.length > 0) body.details = safe.details;\n const payload = JSON.stringify(body);\n // A failure owes the same byte budget, and its details are what can grow.\n writeJson(\n res,\n safe.statusCode,\n Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES\n ? JSON.stringify({ error: safe.clientMessage })\n : payload,\n );\n}\n"],"mappings":";;;;;;;AAcA,SAAgB,aACd,OACqC;CACrC,MAAM,aACJ,iBAAiB,0BACb,MAAM,aACN,sBAAsB,OAAO,OAAO,CAAC;AAC3C,KAAI,eAAe,IAAK,QAAO;AAC/B,QAAO,aAAa,MAAM,aAAa;;;;;;AAOzC,SAAS,UAAU,KAAe,QAAgB,SAAuB;AACvE,KAAI,OAAO,OAAO;AAClB,KAAI,KAAK,mBAAmB;AAC5B,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,KAAK,QAAQ;;;AAInB,SAAgB,SAAS,KAAe,QAAgB,MAAuB;CAC7E,MAAM,UAAU,KAAK,UAAU,KAAK;AACpC,KAAI,OAAO,WAAW,SAAS,OAAO,GAAG,mBACvC,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,WAAU,KAAK,QAAQ,QAAQ;;;;;;;AAQjC,SAAgB,aACd,KACA,MACA,WACA,OACA,QACM;CACN,MAAM,SAAS;CACf,MAAM,SAAS,aAAa,MAAM,YAAY,OAAO;CACrD,IAAI,QACF,OAAO,WAAW,QAAQ,OAAO,GAAG,OAAO,WAAW,QAAQ,OAAO;CACvE,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,OAAO,MAAM;EACtB,MAAM,UAAU,KAAK,UAAU,UAAU,IAAI,CAAC;AAC9C,WAAS,OAAO,WAAW,SAAS,OAAO,IAAI,MAAM,SAAS,IAAI,IAAI;AACtE,MAAI,QAAQ,mBACV,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,QAAM,KAAK,QAAQ;;AAErB,WAAU,KAAK,KAAK,SAAS,MAAM,KAAK,IAAI,GAAG,OAAO;;;AAIxD,SAAgB,UAAU,KAAe,QAAsB;AAC7D,KAAI,OAAO,OAAO;AAClB,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,MAAM;;;;;;;AAQZ,SAAS,UACP,OACA,OACA,OACqB;AACrB,KAAI,EAAE,iBAAiB,yBACrB,QAAO,sBAAsB,OAAO,MAAM;AAO5C,QAAO,IAAI,oBACT,qBACA,OACA,QARc,MAAM,OACnB,QACE,UAAU,MAAM,KAAK,SAAS,KAAK,MAAM,WAAW,IAAI,MAAM,KAAK,GAAG,CACxE,CACA,KAAK,WAAW;EAAE,MAAM,CAAC,GAAG,MAAM,KAAK;EAAE,SAAS,MAAM;EAAS,EAAE,CAMrE;;;AAIH,SAAgB,UACd,KACA,OACA,OACA,OACM;AACN,KAAI,IAAI,YAAa;CACrB,MAAM,OAAO,UAAU,OAAO,OAAO,MAAM;CAC3C,MAAM,OAAoE,EACxE,OAAO,KAAK,eACb;AACD,KAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,EAAG,MAAK,UAAU,KAAK;CACjE,MAAM,UAAU,KAAK,UAAU,KAAK;AAEpC,WACE,KACA,KAAK,YACL,OAAO,WAAW,SAAS,OAAO,GAAG,qBACjC,KAAK,UAAU,EAAE,OAAO,KAAK,eAAe,CAAC,GAC7C,QACL"}
@@ -1,14 +1,9 @@
1
- import { DatabasePluginError, classifyDatabaseError, invalidDatabaseInput } from "../../../database/errors.js";
2
- import { MAX_RESPONSE_BYTES } from "../defaults.js";
1
+ import { DatabasePluginError, invalidDatabaseInput } from "../../../database/errors.js";
2
+ import { sendEmpty, sendError, sendJson, sendListPage } from "./response.js";
3
3
  import { decodeDetailQuery, decodeListQuery } from "./query.js";
4
+ import { decodeCreateBody, decodeId, decodeUpdateBody } from "./request.js";
4
5
 
5
6
  //#region src/plugins/database/crud/routes.ts
6
- /** Low-cardinality span outcome for one failed generated read. */
7
- function readRouteOutcome(error) {
8
- const { statusCode } = classifyDatabaseError(error, "read");
9
- if (statusCode === 404) return "not_found";
10
- return statusCode < 500 ? "rejected" : "failed";
11
- }
12
7
  /** Express normalizes `req.query`; the decoders need the untouched string. */
13
8
  function rawQuery(req) {
14
9
  const url = req.originalUrl ?? req.url;
@@ -38,46 +33,15 @@ function serializeRow(deps, operation, row) {
38
33
  return deps.table.sanitizeSerializedRow(shaped);
39
34
  }
40
35
  /**
41
- * Row data is never cacheable by a shared proxy or a browser: the same URL can
42
- * answer differently once the underlying table or the caller's rights change.
43
- */
44
- function writeJson(res, status, payload) {
45
- res.status(status);
46
- res.type("application/json");
47
- res.setHeader("Cache-Control", "no-store");
48
- res.send(payload);
49
- }
50
- /** Measure the encoded body before sending so no partial response escapes. */
51
- function sendJson(res, body) {
52
- const payload = JSON.stringify(body);
53
- if (Buffer.byteLength(payload, "utf8") > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
54
- writeJson(res, 200, payload);
55
- }
56
- /**
57
- * Encode the list envelope one row at a time, charging each encoded row
58
- * against the byte budget before the next row is shaped, so one response
59
- * never costs more than the budget in memory.
36
+ * A write takes its whole input from the path and the body. A query string
37
+ * would silently look like a filter, so it is refused rather than ignored.
60
38
  */
61
- function encodeListPayload(deps, rows, limit, offset) {
62
- const prefix = "{\"items\":[";
63
- const suffix = `],"limit":${limit},"offset":${offset}}`;
64
- let bytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
65
- const items = [];
66
- for (const row of rows) {
67
- const encoded = JSON.stringify(serializeRow(deps, "list", row));
68
- bytes += Buffer.byteLength(encoded, "utf8") + (items.length > 0 ? 1 : 0);
69
- if (bytes > MAX_RESPONSE_BYTES) throw new DatabasePluginError("PAYLOAD_TOO_LARGE", "read");
70
- items.push(encoded);
71
- }
72
- return prefix + items.join(",") + suffix;
39
+ function assertNoQuery(req) {
40
+ if (rawQuery(req) !== "") throw invalidDatabaseInput(["query"], "Writes accept no query parameters");
73
41
  }
74
- /** Answer with the failure's safe category and the field it concerns. */
75
- function writeError(res, error) {
76
- if (res.headersSent) return;
77
- const safe = classifyDatabaseError(error, "read");
78
- const body = { error: safe.clientMessage };
79
- if (safe.details && safe.details.length > 0) body.details = safe.details;
80
- writeJson(res, safe.statusCode, JSON.stringify(body));
42
+ /** The body parser only ran if the caller declared JSON. */
43
+ function assertJsonBody(req) {
44
+ if (!req.is("application/json")) throw new DatabasePluginError("UNSUPPORTED_MEDIA_TYPE", "write");
81
45
  }
82
46
  /** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */
83
47
  function createListHandler(deps) {
@@ -91,10 +55,10 @@ function createListHandler(deps) {
91
55
  if (decoded.where) query = query.where(decoded.where);
92
56
  if (decoded.select) query = query.select(decoded.select);
93
57
  if (decoded.include) query = query.include(decoded.include);
94
- writeJson(res, 200, encodeListPayload(deps, await query.toArray(), decoded.limit, decoded.offset));
58
+ sendListPage(res, await query.toArray(), (row) => serializeRow(deps, "list", row), decoded.limit, decoded.offset);
95
59
  });
96
60
  } catch (error) {
97
- writeError(res, error);
61
+ sendError(res, deps.table, "read", error);
98
62
  }
99
63
  };
100
64
  }
@@ -104,21 +68,76 @@ function createDetailHandler(deps) {
104
68
  return async (req, res) => {
105
69
  try {
106
70
  await deps.runRouteSpan("detail", route, async () => {
107
- const id = deps.table.decodeId(req.params.id);
71
+ const id = decodeId(deps.table, req.params.id);
108
72
  const decoded = decodeDetailQuery(deps.table, rawQuery(req));
109
73
  let query = deps.entity();
110
74
  if (decoded.select) query = query.select(decoded.select);
111
75
  if (decoded.include) query = query.include(decoded.include);
112
76
  const row = await query.find(id);
113
77
  if (row === null) throw new DatabasePluginError("NOT_FOUND", "read");
114
- sendJson(res, serializeRow(deps, "detail", row));
78
+ sendJson(res, 200, serializeRow(deps, "detail", row));
79
+ });
80
+ } catch (error) {
81
+ sendError(res, deps.table, "read", error);
82
+ }
83
+ };
84
+ }
85
+ /**
86
+ * `POST /:table` — the created row at `201`. A mutation answers the row the
87
+ * database actually holds, so a read serializer never reshapes it.
88
+ */
89
+ function createCreateHandler(deps) {
90
+ const route = `/${deps.table.name}`;
91
+ return async (req, res) => {
92
+ try {
93
+ await deps.runRouteSpan("create", route, async () => {
94
+ assertNoQuery(req);
95
+ assertJsonBody(req);
96
+ const values = decodeCreateBody(deps.table, req.body);
97
+ const row = await deps.entity().create(values);
98
+ sendJson(res, 201, deps.table.projectPublicRow(row));
99
+ });
100
+ } catch (error) {
101
+ sendError(res, deps.table, "write", error);
102
+ }
103
+ };
104
+ }
105
+ /** `PATCH /:table/:id` — the updated row at `200`, or 404 when it is gone. */
106
+ function createUpdateHandler(deps) {
107
+ const route = `/${deps.table.name}/:id`;
108
+ return async (req, res) => {
109
+ try {
110
+ await deps.runRouteSpan("update", route, async () => {
111
+ assertNoQuery(req);
112
+ assertJsonBody(req);
113
+ const id = decodeId(deps.table, req.params.id);
114
+ const values = decodeUpdateBody(deps.table, req.body);
115
+ const row = await deps.entity().update(id, values);
116
+ if (row === null) throw new DatabasePluginError("NOT_FOUND", "write");
117
+ sendJson(res, 200, deps.table.projectPublicRow(row));
118
+ });
119
+ } catch (error) {
120
+ sendError(res, deps.table, "write", error);
121
+ }
122
+ };
123
+ }
124
+ /** `DELETE /:table/:id` — `204` with no body, or 404 when nothing matched. */
125
+ function createDeleteHandler(deps) {
126
+ const route = `/${deps.table.name}/:id`;
127
+ return async (req, res) => {
128
+ try {
129
+ await deps.runRouteSpan("delete", route, async () => {
130
+ assertNoQuery(req);
131
+ const id = decodeId(deps.table, req.params.id);
132
+ if (!await deps.entity().delete(id)) throw new DatabasePluginError("NOT_FOUND", "write");
133
+ sendEmpty(res, 204);
115
134
  });
116
135
  } catch (error) {
117
- writeError(res, error);
136
+ sendError(res, deps.table, "write", error);
118
137
  }
119
138
  };
120
139
  }
121
140
 
122
141
  //#endregion
123
- export { createDetailHandler, createListHandler, readRouteOutcome };
142
+ export { createCreateHandler, createDeleteHandler, createDetailHandler, createListHandler, createUpdateHandler };
124
143
  //# sourceMappingURL=routes.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.js","names":[],"sources":["../../../../src/plugins/database/crud/routes.ts"],"sourcesContent":["import type { Request, Response } from \"express\";\n\nimport {\n classifyDatabaseError,\n type DatabaseErrorDetail,\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type {\n IdValue,\n IncludeSpec,\n OrderSpec,\n Row,\n WhereClause,\n} from \"../../../database/runtime\";\nimport { MAX_RESPONSE_BYTES } from \"../defaults\";\nimport type { ReadSerializer } from \"../types\";\nimport type { JsonValue } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\nimport { decodeDetailQuery, decodeListQuery } from \"./query\";\n\n/** The `EntityClient` subset a generated read drives. */\nexport interface CrudReadEntity {\n where(where: WhereClause): CrudReadEntity;\n order(order: OrderSpec): CrudReadEntity;\n select(columns: string[]): CrudReadEntity;\n include(include: IncludeSpec): CrudReadEntity;\n limit(limit: number): CrudReadEntity;\n offset(offset: number): CrudReadEntity;\n toArray(): Promise<Row[]>;\n find(id: IdValue): Promise<Row | null>;\n}\n\n/** Everything one table's generated reads need from the plugin instance. */\nexport interface ReadRouteDeps {\n readonly table: CrudTable;\n /** Resolved per request so a draining plugin cannot serve a stale client. */\n entity(): CrudReadEntity;\n readonly serialize?: ReadSerializer;\n runRouteSpan(\n operation: \"list\" | \"detail\",\n route: string,\n run: () => Promise<void>,\n ): Promise<void>;\n}\n\ntype ReadHandler = (req: Request, res: Response) => Promise<void>;\n\n/** Low-cardinality span outcome for one failed generated read. */\nexport function readRouteOutcome(\n error: unknown,\n): \"not_found\" | \"rejected\" | \"failed\" {\n const { statusCode } = classifyDatabaseError(error, \"read\");\n if (statusCode === 404) return \"not_found\";\n return statusCode < 500 ? \"rejected\" : \"failed\";\n}\n\n/** Express normalizes `req.query`; the decoders need the untouched string. */\nfunction rawQuery(req: Request): string {\n const url = req.originalUrl ?? req.url;\n const start = url.indexOf(\"?\");\n return start === -1 ? \"\" : url.slice(start + 1);\n}\n\n/**\n * Append the primary key so equal sort keys cannot reshuffle between pages.\n * A keyless table has no unique tie-breaker to append, so it must order itself\n * and its pages stay stable only while no concurrent write reorders them.\n */\nfunction stableOrder(\n primaryKey: string | undefined,\n order: OrderSpec | undefined,\n): OrderSpec {\n if (primaryKey)\n return { ...order, [primaryKey]: order?.[primaryKey] ?? \"asc\" };\n if (!order) {\n throw invalidDatabaseInput(\n [\"order\"],\n \"A table without a primary key requires an explicit order\",\n );\n }\n return order;\n}\n\nfunction serializeRow(\n deps: ReadRouteDeps,\n operation: \"list\" | \"detail\",\n row: Row,\n): JsonValue {\n const projected = deps.table.projectPublicRow(row);\n if (!deps.serialize) return projected;\n const shaped = deps.serialize(projected as Record<string, unknown>, {\n entity: deps.table.name,\n operation,\n });\n return deps.table.sanitizeSerializedRow(shaped);\n}\n\n/**\n * Row data is never cacheable by a shared proxy or a browser: the same URL can\n * answer differently once the underlying table or the caller's rights change.\n */\nfunction writeJson(res: Response, status: number, payload: string): void {\n res.status(status);\n res.type(\"application/json\");\n res.setHeader(\"Cache-Control\", \"no-store\");\n res.send(payload);\n}\n\n/** Measure the encoded body before sending so no partial response escapes. */\nfunction sendJson(res: Response, body: JsonValue): void {\n const payload = JSON.stringify(body);\n if (Buffer.byteLength(payload, \"utf8\") > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n writeJson(res, 200, payload);\n}\n\n/**\n * Encode the list envelope one row at a time, charging each encoded row\n * against the byte budget before the next row is shaped, so one response\n * never costs more than the budget in memory.\n */\nfunction encodeListPayload(\n deps: ReadRouteDeps,\n rows: Row[],\n limit: number,\n offset: number,\n): string {\n const prefix = '{\"items\":[';\n const suffix = `],\"limit\":${limit},\"offset\":${offset}}`;\n let bytes =\n Buffer.byteLength(prefix, \"utf8\") + Buffer.byteLength(suffix, \"utf8\");\n const items: string[] = [];\n for (const row of rows) {\n const encoded = JSON.stringify(serializeRow(deps, \"list\", row));\n bytes += Buffer.byteLength(encoded, \"utf8\") + (items.length > 0 ? 1 : 0);\n if (bytes > MAX_RESPONSE_BYTES) {\n throw new DatabasePluginError(\"PAYLOAD_TOO_LARGE\", \"read\");\n }\n items.push(encoded);\n }\n return prefix + items.join(\",\") + suffix;\n}\n\n/** Answer with the failure's safe category and the field it concerns. */\nfunction writeError(res: Response, error: unknown): void {\n if (res.headersSent) return;\n const safe = classifyDatabaseError(error, \"read\");\n const body: { error: string; details?: readonly DatabaseErrorDetail[] } = {\n error: safe.clientMessage,\n };\n if (safe.details && safe.details.length > 0) body.details = safe.details;\n writeJson(res, safe.statusCode, JSON.stringify(body));\n}\n\n/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */\nexport function createListHandler(deps: ReadRouteDeps): ReadHandler {\n const primaryKey = deps.table.primaryKey?.meta.columnName;\n const route = `/${deps.table.name}`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"list\", route, async () => {\n const decoded = decodeListQuery(deps.table, rawQuery(req));\n let query = deps\n .entity()\n .order(stableOrder(primaryKey, decoded.order))\n .limit(decoded.limit)\n .offset(decoded.offset);\n if (decoded.where) query = query.where(decoded.where);\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const rows = await query.toArray();\n writeJson(\n res,\n 200,\n encodeListPayload(deps, rows, decoded.limit, decoded.offset),\n );\n });\n } catch (error) {\n writeError(res, error);\n }\n };\n}\n\n/** `GET /:table/:id` — one public row, or 404 when nothing matches. */\nexport function createDetailHandler(deps: ReadRouteDeps): ReadHandler {\n const route = `/${deps.table.name}/:id`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"detail\", route, async () => {\n const id = deps.table.decodeId(req.params.id);\n const decoded = decodeDetailQuery(deps.table, rawQuery(req));\n let query = deps.entity();\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const row = await query.find(id);\n if (row === null) throw new DatabasePluginError(\"NOT_FOUND\", \"read\");\n sendJson(res, serializeRow(deps, \"detail\", row));\n });\n } catch (error) {\n writeError(res, error);\n }\n };\n}\n"],"mappings":";;;;;;AAiDA,SAAgB,iBACd,OACqC;CACrC,MAAM,EAAE,eAAe,sBAAsB,OAAO,OAAO;AAC3D,KAAI,eAAe,IAAK,QAAO;AAC/B,QAAO,aAAa,MAAM,aAAa;;;AAIzC,SAAS,SAAS,KAAsB;CACtC,MAAM,MAAM,IAAI,eAAe,IAAI;CACnC,MAAM,QAAQ,IAAI,QAAQ,IAAI;AAC9B,QAAO,UAAU,KAAK,KAAK,IAAI,MAAM,QAAQ,EAAE;;;;;;;AAQjD,SAAS,YACP,YACA,OACW;AACX,KAAI,WACF,QAAO;EAAE,GAAG;GAAQ,aAAa,QAAQ,eAAe;EAAO;AACjE,KAAI,CAAC,MACH,OAAM,qBACJ,CAAC,QAAQ,EACT,2DACD;AAEH,QAAO;;AAGT,SAAS,aACP,MACA,WACA,KACW;CACX,MAAM,YAAY,KAAK,MAAM,iBAAiB,IAAI;AAClD,KAAI,CAAC,KAAK,UAAW,QAAO;CAC5B,MAAM,SAAS,KAAK,UAAU,WAAsC;EAClE,QAAQ,KAAK,MAAM;EACnB;EACD,CAAC;AACF,QAAO,KAAK,MAAM,sBAAsB,OAAO;;;;;;AAOjD,SAAS,UAAU,KAAe,QAAgB,SAAuB;AACvE,KAAI,OAAO,OAAO;AAClB,KAAI,KAAK,mBAAmB;AAC5B,KAAI,UAAU,iBAAiB,WAAW;AAC1C,KAAI,KAAK,QAAQ;;;AAInB,SAAS,SAAS,KAAe,MAAuB;CACtD,MAAM,UAAU,KAAK,UAAU,KAAK;AACpC,KAAI,OAAO,WAAW,SAAS,OAAO,GAAG,mBACvC,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,WAAU,KAAK,KAAK,QAAQ;;;;;;;AAQ9B,SAAS,kBACP,MACA,MACA,OACA,QACQ;CACR,MAAM,SAAS;CACf,MAAM,SAAS,aAAa,MAAM,YAAY,OAAO;CACrD,IAAI,QACF,OAAO,WAAW,QAAQ,OAAO,GAAG,OAAO,WAAW,QAAQ,OAAO;CACvE,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,OAAO,MAAM;EACtB,MAAM,UAAU,KAAK,UAAU,aAAa,MAAM,QAAQ,IAAI,CAAC;AAC/D,WAAS,OAAO,WAAW,SAAS,OAAO,IAAI,MAAM,SAAS,IAAI,IAAI;AACtE,MAAI,QAAQ,mBACV,OAAM,IAAI,oBAAoB,qBAAqB,OAAO;AAE5D,QAAM,KAAK,QAAQ;;AAErB,QAAO,SAAS,MAAM,KAAK,IAAI,GAAG;;;AAIpC,SAAS,WAAW,KAAe,OAAsB;AACvD,KAAI,IAAI,YAAa;CACrB,MAAM,OAAO,sBAAsB,OAAO,OAAO;CACjD,MAAM,OAAoE,EACxE,OAAO,KAAK,eACb;AACD,KAAI,KAAK,WAAW,KAAK,QAAQ,SAAS,EAAG,MAAK,UAAU,KAAK;AACjE,WAAU,KAAK,KAAK,YAAY,KAAK,UAAU,KAAK,CAAC;;;AAIvD,SAAgB,kBAAkB,MAAkC;CAClE,MAAM,aAAa,KAAK,MAAM,YAAY,KAAK;CAC/C,MAAM,QAAQ,IAAI,KAAK,MAAM;AAE7B,QAAO,OAAO,KAAK,QAAQ;AACzB,MAAI;AACF,SAAM,KAAK,aAAa,QAAQ,OAAO,YAAY;IACjD,MAAM,UAAU,gBAAgB,KAAK,OAAO,SAAS,IAAI,CAAC;IAC1D,IAAI,QAAQ,KACT,QAAQ,CACR,MAAM,YAAY,YAAY,QAAQ,MAAM,CAAC,CAC7C,MAAM,QAAQ,MAAM,CACpB,OAAO,QAAQ,OAAO;AACzB,QAAI,QAAQ,MAAO,SAAQ,MAAM,MAAM,QAAQ,MAAM;AACrD,QAAI,QAAQ,OAAQ,SAAQ,MAAM,OAAO,QAAQ,OAAO;AACxD,QAAI,QAAQ,QAAS,SAAQ,MAAM,QAAQ,QAAQ,QAAQ;AAG3D,cACE,KACA,KACA,kBAAkB,MAJP,MAAM,MAAM,SAAS,EAIF,QAAQ,OAAO,QAAQ,OAAO,CAC7D;KACD;WACK,OAAO;AACd,cAAW,KAAK,MAAM;;;;;AAM5B,SAAgB,oBAAoB,MAAkC;CACpE,MAAM,QAAQ,IAAI,KAAK,MAAM,KAAK;AAElC,QAAO,OAAO,KAAK,QAAQ;AACzB,MAAI;AACF,SAAM,KAAK,aAAa,UAAU,OAAO,YAAY;IACnD,MAAM,KAAK,KAAK,MAAM,SAAS,IAAI,OAAO,GAAG;IAC7C,MAAM,UAAU,kBAAkB,KAAK,OAAO,SAAS,IAAI,CAAC;IAC5D,IAAI,QAAQ,KAAK,QAAQ;AACzB,QAAI,QAAQ,OAAQ,SAAQ,MAAM,OAAO,QAAQ,OAAO;AACxD,QAAI,QAAQ,QAAS,SAAQ,MAAM,QAAQ,QAAQ,QAAQ;IAE3D,MAAM,MAAM,MAAM,MAAM,KAAK,GAAG;AAChC,QAAI,QAAQ,KAAM,OAAM,IAAI,oBAAoB,aAAa,OAAO;AACpE,aAAS,KAAK,aAAa,MAAM,UAAU,IAAI,CAAC;KAChD;WACK,OAAO;AACd,cAAW,KAAK,MAAM"}
1
+ {"version":3,"file":"routes.js","names":[],"sources":["../../../../src/plugins/database/crud/routes.ts"],"sourcesContent":["import type { Request, Response } from \"express\";\n\nimport {\n DatabasePluginError,\n invalidDatabaseInput,\n} from \"../../../database/errors\";\nimport type {\n IdValue,\n IncludeSpec,\n OrderSpec,\n Row,\n WhereClause,\n} from \"../../../database/runtime\";\nimport type { ReadSerializer } from \"../types\";\nimport type { JsonValue } from \"./codecs\";\nimport type { CrudTable } from \"./contract\";\nimport { decodeDetailQuery, decodeListQuery } from \"./query\";\nimport { decodeCreateBody, decodeId, decodeUpdateBody } from \"./request\";\nimport { sendEmpty, sendError, sendJson, sendListPage } from \"./response\";\n\n/** The `EntityClient` subset the generated routes drive. */\nexport interface CrudEntity {\n where(where: WhereClause): CrudEntity;\n order(order: OrderSpec): CrudEntity;\n select(columns: string[]): CrudEntity;\n include(include: IncludeSpec): CrudEntity;\n limit(limit: number): CrudEntity;\n offset(offset: number): CrudEntity;\n toArray(): Promise<Row[]>;\n find(id: IdValue): Promise<Row | null>;\n create(values: Row): Promise<Row>;\n update(id: IdValue, values: Row): Promise<Row | null>;\n delete(id: IdValue): Promise<boolean>;\n}\n\n/** Which generated operation a span and its route belong to. */\nexport type CrudOperation = \"list\" | \"detail\" | \"create\" | \"update\" | \"delete\";\n\n/** Everything one table's generated routes need from the plugin instance. */\nexport interface CrudRouteDeps {\n readonly table: CrudTable;\n /** Resolved per request so a draining plugin cannot serve a stale client. */\n entity(): CrudEntity;\n readonly serialize?: ReadSerializer;\n runRouteSpan(\n operation: CrudOperation,\n route: string,\n run: () => Promise<void>,\n ): Promise<void>;\n}\n\ntype RouteHandler = (req: Request, res: Response) => Promise<void>;\n\n/** Express normalizes `req.query`; the decoders need the untouched string. */\nfunction rawQuery(req: Request): string {\n const url = req.originalUrl ?? req.url;\n const start = url.indexOf(\"?\");\n return start === -1 ? \"\" : url.slice(start + 1);\n}\n\n/**\n * Append the primary key so equal sort keys cannot reshuffle between pages.\n * A keyless table has no unique tie-breaker to append, so it must order itself\n * and its pages stay stable only while no concurrent write reorders them.\n */\nfunction stableOrder(\n primaryKey: string | undefined,\n order: OrderSpec | undefined,\n): OrderSpec {\n if (primaryKey)\n return { ...order, [primaryKey]: order?.[primaryKey] ?? \"asc\" };\n if (!order) {\n throw invalidDatabaseInput(\n [\"order\"],\n \"A table without a primary key requires an explicit order\",\n );\n }\n return order;\n}\n\nfunction serializeRow(\n deps: CrudRouteDeps,\n operation: \"list\" | \"detail\",\n row: Row,\n): JsonValue {\n const projected = deps.table.projectPublicRow(row);\n if (!deps.serialize) return projected;\n const shaped = deps.serialize(projected as Record<string, unknown>, {\n entity: deps.table.name,\n operation,\n });\n return deps.table.sanitizeSerializedRow(shaped);\n}\n\n/**\n * A write takes its whole input from the path and the body. A query string\n * would silently look like a filter, so it is refused rather than ignored.\n */\nfunction assertNoQuery(req: Request): void {\n if (rawQuery(req) !== \"\") {\n throw invalidDatabaseInput([\"query\"], \"Writes accept no query parameters\");\n }\n}\n\n/** The body parser only ran if the caller declared JSON. */\nfunction assertJsonBody(req: Request): void {\n if (!req.is(\"application/json\")) {\n throw new DatabasePluginError(\"UNSUPPORTED_MEDIA_TYPE\", \"write\");\n }\n}\n\n/** `GET /:table` — one bounded page in the `{ items, limit, offset }` envelope. */\nexport function createListHandler(deps: CrudRouteDeps): RouteHandler {\n const primaryKey = deps.table.primaryKey?.meta.columnName;\n const route = `/${deps.table.name}`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"list\", route, async () => {\n const decoded = decodeListQuery(deps.table, rawQuery(req));\n let query = deps\n .entity()\n .order(stableOrder(primaryKey, decoded.order))\n .limit(decoded.limit)\n .offset(decoded.offset);\n if (decoded.where) query = query.where(decoded.where);\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const rows = await query.toArray();\n sendListPage(\n res,\n rows,\n (row) => serializeRow(deps, \"list\", row),\n decoded.limit,\n decoded.offset,\n );\n });\n } catch (error) {\n sendError(res, deps.table, \"read\", error);\n }\n };\n}\n\n/** `GET /:table/:id` — one public row, or 404 when nothing matches. */\nexport function createDetailHandler(deps: CrudRouteDeps): RouteHandler {\n const route = `/${deps.table.name}/:id`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"detail\", route, async () => {\n const id = decodeId(deps.table, req.params.id);\n const decoded = decodeDetailQuery(deps.table, rawQuery(req));\n let query = deps.entity();\n if (decoded.select) query = query.select(decoded.select);\n if (decoded.include) query = query.include(decoded.include);\n\n const row = await query.find(id);\n if (row === null) throw new DatabasePluginError(\"NOT_FOUND\", \"read\");\n sendJson(res, 200, serializeRow(deps, \"detail\", row));\n });\n } catch (error) {\n sendError(res, deps.table, \"read\", error);\n }\n };\n}\n\n/**\n * `POST /:table` — the created row at `201`. A mutation answers the row the\n * database actually holds, so a read serializer never reshapes it.\n */\nexport function createCreateHandler(deps: CrudRouteDeps): RouteHandler {\n const route = `/${deps.table.name}`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"create\", route, async () => {\n assertNoQuery(req);\n assertJsonBody(req);\n const values = decodeCreateBody(deps.table, req.body);\n const row = await deps.entity().create(values);\n sendJson(res, 201, deps.table.projectPublicRow(row));\n });\n } catch (error) {\n sendError(res, deps.table, \"write\", error);\n }\n };\n}\n\n/** `PATCH /:table/:id` — the updated row at `200`, or 404 when it is gone. */\nexport function createUpdateHandler(deps: CrudRouteDeps): RouteHandler {\n const route = `/${deps.table.name}/:id`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"update\", route, async () => {\n assertNoQuery(req);\n assertJsonBody(req);\n const id = decodeId(deps.table, req.params.id);\n const values = decodeUpdateBody(deps.table, req.body);\n const row = await deps.entity().update(id, values);\n if (row === null) throw new DatabasePluginError(\"NOT_FOUND\", \"write\");\n sendJson(res, 200, deps.table.projectPublicRow(row));\n });\n } catch (error) {\n sendError(res, deps.table, \"write\", error);\n }\n };\n}\n\n/** `DELETE /:table/:id` — `204` with no body, or 404 when nothing matched. */\nexport function createDeleteHandler(deps: CrudRouteDeps): RouteHandler {\n const route = `/${deps.table.name}/:id`;\n\n return async (req, res) => {\n try {\n await deps.runRouteSpan(\"delete\", route, async () => {\n assertNoQuery(req);\n const id = decodeId(deps.table, req.params.id);\n const deleted = await deps.entity().delete(id);\n if (!deleted) throw new DatabasePluginError(\"NOT_FOUND\", \"write\");\n sendEmpty(res, 204);\n });\n } catch (error) {\n sendError(res, deps.table, \"write\", error);\n }\n };\n}\n"],"mappings":"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"}
@@ -19,18 +19,20 @@ declare class DatabasePlugin<TSchema extends Schema> extends Plugin<IDatabaseCon
19
19
  private setupPromise;
20
20
  private draining;
21
21
  private shutdownPromise;
22
- private exposedTables;
22
+ private exposure;
23
23
  constructor(config: IDatabaseConfig<TSchema>);
24
24
  /** Build and verify one candidate state before publishing its exports. */
25
25
  setup(): Promise<void>;
26
- /** Register generated reads for explicitly exposed tables only. */
26
+ /** Register generated CRUD, subject to the configured table and write restrictions. */
27
27
  injectRoutes(router: express.Router): void;
28
+ /** Typed hook keys are schema table names, which routing addresses at runtime. */
29
+ private hooks;
28
30
  /** Return the typed database API only while the plugin is active. */
29
31
  exports(): DatabaseExports;
30
32
  /** Stop new work, wait for setup, and close the owned pool exactly once. */
31
33
  shutdown(): Promise<void>;
32
- /** Trace one generated read with allowlisted, low-cardinality attributes. */
33
- private runReadSpan;
34
+ /** Trace one generated route with allowlisted, low-cardinality attributes. */
35
+ private runRouteSpan;
34
36
  }
35
37
  /** Create a typed database plugin registration for a finalized schema. */
36
38
  declare function database<TSchema extends Schema>(config: IDatabaseConfig<TSchema>): {
@@ -1 +1 @@
1
- {"version":3,"file":"database.d.ts","names":[],"sources":["../../../src/plugins/database/database.ts"],"mappings":";;;;;;;;;;;;;cAyBa,cAAA,iBAA+B,MAAA,UAAgB,MAAA,CAC1D,eAAA,CAAgB,OAAA;;SAGT,QAAA,EAAuB,cAAA;EAAA,UACZ,MAAA,EAAQ,eAAA,CAAgB,OAAA;EAAA,QAClC,KAAA;EAAA,QACA,YAAA;EAAA,QACA,QAAA;EAAA,QACA,eAAA;EAAA,QACA,aAAA;cAEI,MAAA,EAAQ,eAAA,CAAgB,OAAA;EARN;EAkBxB,KAAA,CAAA,GAAS,OAAA;EAjBW;EA2C1B,YAAA,CAAa,MAAA,EAAQ,OAAA,CAAQ,MAAA;EApCT;EA+EpB,OAAA,CAAA,GAOO,eAAA;EAlDc;EAsDf,QAAA,CAAA,GAAY,OAAA;EAAA;EAAA,QAoBV,WAAA;AAAA;;iBA+BM,QAAA,iBAAyB,MAAA,CAAA,CACvC,MAAA,EAAQ,eAAA,CAAgB,OAAA;UAGe,iBAAA,CACnC,gBAAA,EACA,cAAA,CAAe,OAAA"}
1
+ {"version":3,"file":"database.d.ts","names":[],"sources":["../../../src/plugins/database/database.ts"],"mappings":";;;;;;;;;;;;;cA6Ba,cAAA,iBAA+B,MAAA,UAAgB,MAAA,CAC1D,eAAA,CAAgB,OAAA;;SAGT,QAAA,EAAuB,cAAA;EAAA,UACZ,MAAA,EAAQ,eAAA,CAAgB,OAAA;EAAA,QAClC,KAAA;EAAA,QACA,YAAA;EAAA,QACA,QAAA;EAAA,QACA,eAAA;EAAA,QACA,QAAA;cAEI,MAAA,EAAQ,eAAA,CAAgB,OAAA;EARN;EAwBxB,KAAA,CAAA,GAAS,OAAA;EAvBW;EA0D1B,YAAA,CAAa,MAAA,EAAQ,OAAA,CAAQ,MAAA;EAnDT;EAAA,QAqHZ,KAAA;EAlEa;EAuErB,OAAA,CAAA,GAOO,eAAA;EAIW;EAAZ,QAAA,CAAA,GAAY,OAAA;EAjJ8C;EAAA,QAqKxD,YAAA;AAAA;;iBA+BM,QAAA,iBAAyB,MAAA,CAAA,CACvC,MAAA,EAAQ,eAAA,CAAgB,OAAA;UAGe,iBAAA,CACnC,gBAAA,EACA,cAAA,CAAe,OAAA"}