@databricks/appkit 0.71.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +63 -0
- package/NOTICE.md +3 -2
- package/dist/appkit/package.js +1 -1
- package/dist/beta.d.ts +18 -3
- package/dist/beta.js +14 -1
- package/dist/cli/commands/agent/eval.js +120 -0
- package/dist/cli/commands/agent/eval.js.map +1 -0
- package/dist/cli/commands/agent/index.js +18 -0
- package/dist/cli/commands/agent/index.js.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/connectors/index.js +2 -0
- package/dist/connectors/mlflow/auth.d.ts +28 -0
- package/dist/connectors/mlflow/auth.d.ts.map +1 -0
- package/dist/connectors/mlflow/auth.js +70 -0
- package/dist/connectors/mlflow/auth.js.map +1 -0
- package/dist/connectors/mlflow/client.d.ts +51 -0
- package/dist/connectors/mlflow/client.d.ts.map +1 -0
- package/dist/connectors/mlflow/client.js +93 -0
- package/dist/connectors/mlflow/client.js.map +1 -0
- package/dist/connectors/mlflow/index.d.ts +2 -0
- package/dist/database/errors.js +15 -5
- package/dist/database/errors.js.map +1 -1
- package/dist/database/runtime/data-path.d.ts +7 -0
- package/dist/database/runtime/data-path.d.ts.map +1 -0
- package/dist/database/runtime/data-path.js.map +1 -1
- package/dist/database/runtime/engine/drizzle-data-path.js +7 -5
- package/dist/database/runtime/engine/drizzle-data-path.js.map +1 -1
- package/dist/database/schema-builder/define-schema.d.ts +1 -1
- package/dist/database/schema-builder/define-schema.js +1 -1
- package/dist/database/schema-builder/define-schema.js.map +1 -1
- package/dist/errors/database-validation.d.ts +23 -0
- package/dist/errors/database-validation.d.ts.map +1 -0
- package/dist/errors/database-validation.js +24 -0
- package/dist/errors/database-validation.js.map +1 -0
- package/dist/errors/index.js +1 -0
- package/dist/evals/dataset.d.ts +36 -0
- package/dist/evals/dataset.d.ts.map +1 -0
- package/dist/evals/dataset.js +36 -0
- package/dist/evals/dataset.js.map +1 -0
- package/dist/evals/define-eval.d.ts +26 -0
- package/dist/evals/define-eval.d.ts.map +1 -0
- package/dist/evals/define-eval.js +28 -0
- package/dist/evals/define-eval.js.map +1 -0
- package/dist/evals/discover.d.ts +20 -0
- package/dist/evals/discover.d.ts.map +1 -0
- package/dist/evals/discover.js +49 -0
- package/dist/evals/discover.js.map +1 -0
- package/dist/evals/http-driver.d.ts +33 -0
- package/dist/evals/http-driver.d.ts.map +1 -0
- package/dist/evals/http-driver.js +123 -0
- package/dist/evals/http-driver.js.map +1 -0
- package/dist/evals/index.d.ts +14 -0
- package/dist/evals/index.js +14 -0
- package/dist/evals/judge.d.ts +27 -0
- package/dist/evals/judge.d.ts.map +1 -0
- package/dist/evals/judge.js +77 -0
- package/dist/evals/judge.js.map +1 -0
- package/dist/evals/matchers.d.ts +12 -0
- package/dist/evals/matchers.d.ts.map +1 -0
- package/dist/evals/matchers.js +26 -0
- package/dist/evals/matchers.js.map +1 -0
- package/dist/evals/mlflow-report.d.ts +37 -0
- package/dist/evals/mlflow-report.d.ts.map +1 -0
- package/dist/evals/mlflow-report.js +161 -0
- package/dist/evals/mlflow-report.js.map +1 -0
- package/dist/evals/mlflow-run.d.ts +13 -0
- package/dist/evals/mlflow-run.d.ts.map +1 -0
- package/dist/evals/mlflow-run.js +101 -0
- package/dist/evals/mlflow-run.js.map +1 -0
- package/dist/evals/pool.js +24 -0
- package/dist/evals/pool.js.map +1 -0
- package/dist/evals/report.d.ts +25 -0
- package/dist/evals/report.d.ts.map +1 -0
- package/dist/evals/report.js +57 -0
- package/dist/evals/report.js.map +1 -0
- package/dist/evals/run-eval.d.ts +23 -0
- package/dist/evals/run-eval.d.ts.map +1 -0
- package/dist/evals/run-eval.js +152 -0
- package/dist/evals/run-eval.js.map +1 -0
- package/dist/evals/run-evals.d.ts +94 -0
- package/dist/evals/run-evals.d.ts.map +1 -0
- package/dist/evals/run-evals.js +257 -0
- package/dist/evals/run-evals.js.map +1 -0
- package/dist/evals/types.d.ts +163 -0
- package/dist/evals/types.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +1 -1
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugins/agents/agents.js +1 -1
- package/dist/plugins/database/crud/contract.js +17 -8
- package/dist/plugins/database/crud/contract.js.map +1 -1
- package/dist/plugins/database/crud/exposure.js +63 -22
- package/dist/plugins/database/crud/exposure.js.map +1 -1
- package/dist/plugins/database/crud/request.js +50 -0
- package/dist/plugins/database/crud/request.js.map +1 -0
- package/dist/plugins/database/crud/response.js +77 -0
- package/dist/plugins/database/crud/response.js.map +1 -0
- package/dist/plugins/database/crud/routes.js +71 -52
- package/dist/plugins/database/crud/routes.js.map +1 -1
- package/dist/plugins/database/database.d.ts +6 -4
- package/dist/plugins/database/database.d.ts.map +1 -1
- package/dist/plugins/database/database.js +46 -16
- package/dist/plugins/database/database.js.map +1 -1
- package/dist/plugins/database/defaults.js +5 -1
- package/dist/plugins/database/defaults.js.map +1 -1
- package/dist/plugins/database/entity-client.js +143 -10
- package/dist/plugins/database/entity-client.js.map +1 -1
- package/dist/plugins/database/entity-types.d.ts +1 -1
- package/dist/plugins/database/hooks.d.ts +38 -0
- package/dist/plugins/database/hooks.d.ts.map +1 -0
- package/dist/plugins/database/index.d.ts +3 -2
- package/dist/plugins/database/lifecycle.js +67 -28
- package/dist/plugins/database/lifecycle.js.map +1 -1
- package/dist/plugins/database/scope.js +58 -0
- package/dist/plugins/database/scope.js.map +1 -0
- package/dist/plugins/database/types.d.ts +40 -12
- package/dist/plugins/database/types.d.ts.map +1 -1
- package/docs/api/appkit/Class.AppKitError.md +1 -0
- package/docs/api/appkit/Class.DatabaseValidationError.md +191 -0
- package/docs/api/appkit/Class.MlflowClient.md +103 -0
- package/docs/api/appkit/Function.buildAssessments.md +16 -0
- package/docs/api/appkit/Function.configureJudge.md +18 -0
- package/docs/api/appkit/Function.createHttpDriver.md +18 -0
- package/docs/api/appkit/Function.defineEval.md +35 -0
- package/docs/api/appkit/Function.defineSchema.md +1 -1
- package/docs/api/appkit/Function.discoverEvalFiles.md +18 -0
- package/docs/api/appkit/Function.equals.md +18 -0
- package/docs/api/appkit/Function.evalGlyph.md +18 -0
- package/docs/api/appkit/Function.formatEvalDetail.md +18 -0
- package/docs/api/appkit/Function.formatEvalHeadline.md +18 -0
- package/docs/api/appkit/Function.formatEvalResults.md +18 -0
- package/docs/api/appkit/Function.formatSummaryLine.md +18 -0
- package/docs/api/appkit/Function.includes.md +18 -0
- package/docs/api/appkit/Function.isJudgeConfigured.md +10 -0
- package/docs/api/appkit/Function.matches.md +18 -0
- package/docs/api/appkit/Function.normalizeHost.md +18 -0
- package/docs/api/appkit/Function.readEvalDataset.md +21 -0
- package/docs/api/appkit/Function.reportToMlflow.md +23 -0
- package/docs/api/appkit/Function.resolveDatabricksAuth.md +16 -0
- package/docs/api/appkit/Function.resolveWorkspaceClient.md +18 -0
- package/docs/api/appkit/Function.runEval.md +19 -0
- package/docs/api/appkit/Function.runEvalsInDir.md +18 -0
- package/docs/api/appkit/Function.summarize.md +16 -0
- package/docs/api/appkit/Interface.AssertionHandle.md +54 -0
- package/docs/api/appkit/Interface.AssertionResult.md +48 -0
- package/docs/api/appkit/Interface.Assessment.md +83 -0
- package/docs/api/appkit/Interface.CustomJudgeSpec.md +30 -0
- package/docs/api/appkit/Interface.DatabaseValidationIssue.md +21 -0
- package/docs/api/appkit/Interface.DatabricksAuth.md +21 -0
- package/docs/api/appkit/Interface.DatasetRow.md +21 -0
- package/docs/api/appkit/Interface.DiscoveredEval.md +36 -0
- package/docs/api/appkit/Interface.DriveResult.md +58 -0
- package/docs/api/appkit/Interface.EntityMutationHooks.md +173 -0
- package/docs/api/appkit/Interface.EvalDefinition.md +74 -0
- package/docs/api/appkit/Interface.EvalDriver.md +37 -0
- package/docs/api/appkit/Interface.EvalResult.md +83 -0
- package/docs/api/appkit/Interface.EvalRunSummary.md +46 -0
- package/docs/api/appkit/Interface.EvalSummary.md +48 -0
- package/docs/api/appkit/Interface.HookApp.md +12 -0
- package/docs/api/appkit/Interface.HookContext.md +21 -0
- package/docs/api/appkit/Interface.HttpDriverOptions.md +67 -0
- package/docs/api/appkit/Interface.JudgeConfig.md +34 -0
- package/docs/api/appkit/Interface.JudgeScore.md +21 -0
- package/docs/api/appkit/Interface.MatchResult.md +34 -0
- package/docs/api/appkit/Interface.PostResult.md +30 -0
- package/docs/api/appkit/Interface.ReadEvalDatasetOptions.md +34 -0
- package/docs/api/appkit/Interface.ReadSerializerContext.md +21 -0
- package/docs/api/appkit/Interface.ReportOutcome.md +53 -0
- package/docs/api/appkit/Interface.ResolveDatabricksAuthOptions.md +34 -0
- package/docs/api/appkit/Interface.RunEvalOptions.md +45 -0
- package/docs/api/appkit/Interface.RunEvalsOptions.md +214 -0
- package/docs/api/appkit/Interface.TestContext.md +245 -0
- package/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +53 -0
- package/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md +8 -0
- package/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +49 -0
- package/docs/api/appkit/TypeAlias.DatabaseExports.md +3 -3
- package/docs/api/appkit/TypeAlias.EntityHooks.md +25 -0
- package/docs/api/appkit/TypeAlias.EvalProgress.md +26 -0
- package/docs/api/appkit/TypeAlias.IDatabaseConfig.md +16 -5
- package/docs/api/appkit/TypeAlias.Matcher.md +18 -0
- package/docs/api/appkit/TypeAlias.ReadSerializer.md +19 -0
- package/docs/api/appkit/TypeAlias.Severity.md +8 -0
- package/docs/api/appkit/TypeAlias.TransactionClient.md +19 -0
- package/docs/api/appkit.md +157 -95
- package/docs/plugins/database.md +144 -0
- package/llms.txt +63 -0
- package/package.json +3 -2
- package/sbom.cdx.json +1 -1
|
@@ -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
|
|
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":";;;;AAIA,MAAM,iBAAiB;AACvB,MAAM,mBAAyD;CAC7D;CACA;CACA;CACD;;AASD,SAAS,eAAe,OAAgC;CACtD,MAAM,6BAAa,IAAI,KAAqB;AAC5C,MAAK,MAAM,QAAQ,OAAO;AACxB,MAAI,CAAC,eAAe,KAAK,KAAK,CAC5B,OAAM,oBACJ,SAAS,KAAK,UAAU,KAAK,CAAC,iNAC/B;EAGH,MAAM,WAAW,WAAW,IAAI,KAAK,aAAa,CAAC;AACnD,MAAI,aAAa,OACf,OAAM,oBACJ,UAAU,KAAK,UAAU,SAAS,CAAC,OAAO,KAAK,UAAU,KAAK,CAAC,2HAChE;AAEH,aAAW,IAAI,KAAK,aAAa,EAAE,KAAK;;;;AAK5C,SAAS,eACP,OACA,SACA,MACU;AACV,KAAI,CAAC,MAAM,QAAQ,MAAM,CACvB,OAAM,oBAAoB,GAAG,KAAK,6BAA6B;CAEjE,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,QAAQ,OAAO;AACxB,MAAI,OAAO,SAAS,SAClB,OAAM,oBAAoB,GAAG,KAAK,kCAAkC;AAEtE,MAAI,CAAC,QAAQ,SAAS,KAAK,CACzB,OAAM,oBACJ,GAAG,KAAK,6BAA6B,KAAK,UAAU,KAAK,CAAC,mBAAmB,QAAQ,KAAK,UAAU,KAAK,UAAU,MAAM,CAAC,CAAC,KAAK,KAAK,IAAI,OAAO,GACjJ;AAEH,MAAI,MAAM,SAAS,KAAK,CACtB,OAAM,oBACJ,GAAG,KAAK,2BAA2B,KAAK,UAAU,KAAK,CAAC,wBACzD;AAEH,QAAM,KAAK,KAAK;;AAElB,QAAO;;;AAIT,SAAS,cACP,OACA,aACA,MACyB;AACzB,KAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,MAAM,QAAQ,MAAM,CACrE,OAAM,oBACJ,GAAG,KAAK,kDACT;CAEH,MAAM,YAAY,OAAO,eAAe,MAAM;AAC9C,KAAI,cAAc,OAAO,aAAa,cAAc,KAClD,OAAM,oBAAoB,GAAG,KAAK,wCAAwC;AAE5E,MAAK,MAAM,OAAO,OAAO,KAAK,MAAM,CAClC,KAAI,CAAC,YAAY,SAAS,IAAI,CAC5B,OAAM,oBACJ,kBAAkB,KAAK,UAAU,GAAG,KAAK,GAAG,MAAM,CAAC,qBAAqB,YAAY,KAAK,KAAK,CAAC,GAChG;AAGL,QAAO;;;AAIT,SAAgB,oBACd,UACA,UACc;AACd,KAAI,aAAa,MACf,QAAO;EAAE,QAAQ,EAAE;EAAE,wBAAQ,IAAI,KAAK;EAAE;CAG1C,IAAI;CACJ,IAAI;AACJ,KAAI,aAAa,UAAa,aAAa,KACzC,UAAS,CAAC,GAAG,SAAS;MACjB;EACL,MAAM,aAAa,cAAc,UAAU,CAAC,UAAU,SAAS,EAAE,MAAM;AACvE,WACE,WAAW,WAAW,SAClB,CAAC,GAAG,SAAS,GACb,eAAe,WAAW,QAAQ,UAAU,aAAa;AAC/D,gBAAc,WAAW;;AAE3B,gBAAe,OAAO;CAEtB,MAAM,yBAAS,IAAI,KAAqD;AACxE,KAAI,gBAAgB,MAAO,QAAO;EAAE;EAAQ;EAAQ;CAEpD,IAAI;CACJ,IAAI;AACJ,KAAI,gBAAgB,UAAa,gBAAgB,MAAM;AACrD,gBAAc;AACd,eAAa,CAAC,GAAG,iBAAiB;QAC7B;EACL,MAAM,aAAa,cACjB,aACA,CAAC,UAAU,aAAa,EACxB,aACD;AACD,gBACE,WAAW,WAAW,SAClB,SACA,eAAe,WAAW,QAAQ,QAAQ,oBAAoB;AACpE,eACE,WAAW,eAAe,SACtB,CAAC,GAAG,iBAAiB,GACpB,eACC,WAAW,YACX,kBACA,wBACD;;CAGT,MAAM,UAAU,IAAI,IAAI,WAAW;AACnC,MAAK,MAAM,SAAS,YAAa,QAAO,IAAI,OAAO,QAAQ;AAC3D,QAAO;EAAE;EAAQ;EAAQ"}
|
|
@@ -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,
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
62
|
-
|
|
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
|
-
/**
|
|
75
|
-
function
|
|
76
|
-
if (
|
|
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
|
-
|
|
58
|
+
sendListPage(res, await query.toArray(), (row) => serializeRow(deps, "list", row), decoded.limit, decoded.offset);
|
|
95
59
|
});
|
|
96
60
|
} catch (error) {
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
136
|
+
sendError(res, deps.table, "write", error);
|
|
118
137
|
}
|
|
119
138
|
};
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
//#endregion
|
|
123
|
-
export { createDetailHandler, createListHandler,
|
|
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":"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"}
|
|
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
|
|
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
|
|
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
|
|
33
|
-
private
|
|
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":";;;;;;;;;;;;;
|
|
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"}
|
|
@@ -2,8 +2,9 @@ import { Plugin } from "../../plugin/plugin.js";
|
|
|
2
2
|
import "../../plugin/index.js";
|
|
3
3
|
import { DatabasePluginError, databaseSetupFailed } from "../../database/errors.js";
|
|
4
4
|
import { compileCrudTables } from "./crud/contract.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { resolveCrudExposure } from "./crud/exposure.js";
|
|
6
|
+
import { routeOutcome } from "./crud/response.js";
|
|
7
|
+
import { createCreateHandler, createDeleteHandler, createDetailHandler, createListHandler, createUpdateHandler } from "./crud/routes.js";
|
|
7
8
|
import { createDatabaseState } from "./lifecycle.js";
|
|
8
9
|
import manifest_default from "./manifest.js";
|
|
9
10
|
|
|
@@ -16,12 +17,16 @@ var DatabasePlugin = class extends Plugin {
|
|
|
16
17
|
setupPromise = null;
|
|
17
18
|
draining = false;
|
|
18
19
|
shutdownPromise = null;
|
|
19
|
-
|
|
20
|
+
exposure = {
|
|
21
|
+
tables: [],
|
|
22
|
+
writes: /* @__PURE__ */ new Map()
|
|
23
|
+
};
|
|
20
24
|
constructor(config) {
|
|
21
25
|
super({ schema: config.schema });
|
|
26
|
+
if ("crudRoutes" in config) throw databaseSetupFailed("\"crudRoutes\" was renamed to \"api\". Use api: false to disable generated routes or api: { writes: false } for reads only.");
|
|
22
27
|
this.config = {
|
|
23
28
|
schema: config.schema,
|
|
24
|
-
|
|
29
|
+
api: config.api,
|
|
25
30
|
hooks: config.hooks
|
|
26
31
|
};
|
|
27
32
|
}
|
|
@@ -29,8 +34,9 @@ var DatabasePlugin = class extends Plugin {
|
|
|
29
34
|
async setup() {
|
|
30
35
|
if (this.draining || this.state) throw databaseSetupFailed();
|
|
31
36
|
if (!this.setupPromise) this.setupPromise = (async () => {
|
|
32
|
-
this.
|
|
33
|
-
const
|
|
37
|
+
this.exposure = resolveCrudExposure(this.config.api, Object.keys(this.config.schema.$tables));
|
|
38
|
+
for (const name of Object.keys(this.hooks() ?? {})) if (!Object.hasOwn(this.config.schema.$tables, name)) throw databaseSetupFailed(`hooks names undeclared table ${JSON.stringify(name)}. Use a table declared in schema.`);
|
|
39
|
+
const candidate = await createDatabaseState(this.config.schema, (operation, options) => this.execute(operation, options), this.hooks());
|
|
34
40
|
if (this.draining) {
|
|
35
41
|
candidate.deactivate();
|
|
36
42
|
await candidate.pool.end().catch(() => void 0);
|
|
@@ -40,18 +46,18 @@ var DatabasePlugin = class extends Plugin {
|
|
|
40
46
|
})();
|
|
41
47
|
return this.setupPromise;
|
|
42
48
|
}
|
|
43
|
-
/** Register generated
|
|
49
|
+
/** Register generated CRUD, subject to the configured table and write restrictions. */
|
|
44
50
|
injectRoutes(router) {
|
|
45
|
-
if (this.
|
|
46
|
-
const tables = compileCrudTables(Object.fromEntries(this.
|
|
47
|
-
const
|
|
51
|
+
if (this.exposure.tables.length === 0) return;
|
|
52
|
+
const tables = compileCrudTables(Object.fromEntries(this.exposure.tables.map((name) => [name, this.config.schema.$tables[name]])));
|
|
53
|
+
const hooks = this.hooks();
|
|
48
54
|
const entities = () => this.exports();
|
|
49
55
|
for (const table of tables.values()) {
|
|
50
56
|
const deps = {
|
|
51
57
|
table,
|
|
52
58
|
entity: () => entities()[table.name],
|
|
53
|
-
serialize:
|
|
54
|
-
runRouteSpan: (operation, route, run) => this.
|
|
59
|
+
serialize: hooks?.[table.name]?.serialize,
|
|
60
|
+
runRouteSpan: (operation, route, run) => this.runRouteSpan(table.name, operation, route, run)
|
|
55
61
|
};
|
|
56
62
|
this.route(router, {
|
|
57
63
|
name: `${table.name}.list`,
|
|
@@ -59,14 +65,38 @@ var DatabasePlugin = class extends Plugin {
|
|
|
59
65
|
path: `/${table.name}`,
|
|
60
66
|
handler: createListHandler(deps)
|
|
61
67
|
});
|
|
62
|
-
|
|
68
|
+
const writes = this.exposure.writes.get(table.name);
|
|
69
|
+
if (writes?.has("create")) this.route(router, {
|
|
70
|
+
name: `${table.name}.create`,
|
|
71
|
+
method: "post",
|
|
72
|
+
path: `/${table.name}`,
|
|
73
|
+
handler: createCreateHandler(deps)
|
|
74
|
+
});
|
|
75
|
+
if (!table.primaryKey) continue;
|
|
76
|
+
this.route(router, {
|
|
63
77
|
name: `${table.name}.detail`,
|
|
64
78
|
method: "get",
|
|
65
79
|
path: `/${table.name}/:id`,
|
|
66
80
|
handler: createDetailHandler(deps)
|
|
67
81
|
});
|
|
82
|
+
if (writes?.has("update")) this.route(router, {
|
|
83
|
+
name: `${table.name}.update`,
|
|
84
|
+
method: "patch",
|
|
85
|
+
path: `/${table.name}/:id`,
|
|
86
|
+
handler: createUpdateHandler(deps)
|
|
87
|
+
});
|
|
88
|
+
if (writes?.has("delete")) this.route(router, {
|
|
89
|
+
name: `${table.name}.delete`,
|
|
90
|
+
method: "delete",
|
|
91
|
+
path: `/${table.name}/:id`,
|
|
92
|
+
handler: createDeleteHandler(deps)
|
|
93
|
+
});
|
|
68
94
|
}
|
|
69
95
|
}
|
|
96
|
+
/** Typed hook keys are schema table names, which routing addresses at runtime. */
|
|
97
|
+
hooks() {
|
|
98
|
+
return this.config.hooks;
|
|
99
|
+
}
|
|
70
100
|
/** Return the typed database API only while the plugin is active. */
|
|
71
101
|
exports() {
|
|
72
102
|
if (!this.state || this.draining) throw new DatabasePluginError("INTERNAL", "read");
|
|
@@ -89,8 +119,8 @@ var DatabasePlugin = class extends Plugin {
|
|
|
89
119
|
})();
|
|
90
120
|
return this.shutdownPromise;
|
|
91
121
|
}
|
|
92
|
-
/** Trace one generated
|
|
93
|
-
|
|
122
|
+
/** Trace one generated route with allowlisted, low-cardinality attributes. */
|
|
123
|
+
runRouteSpan(table, operation, route, run) {
|
|
94
124
|
return this.telemetry.startActiveSpan("database.crud.route", { attributes: {
|
|
95
125
|
table_name: table,
|
|
96
126
|
operation,
|
|
@@ -100,7 +130,7 @@ var DatabasePlugin = class extends Plugin {
|
|
|
100
130
|
await run();
|
|
101
131
|
span.setAttribute("outcome", "success");
|
|
102
132
|
} catch (error) {
|
|
103
|
-
span.setAttribute("outcome",
|
|
133
|
+
span.setAttribute("outcome", routeOutcome(error));
|
|
104
134
|
throw error;
|
|
105
135
|
} finally {
|
|
106
136
|
span.end();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"database.js","names":["manifest"],"sources":["../../../src/plugins/database/database.ts"],"sourcesContent":["import type express from \"express\";\nimport type { BasePluginConfig, PluginConstructor } from \"shared\";\n\nimport {\n DatabasePluginError,\n databaseSetupFailed,\n} from \"../../database/errors\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { Plugin } from \"../../plugin\";\nimport type { PluginManifest } from \"../../registry\";\nimport { compileCrudTables } from \"./crud/contract\";\nimport { resolveExposedTables } from \"./crud/exposure\";\nimport {\n type CrudReadEntity,\n createDetailHandler,\n createListHandler,\n type ReadRouteDeps,\n readRouteOutcome,\n} from \"./crud/routes\";\nimport type { DatabaseExports } from \"./entity-types\";\nimport { createDatabaseState, type DatabaseState } from \"./lifecycle\";\nimport manifest from \"./manifest.json\";\nimport type { IDatabaseConfig, ReadSerializer } from \"./types\";\n\n/** Schema-driven database plugin */\nexport class DatabasePlugin<TSchema extends Schema> extends Plugin<\n IDatabaseConfig<TSchema>\n> {\n /** Plugin metadata and required PostgreSQL resource. */\n static manifest = manifest as PluginManifest<\"database\">;\n declare protected config: IDatabaseConfig<TSchema>;\n private state: DatabaseState | null = null;\n private setupPromise: Promise<void> | null = null;\n private draining = false;\n private shutdownPromise: Promise<void> | null = null;\n private exposedTables: string[] = [];\n\n constructor(config: IDatabaseConfig<TSchema>) {\n super({ schema: config.schema });\n this.config = {\n schema: config.schema,\n crudRoutes: config.crudRoutes,\n hooks: config.hooks,\n };\n }\n\n /** Build and verify one candidate state before publishing its exports. */\n async setup(): Promise<void> {\n if (this.draining || this.state) throw databaseSetupFailed();\n if (!this.setupPromise) {\n const attempt = (async () => {\n this.exposedTables = resolveExposedTables(\n this.config.crudRoutes,\n Object.keys(this.config.schema.$tables),\n );\n const candidate = await createDatabaseState(\n this.config.schema,\n (operation, options) => this.execute(operation, options),\n );\n if (this.draining) {\n // Setup may finish while shutdown is waiting; never publish that state.\n candidate.deactivate();\n await candidate.pool.end().catch(() => undefined);\n throw databaseSetupFailed();\n }\n this.state = candidate;\n })();\n this.setupPromise = attempt;\n }\n return this.setupPromise;\n }\n\n /** Register generated reads for explicitly exposed tables only. */\n injectRoutes(router: express.Router): void {\n if (this.exposedTables.length === 0) return;\n const tables = compileCrudTables(\n Object.fromEntries(\n this.exposedTables.map((name) => [\n name,\n this.config.schema.$tables[name],\n ]),\n ),\n );\n const serializers = this.config.hooks as\n | Record<string, { serialize?: ReadSerializer } | undefined>\n | undefined;\n // Every exposed name is a declared table, so its export is an entity client.\n const entities = () =>\n this.exports() as unknown as Record<string, CrudReadEntity>;\n\n for (const table of tables.values()) {\n const deps: ReadRouteDeps = {\n table,\n entity: () => entities()[table.name],\n serialize: serializers?.[table.name]?.serialize,\n runRouteSpan: (operation, route, run) =>\n this.runReadSpan(table.name, operation, route, run),\n };\n this.route(router, {\n name: `${table.name}.list`,\n method: \"get\",\n path: `/${table.name}`,\n handler: createListHandler(deps),\n });\n if (table.primaryKey) {\n this.route(router, {\n name: `${table.name}.detail`,\n method: \"get\",\n path: `/${table.name}/:id`,\n handler: createDetailHandler(deps),\n });\n }\n }\n }\n\n /** Return the typed database API only while the plugin is active. */\n exports() {\n if (!this.state || this.draining)\n throw new DatabasePluginError(\"INTERNAL\", \"read\");\n // AppKit binds exported functions onto this object on every access.\n return Object.assign(\n Object.create(null),\n this.state.exports,\n ) as DatabaseExports;\n }\n\n /** Stop new work, wait for setup, and close the owned pool exactly once. */\n async shutdown(): Promise<void> {\n if (this.shutdownPromise) return this.shutdownPromise;\n this.draining = true;\n this.shutdownPromise = (async () => {\n await this.setupPromise?.catch(() => undefined);\n const state = this.state;\n state?.deactivate();\n this.state = null;\n if (state) {\n try {\n await state.pool.end();\n } catch {\n throw new DatabasePluginError(\"INTERNAL\", \"shutdown\");\n }\n }\n })();\n return this.shutdownPromise;\n }\n\n /** Trace one generated read with allowlisted, low-cardinality attributes. */\n private runReadSpan(\n table: string,\n operation: \"list\" | \"detail\",\n route: string,\n run: () => Promise<void>,\n ): Promise<void> {\n return this.telemetry.startActiveSpan(\n \"database.crud.route\",\n {\n attributes: {\n table_name: table,\n operation,\n \"http.route\": `/api/${this.name}${route}`,\n },\n },\n async (span) => {\n try {\n await run();\n span.setAttribute(\"outcome\", \"success\");\n } catch (error) {\n span.setAttribute(\"outcome\", readRouteOutcome(error));\n throw error;\n } finally {\n span.end();\n }\n },\n );\n }\n}\n\n/** Create a typed database plugin registration for a finalized schema. */\nexport function database<TSchema extends Schema>(\n config: IDatabaseConfig<TSchema>,\n) {\n return {\n plugin: DatabasePlugin as unknown as PluginConstructor<\n BasePluginConfig,\n DatabasePlugin<TSchema>\n >,\n config,\n name: \"database\" as const,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"database.js","names":["manifest"],"sources":["../../../src/plugins/database/database.ts"],"sourcesContent":["import type express from \"express\";\nimport type { BasePluginConfig, PluginConstructor } from \"shared\";\n\nimport {\n DatabasePluginError,\n databaseSetupFailed,\n} from \"../../database/errors\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { Plugin } from \"../../plugin\";\nimport type { PluginManifest } from \"../../registry\";\nimport { compileCrudTables } from \"./crud/contract\";\nimport { type CrudExposure, resolveCrudExposure } from \"./crud/exposure\";\nimport { routeOutcome } from \"./crud/response\";\nimport {\n type CrudEntity,\n type CrudOperation,\n type CrudRouteDeps,\n createCreateHandler,\n createDeleteHandler,\n createDetailHandler,\n createListHandler,\n createUpdateHandler,\n} from \"./crud/routes\";\nimport type { DatabaseExports } from \"./entity-types\";\nimport { createDatabaseState, type DatabaseState } from \"./lifecycle\";\nimport manifest from \"./manifest.json\";\nimport type { DatabaseHooks, IDatabaseConfig } from \"./types\";\n\n/** Schema-driven database plugin */\nexport class DatabasePlugin<TSchema extends Schema> extends Plugin<\n IDatabaseConfig<TSchema>\n> {\n /** Plugin metadata and required PostgreSQL resource. */\n static manifest = manifest as PluginManifest<\"database\">;\n declare protected config: IDatabaseConfig<TSchema>;\n private state: DatabaseState | null = null;\n private setupPromise: Promise<void> | null = null;\n private draining = false;\n private shutdownPromise: Promise<void> | null = null;\n private exposure: CrudExposure = { tables: [], writes: new Map() };\n\n constructor(config: IDatabaseConfig<TSchema>) {\n super({ schema: config.schema });\n // Do not silently turn a previous opt-out into the default full API.\n if (\"crudRoutes\" in config) {\n throw databaseSetupFailed(\n '\"crudRoutes\" was renamed to \"api\". Use api: false to disable generated routes or api: { writes: false } for reads only.',\n );\n }\n this.config = {\n schema: config.schema,\n api: config.api,\n hooks: config.hooks,\n };\n }\n\n /** Build and verify one candidate state before publishing its exports. */\n async setup(): Promise<void> {\n if (this.draining || this.state) throw databaseSetupFailed();\n if (!this.setupPromise) {\n const attempt = (async () => {\n this.exposure = resolveCrudExposure(\n this.config.api,\n Object.keys(this.config.schema.$tables),\n );\n // A hook key naming no declared table would silently never run.\n for (const name of Object.keys(this.hooks() ?? {})) {\n if (!Object.hasOwn(this.config.schema.$tables, name)) {\n throw databaseSetupFailed(\n `hooks names undeclared table ${JSON.stringify(name)}. Use a table declared in schema.`,\n );\n }\n }\n const candidate = await createDatabaseState(\n this.config.schema,\n (operation, options) => this.execute(operation, options),\n this.hooks(),\n );\n if (this.draining) {\n // Setup may finish while shutdown is waiting; never publish that state.\n candidate.deactivate();\n await candidate.pool.end().catch(() => undefined);\n throw databaseSetupFailed();\n }\n this.state = candidate;\n })();\n this.setupPromise = attempt;\n }\n return this.setupPromise;\n }\n\n /** Register generated CRUD, subject to the configured table and write restrictions. */\n injectRoutes(router: express.Router): void {\n if (this.exposure.tables.length === 0) return;\n const tables = compileCrudTables(\n Object.fromEntries(\n this.exposure.tables.map((name) => [\n name,\n this.config.schema.$tables[name],\n ]),\n ),\n );\n const hooks = this.hooks();\n // Every exposed name is a declared table, so its export is an entity client.\n const entities = () =>\n this.exports() as unknown as Record<string, CrudEntity>;\n\n for (const table of tables.values()) {\n const deps: CrudRouteDeps = {\n table,\n entity: () => entities()[table.name],\n serialize: hooks?.[table.name]?.serialize,\n runRouteSpan: (operation, route, run) =>\n this.runRouteSpan(table.name, operation, route, run),\n };\n this.route(router, {\n name: `${table.name}.list`,\n method: \"get\",\n path: `/${table.name}`,\n handler: createListHandler(deps),\n });\n const writes = this.exposure.writes.get(table.name);\n if (writes?.has(\"create\")) {\n this.route(router, {\n name: `${table.name}.create`,\n method: \"post\",\n path: `/${table.name}`,\n handler: createCreateHandler(deps),\n });\n }\n // Addressing one row needs a public key.\n if (!table.primaryKey) continue;\n this.route(router, {\n name: `${table.name}.detail`,\n method: \"get\",\n path: `/${table.name}/:id`,\n handler: createDetailHandler(deps),\n });\n if (writes?.has(\"update\")) {\n this.route(router, {\n name: `${table.name}.update`,\n method: \"patch\",\n path: `/${table.name}/:id`,\n handler: createUpdateHandler(deps),\n });\n }\n if (writes?.has(\"delete\")) {\n this.route(router, {\n name: `${table.name}.delete`,\n method: \"delete\",\n path: `/${table.name}/:id`,\n handler: createDeleteHandler(deps),\n });\n }\n }\n }\n\n /** Typed hook keys are schema table names, which routing addresses at runtime. */\n private hooks(): DatabaseHooks | undefined {\n return this.config.hooks as DatabaseHooks | undefined;\n }\n\n /** Return the typed database API only while the plugin is active. */\n exports() {\n if (!this.state || this.draining)\n throw new DatabasePluginError(\"INTERNAL\", \"read\");\n // AppKit binds exported functions onto this object on every access.\n return Object.assign(\n Object.create(null),\n this.state.exports,\n ) as DatabaseExports;\n }\n\n /** Stop new work, wait for setup, and close the owned pool exactly once. */\n async shutdown(): Promise<void> {\n if (this.shutdownPromise) return this.shutdownPromise;\n this.draining = true;\n this.shutdownPromise = (async () => {\n await this.setupPromise?.catch(() => undefined);\n const state = this.state;\n state?.deactivate();\n this.state = null;\n if (state) {\n try {\n await state.pool.end();\n } catch {\n throw new DatabasePluginError(\"INTERNAL\", \"shutdown\");\n }\n }\n })();\n return this.shutdownPromise;\n }\n\n /** Trace one generated route with allowlisted, low-cardinality attributes. */\n private runRouteSpan(\n table: string,\n operation: CrudOperation,\n route: string,\n run: () => Promise<void>,\n ): Promise<void> {\n return this.telemetry.startActiveSpan(\n \"database.crud.route\",\n {\n attributes: {\n table_name: table,\n operation,\n \"http.route\": `/api/${this.name}${route}`,\n },\n },\n async (span) => {\n try {\n await run();\n span.setAttribute(\"outcome\", \"success\");\n } catch (error) {\n span.setAttribute(\"outcome\", routeOutcome(error));\n throw error;\n } finally {\n span.end();\n }\n },\n );\n }\n}\n\n/** Create a typed database plugin registration for a finalized schema. */\nexport function database<TSchema extends Schema>(\n config: IDatabaseConfig<TSchema>,\n) {\n return {\n plugin: DatabasePlugin as unknown as PluginConstructor<\n BasePluginConfig,\n DatabasePlugin<TSchema>\n >,\n config,\n name: \"database\" as const,\n };\n}\n"],"mappings":"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"}
|