@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
@@ -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 { resolveExposedTables } from "./crud/exposure.js";
6
- import { createDetailHandler, createListHandler, readRouteOutcome } from "./crud/routes.js";
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
- exposedTables = [];
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
- crudRoutes: config.crudRoutes,
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.exposedTables = resolveExposedTables(this.config.crudRoutes, Object.keys(this.config.schema.$tables));
33
- const candidate = await createDatabaseState(this.config.schema, (operation, options) => this.execute(operation, options));
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 reads for explicitly exposed tables only. */
49
+ /** Register generated CRUD, subject to the configured table and write restrictions. */
44
50
  injectRoutes(router) {
45
- if (this.exposedTables.length === 0) return;
46
- const tables = compileCrudTables(Object.fromEntries(this.exposedTables.map((name) => [name, this.config.schema.$tables[name]])));
47
- const serializers = this.config.hooks;
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: serializers?.[table.name]?.serialize,
54
- runRouteSpan: (operation, route, run) => this.runReadSpan(table.name, operation, route, run)
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
- if (table.primaryKey) this.route(router, {
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 read with allowlisted, low-cardinality attributes. */
93
- runReadSpan(table, operation, route, run) {
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", readRouteOutcome(error));
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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"}
@@ -13,6 +13,10 @@ const databaseWriteDefaults = {
13
13
  * connection past the deadline.
14
14
  */
15
15
  const STATEMENT_TIMEOUT_MS = 3e4;
16
+ /** Server-side limit for an idle open transaction, independent of JS timers (ms). */
17
+ const IDLE_IN_TRANSACTION_TIMEOUT_MS = 3e4;
18
+ /** Deadline for the transaction callback, not cancellation of arbitrary hook work (ms). */
19
+ const TRANSACTION_TIMEOUT_MS = 3e4;
16
20
  /** Max encoded size of a generated read query string. */
17
21
  const MAX_QUERY_BYTES = 8 * 1024;
18
22
  /** Max nesting depth of `and`/`or` groups in one generated filter. */
@@ -35,5 +39,5 @@ const MAX_SERIALIZED_NODES = 1e4;
35
39
  const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
36
40
 
37
41
  //#endregion
38
- export { MAX_GROUP_ITEMS, MAX_MATERIALIZED_NODES, MAX_OFFSET, MAX_ORDER_FIELDS, MAX_QUERY_BYTES, MAX_RESPONSE_BYTES, MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES, MAX_WHERE_CONDITIONS, MAX_WHERE_DEPTH, STATEMENT_TIMEOUT_MS, databaseReadDefaults, databaseWriteDefaults };
42
+ export { IDLE_IN_TRANSACTION_TIMEOUT_MS, MAX_GROUP_ITEMS, MAX_MATERIALIZED_NODES, MAX_OFFSET, MAX_ORDER_FIELDS, MAX_QUERY_BYTES, MAX_RESPONSE_BYTES, MAX_SERIALIZED_DEPTH, MAX_SERIALIZED_NODES, MAX_WHERE_CONDITIONS, MAX_WHERE_DEPTH, STATEMENT_TIMEOUT_MS, TRANSACTION_TIMEOUT_MS, databaseReadDefaults, databaseWriteDefaults };
39
43
  //# sourceMappingURL=defaults.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.js","names":[],"sources":["../../../src/plugins/database/defaults.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\n/** Default interceptor policy for bounded reads. */\nexport const databaseReadDefaults: PluginExecuteConfig = {\n retry: { enabled: false },\n};\n\n/** Default interceptor policy for mutations. */\nexport const databaseWriteDefaults: PluginExecuteConfig = {\n cache: { enabled: false },\n retry: { enabled: false },\n};\n\n// Connection posture: how long any one statement may hold a pooled connection.\n/**\n * Server-side `statement_timeout` applied to every pooled connection (ms).\n * PostgreSQL cancels the statement itself, so an expensive caller-supplied\n * filter (for example a leading-wildcard `ilike`) cannot hold its pooled\n * connection past the deadline.\n */\nexport const STATEMENT_TIMEOUT_MS = 30_000;\n\n// Generated-read limits. The wire caps a typed caller shares with HTTP live in\n// `database/contract`; these bound only what an untrusted request may ask for.\n\n// Request decoding: rejected before any database work is scheduled.\n/** Max encoded size of a generated read query string. */\nexport const MAX_QUERY_BYTES = 8 * 1024;\n/** Max nesting depth of `and`/`or` groups in one generated filter. */\nexport const MAX_WHERE_DEPTH = 5;\n/** Max column conditions across one generated filter. */\nexport const MAX_WHERE_CONDITIONS = 50;\n/** Max members in one `and`/`or` group. */\nexport const MAX_GROUP_ITEMS = 20;\n/** Max columns one generated `order` may name. */\nexport const MAX_ORDER_FIELDS = 10;\n/** Max accepted generated `offset`. */\nexport const MAX_OFFSET = 10_000;\n/** Max rows one generated read may materialize across its include tree. */\nexport const MAX_MATERIALIZED_NODES = 10_000;\n\n// Response shaping: bounds trusted output on its way to the wire.\n/** Max nesting depth of read-serializer output. */\nexport const MAX_SERIALIZED_DEPTH = 32;\n/** Max node count of read-serializer output. */\nexport const MAX_SERIALIZED_NODES = 10_000;\n/** Max UTF-8 byte length of one generated read response body. */\nexport const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;\n"],"mappings":";;AAGA,MAAa,uBAA4C,EACvD,OAAO,EAAE,SAAS,OAAO,EAC1B;;AAGD,MAAa,wBAA6C;CACxD,OAAO,EAAE,SAAS,OAAO;CACzB,OAAO,EAAE,SAAS,OAAO;CAC1B;;;;;;;AASD,MAAa,uBAAuB;;AAOpC,MAAa,kBAAkB,IAAI;;AAEnC,MAAa,kBAAkB;;AAE/B,MAAa,uBAAuB;;AAEpC,MAAa,kBAAkB;;AAE/B,MAAa,mBAAmB;;AAEhC,MAAa,aAAa;;AAE1B,MAAa,yBAAyB;;AAItC,MAAa,uBAAuB;;AAEpC,MAAa,uBAAuB;;AAEpC,MAAa,qBAAqB,IAAI,OAAO"}
1
+ {"version":3,"file":"defaults.js","names":[],"sources":["../../../src/plugins/database/defaults.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\n/** Default interceptor policy for bounded reads. */\nexport const databaseReadDefaults: PluginExecuteConfig = {\n retry: { enabled: false },\n};\n\n/** Default interceptor policy for mutations. */\nexport const databaseWriteDefaults: PluginExecuteConfig = {\n cache: { enabled: false },\n retry: { enabled: false },\n};\n\n// Connection posture: how long any one statement may hold a pooled connection.\n/**\n * Server-side `statement_timeout` applied to every pooled connection (ms).\n * PostgreSQL cancels the statement itself, so an expensive caller-supplied\n * filter (for example a leading-wildcard `ilike`) cannot hold its pooled\n * connection past the deadline.\n */\nexport const STATEMENT_TIMEOUT_MS = 30_000;\n\n/** Server-side limit for an idle open transaction, independent of JS timers (ms). */\nexport const IDLE_IN_TRANSACTION_TIMEOUT_MS = 30_000;\n\n/** Deadline for the transaction callback, not cancellation of arbitrary hook work (ms). */\nexport const TRANSACTION_TIMEOUT_MS = 30_000;\n\n// Generated-read limits. The wire caps a typed caller shares with HTTP live in\n// `database/contract`; these bound only what an untrusted request may ask for.\n\n// Request decoding: rejected before any database work is scheduled.\n/** Max encoded size of a generated read query string. */\nexport const MAX_QUERY_BYTES = 8 * 1024;\n/** Max nesting depth of `and`/`or` groups in one generated filter. */\nexport const MAX_WHERE_DEPTH = 5;\n/** Max column conditions across one generated filter. */\nexport const MAX_WHERE_CONDITIONS = 50;\n/** Max members in one `and`/`or` group. */\nexport const MAX_GROUP_ITEMS = 20;\n/** Max columns one generated `order` may name. */\nexport const MAX_ORDER_FIELDS = 10;\n/** Max accepted generated `offset`. */\nexport const MAX_OFFSET = 10_000;\n/** Max rows one generated read may materialize across its include tree. */\nexport const MAX_MATERIALIZED_NODES = 10_000;\n\n// Response shaping: bounds trusted output on its way to the wire.\n/** Max nesting depth of read-serializer output. */\nexport const MAX_SERIALIZED_DEPTH = 32;\n/** Max node count of read-serializer output. */\nexport const MAX_SERIALIZED_NODES = 10_000;\n/** Max UTF-8 byte length of one generated read response body. */\nexport const MAX_RESPONSE_BYTES = 5 * 1024 * 1024;\n"],"mappings":";;AAGA,MAAa,uBAA4C,EACvD,OAAO,EAAE,SAAS,OAAO,EAC1B;;AAGD,MAAa,wBAA6C;CACxD,OAAO,EAAE,SAAS,OAAO;CACzB,OAAO,EAAE,SAAS,OAAO;CAC1B;;;;;;;AASD,MAAa,uBAAuB;;AAGpC,MAAa,iCAAiC;;AAG9C,MAAa,yBAAyB;;AAOtC,MAAa,kBAAkB,IAAI;;AAEnC,MAAa,kBAAkB;;AAE/B,MAAa,uBAAuB;;AAEpC,MAAa,kBAAkB;;AAE/B,MAAa,mBAAmB;;AAEhC,MAAa,aAAa;;AAE1B,MAAa,yBAAyB;;AAItC,MAAa,uBAAuB;;AAEpC,MAAa,uBAAuB;;AAEpC,MAAa,qBAAqB,IAAI,OAAO"}
@@ -1,8 +1,16 @@
1
- import { DatabasePluginError, classifyDatabaseError, databaseErrorFromStatus } from "../../database/errors.js";
1
+ import { DatabaseValidationError } from "../../errors/database-validation.js";
2
+ import "../../errors/index.js";
3
+ import { DatabasePluginError, classifyDatabaseError, databaseErrorFromStatus, invalidDatabaseRequest } from "../../database/errors.js";
2
4
  import { databaseReadDefaults, databaseWriteDefaults } from "./defaults.js";
3
5
  import { andWhere, validateLimit, validateOffset } from "../../database/runtime/data-path.js";
4
6
 
5
7
  //#region src/plugins/database/entity-client.ts
8
+ /** Internal rollback signal for a hooked keyed mutation that matched no row. */
9
+ var MutationNoMatchError = class extends DatabasePluginError {
10
+ constructor() {
11
+ super("NOT_FOUND", "write");
12
+ }
13
+ };
6
14
  /** Clone plain acyclic input so caller mutation cannot change a built query. */
7
15
  function snapshotPlain(value, seen = /* @__PURE__ */ new Set()) {
8
16
  if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "number" && Number.isFinite(value)) return value;
@@ -22,6 +30,10 @@ function validateReadInput(validate) {
22
30
  throw classifyDatabaseError(error, "read");
23
31
  }
24
32
  }
33
+ /** A hook replaced the payload only if it returned an object to persist. */
34
+ function isReplacement(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
25
37
  /**
26
38
  * Plugin-facing client that composes immutable query state. The DataPath remains
27
39
  * the schema-aware validation and execution boundary.
@@ -99,27 +111,148 @@ var EntityClient = class EntityClient {
99
111
  count() {
100
112
  return this.run("read", (dataPath) => dataPath.count(this.ctx.table, this.state.where));
101
113
  }
102
- create(values) {
103
- return this.write("create", values, (dataPath, payload) => dataPath.insert(this.ctx.table, payload));
114
+ async create(values) {
115
+ this.assertUnfiltered("create");
116
+ const hooks = this.ctx.hooks;
117
+ const insert = (payload, byHook = false) => this.write("create", payload, (dataPath, parsed) => dataPath.insert(this.ctx.table, parsed), byHook);
118
+ if (this.isDirect("create")) return this.directMutation("create", () => insert(values));
119
+ return this.mutate("create", (entity) => entity.create(values), async (context) => {
120
+ const payload = await this.runBefore(() => hooks?.beforeCreate?.(values, context), values);
121
+ const row = await insert(payload, payload !== values);
122
+ await this.runHook(() => hooks?.afterCreate?.(row, context));
123
+ return row;
124
+ });
104
125
  }
105
126
  update(id, values) {
106
- return this.write("update", values, (dataPath, payload) => dataPath.update(this.ctx.table, id, payload));
127
+ return this.updateWithNoMatch(id, values, new MutationNoMatchError());
128
+ }
129
+ async updateWithNoMatch(id, values, noMatch) {
130
+ const hooks = this.ctx.hooks;
131
+ const apply = (payload, byHook = false) => this.write("update", payload, (dataPath, parsed) => dataPath.update(this.ctx.table, id, parsed, this.state.where), byHook);
132
+ if (this.isDirect("update")) return this.directMutation("update", () => apply(values));
133
+ try {
134
+ return await this.mutate("update", (entity) => entity.updateWithNoMatch(id, values, noMatch), async (context) => {
135
+ const payload = await this.runBefore(() => hooks?.beforeUpdate?.(id, values, context), values);
136
+ const row = await apply(payload, payload !== values);
137
+ if (row === null) throw noMatch;
138
+ await this.runHook(() => hooks?.afterUpdate?.(row, context));
139
+ return row;
140
+ });
141
+ } catch (error) {
142
+ if (error === noMatch && !this.ctx.transactionBound && !this.ctx.scope.activeTransaction()) return null;
143
+ throw error;
144
+ }
107
145
  }
108
- upsert(values, options) {
146
+ async upsert(values, options) {
147
+ this.assertUnfiltered("upsert");
148
+ const hooks = this.ctx.hooks;
109
149
  const onConflict = options.onConflict;
110
- return this.write("create", values, (dataPath, payload) => dataPath.upsert(this.ctx.table, payload, onConflict));
150
+ const apply = (payload, byHook = false) => this.write("create", payload, (dataPath, parsed) => dataPath.upsert(this.ctx.table, parsed, onConflict), byHook);
151
+ if (this.isDirect("upsert")) return this.directMutation("upsert", () => apply(values));
152
+ return this.mutate("upsert", (entity) => entity.upsert(values, { onConflict }), async (context) => {
153
+ const payload = await this.runBefore(() => hooks?.beforeUpsert?.(values, context), values);
154
+ const row = await apply(payload, payload !== values);
155
+ await this.runHook(() => hooks?.afterUpsert?.(row, context));
156
+ return row;
157
+ });
111
158
  }
112
159
  delete(id) {
113
- return this.run("write", (dataPath) => dataPath.delete(this.ctx.table, id), databaseWriteDefaults);
160
+ return this.deleteWithNoMatch(id, new MutationNoMatchError());
161
+ }
162
+ async deleteWithNoMatch(id, noMatch) {
163
+ const hooks = this.ctx.hooks;
164
+ const remove = () => this.run("write", (dataPath) => dataPath.delete(this.ctx.table, id, this.state.where), databaseWriteDefaults);
165
+ if (this.isDirect("delete")) return this.directMutation("delete", remove);
166
+ try {
167
+ return await this.mutate("delete", (entity) => entity.deleteWithNoMatch(id, noMatch), async (context) => {
168
+ await this.runHook(() => hooks?.beforeDelete?.(id, context));
169
+ if (!await remove()) throw noMatch;
170
+ await this.runHook(() => hooks?.afterDelete?.(id, context));
171
+ return true;
172
+ });
173
+ } catch (error) {
174
+ if (error === noMatch && !this.ctx.transactionBound && !this.ctx.scope.activeTransaction()) return false;
175
+ throw error;
176
+ }
177
+ }
178
+ /** Count every write that runs on an already bound transaction. */
179
+ directMutation(operation, run) {
180
+ if (!this.ctx.transactionBound) return run();
181
+ return this.ctx.scope.runMutation(this.ctx.table.$name, operation, run);
182
+ }
183
+ /** An insert matches no existing row, so a predicate could only be dropped. */
184
+ assertUnfiltered(operation) {
185
+ if (this.state.where !== void 0) throw invalidDatabaseRequest(`${operation}() cannot follow where(); it does not select rows to change`);
186
+ }
187
+ /**
188
+ * Go straight to the DataPath only when there is nothing to sequence and no
189
+ * open transaction to join. A root client that skipped the second check would
190
+ * commit on the pool while a transaction it cannot see is still open.
191
+ */
192
+ isDirect(operation) {
193
+ if (this.hasHooks(operation)) return false;
194
+ return this.ctx.transactionBound || !this.ctx.scope.activeTransaction();
195
+ }
196
+ hasHooks(operation) {
197
+ const hooks = this.ctx.hooks;
198
+ if (!hooks) return false;
199
+ switch (operation) {
200
+ case "create": return Boolean(hooks.beforeCreate ?? hooks.afterCreate);
201
+ case "update": return Boolean(hooks.beforeUpdate ?? hooks.afterUpdate);
202
+ case "upsert": return Boolean(hooks.beforeUpsert ?? hooks.afterUpsert);
203
+ case "delete": return Boolean(hooks.beforeDelete ?? hooks.afterDelete);
204
+ }
205
+ }
206
+ /**
207
+ * Run one mutation atomically. A top-level call restarts itself on the
208
+ * transaction-bound client, so hooks, validation, the mutation, and its after
209
+ * hook commit or roll back together. An unhooked root mutation reaches this
210
+ * only to join an open transaction; its bound client counts the direct write
211
+ * without constructing a hook context.
212
+ */
213
+ async mutate(operation, restart, body) {
214
+ if (this.ctx.transactionBound) return this.ctx.scope.runMutation(this.ctx.table.$name, operation, () => body(this.hookContext()));
215
+ try {
216
+ return await this.ctx.runInTransaction((entity) => restart(entity.clone(this.state)));
217
+ } catch (error) {
218
+ if (error instanceof DatabaseValidationError || error instanceof MutationNoMatchError) throw error;
219
+ throw classifyDatabaseError(error, "write");
220
+ }
221
+ }
222
+ /** Resolve the surface at hook time so it cannot outlive its transaction. */
223
+ hookContext() {
224
+ const database = this.ctx.scope.activeTransaction();
225
+ if (!database) throw new DatabasePluginError("INTERNAL", "write");
226
+ return {
227
+ entity: this.ctx.table.$name,
228
+ app: { database }
229
+ };
230
+ }
231
+ /** Substitute the payload a before hook returned, if it returned one. */
232
+ async runBefore(invoke, values) {
233
+ const replacement = await this.runHook(invoke);
234
+ if (replacement === void 0 || replacement === null) return values;
235
+ if (!isReplacement(replacement)) throw new DatabasePluginError("INTERNAL", "write");
236
+ return replacement;
237
+ }
238
+ /** Keep a deliberate validation signal; collapse every other hook failure. */
239
+ async runHook(invoke) {
240
+ try {
241
+ return await invoke();
242
+ } catch (error) {
243
+ if (error instanceof DatabaseValidationError) throw error;
244
+ throw classifyDatabaseError(error, "write");
245
+ }
114
246
  }
115
247
  /** Validate caller values against the finalized trusted-write schema. */
116
- async write(kind, values, operation) {
117
- if (kind === "update" && Object.keys(values).length === 0) throw new DatabasePluginError("INVALID_REQUEST", "write");
248
+ async write(kind, values, operation, byHook = false) {
249
+ const rejection = byHook ? "INTERNAL" : "INVALID_REQUEST";
250
+ if (kind === "update" && Object.keys(values).length === 0) throw new DatabasePluginError(rejection, "write");
118
251
  let parsed;
119
252
  try {
120
253
  parsed = (kind === "create" ? this.ctx.table.$insertSchema : this.ctx.table.$updateSchema).parse(values);
121
254
  } catch {
122
- throw new DatabasePluginError("INVALID_REQUEST", "write");
255
+ throw new DatabasePluginError(rejection, "write");
123
256
  }
124
257
  return this.run("write", (dataPath) => operation(dataPath, parsed), databaseWriteDefaults);
125
258
  }
@@ -1 +1 @@
1
- {"version":3,"file":"entity-client.js","names":[],"sources":["../../../src/plugins/database/entity-client.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n databaseErrorFromStatus,\n} from \"../../database/errors\";\nimport type {\n DataPath,\n IdValue,\n IncludeSpec,\n OrderSpec,\n QuerySpec,\n Row,\n WhereClause,\n} from \"../../database/runtime\";\nimport {\n andWhere,\n validateLimit,\n validateOffset,\n} from \"../../database/runtime/data-path\";\nimport type { AppKitTable } from \"../../database/schema-builder\";\nimport type { ExecutionResult } from \"../../plugin/execution-result\";\nimport { databaseReadDefaults, databaseWriteDefaults } from \"./defaults\";\n\n/** Apply AppKit execution policy to an entity operation. */\nexport type EntityExecute = <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n options: { default: PluginExecuteConfig; user?: PluginExecuteConfig },\n) => Promise<ExecutionResult<T>>;\n\n/** Runtime dependencies shared by immutable clients for one table. */\nexport interface EntityClientContext {\n readonly table: AppKitTable;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n}\n\n/** Clone plain acyclic input so caller mutation cannot change a built query. */\nfunction snapshotPlain<T>(value: T, seen = new Set<object>()): T {\n if (\n value === null ||\n typeof value === \"string\" ||\n typeof value === \"boolean\" ||\n typeof value === \"bigint\" ||\n (typeof value === \"number\" && Number.isFinite(value))\n ) {\n return value;\n }\n if (typeof value !== \"object\") {\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n }\n if (seen.has(value)) throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype\n )\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n seen.add(value);\n const copy = (\n Array.isArray(value)\n ? value.map((item) => snapshotPlain(item, seen))\n : Object.fromEntries(\n Object.entries(value).map(([key, item]) => [\n key,\n snapshotPlain(item, seen),\n ]),\n )\n ) as T;\n seen.delete(value);\n return copy;\n}\n\n/** Report eager fluent-input failures through the public read phase. */\nfunction validateReadInput<T>(validate: () => T): T {\n try {\n return validate();\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n}\n\n/**\n * Plugin-facing client that composes immutable query state. The DataPath remains\n * the schema-aware validation and execution boundary.\n */\nexport class EntityClient {\n constructor(\n private readonly ctx: EntityClientContext,\n private readonly state: QuerySpec = {},\n ) {}\n\n private clone(state: QuerySpec): EntityClient {\n return new EntityClient(this.ctx, state);\n }\n\n /** Snapshot and compose predicates; the adapter validates columns/operators. */\n where(filter: WhereClause): EntityClient {\n const snapshot = snapshotPlain(filter);\n return this.clone({\n ...this.state,\n where: andWhere(this.state.where, snapshot),\n });\n }\n\n /** Merge ordering, replacing the direction for repeated columns. */\n order(order: OrderSpec): EntityClient {\n const snapshot = snapshotPlain(order);\n return this.clone({\n ...this.state,\n order: { ...this.state.order, ...snapshot },\n });\n }\n\n select(columns: readonly string[]): EntityClient {\n return this.clone({ ...this.state, select: snapshotPlain(columns) });\n }\n\n /** Merge relation includes, replacing repeated relation options. */\n include(include: IncludeSpec): EntityClient {\n const snapshot = snapshotPlain(include);\n return this.clone({\n ...this.state,\n include: { ...this.state.include, ...snapshot },\n });\n }\n\n limit(limit: number): EntityClient {\n return validateReadInput(() =>\n this.clone({ ...this.state, limit: validateLimit(limit) }),\n );\n }\n\n offset(offset: number): EntityClient {\n return validateReadInput(() =>\n this.clone({ ...this.state, offset: validateOffset(offset) }),\n );\n }\n\n /** Execute a collection read; DataPath applies the default upper bound. */\n async toArray(): Promise<Row[]> {\n return this.run(\"read\", (dataPath) =>\n dataPath.select(this.ctx.table, this.state),\n );\n }\n\n async first(): Promise<Row | null> {\n return (await this.limit(1).toArray())[0] ?? null;\n }\n\n find(id: IdValue): Promise<Row | null> {\n return this.run(\"read\", (dataPath) =>\n dataPath.findOne(this.ctx.table, id, {\n where: this.state.where,\n select: this.state.select,\n include: this.state.include,\n }),\n );\n }\n\n count(): Promise<number> {\n return this.run(\"read\", (dataPath) =>\n dataPath.count(this.ctx.table, this.state.where),\n );\n }\n\n create(values: Row): Promise<Row> {\n return this.write(\"create\", values, (dataPath, payload) =>\n dataPath.insert(this.ctx.table, payload),\n );\n }\n\n update(id: IdValue, values: Row): Promise<Row | null> {\n return this.write(\"update\", values, (dataPath, payload) =>\n dataPath.update(this.ctx.table, id, payload),\n );\n }\n\n upsert(values: Row, options: { onConflict: string }): Promise<Row> {\n const onConflict = options.onConflict;\n return this.write(\"create\", values, (dataPath, payload) =>\n dataPath.upsert(this.ctx.table, payload, onConflict),\n );\n }\n\n delete(id: IdValue): Promise<boolean> {\n return this.run(\n \"write\",\n (dataPath) => dataPath.delete(this.ctx.table, id),\n databaseWriteDefaults,\n );\n }\n\n /** Validate caller values against the finalized trusted-write schema. */\n private async write<T>(\n kind: \"create\" | \"update\",\n values: Row,\n operation: (dataPath: DataPath, values: Row) => Promise<T>,\n ): Promise<T> {\n if (kind === \"update\" && Object.keys(values).length === 0) {\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"write\");\n }\n let parsed: Row;\n try {\n const validator = (\n kind === \"create\"\n ? this.ctx.table.$insertSchema\n : this.ctx.table.$updateSchema\n ) as { parse(value: unknown): Row };\n parsed = validator.parse(values);\n } catch {\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"write\");\n }\n return this.run(\n \"write\",\n (dataPath) => operation(dataPath, parsed),\n databaseWriteDefaults,\n );\n }\n\n /** Execute through AppKit interceptors and expose only safe failures. */\n private async run<T>(\n phase: \"read\" | \"write\",\n operation: (dataPath: DataPath) => Promise<T>,\n defaults: PluginExecuteConfig = databaseReadDefaults,\n ): Promise<T> {\n try {\n this.ctx.assertActive();\n } catch (error) {\n throw classifyDatabaseError(error, phase);\n }\n const result = await this.ctx.execute(\n async () => {\n try {\n this.ctx.assertActive();\n return await operation(this.ctx.getDataPath());\n } catch (error) {\n throw classifyDatabaseError(error, phase);\n }\n },\n { default: defaults },\n );\n if (result.ok) return result.data;\n throw databaseErrorFromStatus(result.status, phase);\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"entity-client.js","names":[],"sources":["../../../src/plugins/database/entity-client.ts"],"sourcesContent":["import type { PluginExecuteConfig } from \"shared\";\n\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n databaseErrorFromStatus,\n invalidDatabaseRequest,\n} from \"../../database/errors\";\nimport type {\n DataPath,\n IdValue,\n IncludeSpec,\n OrderSpec,\n QuerySpec,\n Row,\n WhereClause,\n} from \"../../database/runtime\";\nimport {\n andWhere,\n validateLimit,\n validateOffset,\n} from \"../../database/runtime/data-path\";\nimport type { AppKitTable } from \"../../database/schema-builder\";\nimport { DatabaseValidationError } from \"../../errors\";\nimport type { ExecutionResult } from \"../../plugin/execution-result\";\nimport { databaseReadDefaults, databaseWriteDefaults } from \"./defaults\";\nimport type { HookContext, MutationOperation } from \"./hooks\";\nimport type { MutationScope } from \"./scope\";\nimport type { EntityHooks } from \"./types\";\n\n/** Apply AppKit execution policy to an entity operation. */\nexport type EntityExecute = <T>(\n fn: (signal?: AbortSignal) => Promise<T>,\n options: { default: PluginExecuteConfig; user?: PluginExecuteConfig },\n) => Promise<ExecutionResult<T>>;\n\n/** Runtime dependencies shared by immutable clients for one table. */\nexport interface EntityClientContext {\n readonly table: AppKitTable;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n readonly scope: MutationScope;\n readonly hooks?: EntityHooks;\n /** True once this client is bound to an already open transaction. */\n readonly transactionBound: boolean;\n /** Continue on the same table's client inside a new or reused transaction. */\n readonly runInTransaction: <T>(\n run: (entity: EntityClient) => Promise<T>,\n ) => Promise<T>;\n}\n\n/** Internal rollback signal for a hooked keyed mutation that matched no row. */\nclass MutationNoMatchError extends DatabasePluginError {\n constructor() {\n super(\"NOT_FOUND\", \"write\");\n }\n}\n\n/** Clone plain acyclic input so caller mutation cannot change a built query. */\nfunction snapshotPlain<T>(value: T, seen = new Set<object>()): T {\n if (\n value === null ||\n typeof value === \"string\" ||\n typeof value === \"boolean\" ||\n typeof value === \"bigint\" ||\n (typeof value === \"number\" && Number.isFinite(value))\n ) {\n return value;\n }\n if (typeof value !== \"object\") {\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n }\n if (seen.has(value)) throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n if (\n !Array.isArray(value) &&\n Object.getPrototypeOf(value) !== Object.prototype\n )\n throw new DatabasePluginError(\"INVALID_REQUEST\", \"read\");\n seen.add(value);\n const copy = (\n Array.isArray(value)\n ? value.map((item) => snapshotPlain(item, seen))\n : Object.fromEntries(\n Object.entries(value).map(([key, item]) => [\n key,\n snapshotPlain(item, seen),\n ]),\n )\n ) as T;\n seen.delete(value);\n return copy;\n}\n\n/** Report eager fluent-input failures through the public read phase. */\nfunction validateReadInput<T>(validate: () => T): T {\n try {\n return validate();\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n}\n\n/** A hook replaced the payload only if it returned an object to persist. */\nfunction isReplacement(value: unknown): value is Row {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Plugin-facing client that composes immutable query state. The DataPath remains\n * the schema-aware validation and execution boundary.\n */\nexport class EntityClient {\n constructor(\n private readonly ctx: EntityClientContext,\n private readonly state: QuerySpec = {},\n ) {}\n\n private clone(state: QuerySpec): EntityClient {\n return new EntityClient(this.ctx, state);\n }\n\n /** Snapshot and compose predicates; the adapter validates columns/operators. */\n where(filter: WhereClause): EntityClient {\n const snapshot = snapshotPlain(filter);\n return this.clone({\n ...this.state,\n where: andWhere(this.state.where, snapshot),\n });\n }\n\n /** Merge ordering, replacing the direction for repeated columns. */\n order(order: OrderSpec): EntityClient {\n const snapshot = snapshotPlain(order);\n return this.clone({\n ...this.state,\n order: { ...this.state.order, ...snapshot },\n });\n }\n\n select(columns: readonly string[]): EntityClient {\n return this.clone({ ...this.state, select: snapshotPlain(columns) });\n }\n\n /** Merge relation includes, replacing repeated relation options. */\n include(include: IncludeSpec): EntityClient {\n const snapshot = snapshotPlain(include);\n return this.clone({\n ...this.state,\n include: { ...this.state.include, ...snapshot },\n });\n }\n\n limit(limit: number): EntityClient {\n return validateReadInput(() =>\n this.clone({ ...this.state, limit: validateLimit(limit) }),\n );\n }\n\n offset(offset: number): EntityClient {\n return validateReadInput(() =>\n this.clone({ ...this.state, offset: validateOffset(offset) }),\n );\n }\n\n /** Execute a collection read; DataPath applies the default upper bound. */\n async toArray(): Promise<Row[]> {\n return this.run(\"read\", (dataPath) =>\n dataPath.select(this.ctx.table, this.state),\n );\n }\n\n async first(): Promise<Row | null> {\n return (await this.limit(1).toArray())[0] ?? null;\n }\n\n find(id: IdValue): Promise<Row | null> {\n return this.run(\"read\", (dataPath) =>\n dataPath.findOne(this.ctx.table, id, {\n where: this.state.where,\n select: this.state.select,\n include: this.state.include,\n }),\n );\n }\n\n count(): Promise<number> {\n return this.run(\"read\", (dataPath) =>\n dataPath.count(this.ctx.table, this.state.where),\n );\n }\n\n async create(values: Row): Promise<Row> {\n this.assertUnfiltered(\"create\");\n const hooks = this.ctx.hooks;\n const insert = (payload: Row, byHook = false) =>\n this.write(\n \"create\",\n payload,\n (dataPath, parsed) => dataPath.insert(this.ctx.table, parsed),\n byHook,\n );\n if (this.isDirect(\"create\")) {\n return this.directMutation(\"create\", () => insert(values));\n }\n return this.mutate(\n \"create\",\n (entity) => entity.create(values),\n async (context) => {\n const payload = await this.runBefore(\n () => hooks?.beforeCreate?.(values, context),\n values,\n );\n const row = await insert(payload, payload !== values);\n await this.runHook(() => hooks?.afterCreate?.(row, context));\n return row;\n },\n );\n }\n\n update(id: IdValue, values: Row): Promise<Row | null> {\n return this.updateWithNoMatch(id, values, new MutationNoMatchError());\n }\n\n private async updateWithNoMatch(\n id: IdValue,\n values: Row,\n noMatch: MutationNoMatchError,\n ): Promise<Row | null> {\n const hooks = this.ctx.hooks;\n const apply = (payload: Row, byHook = false) =>\n this.write(\n \"update\",\n payload,\n (dataPath, parsed) =>\n dataPath.update(this.ctx.table, id, parsed, this.state.where),\n byHook,\n );\n if (this.isDirect(\"update\")) {\n return this.directMutation(\"update\", () => apply(values));\n }\n try {\n return await this.mutate(\n \"update\",\n (entity) => entity.updateWithNoMatch(id, values, noMatch),\n async (context) => {\n const payload = await this.runBefore(\n () => hooks?.beforeUpdate?.(id, values, context),\n values,\n );\n const row = await apply(payload, payload !== values);\n if (row === null) throw noMatch;\n await this.runHook(() => hooks?.afterUpdate?.(row, context));\n return row;\n },\n );\n } catch (error) {\n if (\n error === noMatch &&\n !this.ctx.transactionBound &&\n !this.ctx.scope.activeTransaction()\n ) {\n return null;\n }\n throw error;\n }\n }\n\n async upsert(values: Row, options: { onConflict: string }): Promise<Row> {\n this.assertUnfiltered(\"upsert\");\n const hooks = this.ctx.hooks;\n const onConflict = options.onConflict;\n const apply = (payload: Row, byHook = false) =>\n this.write(\n \"create\",\n payload,\n (dataPath, parsed) =>\n dataPath.upsert(this.ctx.table, parsed, onConflict),\n byHook,\n );\n if (this.isDirect(\"upsert\")) {\n return this.directMutation(\"upsert\", () => apply(values));\n }\n return this.mutate(\n \"upsert\",\n (entity) => entity.upsert(values, { onConflict }),\n async (context) => {\n const payload = await this.runBefore(\n () => hooks?.beforeUpsert?.(values, context),\n values,\n );\n // Which branch the database took stays invisible to the hook.\n const row = await apply(payload, payload !== values);\n await this.runHook(() => hooks?.afterUpsert?.(row, context));\n return row;\n },\n );\n }\n\n delete(id: IdValue): Promise<boolean> {\n return this.deleteWithNoMatch(id, new MutationNoMatchError());\n }\n\n private async deleteWithNoMatch(\n id: IdValue,\n noMatch: MutationNoMatchError,\n ): Promise<boolean> {\n const hooks = this.ctx.hooks;\n const remove = () =>\n this.run(\n \"write\",\n (dataPath) => dataPath.delete(this.ctx.table, id, this.state.where),\n databaseWriteDefaults,\n );\n if (this.isDirect(\"delete\")) {\n return this.directMutation(\"delete\", remove);\n }\n try {\n return await this.mutate(\n \"delete\",\n (entity) => entity.deleteWithNoMatch(id, noMatch),\n async (context) => {\n await this.runHook(() => hooks?.beforeDelete?.(id, context));\n const deleted = await remove();\n if (!deleted) throw noMatch;\n await this.runHook(() => hooks?.afterDelete?.(id, context));\n return true;\n },\n );\n } catch (error) {\n if (\n error === noMatch &&\n !this.ctx.transactionBound &&\n !this.ctx.scope.activeTransaction()\n ) {\n return false;\n }\n throw error;\n }\n }\n\n /** Count every write that runs on an already bound transaction. */\n private directMutation<T>(\n operation: MutationOperation,\n run: () => Promise<T>,\n ): Promise<T> {\n if (!this.ctx.transactionBound) return run();\n return this.ctx.scope.runMutation(this.ctx.table.$name, operation, run);\n }\n\n /** An insert matches no existing row, so a predicate could only be dropped. */\n private assertUnfiltered(operation: \"create\" | \"upsert\"): void {\n if (this.state.where !== undefined) {\n throw invalidDatabaseRequest(\n `${operation}() cannot follow where(); it does not select rows to change`,\n );\n }\n }\n\n /**\n * Go straight to the DataPath only when there is nothing to sequence and no\n * open transaction to join. A root client that skipped the second check would\n * commit on the pool while a transaction it cannot see is still open.\n */\n private isDirect(operation: MutationOperation): boolean {\n if (this.hasHooks(operation)) return false;\n return this.ctx.transactionBound || !this.ctx.scope.activeTransaction();\n }\n\n private hasHooks(operation: MutationOperation): boolean {\n const hooks = this.ctx.hooks;\n if (!hooks) return false;\n switch (operation) {\n case \"create\":\n return Boolean(hooks.beforeCreate ?? hooks.afterCreate);\n case \"update\":\n return Boolean(hooks.beforeUpdate ?? hooks.afterUpdate);\n case \"upsert\":\n return Boolean(hooks.beforeUpsert ?? hooks.afterUpsert);\n case \"delete\":\n return Boolean(hooks.beforeDelete ?? hooks.afterDelete);\n }\n }\n\n /**\n * Run one mutation atomically. A top-level call restarts itself on the\n * transaction-bound client, so hooks, validation, the mutation, and its after\n * hook commit or roll back together. An unhooked root mutation reaches this\n * only to join an open transaction; its bound client counts the direct write\n * without constructing a hook context.\n */\n private async mutate<T>(\n operation: MutationOperation,\n restart: (entity: EntityClient) => Promise<T>,\n body: (context: HookContext) => Promise<T>,\n ): Promise<T> {\n if (this.ctx.transactionBound) {\n return this.ctx.scope.runMutation(this.ctx.table.$name, operation, () =>\n body(this.hookContext()),\n );\n }\n try {\n return await this.ctx.runInTransaction((entity) =>\n // Change the execution context without losing the caller's query state\n // or mutating the shared transaction client.\n restart(entity.clone(this.state)),\n );\n } catch (error) {\n if (\n error instanceof DatabaseValidationError ||\n error instanceof MutationNoMatchError\n ) {\n throw error;\n }\n // BEGIN and COMMIT fail outside the inner classification boundary.\n throw classifyDatabaseError(error, \"write\");\n }\n }\n\n /** Resolve the surface at hook time so it cannot outlive its transaction. */\n private hookContext(): HookContext {\n const database = this.ctx.scope.activeTransaction();\n if (!database) throw new DatabasePluginError(\"INTERNAL\", \"write\");\n return { entity: this.ctx.table.$name, app: { database } };\n }\n\n /** Substitute the payload a before hook returned, if it returned one. */\n private async runBefore(invoke: () => unknown, values: Row): Promise<Row> {\n const replacement = await this.runHook(invoke);\n if (replacement === undefined || replacement === null) return values;\n if (!isReplacement(replacement)) {\n throw new DatabasePluginError(\"INTERNAL\", \"write\");\n }\n return replacement;\n }\n\n /** Keep a deliberate validation signal; collapse every other hook failure. */\n private async runHook(invoke: () => unknown): Promise<unknown> {\n try {\n return await invoke();\n } catch (error) {\n if (error instanceof DatabaseValidationError) throw error;\n throw classifyDatabaseError(error, \"write\");\n }\n }\n\n /** Validate caller values against the finalized trusted-write schema. */\n private async write<T>(\n kind: \"create\" | \"update\",\n values: Row,\n operation: (dataPath: DataPath, values: Row) => Promise<T>,\n // A payload a hook replaced is server-authored, so blaming the caller lies.\n byHook = false,\n ): Promise<T> {\n const rejection = byHook ? \"INTERNAL\" : \"INVALID_REQUEST\";\n if (kind === \"update\" && Object.keys(values).length === 0) {\n throw new DatabasePluginError(rejection, \"write\");\n }\n let parsed: Row;\n try {\n const validator = (\n kind === \"create\"\n ? this.ctx.table.$insertSchema\n : this.ctx.table.$updateSchema\n ) as { parse(value: unknown): Row };\n parsed = validator.parse(values);\n } catch {\n throw new DatabasePluginError(rejection, \"write\");\n }\n return this.run(\n \"write\",\n (dataPath) => operation(dataPath, parsed),\n databaseWriteDefaults,\n );\n }\n\n /** Execute through AppKit interceptors and expose only safe failures. */\n private async run<T>(\n phase: \"read\" | \"write\",\n operation: (dataPath: DataPath) => Promise<T>,\n defaults: PluginExecuteConfig = databaseReadDefaults,\n ): Promise<T> {\n try {\n this.ctx.assertActive();\n } catch (error) {\n throw classifyDatabaseError(error, phase);\n }\n const result = await this.ctx.execute(\n async () => {\n try {\n this.ctx.assertActive();\n return await operation(this.ctx.getDataPath());\n } catch (error) {\n throw classifyDatabaseError(error, phase);\n }\n },\n { default: defaults },\n );\n if (result.ok) return result.data;\n throw databaseErrorFromStatus(result.status, phase);\n }\n}\n"],"mappings":"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"}
@@ -81,5 +81,5 @@ type DatabaseExports = TransactionClient & {
81
81
  transaction<T>(callback: (tx: TransactionClient) => Promise<T>): Promise<T>;
82
82
  };
83
83
  //#endregion
84
- export { DatabaseExports };
84
+ export { DatabaseExports, TransactionClient };
85
85
  //# sourceMappingURL=entity-types.d.ts.map
@@ -0,0 +1,38 @@
1
+ import { DatabaseRegistry, DatabaseRegistryEntry } from "../../database/contract/registry.js";
2
+ import { IdValue } from "../../database/contract/wire.js";
3
+ import { TransactionClient } from "./entity-types.js";
4
+ import { Row } from "../../database/runtime/data-path.js";
5
+
6
+ //#region src/plugins/database/hooks.d.ts
7
+ /** The only capability a hook receives: entities bound to its transaction. */
8
+ interface HookApp {
9
+ readonly database: TransactionClient;
10
+ }
11
+ /** Which entity is being mutated, and the surface a hook may write through. */
12
+ interface HookContext {
13
+ readonly entity: string;
14
+ readonly app: HookApp;
15
+ }
16
+ type FacetOf<TTable, TFacet extends "row" | "insert" | "update"> = TTable extends keyof DatabaseRegistry ? DatabaseRegistry[TTable] extends DatabaseRegistryEntry ? DatabaseRegistry[TTable][TFacet] : Row : Row;
17
+ type MaybePromise<T> = T | Promise<T>;
18
+ /**
19
+ * Mutation lifecycle for one entity. A before hook may return a replacement
20
+ * payload, which is revalidated against the trusted schema before it is
21
+ * persisted. Every hook, the mutation, and any write a hook issues through
22
+ * `ctx.app.database` share one transaction, so a rejection anywhere rolls all
23
+ * of them back. Throw `DatabaseValidationError` to answer a generated route
24
+ * with `422`; any other failure stays an opaque server error.
25
+ */
26
+ interface EntityMutationHooks<TTable extends string = string> {
27
+ beforeCreate?(values: FacetOf<TTable, "insert">, context: HookContext): MaybePromise<FacetOf<TTable, "insert"> | void>;
28
+ afterCreate?(row: FacetOf<TTable, "row">, context: HookContext): MaybePromise<void>;
29
+ beforeUpdate?(id: IdValue, values: FacetOf<TTable, "update">, context: HookContext): MaybePromise<FacetOf<TTable, "update"> | void>;
30
+ afterUpdate?(row: FacetOf<TTable, "row">, context: HookContext): MaybePromise<void>;
31
+ beforeUpsert?(values: FacetOf<TTable, "insert">, context: HookContext): MaybePromise<FacetOf<TTable, "insert"> | void>;
32
+ afterUpsert?(row: FacetOf<TTable, "row">, context: HookContext): MaybePromise<void>;
33
+ beforeDelete?(id: IdValue, context: HookContext): MaybePromise<void>;
34
+ afterDelete?(id: IdValue, context: HookContext): MaybePromise<void>;
35
+ }
36
+ //#endregion
37
+ export { EntityMutationHooks, HookApp, HookContext };
38
+ //# sourceMappingURL=hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hooks.d.ts","names":[],"sources":["../../../src/plugins/database/hooks.ts"],"mappings":";;;;;;;UAWiB,OAAA;EAAA,SACN,QAAA,EAAU,iBAAA;AAAA;;UAIJ,WAAA;EAAA,SACN,MAAA;EAAA,SACA,GAAA,EAAK,OAAA;AAAA;AAAA,KAIX,OAAA,uDAGD,MAAA,eAAqB,gBAAA,GACrB,gBAAA,CAAiB,MAAA,UAAgB,qBAAA,GAC/B,gBAAA,CAAiB,MAAA,EAAQ,MAAA,IACzB,GAAA,GACF,GAAA;AAAA,KAEC,YAAA,MAAkB,CAAA,GAAI,OAAA,CAAQ,CAAA;;;;;;;AAZlC;;UAsBgB,mBAAA;EACf,YAAA,EACE,MAAA,EAAQ,OAAA,CAAQ,MAAA,aAChB,OAAA,EAAS,WAAA,GACR,YAAA,CAAa,OAAA,CAAQ,MAAA;EACxB,WAAA,EACE,GAAA,EAAK,OAAA,CAAQ,MAAA,UACb,OAAA,EAAS,WAAA,GACR,YAAA;EACH,YAAA,EACE,EAAA,EAAI,OAAA,EACJ,MAAA,EAAQ,OAAA,CAAQ,MAAA,aAChB,OAAA,EAAS,WAAA,GACR,YAAA,CAAa,OAAA,CAAQ,MAAA;EACxB,WAAA,EACE,GAAA,EAAK,OAAA,CAAQ,MAAA,UACb,OAAA,EAAS,WAAA,GACR,YAAA;EACH,YAAA,EACE,MAAA,EAAQ,OAAA,CAAQ,MAAA,aAChB,OAAA,EAAS,WAAA,GACR,YAAA,CAAa,OAAA,CAAQ,MAAA;EACxB,WAAA,EACE,GAAA,EAAK,OAAA,CAAQ,MAAA,UACb,OAAA,EAAS,WAAA,GACR,YAAA;EACH,YAAA,EAAc,EAAA,EAAI,OAAA,EAAS,OAAA,EAAS,WAAA,GAAc,YAAA;EAClD,WAAA,EAAa,EAAA,EAAI,OAAA,EAAS,OAAA,EAAS,WAAA,GAAc,YAAA;AAAA"}
@@ -1,3 +1,4 @@
1
- import { DatabaseExports } from "./entity-types.js";
2
- import { IDatabaseConfig } from "./types.js";
1
+ import { DatabaseExports, TransactionClient } from "./entity-types.js";
2
+ import { EntityMutationHooks, HookApp, HookContext } from "./hooks.js";
3
+ import { DatabaseApiConfig, DatabaseApiWriteOperation, DatabaseApiWritesConfig, EntityHooks, IDatabaseConfig, ReadSerializer, ReadSerializerContext } from "./types.js";
3
4
  import { database } from "./database.js";