@databricks/appkit 0.72.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.
Files changed (117) hide show
  1. package/CLAUDE.md +17 -0
  2. package/NOTICE.md +1 -0
  3. package/dist/appkit/package.js +1 -1
  4. package/dist/beta.d.ts +7 -4
  5. package/dist/beta.js +3 -2
  6. package/dist/cli/commands/agent/eval.js +9 -1
  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 +36 -0
  30. package/dist/evals/dataset.d.ts.map +1 -0
  31. package/dist/evals/dataset.js +36 -0
  32. package/dist/evals/dataset.js.map +1 -0
  33. package/dist/evals/http-driver.js +56 -51
  34. package/dist/evals/http-driver.js.map +1 -1
  35. package/dist/evals/index.d.ts +14 -0
  36. package/dist/evals/index.js +2 -1
  37. package/dist/evals/judge.d.ts +1 -0
  38. package/dist/evals/judge.d.ts.map +1 -1
  39. package/dist/evals/mlflow-report.d.ts +1 -0
  40. package/dist/evals/mlflow-report.d.ts.map +1 -1
  41. package/dist/evals/mlflow-run.d.ts +2 -0
  42. package/dist/evals/mlflow-run.d.ts.map +1 -1
  43. package/dist/evals/run-eval.d.ts +3 -0
  44. package/dist/evals/run-eval.d.ts.map +1 -1
  45. package/dist/evals/run-eval.js +10 -2
  46. package/dist/evals/run-eval.js.map +1 -1
  47. package/dist/evals/run-evals.d.ts +9 -0
  48. package/dist/evals/run-evals.d.ts.map +1 -1
  49. package/dist/evals/run-evals.js +101 -22
  50. package/dist/evals/run-evals.js.map +1 -1
  51. package/dist/evals/types.d.ts +40 -4
  52. package/dist/evals/types.d.ts.map +1 -1
  53. package/dist/index.d.ts +2 -1
  54. package/dist/index.js +2 -1
  55. package/dist/plugin/plugin.d.ts.map +1 -1
  56. package/dist/plugin/plugin.js +1 -1
  57. package/dist/plugin/plugin.js.map +1 -1
  58. package/dist/plugins/database/crud/contract.js +17 -8
  59. package/dist/plugins/database/crud/contract.js.map +1 -1
  60. package/dist/plugins/database/crud/exposure.js +63 -22
  61. package/dist/plugins/database/crud/exposure.js.map +1 -1
  62. package/dist/plugins/database/crud/request.js +50 -0
  63. package/dist/plugins/database/crud/request.js.map +1 -0
  64. package/dist/plugins/database/crud/response.js +77 -0
  65. package/dist/plugins/database/crud/response.js.map +1 -0
  66. package/dist/plugins/database/crud/routes.js +71 -52
  67. package/dist/plugins/database/crud/routes.js.map +1 -1
  68. package/dist/plugins/database/database.d.ts +6 -4
  69. package/dist/plugins/database/database.d.ts.map +1 -1
  70. package/dist/plugins/database/database.js +46 -16
  71. package/dist/plugins/database/database.js.map +1 -1
  72. package/dist/plugins/database/defaults.js +5 -1
  73. package/dist/plugins/database/defaults.js.map +1 -1
  74. package/dist/plugins/database/entity-client.js +143 -10
  75. package/dist/plugins/database/entity-client.js.map +1 -1
  76. package/dist/plugins/database/entity-types.d.ts +1 -1
  77. package/dist/plugins/database/hooks.d.ts +38 -0
  78. package/dist/plugins/database/hooks.d.ts.map +1 -0
  79. package/dist/plugins/database/index.d.ts +3 -2
  80. package/dist/plugins/database/lifecycle.js +67 -28
  81. package/dist/plugins/database/lifecycle.js.map +1 -1
  82. package/dist/plugins/database/scope.js +58 -0
  83. package/dist/plugins/database/scope.js.map +1 -0
  84. package/dist/plugins/database/types.d.ts +40 -12
  85. package/dist/plugins/database/types.d.ts.map +1 -1
  86. package/dist/shared/src/schemas/manifest.d.ts +33 -33
  87. package/docs/api/appkit/Class.AppKitError.md +1 -0
  88. package/docs/api/appkit/Class.DatabaseValidationError.md +191 -0
  89. package/docs/api/appkit/Function.defineSchema.md +1 -1
  90. package/docs/api/appkit/Function.readEvalDataset.md +21 -0
  91. package/docs/api/appkit/Function.resolveWorkspaceClient.md +18 -0
  92. package/docs/api/appkit/Interface.AssertionHandle.md +1 -1
  93. package/docs/api/appkit/Interface.DatabaseValidationIssue.md +21 -0
  94. package/docs/api/appkit/Interface.DatasetRow.md +21 -0
  95. package/docs/api/appkit/Interface.EntityMutationHooks.md +173 -0
  96. package/docs/api/appkit/Interface.EvalDefinition.md +28 -0
  97. package/docs/api/appkit/Interface.EvalDriver.md +16 -1
  98. package/docs/api/appkit/Interface.HookApp.md +12 -0
  99. package/docs/api/appkit/Interface.HookContext.md +21 -0
  100. package/docs/api/appkit/Interface.ReadEvalDatasetOptions.md +34 -0
  101. package/docs/api/appkit/Interface.ReadSerializerContext.md +21 -0
  102. package/docs/api/appkit/Interface.RunEvalOptions.md +11 -0
  103. package/docs/api/appkit/Interface.RunEvalsOptions.md +22 -0
  104. package/docs/api/appkit/Interface.TestContext.md +41 -4
  105. package/docs/api/appkit/TypeAlias.DatabaseApiConfig.md +53 -0
  106. package/docs/api/appkit/TypeAlias.DatabaseApiWriteOperation.md +8 -0
  107. package/docs/api/appkit/TypeAlias.DatabaseApiWritesConfig.md +49 -0
  108. package/docs/api/appkit/TypeAlias.DatabaseExports.md +3 -3
  109. package/docs/api/appkit/TypeAlias.EntityHooks.md +25 -0
  110. package/docs/api/appkit/TypeAlias.IDatabaseConfig.md +16 -5
  111. package/docs/api/appkit/TypeAlias.ReadSerializer.md +19 -0
  112. package/docs/api/appkit/TypeAlias.TransactionClient.md +19 -0
  113. package/docs/api/appkit.md +135 -119
  114. package/docs/plugins/database.md +144 -0
  115. package/llms.txt +17 -0
  116. package/package.json +2 -2
  117. package/sbom.cdx.json +1 -1
@@ -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";
@@ -1,10 +1,13 @@
1
1
  import { createLogger } from "../../logging/logger.js";
2
+ import { DatabaseValidationError } from "../../errors/database-validation.js";
3
+ import "../../errors/index.js";
2
4
  import { createLakebasePool } from "../../connectors/lakebase/index.js";
3
5
  import { assertFinalizedSchema } from "../../database/schema-builder/define-schema.js";
4
6
  import { DatabasePluginError, classifyDatabaseError } from "../../database/errors.js";
5
- import { STATEMENT_TIMEOUT_MS } from "./defaults.js";
7
+ import { IDLE_IN_TRANSACTION_TIMEOUT_MS, STATEMENT_TIMEOUT_MS, TRANSACTION_TIMEOUT_MS } from "./defaults.js";
6
8
  import { createDrizzleDataPath, createDrizzleDb } from "../../database/runtime/engine/drizzle-data-path.js";
7
9
  import { EntityClient } from "./entity-client.js";
10
+ import { createMutationScope } from "./scope.js";
8
11
 
9
12
  //#region src/plugins/database/lifecycle.ts
10
13
  const logger = createLogger("database");
@@ -13,6 +16,7 @@ function buildSql(context) {
13
16
  return async (strings, ...values) => {
14
17
  try {
15
18
  context.assertActive();
19
+ if (context.transactionBound) context.scope.consumeTransactionOperation();
16
20
  return await context.getDataPath().raw(strings, ...values);
17
21
  } catch (error) {
18
22
  throw classifyDatabaseError(error, "read");
@@ -26,47 +30,76 @@ function buildTransactionClient(context) {
26
30
  table,
27
31
  getDataPath: context.getDataPath,
28
32
  execute: context.execute,
29
- assertActive: context.assertActive
33
+ assertActive: context.assertActive,
34
+ scope: context.scope,
35
+ hooks: context.hooks?.[name],
36
+ transactionBound: context.transactionBound,
37
+ runInTransaction: (run) => runTransaction(context, (tx) => run(entityOf(tx, name)))
30
38
  });
31
39
  result.sql = buildSql(context);
32
40
  return result;
33
41
  }
42
+ /** Typegen owns the entity keys, so reaching one by table name stays dynamic. */
43
+ function entityOf(tx, name) {
44
+ return tx[name];
45
+ }
46
+ /** Reject from inside the driver callback so the driver rolls back on expiry. */
47
+ function runWithTransactionDeadline(run) {
48
+ let timer;
49
+ const deadline = new Promise((_, reject) => {
50
+ timer = setTimeout(() => reject(new DatabasePluginError("TRANSIENT", "transaction")), TRANSACTION_TIMEOUT_MS);
51
+ timer.unref?.();
52
+ });
53
+ return Promise.race([Promise.resolve().then(run), deadline]).finally(() => clearTimeout(timer));
54
+ }
55
+ /**
56
+ * Run inside one transaction, joining the active one rather than nesting:
57
+ * PostgreSQL has no nested transaction, and a savepoint would let part of a
58
+ * hooked mutation survive a rollback.
59
+ */
60
+ function runTransaction(context, callback) {
61
+ context.assertActive();
62
+ const active = context.scope.activeTransaction();
63
+ if (active) return callback(active);
64
+ return context.getDataPath().transaction(async (txDataPath) => {
65
+ let open = true;
66
+ const assertTransactionActive = () => {
67
+ context.assertActive();
68
+ if (!open) throw new DatabasePluginError("INTERNAL", "transaction");
69
+ };
70
+ const directExecute = async (operation) => ({
71
+ ok: true,
72
+ data: await operation()
73
+ });
74
+ const tx = buildTransactionClient({
75
+ ...context,
76
+ getDataPath: () => txDataPath,
77
+ execute: directExecute,
78
+ assertActive: assertTransactionActive,
79
+ transactionBound: true
80
+ });
81
+ try {
82
+ return await runWithTransactionDeadline(() => context.scope.runWithTransaction(tx, () => callback(tx)));
83
+ } finally {
84
+ open = false;
85
+ }
86
+ });
87
+ }
34
88
  /** Add the transaction entry point to the root database surface. */
35
89
  function buildDatabaseExports(context) {
36
90
  const result = buildTransactionClient(context);
37
91
  result.transaction = async (callback) => {
38
92
  try {
39
- context.assertActive();
40
- return await context.getDataPath().transaction(async (txDataPath) => {
41
- let active = true;
42
- const assertTransactionActive = () => {
43
- context.assertActive();
44
- if (!active) throw new DatabasePluginError("INTERNAL", "transaction");
45
- };
46
- const directExecute = async (operation) => ({
47
- ok: true,
48
- data: await operation()
49
- });
50
- const tx = buildTransactionClient({
51
- ...context,
52
- getDataPath: () => txDataPath,
53
- execute: directExecute,
54
- assertActive: assertTransactionActive
55
- });
56
- try {
57
- return await callback(tx);
58
- } finally {
59
- active = false;
60
- }
61
- });
93
+ return await runTransaction(context, callback);
62
94
  } catch (error) {
95
+ if (error instanceof DatabaseValidationError) throw error;
63
96
  throw classifyDatabaseError(error, "transaction");
64
97
  }
65
98
  };
66
99
  return result;
67
100
  }
68
101
  /** Validate the schema, create one pool-backed API, and verify connectivity. */
69
- async function createDatabaseState(schema, execute) {
102
+ async function createDatabaseState(schema, execute, hooks) {
70
103
  try {
71
104
  assertFinalizedSchema(schema);
72
105
  } catch (error) {
@@ -79,13 +112,19 @@ async function createDatabaseState(schema, execute) {
79
112
  if (!active) throw new DatabasePluginError("INTERNAL", "runtime");
80
113
  };
81
114
  try {
82
- pool = createLakebasePool({ statement_timeout: STATEMENT_TIMEOUT_MS });
115
+ pool = createLakebasePool({
116
+ statement_timeout: STATEMENT_TIMEOUT_MS,
117
+ idle_in_transaction_session_timeout: IDLE_IN_TRANSACTION_TIMEOUT_MS
118
+ });
83
119
  const dataPath = createDrizzleDataPath(createDrizzleDb(pool, schema), schema, { columnAccess: "trusted" });
84
120
  const exports = buildDatabaseExports({
85
121
  schema,
86
122
  getDataPath: () => dataPath,
87
123
  execute,
88
- assertActive
124
+ assertActive,
125
+ scope: createMutationScope(),
126
+ hooks,
127
+ transactionBound: false
89
128
  });
90
129
  await dataPath.raw`select 1`;
91
130
  return {
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.js","names":[],"sources":["../../../src/plugins/database/lifecycle.ts"],"sourcesContent":["import { createLakebasePool } from \"../../connectors/lakebase\";\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n} from \"../../database/errors\";\nimport type { DataPath } from \"../../database/runtime\";\nimport {\n createDrizzleDataPath,\n createDrizzleDb,\n} from \"../../database/runtime/engine/drizzle-data-path\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { createLogger } from \"../../logging/logger\";\nimport { STATEMENT_TIMEOUT_MS } from \"./defaults\";\nimport { EntityClient, type EntityExecute } from \"./entity-client\";\nimport type {\n DatabaseExports,\n SqlTag,\n TransactionClient,\n} from \"./entity-types\";\n\nconst logger = createLogger(\"database\");\n\n/** Resources owned by one successfully initialized plugin instance. */\nexport interface DatabaseState {\n readonly pool: ReturnType<typeof createLakebasePool>;\n readonly exports: DatabaseExports;\n readonly deactivate: () => void;\n}\n\ninterface ExportContext {\n readonly schema: Schema;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n}\n\n/** Execute value-only SQL on the bound DataPath without per-statement retries. */\nfunction buildSql(context: ExportContext): SqlTag {\n return async <T = Record<string, unknown>>(\n strings: TemplateStringsArray,\n ...values: unknown[]\n ) => {\n try {\n context.assertActive();\n // DataPath accepts value interpolation only and keeps Drizzle private.\n return await context.getDataPath().raw<T>(strings, ...values);\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n };\n}\n\n/** Build entities and SQL bound to either the root pool or one transaction. */\nfunction buildTransactionClient(context: ExportContext): TransactionClient {\n const result: Record<string, unknown> = Object.create(null);\n for (const [name, table] of Object.entries(context.schema.$tables)) {\n result[name] = new EntityClient({\n table,\n getDataPath: context.getDataPath,\n execute: context.execute,\n assertActive: context.assertActive,\n });\n }\n result.sql = buildSql(context);\n return result as TransactionClient;\n}\n\n/** Add the transaction entry point to the root database surface. */\nfunction buildDatabaseExports(context: ExportContext): DatabaseExports {\n const result = buildTransactionClient(context) as DatabaseExports;\n result.transaction = async <T>(\n callback: (tx: TransactionClient) => Promise<T>,\n ) => {\n try {\n context.assertActive();\n return await context.getDataPath().transaction(async (txDataPath) => {\n let active = true;\n const assertTransactionActive = () => {\n context.assertActive();\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"transaction\");\n };\n // The outer transaction owns execution; inner clients use its connection.\n const directExecute: EntityExecute = async (operation) => ({\n ok: true,\n data: await operation(),\n });\n const tx = buildTransactionClient({\n ...context,\n getDataPath: () => txDataPath,\n execute: directExecute,\n assertActive: assertTransactionActive,\n });\n try {\n return await callback(tx);\n } finally {\n // Captured clients must not outlive the transaction callback.\n active = false;\n }\n });\n } catch (error) {\n throw classifyDatabaseError(error, \"transaction\");\n }\n };\n return result;\n}\n\n/** Validate the schema, create one pool-backed API, and verify connectivity. */\nexport async function createDatabaseState<TSchema extends Schema>(\n schema: TSchema,\n execute: EntityExecute,\n): Promise<DatabaseState> {\n try {\n assertFinalizedSchema(schema);\n } catch (error) {\n logger.error(\"Database schema failed validation: %O\", error);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n\n let active = true;\n let pool: ReturnType<typeof createLakebasePool> | undefined;\n const assertActive = () => {\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"runtime\");\n };\n try {\n pool = createLakebasePool({ statement_timeout: STATEMENT_TIMEOUT_MS });\n const db = createDrizzleDb(pool, schema);\n const dataPath = createDrizzleDataPath(db, schema, {\n columnAccess: \"trusted\",\n });\n const exports = buildDatabaseExports({\n schema,\n getDataPath: () => dataPath,\n execute,\n assertActive,\n });\n // Do not publish exports until an authenticated statement succeeds.\n await dataPath.raw`select 1`;\n return {\n pool,\n exports,\n deactivate: () => {\n active = false;\n },\n };\n } catch (error) {\n logger.error(\"Database setup failed: %O\", error);\n active = false;\n await pool?.end().catch(() => undefined);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n}\n"],"mappings":";;;;;;;;;AAqBA,MAAM,SAAS,aAAa,WAAW;;AAiBvC,SAAS,SAAS,SAAgC;AAChD,QAAO,OACL,SACA,GAAG,WACA;AACH,MAAI;AACF,WAAQ,cAAc;AAEtB,UAAO,MAAM,QAAQ,aAAa,CAAC,IAAO,SAAS,GAAG,OAAO;WACtD,OAAO;AACd,SAAM,sBAAsB,OAAO,OAAO;;;;;AAMhD,SAAS,uBAAuB,SAA2C;CACzE,MAAM,SAAkC,OAAO,OAAO,KAAK;AAC3D,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,QAAQ,OAAO,QAAQ,CAChE,QAAO,QAAQ,IAAI,aAAa;EAC9B;EACA,aAAa,QAAQ;EACrB,SAAS,QAAQ;EACjB,cAAc,QAAQ;EACvB,CAAC;AAEJ,QAAO,MAAM,SAAS,QAAQ;AAC9B,QAAO;;;AAIT,SAAS,qBAAqB,SAAyC;CACrE,MAAM,SAAS,uBAAuB,QAAQ;AAC9C,QAAO,cAAc,OACnB,aACG;AACH,MAAI;AACF,WAAQ,cAAc;AACtB,UAAO,MAAM,QAAQ,aAAa,CAAC,YAAY,OAAO,eAAe;IACnE,IAAI,SAAS;IACb,MAAM,gCAAgC;AACpC,aAAQ,cAAc;AACtB,SAAI,CAAC,OAAQ,OAAM,IAAI,oBAAoB,YAAY,cAAc;;IAGvE,MAAM,gBAA+B,OAAO,eAAe;KACzD,IAAI;KACJ,MAAM,MAAM,WAAW;KACxB;IACD,MAAM,KAAK,uBAAuB;KAChC,GAAG;KACH,mBAAmB;KACnB,SAAS;KACT,cAAc;KACf,CAAC;AACF,QAAI;AACF,YAAO,MAAM,SAAS,GAAG;cACjB;AAER,cAAS;;KAEX;WACK,OAAO;AACd,SAAM,sBAAsB,OAAO,cAAc;;;AAGrD,QAAO;;;AAIT,eAAsB,oBACpB,QACA,SACwB;AACxB,KAAI;AACF,wBAAsB,OAAO;UACtB,OAAO;AACd,SAAO,MAAM,yCAAyC,MAAM;AAC5D,QAAM,IAAI,oBAAoB,gBAAgB,QAAQ;;CAGxD,IAAI,SAAS;CACb,IAAI;CACJ,MAAM,qBAAqB;AACzB,MAAI,CAAC,OAAQ,OAAM,IAAI,oBAAoB,YAAY,UAAU;;AAEnE,KAAI;AACF,SAAO,mBAAmB,EAAE,mBAAmB,sBAAsB,CAAC;EAEtE,MAAM,WAAW,sBADN,gBAAgB,MAAM,OAAO,EACG,QAAQ,EACjD,cAAc,WACf,CAAC;EACF,MAAM,UAAU,qBAAqB;GACnC;GACA,mBAAmB;GACnB;GACA;GACD,CAAC;AAEF,QAAM,SAAS,GAAG;AAClB,SAAO;GACL;GACA;GACA,kBAAkB;AAChB,aAAS;;GAEZ;UACM,OAAO;AACd,SAAO,MAAM,6BAA6B,MAAM;AAChD,WAAS;AACT,QAAM,MAAM,KAAK,CAAC,YAAY,OAAU;AACxC,QAAM,IAAI,oBAAoB,gBAAgB,QAAQ"}
1
+ {"version":3,"file":"lifecycle.js","names":[],"sources":["../../../src/plugins/database/lifecycle.ts"],"sourcesContent":["import { createLakebasePool } from \"../../connectors/lakebase\";\nimport {\n classifyDatabaseError,\n DatabasePluginError,\n} from \"../../database/errors\";\nimport type { DataPath } from \"../../database/runtime\";\nimport {\n createDrizzleDataPath,\n createDrizzleDb,\n} from \"../../database/runtime/engine/drizzle-data-path\";\nimport type { Schema } from \"../../database/schema-builder\";\nimport { assertFinalizedSchema } from \"../../database/schema-builder/define-schema\";\nimport { DatabaseValidationError } from \"../../errors\";\nimport { createLogger } from \"../../logging/logger\";\nimport {\n IDLE_IN_TRANSACTION_TIMEOUT_MS,\n STATEMENT_TIMEOUT_MS,\n TRANSACTION_TIMEOUT_MS,\n} from \"./defaults\";\nimport { EntityClient, type EntityExecute } from \"./entity-client\";\nimport type {\n DatabaseExports,\n SqlTag,\n TransactionClient,\n} from \"./entity-types\";\nimport { createMutationScope, type MutationScope } from \"./scope\";\nimport type { DatabaseHooks } from \"./types\";\n\nconst logger = createLogger(\"database\");\n\n/** Resources owned by one successfully initialized plugin instance. */\nexport interface DatabaseState {\n readonly pool: ReturnType<typeof createLakebasePool>;\n readonly exports: DatabaseExports;\n readonly deactivate: () => void;\n}\n\ninterface ExportContext {\n readonly schema: Schema;\n readonly getDataPath: () => DataPath;\n readonly execute: EntityExecute;\n readonly assertActive: () => void;\n readonly scope: MutationScope;\n readonly hooks?: DatabaseHooks;\n readonly transactionBound: boolean;\n}\n\n/** Execute value-only SQL on the bound DataPath without per-statement retries. */\nfunction buildSql(context: ExportContext): SqlTag {\n return async <T = Record<string, unknown>>(\n strings: TemplateStringsArray,\n ...values: unknown[]\n ) => {\n try {\n context.assertActive();\n if (context.transactionBound) {\n context.scope.consumeTransactionOperation();\n }\n // DataPath accepts value interpolation only and keeps Drizzle private.\n return await context.getDataPath().raw<T>(strings, ...values);\n } catch (error) {\n throw classifyDatabaseError(error, \"read\");\n }\n };\n}\n\n/** Build entities and SQL bound to either the root pool or one transaction. */\nfunction buildTransactionClient(context: ExportContext): TransactionClient {\n const result: Record<string, unknown> = Object.create(null);\n for (const [name, table] of Object.entries(context.schema.$tables)) {\n result[name] = new EntityClient({\n table,\n getDataPath: context.getDataPath,\n execute: context.execute,\n assertActive: context.assertActive,\n scope: context.scope,\n hooks: context.hooks?.[name],\n transactionBound: context.transactionBound,\n runInTransaction: (run) =>\n runTransaction(context, (tx) => run(entityOf(tx, name))),\n });\n }\n result.sql = buildSql(context);\n return result as TransactionClient;\n}\n\n/** Typegen owns the entity keys, so reaching one by table name stays dynamic. */\nfunction entityOf(tx: TransactionClient, name: string): EntityClient {\n return (tx as unknown as Record<string, EntityClient>)[name];\n}\n\n/** Reject from inside the driver callback so the driver rolls back on expiry. */\nfunction runWithTransactionDeadline<T>(run: () => Promise<T>): Promise<T> {\n let timer: ReturnType<typeof setTimeout>;\n const deadline = new Promise<never>((_, reject) => {\n timer = setTimeout(\n () => reject(new DatabasePluginError(\"TRANSIENT\", \"transaction\")),\n TRANSACTION_TIMEOUT_MS,\n );\n timer.unref?.();\n });\n return Promise.race([Promise.resolve().then(run), deadline]).finally(() =>\n clearTimeout(timer),\n );\n}\n\n/**\n * Run inside one transaction, joining the active one rather than nesting:\n * PostgreSQL has no nested transaction, and a savepoint would let part of a\n * hooked mutation survive a rollback.\n */\nfunction runTransaction<T>(\n context: ExportContext,\n callback: (tx: TransactionClient) => Promise<T>,\n): Promise<T> {\n context.assertActive();\n const active = context.scope.activeTransaction();\n if (active) return callback(active);\n return context.getDataPath().transaction(async (txDataPath) => {\n let open = true;\n const assertTransactionActive = () => {\n context.assertActive();\n if (!open) throw new DatabasePluginError(\"INTERNAL\", \"transaction\");\n };\n // The outer transaction owns execution; inner clients use its connection.\n const directExecute: EntityExecute = async (operation) => ({\n ok: true,\n data: await operation(),\n });\n const tx = buildTransactionClient({\n ...context,\n getDataPath: () => txDataPath,\n execute: directExecute,\n assertActive: assertTransactionActive,\n transactionBound: true,\n });\n try {\n return await runWithTransactionDeadline(() =>\n context.scope.runWithTransaction(tx, () => callback(tx)),\n );\n } finally {\n // Captured clients must not outlive the transaction callback.\n open = false;\n }\n });\n}\n\n/** Add the transaction entry point to the root database surface. */\nfunction buildDatabaseExports(context: ExportContext): DatabaseExports {\n const result = buildTransactionClient(context) as DatabaseExports;\n result.transaction = async <T>(\n callback: (tx: TransactionClient) => Promise<T>,\n ) => {\n try {\n return await runTransaction(context, callback);\n } catch (error) {\n if (error instanceof DatabaseValidationError) throw error;\n throw classifyDatabaseError(error, \"transaction\");\n }\n };\n return result;\n}\n\n/** Validate the schema, create one pool-backed API, and verify connectivity. */\nexport async function createDatabaseState<TSchema extends Schema>(\n schema: TSchema,\n execute: EntityExecute,\n hooks?: DatabaseHooks,\n): Promise<DatabaseState> {\n try {\n assertFinalizedSchema(schema);\n } catch (error) {\n logger.error(\"Database schema failed validation: %O\", error);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n\n let active = true;\n let pool: ReturnType<typeof createLakebasePool> | undefined;\n const assertActive = () => {\n if (!active) throw new DatabasePluginError(\"INTERNAL\", \"runtime\");\n };\n try {\n pool = createLakebasePool({\n statement_timeout: STATEMENT_TIMEOUT_MS,\n idle_in_transaction_session_timeout: IDLE_IN_TRANSACTION_TIMEOUT_MS,\n });\n const db = createDrizzleDb(pool, schema);\n const dataPath = createDrizzleDataPath(db, schema, {\n columnAccess: \"trusted\",\n });\n const exports = buildDatabaseExports({\n schema,\n getDataPath: () => dataPath,\n execute,\n assertActive,\n // One scope per state keeps concurrent instances from sharing a transaction.\n scope: createMutationScope(),\n hooks,\n transactionBound: false,\n });\n // Do not publish exports until an authenticated statement succeeds.\n await dataPath.raw`select 1`;\n return {\n pool,\n exports,\n deactivate: () => {\n active = false;\n },\n };\n } catch (error) {\n logger.error(\"Database setup failed: %O\", error);\n active = false;\n await pool?.end().catch(() => undefined);\n throw new DatabasePluginError(\"SETUP_FAILED\", \"setup\");\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { DatabasePluginError } from "../../database/errors.js";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+
4
+ //#region src/plugins/database/scope.ts
5
+ /** How deep hook-issued mutations may nest before the guard rejects. */
6
+ const MAX_MUTATION_DEPTH = 8;
7
+ /** Max database operations admitted by one transaction. */
8
+ const MAX_TRANSACTION_OPERATIONS = 100;
9
+ /** Charge one operation to a transaction-owned shared budget. */
10
+ function consumeBudget(state, phase) {
11
+ const budget = state?.budget ?? { used: 0 };
12
+ if (budget.used >= MAX_TRANSACTION_OPERATIONS) throw new DatabasePluginError("INTERNAL", phase);
13
+ budget.used += 1;
14
+ return budget;
15
+ }
16
+ /**
17
+ * Async context owned by one plugin instance. It carries the transaction,
18
+ * mutation budget, and frames that bound recursion, so neither two instances
19
+ * nor two concurrent calls can observe each other.
20
+ */
21
+ function createMutationScope() {
22
+ const storage = new AsyncLocalStorage();
23
+ return {
24
+ activeTransaction() {
25
+ return storage.getStore()?.transaction;
26
+ },
27
+ runWithTransaction(transaction, run) {
28
+ const current = storage.getStore();
29
+ return storage.run({
30
+ transaction,
31
+ frames: current?.frames ?? [],
32
+ budget: current?.budget ?? { used: 0 }
33
+ }, run);
34
+ },
35
+ consumeTransactionOperation() {
36
+ const state = storage.getStore();
37
+ if (state?.transaction) consumeBudget(state, "transaction");
38
+ },
39
+ async runMutation(entity, operation, run) {
40
+ const state = storage.getStore();
41
+ const frames = state?.frames ?? [];
42
+ if (frames.some((frame) => frame.entity === entity && frame.operation === operation) || frames.length >= MAX_MUTATION_DEPTH) throw new DatabasePluginError("INTERNAL", "write");
43
+ const budget = consumeBudget(state, "write");
44
+ return storage.run({
45
+ transaction: state?.transaction,
46
+ frames: [...frames, {
47
+ entity,
48
+ operation
49
+ }],
50
+ budget
51
+ }, run);
52
+ }
53
+ };
54
+ }
55
+
56
+ //#endregion
57
+ export { createMutationScope };
58
+ //# sourceMappingURL=scope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope.js","names":[],"sources":["../../../src/plugins/database/scope.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport { DatabasePluginError } from \"../../database/errors\";\nimport type { TransactionClient } from \"./entity-types\";\nimport type { MutationOperation } from \"./hooks\";\n\n/** How deep hook-issued mutations may nest before the guard rejects. */\nconst MAX_MUTATION_DEPTH = 8;\n/** Max database operations admitted by one transaction. */\nexport const MAX_TRANSACTION_OPERATIONS = 100;\n\ninterface MutationFrame {\n readonly entity: string;\n readonly operation: MutationOperation;\n}\n\ninterface MutationBudget {\n used: number;\n}\n\ninterface ScopeState {\n readonly transaction?: TransactionClient;\n readonly frames: readonly MutationFrame[];\n readonly budget: MutationBudget;\n}\n\n/** Charge one operation to a transaction-owned shared budget. */\nfunction consumeBudget(\n state: ScopeState | undefined,\n phase: \"transaction\" | \"write\",\n): MutationBudget {\n const budget = state?.budget ?? { used: 0 };\n if (budget.used >= MAX_TRANSACTION_OPERATIONS) {\n throw new DatabasePluginError(\"INTERNAL\", phase);\n }\n budget.used += 1;\n return budget;\n}\n\n/** Instance-owned async context created by `createMutationScope()`. */\nexport type MutationScope = ReturnType<typeof createMutationScope>;\n\n/**\n * Async context owned by one plugin instance. It carries the transaction,\n * mutation budget, and frames that bound recursion, so neither two instances\n * nor two concurrent calls can observe each other.\n */\nexport function createMutationScope() {\n const storage = new AsyncLocalStorage<ScopeState>();\n\n return {\n /** The transaction a hook must join, or `undefined` outside one. */\n activeTransaction(): TransactionClient | undefined {\n return storage.getStore()?.transaction;\n },\n\n /** Publish one transaction to everything the callback awaits. */\n runWithTransaction<T>(\n transaction: TransactionClient,\n run: () => Promise<T>,\n ): Promise<T> {\n const current = storage.getStore();\n return storage.run(\n {\n transaction,\n frames: current?.frames ?? [],\n budget: current?.budget ?? { used: 0 },\n },\n run,\n );\n },\n\n /** Charge SQL and other direct work bound to the active transaction. */\n consumeTransactionOperation(): void {\n const state = storage.getStore();\n if (state?.transaction) consumeBudget(state, \"transaction\");\n },\n\n /** Open one frame, refusing a repeated entity/operation pair or deep nesting. */\n async runMutation<T>(\n entity: string,\n operation: MutationOperation,\n run: () => Promise<T>,\n ): Promise<T> {\n const state = storage.getStore();\n const frames = state?.frames ?? [];\n const repeated = frames.some(\n (frame) => frame.entity === entity && frame.operation === operation,\n );\n if (repeated || frames.length >= MAX_MUTATION_DEPTH) {\n throw new DatabasePluginError(\"INTERNAL\", \"write\");\n }\n const budget = consumeBudget(state, \"write\");\n return storage.run(\n {\n transaction: state?.transaction,\n frames: [...frames, { entity, operation }],\n budget,\n },\n run,\n );\n },\n };\n}\n"],"mappings":";;;;;AAOA,MAAM,qBAAqB;;AAE3B,MAAa,6BAA6B;;AAkB1C,SAAS,cACP,OACA,OACgB;CAChB,MAAM,SAAS,OAAO,UAAU,EAAE,MAAM,GAAG;AAC3C,KAAI,OAAO,QAAQ,2BACjB,OAAM,IAAI,oBAAoB,YAAY,MAAM;AAElD,QAAO,QAAQ;AACf,QAAO;;;;;;;AAWT,SAAgB,sBAAsB;CACpC,MAAM,UAAU,IAAI,mBAA+B;AAEnD,QAAO;EAEL,oBAAmD;AACjD,UAAO,QAAQ,UAAU,EAAE;;EAI7B,mBACE,aACA,KACY;GACZ,MAAM,UAAU,QAAQ,UAAU;AAClC,UAAO,QAAQ,IACb;IACE;IACA,QAAQ,SAAS,UAAU,EAAE;IAC7B,QAAQ,SAAS,UAAU,EAAE,MAAM,GAAG;IACvC,EACD,IACD;;EAIH,8BAAoC;GAClC,MAAM,QAAQ,QAAQ,UAAU;AAChC,OAAI,OAAO,YAAa,eAAc,OAAO,cAAc;;EAI7D,MAAM,YACJ,QACA,WACA,KACY;GACZ,MAAM,QAAQ,QAAQ,UAAU;GAChC,MAAM,SAAS,OAAO,UAAU,EAAE;AAIlC,OAHiB,OAAO,MACrB,UAAU,MAAM,WAAW,UAAU,MAAM,cAAc,UAC3D,IACe,OAAO,UAAU,mBAC/B,OAAM,IAAI,oBAAoB,YAAY,QAAQ;GAEpD,MAAM,SAAS,cAAc,OAAO,QAAQ;AAC5C,UAAO,QAAQ,IACb;IACE,aAAa,OAAO;IACpB,QAAQ,CAAC,GAAG,QAAQ;KAAE;KAAQ;KAAW,CAAC;IAC1C;IACD,EACD,IACD;;EAEJ"}