@cadenza.io/service 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1649,7 +1649,7 @@ var ServiceRegistry = class _ServiceRegistry {
1649
1649
  }
1650
1650
  ).emits("meta.service_registry.service_inserted").emitsOnFail("meta.service_registry.service_insertion_failed");
1651
1651
  this.insertServiceInstanceTask = CadenzaService.createCadenzaDBInsertTask(
1652
- "serviceInstance",
1652
+ "service_instance",
1653
1653
  {},
1654
1654
  {
1655
1655
  inputSchema: {
@@ -2593,20 +2593,44 @@ var RestController = class _RestController {
2593
2593
  let ctx2;
2594
2594
  ctx2 = req.body;
2595
2595
  deputyExecId = ctx2.__metadata.__deputyExecId;
2596
+ const remoteRoutineName = ctx2.__remoteRoutineName;
2597
+ const targetNotFoundSignal = `meta.rest.delegation_target_not_found:${deputyExecId}`;
2598
+ let resolved = false;
2599
+ const resolveDelegation = (endCtx, status) => {
2600
+ if (resolved || res.headersSent) {
2601
+ return;
2602
+ }
2603
+ resolved = true;
2604
+ const metadata = endCtx?.__metadata && typeof endCtx.__metadata === "object" ? endCtx.__metadata : {};
2605
+ if (endCtx?.__metadata) {
2606
+ delete endCtx.__metadata;
2607
+ }
2608
+ res.json({
2609
+ ...endCtx,
2610
+ ...metadata,
2611
+ __status: status
2612
+ });
2613
+ };
2596
2614
  CadenzaService.createEphemeralMetaTask(
2597
2615
  "Resolve delegation",
2598
- (endCtx) => {
2599
- const metadata = endCtx.__metadata;
2600
- delete endCtx.__metadata;
2601
- res.json({
2602
- ...endCtx,
2603
- ...metadata,
2604
- __status: "success"
2605
- });
2606
- },
2616
+ (endCtx) => resolveDelegation(endCtx, "success"),
2607
2617
  "Resolves a delegation request",
2608
2618
  { register: false }
2609
2619
  ).doOn(`meta.node.graph_completed:${deputyExecId}`).emits(`meta.rest.delegation_resolved:${deputyExecId}`);
2620
+ CadenzaService.createEphemeralMetaTask(
2621
+ "Resolve delegation target lookup failure",
2622
+ (endCtx) => resolveDelegation(endCtx, "error"),
2623
+ "Resolves delegation requests that cannot find a local task or routine",
2624
+ { register: false }
2625
+ ).doOn(targetNotFoundSignal);
2626
+ if (!CadenzaService.get(remoteRoutineName) && !CadenzaService.registry.routines.get(remoteRoutineName)) {
2627
+ CadenzaService.emit(targetNotFoundSignal, {
2628
+ ...ctx2,
2629
+ __error: `No task or routine registered for delegation target ${remoteRoutineName}.`,
2630
+ errored: true
2631
+ });
2632
+ return;
2633
+ }
2610
2634
  CadenzaService.emit("meta.rest.delegation_requested", {
2611
2635
  ...ctx2,
2612
2636
  __name: ctx2.__remoteRoutineName
@@ -2777,8 +2801,16 @@ var RestController = class _RestController {
2777
2801
  CadenzaService.runner.run(routine, context);
2778
2802
  return true;
2779
2803
  } else {
2804
+ const deputyExecId = context.__metadata?.__deputyExecId ?? context.__deputyExecId;
2805
+ const remoteRoutineName = context.__remoteRoutineName ?? context.__name ?? "unknown";
2780
2806
  context.errored = true;
2781
- context.__error = "No routine or task defined.";
2807
+ context.__error = `No task or routine registered for delegation target ${remoteRoutineName}.`;
2808
+ if (deputyExecId) {
2809
+ emit(
2810
+ `meta.rest.delegation_target_not_found:${deputyExecId}`,
2811
+ context
2812
+ );
2813
+ }
2782
2814
  emit("meta.runner.failed", context);
2783
2815
  return false;
2784
2816
  }
@@ -2801,25 +2833,25 @@ var RestController = class _RestController {
2801
2833
  (ctx) => {
2802
2834
  const { serviceName, serviceAddress, servicePort, protocol } = ctx;
2803
2835
  const port = protocol === "https" ? 443 : servicePort;
2804
- const URL = `${protocol}://${serviceAddress}:${port}`;
2836
+ const URL2 = `${protocol}://${serviceAddress}:${port}`;
2805
2837
  const fetchId = `${serviceAddress}_${port}`;
2806
2838
  const fetchDiagnostics = this.ensureFetchClientDiagnostics(
2807
2839
  fetchId,
2808
2840
  serviceName,
2809
- URL
2841
+ URL2
2810
2842
  );
2811
2843
  fetchDiagnostics.destroyed = false;
2812
2844
  fetchDiagnostics.updatedAt = Date.now();
2813
- if (CadenzaService.get(`Send Handshake to ${URL}`)) {
2814
- console.error("Fetch client already exists", URL);
2845
+ if (CadenzaService.get(`Send Handshake to ${URL2}`)) {
2846
+ console.error("Fetch client already exists", URL2);
2815
2847
  return;
2816
2848
  }
2817
2849
  const handshakeTask = CadenzaService.createMetaTask(
2818
- `Send Handshake to ${URL}`,
2850
+ `Send Handshake to ${URL2}`,
2819
2851
  async (ctx2, emit) => {
2820
2852
  try {
2821
2853
  const response = await this.fetchDataWithTimeout(
2822
- `${URL}/handshake`,
2854
+ `${URL2}/handshake`,
2823
2855
  {
2824
2856
  headers: {
2825
2857
  "Content-Type": "application/json"
@@ -2834,10 +2866,10 @@ var RestController = class _RestController {
2834
2866
  fetchDiagnostics.connected = false;
2835
2867
  fetchDiagnostics.lastHandshakeError = error;
2836
2868
  fetchDiagnostics.updatedAt = Date.now();
2837
- this.recordFetchClientError(fetchId, serviceName, URL, error);
2869
+ this.recordFetchClientError(fetchId, serviceName, URL2, error);
2838
2870
  CadenzaService.log(
2839
2871
  "Fetch handshake failed.",
2840
- { error, serviceName, URL },
2872
+ { error, serviceName, URL: URL2 },
2841
2873
  "warning"
2842
2874
  );
2843
2875
  emit(`meta.fetch.handshake_failed:${fetchId}`, response);
@@ -2852,7 +2884,7 @@ var RestController = class _RestController {
2852
2884
  CadenzaService.log("Fetch client connected.", {
2853
2885
  response,
2854
2886
  serviceName,
2855
- URL
2887
+ URL: URL2
2856
2888
  });
2857
2889
  for (const communicationType of ctx2.communicationTypes) {
2858
2890
  emit("global.meta.fetch.service_communication_established", {
@@ -2867,10 +2899,10 @@ var RestController = class _RestController {
2867
2899
  fetchDiagnostics.connected = false;
2868
2900
  fetchDiagnostics.lastHandshakeError = this.getErrorMessage(e);
2869
2901
  fetchDiagnostics.updatedAt = Date.now();
2870
- this.recordFetchClientError(fetchId, serviceName, URL, e);
2902
+ this.recordFetchClientError(fetchId, serviceName, URL2, e);
2871
2903
  CadenzaService.log(
2872
2904
  "Error in fetch handshake",
2873
- { error: e, serviceName, URL, ctx: ctx2 },
2905
+ { error: e, serviceName, URL: URL2, ctx: ctx2 },
2874
2906
  "error"
2875
2907
  );
2876
2908
  return { ...ctx2, __error: e, errored: true };
@@ -2884,7 +2916,7 @@ var RestController = class _RestController {
2884
2916
  "global.meta.fetch.service_communication_established"
2885
2917
  );
2886
2918
  const delegateTask = CadenzaService.createMetaTask(
2887
- `Delegate flow to REST server ${URL}`,
2919
+ `Delegate flow to REST server ${URL2}`,
2888
2920
  async (ctx2, emit) => {
2889
2921
  if (ctx2.__remoteRoutineName === void 0) {
2890
2922
  return;
@@ -2894,7 +2926,7 @@ var RestController = class _RestController {
2894
2926
  let resultContext;
2895
2927
  try {
2896
2928
  resultContext = await this.fetchDataWithTimeout(
2897
- `${URL}/delegation`,
2929
+ `${URL2}/delegation`,
2898
2930
  {
2899
2931
  headers: {
2900
2932
  "Content-Type": "application/json"
@@ -2910,7 +2942,7 @@ var RestController = class _RestController {
2910
2942
  this.recordFetchClientError(
2911
2943
  fetchId,
2912
2944
  serviceName,
2913
- URL,
2945
+ URL2,
2914
2946
  resultContext?.__error ?? resultContext?.error ?? "Delegation failed"
2915
2947
  );
2916
2948
  }
@@ -2918,7 +2950,7 @@ var RestController = class _RestController {
2918
2950
  console.error("Error in delegation", e);
2919
2951
  fetchDiagnostics.delegationFailures++;
2920
2952
  fetchDiagnostics.updatedAt = Date.now();
2921
- this.recordFetchClientError(fetchId, serviceName, URL, e);
2953
+ this.recordFetchClientError(fetchId, serviceName, URL2, e);
2922
2954
  resultContext = {
2923
2955
  __error: `Error: ${e}`,
2924
2956
  errored: true,
@@ -2939,7 +2971,7 @@ var RestController = class _RestController {
2939
2971
  `meta.service_registry.socket_failed:${fetchId}`
2940
2972
  ).emitsOnFail("meta.fetch.delegate_failed").attachSignal("meta.fetch.delegated");
2941
2973
  const transmitTask = CadenzaService.createMetaTask(
2942
- `Transmit signal to server ${URL}`,
2974
+ `Transmit signal to server ${URL2}`,
2943
2975
  async (ctx2, emit) => {
2944
2976
  if (ctx2.__signalName === void 0) {
2945
2977
  return;
@@ -2949,7 +2981,7 @@ var RestController = class _RestController {
2949
2981
  let response;
2950
2982
  try {
2951
2983
  response = await this.fetchDataWithTimeout(
2952
- `${URL}/signal`,
2984
+ `${URL2}/signal`,
2953
2985
  {
2954
2986
  headers: {
2955
2987
  "Content-Type": "application/json"
@@ -2968,7 +3000,7 @@ var RestController = class _RestController {
2968
3000
  this.recordFetchClientError(
2969
3001
  fetchId,
2970
3002
  serviceName,
2971
- URL,
3003
+ URL2,
2972
3004
  response?.__error ?? response?.error ?? "Signal transmission failed"
2973
3005
  );
2974
3006
  }
@@ -2976,7 +3008,7 @@ var RestController = class _RestController {
2976
3008
  console.error("Error in transmission", e);
2977
3009
  fetchDiagnostics.signalFailures++;
2978
3010
  fetchDiagnostics.updatedAt = Date.now();
2979
- this.recordFetchClientError(fetchId, serviceName, URL, e);
3011
+ this.recordFetchClientError(fetchId, serviceName, URL2, e);
2980
3012
  response = {
2981
3013
  __error: `Error: ${e}`,
2982
3014
  errored: true,
@@ -2992,14 +3024,14 @@ var RestController = class _RestController {
2992
3024
  "meta.signal_controller.wildcard_signal_registered"
2993
3025
  ).emitsOnFail("meta.fetch.signal_transmission_failed").attachSignal("meta.fetch.transmitted");
2994
3026
  const statusTask = CadenzaService.createMetaTask(
2995
- `Request status from ${URL}`,
3027
+ `Request status from ${URL2}`,
2996
3028
  async (ctx2) => {
2997
3029
  fetchDiagnostics.statusChecks++;
2998
3030
  fetchDiagnostics.updatedAt = Date.now();
2999
3031
  let status;
3000
3032
  try {
3001
3033
  status = await this.fetchDataWithTimeout(
3002
- `${URL}/status`,
3034
+ `${URL2}/status`,
3003
3035
  {
3004
3036
  method: "GET"
3005
3037
  },
@@ -3011,14 +3043,14 @@ var RestController = class _RestController {
3011
3043
  this.recordFetchClientError(
3012
3044
  fetchId,
3013
3045
  serviceName,
3014
- URL,
3046
+ URL2,
3015
3047
  status?.__error ?? status?.error ?? "Status check failed"
3016
3048
  );
3017
3049
  }
3018
3050
  } catch (e) {
3019
3051
  fetchDiagnostics.statusFailures++;
3020
3052
  fetchDiagnostics.updatedAt = Date.now();
3021
- this.recordFetchClientError(fetchId, serviceName, URL, e);
3053
+ this.recordFetchClientError(fetchId, serviceName, URL2, e);
3022
3054
  status = {
3023
3055
  __error: `Error: ${e}`,
3024
3056
  errored: true,
@@ -3033,7 +3065,7 @@ var RestController = class _RestController {
3033
3065
  fetchDiagnostics.connected = false;
3034
3066
  fetchDiagnostics.destroyed = true;
3035
3067
  fetchDiagnostics.updatedAt = Date.now();
3036
- CadenzaService.log("Destroying fetch client", { URL, serviceName });
3068
+ CadenzaService.log("Destroying fetch client", { URL: URL2, serviceName });
3037
3069
  handshakeTask.destroy();
3038
3070
  delegateTask.destroy();
3039
3071
  transmitTask.destroy();
@@ -3435,13 +3467,13 @@ var SocketController = class _SocketController {
3435
3467
  Object.assign(base, patch);
3436
3468
  base.fetchId = fetchId;
3437
3469
  base.updatedAt = now;
3438
- const errorMessage = input.error !== void 0 ? this.getErrorMessage(input.error) : void 0;
3439
- if (errorMessage) {
3440
- base.lastError = errorMessage;
3470
+ const errorMessage2 = input.error !== void 0 ? this.getErrorMessage(input.error) : void 0;
3471
+ if (errorMessage2) {
3472
+ base.lastError = errorMessage2;
3441
3473
  base.lastErrorAt = now;
3442
3474
  base.errorHistory.push({
3443
3475
  at: new Date(now).toISOString(),
3444
- message: errorMessage
3476
+ message: errorMessage2
3445
3477
  });
3446
3478
  if (base.errorHistory.length > this.diagnosticsErrorHistoryLimit) {
3447
3479
  base.errorHistory.splice(
@@ -3943,12 +3975,12 @@ var SocketController = class _SocketController {
3943
3975
  if (ack) ack(response);
3944
3976
  resolve(response);
3945
3977
  };
3946
- const resolveWithError = (errorMessage, fallbackError) => {
3978
+ const resolveWithError = (errorMessage2, fallbackError) => {
3947
3979
  settle({
3948
3980
  ...data,
3949
3981
  errored: true,
3950
- __error: errorMessage,
3951
- error: fallbackError instanceof Error ? fallbackError.message : errorMessage,
3982
+ __error: errorMessage2,
3983
+ error: fallbackError instanceof Error ? fallbackError.message : errorMessage2,
3952
3984
  socketId: runtimeHandle.socket.id,
3953
3985
  serviceName,
3954
3986
  url
@@ -4253,19 +4285,19 @@ var SocketController = class _SocketController {
4253
4285
  url
4254
4286
  });
4255
4287
  } else {
4256
- const errorMessage = result?.__error ?? result?.error ?? "Socket handshake failed";
4288
+ const errorMessage2 = result?.__error ?? result?.error ?? "Socket handshake failed";
4257
4289
  upsertDiagnostics(
4258
4290
  {
4259
4291
  connected: false,
4260
4292
  handshake: false,
4261
- lastHandshakeError: errorMessage
4293
+ lastHandshakeError: errorMessage2
4262
4294
  },
4263
- errorMessage
4295
+ errorMessage2
4264
4296
  );
4265
4297
  applySessionOperation("handshake", {
4266
4298
  connected: false,
4267
4299
  handshake: false,
4268
- lastHandshakeError: errorMessage
4300
+ lastHandshakeError: errorMessage2
4269
4301
  });
4270
4302
  CadenzaService.log(
4271
4303
  "Socket handshake failed",
@@ -4313,15 +4345,15 @@ var SocketController = class _SocketController {
4313
4345
  });
4314
4346
  }
4315
4347
  if (resultContext?.errored || resultContext?.failed) {
4316
- const errorMessage = resultContext?.__error ?? resultContext?.error ?? "Socket delegation failed";
4348
+ const errorMessage2 = resultContext?.__error ?? resultContext?.error ?? "Socket delegation failed";
4317
4349
  upsertDiagnostics(
4318
4350
  {
4319
- lastHandshakeError: String(errorMessage)
4351
+ lastHandshakeError: String(errorMessage2)
4320
4352
  },
4321
- errorMessage
4353
+ errorMessage2
4322
4354
  );
4323
4355
  applySessionOperation("delegate", {
4324
- lastHandshakeError: String(errorMessage)
4356
+ lastHandshakeError: String(errorMessage2)
4325
4357
  });
4326
4358
  }
4327
4359
  return resultContext;
@@ -4768,6 +4800,7 @@ var SignalController = class _SignalController {
4768
4800
  };
4769
4801
 
4770
4802
  // src/graph/controllers/GraphMetadataController.ts
4803
+ import { META_ACTOR_SESSION_STATE_PERSIST_INTENT } from "@cadenza.io/core";
4771
4804
  var GraphMetadataController = class _GraphMetadataController {
4772
4805
  static get instance() {
4773
4806
  if (!this._instance) this._instance = new _GraphMetadataController();
@@ -5001,6 +5034,123 @@ var GraphMetadataController = class _GraphMetadataController {
5001
5034
  }
5002
5035
  };
5003
5036
  }).doOn("meta.actor.task_associated").emits("global.meta.graph_metadata.actor_task_associated");
5037
+ const actorSessionStateInsertTask = CadenzaService.get("dbInsertActorSessionState") ?? CadenzaService.get("Insert actor_session_state in CadenzaDB") ?? CadenzaService.createCadenzaDBInsertTask(
5038
+ "actor_session_state",
5039
+ {},
5040
+ { concurrency: 100, isSubMeta: true }
5041
+ );
5042
+ const validateActorSessionStatePersistenceTask = CadenzaService.createMetaTask(
5043
+ "Validate actor session state persistence",
5044
+ (ctx) => {
5045
+ if (ctx.errored || ctx.failed || ctx.__success !== true) {
5046
+ throw new Error(
5047
+ String(
5048
+ ctx.__error ?? ctx.error ?? "actor_session_state persistence query failed"
5049
+ )
5050
+ );
5051
+ }
5052
+ const rowCount = Number(ctx.rowCount ?? 0);
5053
+ if (!Number.isFinite(rowCount) || rowCount <= 0) {
5054
+ throw new Error(
5055
+ "actor_session_state persistence did not affect any rows (possible stale durable_version)"
5056
+ );
5057
+ }
5058
+ return {
5059
+ __success: true,
5060
+ persisted: true,
5061
+ actor_name: ctx.actor_name,
5062
+ actor_version: ctx.actor_version,
5063
+ actor_key: ctx.actor_key,
5064
+ service_name: ctx.service_name,
5065
+ durable_version: ctx.durable_version
5066
+ };
5067
+ },
5068
+ "Enforces strict actor session persistence success contract.",
5069
+ { isSubMeta: true, concurrency: 100 }
5070
+ );
5071
+ const insertAndValidateActorSessionStateTask = actorSessionStateInsertTask.then(
5072
+ validateActorSessionStatePersistenceTask
5073
+ );
5074
+ CadenzaService.createMetaTask(
5075
+ "Persist actor session state",
5076
+ (ctx) => {
5077
+ const actorName = typeof ctx.actor_name === "string" ? ctx.actor_name.trim() : "";
5078
+ const actorKey = typeof ctx.actor_key === "string" ? ctx.actor_key.trim() : "";
5079
+ const actorVersion = Number(ctx.actor_version ?? 1);
5080
+ const durableVersion = Number(ctx.durable_version);
5081
+ if (!actorName) {
5082
+ throw new Error("actor_name is required for actor session persistence");
5083
+ }
5084
+ if (!actorKey) {
5085
+ throw new Error("actor_key is required for actor session persistence");
5086
+ }
5087
+ if (!Number.isInteger(actorVersion) || actorVersion < 1) {
5088
+ throw new Error("actor_version must be a positive integer");
5089
+ }
5090
+ if (!Number.isInteger(durableVersion) || durableVersion < 0) {
5091
+ throw new Error("durable_version must be a non-negative integer");
5092
+ }
5093
+ if (typeof ctx.durable_state !== "object" || ctx.durable_state === null || Array.isArray(ctx.durable_state)) {
5094
+ throw new Error("durable_state must be a non-null object");
5095
+ }
5096
+ const serviceName = CadenzaService.serviceRegistry.serviceName;
5097
+ if (!serviceName) {
5098
+ throw new Error("service_name is not available for actor session persistence");
5099
+ }
5100
+ let expiresAt = null;
5101
+ if (ctx.expires_at !== void 0 && ctx.expires_at !== null) {
5102
+ if (ctx.expires_at instanceof Date) {
5103
+ expiresAt = ctx.expires_at.toISOString();
5104
+ } else if (typeof ctx.expires_at === "string" && ctx.expires_at.trim().length > 0) {
5105
+ expiresAt = ctx.expires_at;
5106
+ } else {
5107
+ throw new Error("expires_at must be null, Date, or non-empty string");
5108
+ }
5109
+ }
5110
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5111
+ return {
5112
+ ...ctx,
5113
+ actor_name: actorName,
5114
+ actor_key: actorKey,
5115
+ actor_version: actorVersion,
5116
+ durable_version: durableVersion,
5117
+ expires_at: expiresAt,
5118
+ service_name: serviceName,
5119
+ queryData: {
5120
+ data: {
5121
+ actor_name: actorName,
5122
+ actor_version: actorVersion,
5123
+ actor_key: actorKey,
5124
+ service_name: serviceName,
5125
+ durable_state: ctx.durable_state,
5126
+ durable_version: durableVersion,
5127
+ expires_at: expiresAt,
5128
+ updated: updatedAt
5129
+ },
5130
+ onConflict: {
5131
+ target: [
5132
+ "actor_name",
5133
+ "actor_version",
5134
+ "actor_key",
5135
+ "service_name"
5136
+ ],
5137
+ action: {
5138
+ do: "update",
5139
+ set: {
5140
+ durable_state: "excluded",
5141
+ durable_version: "excluded",
5142
+ expires_at: "excluded",
5143
+ updated: "excluded"
5144
+ },
5145
+ where: "actor_session_state.durable_version <= excluded.durable_version"
5146
+ }
5147
+ }
5148
+ }
5149
+ };
5150
+ },
5151
+ "Validates and prepares actor_session_state payload for strict write-through persistence.",
5152
+ { isSubMeta: true, concurrency: 100 }
5153
+ ).then(insertAndValidateActorSessionStateTask).respondsTo(META_ACTOR_SESSION_STATE_PERSIST_INTENT);
5004
5154
  CadenzaService.createMetaTask("Handle Intent Creation", (ctx) => {
5005
5155
  const intentName = ctx.data?.name;
5006
5156
  return {
@@ -5037,566 +5187,175 @@ var SCHEMA_TYPES = [
5037
5187
 
5038
5188
  // src/database/DatabaseController.ts
5039
5189
  import { Pool } from "pg";
5040
- import { camelCase, snakeCase } from "lodash-es";
5041
- function resolveTableQueryIntents(serviceName, tableName, table, defaultInputSchema) {
5042
- const resolvedServiceName = serviceName ?? "unknown-service";
5043
- const defaultIntentName = `query-${resolvedServiceName}-${tableName}`;
5044
- const defaultDescription = `Perform a query operation on the ${tableName} table`;
5190
+ import { camelCase, kebabCase, snakeCase } from "lodash-es";
5191
+ function normalizeIntentToken(value) {
5192
+ const normalized = kebabCase(String(value ?? "").trim());
5193
+ if (!normalized) {
5194
+ throw new Error("Actor token cannot be empty");
5195
+ }
5196
+ return normalized;
5197
+ }
5198
+ function validateIntentName(intentName) {
5199
+ if (!intentName || typeof intentName !== "string") {
5200
+ throw new Error("Intent name must be a non-empty string");
5201
+ }
5202
+ if (intentName.length > 100) {
5203
+ throw new Error(`Intent name must be <= 100 characters: ${intentName}`);
5204
+ }
5205
+ if (intentName.includes(" ") || intentName.includes(".") || intentName.includes("\\")) {
5206
+ throw new Error(
5207
+ `Intent name cannot contain spaces, dots or backslashes: ${intentName}`
5208
+ );
5209
+ }
5210
+ }
5211
+ function defaultOperationIntentDescription(operation, tableName) {
5212
+ return `Perform a ${operation} operation on the ${tableName} table`;
5213
+ }
5214
+ function readCustomIntentConfig(customIntent) {
5215
+ if (typeof customIntent === "string") {
5216
+ return {
5217
+ intent: customIntent
5218
+ };
5219
+ }
5220
+ return {
5221
+ intent: customIntent.intent,
5222
+ description: customIntent.description,
5223
+ input: customIntent.input
5224
+ };
5225
+ }
5226
+ function resolveTableOperationIntents(actorName, tableName, table, operation, defaultInputSchema) {
5227
+ const actorToken = normalizeIntentToken(actorName);
5228
+ const defaultIntentName = `${operation}-pg-${actorToken}-${tableName}`;
5229
+ validateIntentName(defaultIntentName);
5045
5230
  const intents = [
5046
5231
  {
5047
5232
  name: defaultIntentName,
5048
- description: defaultDescription,
5233
+ description: defaultOperationIntentDescription(operation, tableName),
5049
5234
  input: defaultInputSchema
5050
5235
  }
5051
5236
  ];
5052
- const warnings = [];
5053
- const names = /* @__PURE__ */ new Set([defaultIntentName]);
5054
- for (const customIntent of table.customIntents?.query ?? []) {
5055
- const name = typeof customIntent === "string" ? customIntent.trim() : customIntent.intent?.trim();
5056
- if (!name) {
5057
- warnings.push(
5058
- `Skipped empty custom query intent for table '${tableName}'.`
5059
- );
5060
- continue;
5061
- }
5062
- if (name.length > 100) {
5063
- warnings.push(
5064
- `Skipped custom query intent '${name}' for table '${tableName}': name must be <= 100 characters.`
5065
- );
5066
- continue;
5067
- }
5068
- if (name.includes(" ") || name.includes(".") || name.includes("\\")) {
5069
- warnings.push(
5070
- `Skipped custom query intent '${name}' for table '${tableName}': name cannot contain spaces, dots or backslashes.`
5237
+ const seenNames = /* @__PURE__ */ new Set([defaultIntentName]);
5238
+ const customIntentList = table.customIntents?.[operation] ?? [];
5239
+ for (const rawCustomIntent of customIntentList) {
5240
+ const customIntent = readCustomIntentConfig(rawCustomIntent);
5241
+ const intentName = String(customIntent.intent ?? "").trim();
5242
+ if (!intentName) {
5243
+ throw new Error(
5244
+ `Invalid custom ${operation} intent on table '${tableName}': intent must be a non-empty string`
5071
5245
  );
5072
- continue;
5073
5246
  }
5074
- if (names.has(name)) {
5075
- warnings.push(
5076
- `Skipped duplicate custom query intent '${name}' for table '${tableName}'.`
5247
+ validateIntentName(intentName);
5248
+ if (seenNames.has(intentName)) {
5249
+ throw new Error(
5250
+ `Duplicate ${operation} intent '${intentName}' on table '${tableName}'`
5077
5251
  );
5078
- continue;
5079
5252
  }
5080
- names.add(name);
5253
+ seenNames.add(intentName);
5081
5254
  intents.push({
5082
- name,
5083
- description: typeof customIntent === "string" ? `Perform a query operation on the ${tableName} table` : customIntent.description ?? defaultDescription,
5084
- input: typeof customIntent === "string" ? defaultInputSchema : customIntent.input ?? defaultInputSchema
5255
+ name: intentName,
5256
+ description: customIntent.description ?? defaultOperationIntentDescription(operation, tableName),
5257
+ input: customIntent.input ?? defaultInputSchema
5085
5258
  });
5086
5259
  }
5087
- return { intents, warnings };
5260
+ return { intents };
5261
+ }
5262
+ function ensurePlainObject(value, label) {
5263
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
5264
+ throw new Error(`${label} must be an object`);
5265
+ }
5266
+ return value;
5267
+ }
5268
+ function sleep(timeoutMs) {
5269
+ return new Promise((resolve) => {
5270
+ setTimeout(resolve, timeoutMs);
5271
+ });
5272
+ }
5273
+ function normalizePositiveInteger(value, fallback, min = 1) {
5274
+ if (typeof value !== "number" || !Number.isFinite(value)) {
5275
+ return fallback;
5276
+ }
5277
+ const normalized = Math.trunc(value);
5278
+ if (normalized < min) {
5279
+ return fallback;
5280
+ }
5281
+ return normalized;
5282
+ }
5283
+ function errorMessage(error) {
5284
+ if (error instanceof Error) {
5285
+ return error.message;
5286
+ }
5287
+ return String(error);
5288
+ }
5289
+ function isTransientDatabaseError(error) {
5290
+ if (!error || typeof error !== "object") {
5291
+ return false;
5292
+ }
5293
+ const dbError = error;
5294
+ const code = String(dbError.code ?? "");
5295
+ if (["40001", "40P01", "57P03", "53300", "08006", "08001"].includes(code)) {
5296
+ return true;
5297
+ }
5298
+ const message = String(dbError.message ?? "").toLowerCase();
5299
+ return message.includes("timeout") || message.includes("terminating connection") || message.includes("connection reset");
5300
+ }
5301
+ function isSqlIdentifier(value) {
5302
+ return /^[a-z_][a-z0-9_]*$/.test(value);
5303
+ }
5304
+ function toSafeSqlIdentifier(value, fallback) {
5305
+ const normalized = snakeCase(value || "").replace(/[^a-z0-9_]/g, "_");
5306
+ const candidate = normalized || fallback;
5307
+ if (!isSqlIdentifier(candidate)) {
5308
+ throw new Error(`Invalid SQL identifier: ${value}`);
5309
+ }
5310
+ return candidate;
5311
+ }
5312
+ function isSupportedAggregateFunction(value) {
5313
+ const fn = String(value ?? "").toLowerCase();
5314
+ return ["count", "sum", "avg", "min", "max"].includes(fn);
5315
+ }
5316
+ function buildAggregateAlias(aggregate, index) {
5317
+ const fn = String(aggregate.fn ?? "").toLowerCase();
5318
+ const hasField = typeof aggregate.field === "string" && aggregate.field.trim().length > 0;
5319
+ return toSafeSqlIdentifier(
5320
+ String(aggregate.as ?? `${fn}_${hasField ? aggregate.field : "all"}_${index}`),
5321
+ `${fn || "aggregate"}_${index}`
5322
+ );
5323
+ }
5324
+ function resolveDataRows(data) {
5325
+ if (!data) {
5326
+ return [];
5327
+ }
5328
+ if (Array.isArray(data)) {
5329
+ return data.map((entry) => ensurePlainObject(entry, "data item"));
5330
+ }
5331
+ return [ensurePlainObject(data, "data")];
5088
5332
  }
5089
5333
  var DatabaseController = class _DatabaseController {
5090
- /**
5091
- * Constructor for initializing the `DatabaseService` class.
5092
- *
5093
- * This constructor method initializes a sequence of meta tasks to perform the following database-related operations:
5094
- *
5095
- * 1. **Database Creation**: Creates a new database with the specified name if it doesn't already exist.
5096
- * Validates the database name to ensure it conforms to the required format.
5097
- * 2. **Database Schema Validation**: Validates the structure and constraints of the schema definition provided.
5098
- * 3. **Table Dependency Management**: Sorts tables within the schema by their dependencies to ensure proper creation order.
5099
- * 4. **Schema Definition Processing**:
5100
- * - Converts schema definitions into Data Definition Language (DDL) based on table and field specifications.
5101
- * - Handles constraints, relationships, and field attributes such as uniqueness, primary keys, nullable fields, etc.
5102
- * 5. **Index and Primary Key Definition**: Generates SQL for indices and primary keys based on the schema configuration.
5103
- *
5104
- * These tasks are encapsulated within a meta routine to provide a structured and procedural approach to database initialization and schema management.
5105
- */
5106
5334
  constructor() {
5107
- this.databaseName = "";
5108
- this.dbClient = new Pool({
5335
+ this.registrationsByService = /* @__PURE__ */ new Map();
5336
+ this.adminDbClient = new Pool({
5109
5337
  connectionString: process.env.DATABASE_ADDRESS ?? "",
5110
5338
  database: "postgres",
5111
5339
  ssl: {
5112
5340
  rejectUnauthorized: false
5113
- // ← This bypasses the chain validation error
5114
5341
  }
5115
5342
  });
5116
- CadenzaService.createMetaRoutine(
5117
- "DatabaseServiceInit",
5118
- [
5119
- // TODO: Database health check
5120
- // TODO: Create database role
5121
- // TODO: Create schema version table
5122
- CadenzaService.createMetaTask(
5123
- "Create database",
5124
- async (ctx) => {
5125
- const { databaseName } = ctx;
5126
- try {
5127
- if (!databaseName.split("").every((c) => /[a-z_]/.test(c))) {
5128
- throw new Error(
5129
- `Invalid database name ${databaseName}. Names must only contain lowercase alphanumeric characters and underscores`
5130
- );
5131
- }
5132
- console.log(`Creating database ${databaseName}`, {
5133
- connectionString: process.env.DATABASE_ADDRESS ?? "",
5134
- database: "postgres"
5135
- });
5136
- await this.dbClient.query(`CREATE DATABASE ${databaseName}`);
5137
- console.log(`Database ${databaseName} created`);
5138
- this.dbClient = new Pool({
5139
- connectionString: process.env.DATABASE_ADDRESS ? process.env.DATABASE_ADDRESS.slice(
5140
- 0,
5141
- process.env.DATABASE_ADDRESS.lastIndexOf("/")
5142
- ) + "/" + databaseName + "?sslmode=disable" : "",
5143
- ssl: {
5144
- rejectUnauthorized: false
5145
- // ← This bypasses the chain validation error
5146
- }
5147
- });
5148
- this.databaseName = databaseName;
5149
- return true;
5150
- } catch (error) {
5151
- if (error.code === "42P04") {
5152
- console.log("Database already exists");
5153
- return true;
5154
- }
5155
- console.error("Failed to create database", error);
5156
- throw new Error(`Failed to create database: ${error.message}`);
5157
- }
5158
- },
5159
- "Creates the target database if it doesn't exist"
5160
- ).then(
5161
- CadenzaService.createMetaTask(
5162
- "Validate schema",
5163
- (ctx) => {
5164
- const { schema } = ctx;
5165
- if (!schema?.tables || typeof schema.tables !== "object") {
5166
- throw new Error("Invalid schema: missing or invalid tables");
5167
- }
5168
- for (const [tableName, table] of Object.entries(schema.tables)) {
5169
- if (!table.fields || typeof table.fields !== "object") {
5170
- console.log(tableName, "missing fields");
5171
- throw new Error(`Invalid table ${tableName}: missing fields`);
5172
- }
5173
- for (const [fieldName, field] of Object.entries(table.fields)) {
5174
- if (!fieldName.split("").every((c) => /[a-z_]/.test(c))) {
5175
- console.log(tableName, "field not lowercase", fieldName);
5176
- throw new Error(
5177
- `Invalid field name ${fieldName} for ${tableName}. Field names must only contain lowercase alphanumeric characters and underscores`
5178
- );
5179
- }
5180
- if (!Object.values(SCHEMA_TYPES).includes(field.type)) {
5181
- console.log(
5182
- tableName,
5183
- "field invalid type",
5184
- fieldName,
5185
- field.type
5186
- );
5187
- throw new Error(
5188
- `Invalid type ${field.type} for ${tableName}.${fieldName}`
5189
- );
5190
- }
5191
- if (field.references && !field.references.match(/^[\w]+[(\w)]+$/)) {
5192
- console.log(
5193
- tableName,
5194
- "invalid reference",
5195
- fieldName,
5196
- field.references
5197
- );
5198
- throw new Error(
5199
- `Invalid reference ${field.references} for ${tableName}.${fieldName}`
5200
- );
5201
- }
5202
- if (table.customSignals) {
5203
- for (const op of ["query", "insert", "update", "delete"]) {
5204
- const triggers = table.customSignals.triggers?.[op];
5205
- const emissions = table.customSignals.emissions?.[op];
5206
- if (triggers && !Array.isArray(triggers) && typeof triggers !== "object") {
5207
- console.log(
5208
- tableName,
5209
- "invalid triggers",
5210
- op,
5211
- triggers
5212
- );
5213
- throw new Error(
5214
- `Invalid triggers for ${tableName}.${op}`
5215
- );
5216
- }
5217
- if (emissions && !Array.isArray(emissions) && typeof emissions !== "object") {
5218
- console.log(
5219
- tableName,
5220
- "invalid emissions",
5221
- op,
5222
- emissions
5223
- );
5224
- throw new Error(
5225
- `Invalid emissions for ${tableName}.${op}`
5226
- );
5227
- }
5228
- }
5229
- }
5230
- if (table.customIntents?.query) {
5231
- if (!Array.isArray(table.customIntents.query)) {
5232
- throw new Error(
5233
- `Invalid customIntents.query for ${tableName}: expected array`
5234
- );
5235
- }
5236
- for (const customIntent of table.customIntents.query) {
5237
- if (typeof customIntent !== "string" && (typeof customIntent !== "object" || !customIntent || typeof customIntent.intent !== "string")) {
5238
- throw new Error(
5239
- `Invalid custom query intent on ${tableName}: expected string or object with intent`
5240
- );
5241
- }
5242
- }
5243
- }
5244
- }
5245
- }
5246
- console.log("SCHEMA VALIDATED");
5247
- return true;
5248
- },
5249
- "Validates database schema structure and content"
5250
- ).then(
5251
- CadenzaService.createMetaTask(
5252
- "Sort tables by dependencies",
5253
- this.sortTablesByReferences.bind(this),
5254
- "Sorts tables by dependencies"
5255
- ).then(
5256
- CadenzaService.createMetaTask(
5257
- "Split schema into tables",
5258
- this.splitTables.bind(this),
5259
- "Generates DDL for database schema"
5260
- ).then(
5261
- CadenzaService.createMetaTask(
5262
- "Generate DDL from table",
5263
- async (ctx) => {
5264
- const {
5265
- ddl,
5266
- table,
5267
- tableName,
5268
- schema,
5269
- options,
5270
- sortedTables
5271
- } = ctx;
5272
- const fieldDefs = Object.entries(table.fields).map((value) => {
5273
- const [fieldName, field] = value;
5274
- let def = `${fieldName} ${field.type.toUpperCase()}`;
5275
- if (field.type === "varchar")
5276
- def += `(${field.constraints?.maxLength ?? 255})`;
5277
- if (field.type === "decimal")
5278
- def += `(${field.constraints?.precision ?? 10},${field.constraints?.scale ?? 2})`;
5279
- if (field.primary) def += " PRIMARY KEY";
5280
- if (field.unique) def += " UNIQUE";
5281
- if (field.default !== void 0)
5282
- def += ` DEFAULT ${field.default === "" ? "''" : String(field.default)}`;
5283
- if (field.required && !field.nullable)
5284
- def += " NOT NULL";
5285
- if (field.nullable) def += " NULL";
5286
- if (field.generated)
5287
- def += ` GENERATED ALWAYS AS ${field.generated.toUpperCase()} STORED`;
5288
- if (field.references)
5289
- def += ` REFERENCES ${field.references} ON DELETE ${field.onDelete || "CASCADE"}`;
5290
- if (field.encrypted) def += " ENCRYPTED";
5291
- if (field.constraints?.check) {
5292
- def += ` CHECK (${field.constraints.check})`;
5293
- }
5294
- return def;
5295
- }).join(", ");
5296
- if (schema.meta?.dropExisting) {
5297
- const result = await this.dbClient.query(
5298
- `DELETE FROM ${tableName};`
5299
- );
5300
- console.log("DROP TABLE", tableName, result);
5301
- }
5302
- const sql = `CREATE TABLE IF NOT EXISTS ${tableName} (${fieldDefs})`;
5303
- ddl.push(sql);
5304
- return {
5305
- ddl,
5306
- table,
5307
- tableName,
5308
- schema,
5309
- options,
5310
- sortedTables
5311
- };
5312
- }
5313
- ).then(
5314
- CadenzaService.createMetaTask("Generate index DDL", (ctx) => {
5315
- const {
5316
- ddl,
5317
- table,
5318
- tableName,
5319
- schema,
5320
- options,
5321
- sortedTables
5322
- } = ctx;
5323
- if (table.indexes) {
5324
- table.indexes.forEach((fields) => {
5325
- ddl.push(
5326
- `CREATE INDEX IF NOT EXISTS idx_${tableName}_${fields.join("_")} ON ${tableName} (${fields.join(", ")});`
5327
- );
5328
- });
5329
- }
5330
- return {
5331
- ddl,
5332
- table,
5333
- tableName,
5334
- schema,
5335
- options,
5336
- sortedTables
5337
- };
5338
- }).then(
5339
- CadenzaService.createMetaTask(
5340
- "Generate primary key ddl",
5341
- (ctx) => {
5342
- const {
5343
- ddl,
5344
- table,
5345
- tableName,
5346
- schema,
5347
- options,
5348
- sortedTables
5349
- } = ctx;
5350
- if (table.primaryKey) {
5351
- ddl.push(
5352
- `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS unique_${tableName}_${table.primaryKey.join("_")};`,
5353
- // TODO: should be cascade?
5354
- `ALTER TABLE ${tableName} ADD CONSTRAINT unique_${tableName}_${table.primaryKey.join("_")} PRIMARY KEY (${table.primaryKey.join(", ")});`
5355
- );
5356
- }
5357
- return {
5358
- ddl,
5359
- table,
5360
- tableName,
5361
- schema,
5362
- options,
5363
- sortedTables
5364
- };
5365
- }
5366
- ).then(
5367
- CadenzaService.createMetaTask(
5368
- "Generate unique index DDL",
5369
- (ctx) => {
5370
- const {
5371
- ddl,
5372
- table,
5373
- tableName,
5374
- schema,
5375
- options,
5376
- sortedTables
5377
- } = ctx;
5378
- if (table.uniqueConstraints) {
5379
- table.uniqueConstraints.forEach(
5380
- (fields) => {
5381
- ddl.push(
5382
- `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS unique_${tableName}_${fields.join("_")};`,
5383
- // TODO: should be cascade?
5384
- `ALTER TABLE ${tableName} ADD CONSTRAINT unique_${tableName}_${fields.join("_")} UNIQUE (${fields.join(", ")});`
5385
- );
5386
- }
5387
- );
5388
- }
5389
- return {
5390
- ddl,
5391
- table,
5392
- tableName,
5393
- schema,
5394
- options,
5395
- sortedTables
5396
- };
5397
- }
5398
- ).then(
5399
- CadenzaService.createMetaTask(
5400
- "Generate foreign key DDL",
5401
- (ctx) => {
5402
- const {
5403
- ddl,
5404
- table,
5405
- tableName,
5406
- schema,
5407
- options,
5408
- sortedTables
5409
- } = ctx;
5410
- if (table.foreignKeys) {
5411
- for (const foreignKey of table.foreignKeys) {
5412
- const foreignKeyName = `fk_${tableName}_${foreignKey.fields.join("_")}`;
5413
- ddl.push(
5414
- `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${foreignKeyName};`,
5415
- // TODO: should be cascade?
5416
- `ALTER TABLE ${tableName} ADD CONSTRAINT ${foreignKeyName} FOREIGN KEY (${foreignKey.fields.join(
5417
- ", "
5418
- )}) REFERENCES ${foreignKey.tableName} (${foreignKey.referenceFields.join(
5419
- ", "
5420
- )});`
5421
- );
5422
- }
5423
- }
5424
- return {
5425
- ddl,
5426
- table,
5427
- tableName,
5428
- schema,
5429
- options,
5430
- sortedTables
5431
- };
5432
- }
5433
- ).then(
5434
- CadenzaService.createMetaTask(
5435
- "Generate trigger DDL",
5436
- (ctx) => {
5437
- const {
5438
- ddl,
5439
- table,
5440
- tableName,
5441
- schema,
5442
- options,
5443
- sortedTables
5444
- } = ctx;
5445
- if (table.triggers) {
5446
- for (const [
5447
- triggerName,
5448
- trigger
5449
- ] of Object.entries(table.triggers)) {
5450
- ddl.push(
5451
- `CREATE TRIGGER ${triggerName} ${trigger.when} ${trigger.event} ON ${tableName} FOR EACH STATEMENT EXECUTE FUNCTION ${trigger.function};`
5452
- );
5453
- }
5454
- }
5455
- return {
5456
- ddl,
5457
- table,
5458
- tableName,
5459
- schema,
5460
- options,
5461
- sortedTables
5462
- };
5463
- }
5464
- ).then(
5465
- CadenzaService.createMetaTask(
5466
- "Generate initial data DDL",
5467
- (ctx) => {
5468
- const {
5469
- ddl,
5470
- table,
5471
- tableName,
5472
- schema,
5473
- options,
5474
- sortedTables
5475
- } = ctx;
5476
- if (table.initialData) {
5477
- ddl.push(
5478
- `INSERT INTO ${tableName} (${table.initialData.fields.join(", ")}) VALUES ${table.initialData.data.map(
5479
- (row) => `(${row.map(
5480
- (value) => value === void 0 ? "NULL" : value.charAt(0) === "'" ? value : `'${value}'`
5481
- ).join(", ")})`
5482
- // TODO: handle non string data
5483
- ).join(", ")} ON CONFLICT DO NOTHING;`
5484
- );
5485
- }
5486
- return {
5487
- ddl,
5488
- table,
5489
- tableName,
5490
- schema,
5491
- options,
5492
- sortedTables
5493
- };
5494
- }
5495
- ).then(
5496
- CadenzaService.createUniqueMetaTask(
5497
- "Join DDL",
5498
- (ctx) => {
5499
- const { joinedContexts } = ctx;
5500
- const ddl = [];
5501
- for (const joinedContext of joinedContexts) {
5502
- ddl.push(...joinedContext.ddl);
5503
- }
5504
- ddl.flat();
5505
- return {
5506
- ddl,
5507
- schema: joinedContexts[0].schema,
5508
- options: joinedContexts[0].options,
5509
- table: joinedContexts[0].table,
5510
- tableName: joinedContexts[0].tableName,
5511
- sortedTables: joinedContexts[0].sortedTables
5512
- };
5513
- }
5514
- ).then(
5515
- CadenzaService.createMetaTask(
5516
- "Apply Database Changes",
5517
- async (ctx) => {
5518
- const { ddl } = ctx;
5519
- if (ddl && ddl.length > 0) {
5520
- for (const sql of ddl) {
5521
- try {
5522
- await this.dbClient.query(sql);
5523
- } catch (error) {
5524
- console.error(
5525
- "Error applying DDL",
5526
- sql,
5527
- error
5528
- );
5529
- }
5530
- }
5531
- }
5532
- return true;
5533
- },
5534
- "Applies generated DDL to the database"
5535
- ).then(
5536
- CadenzaService.createMetaTask(
5537
- "Split schema into tables for task creation",
5538
- this.splitTables.bind(this),
5539
- "Splits schema into tables for task creation"
5540
- ).then(
5541
- CadenzaService.createMetaTask(
5542
- "Generate tasks",
5543
- (ctx) => {
5544
- const { table, tableName, options } = ctx;
5545
- this.createDatabaseTask(
5546
- "query",
5547
- tableName,
5548
- table,
5549
- this.queryFunction.bind(this),
5550
- options
5551
- );
5552
- this.createDatabaseTask(
5553
- "insert",
5554
- tableName,
5555
- table,
5556
- this.insertFunction.bind(this),
5557
- options
5558
- );
5559
- this.createDatabaseTask(
5560
- "update",
5561
- tableName,
5562
- table,
5563
- this.updateFunction.bind(this),
5564
- options
5565
- );
5566
- this.createDatabaseTask(
5567
- "delete",
5568
- tableName,
5569
- table,
5570
- this.deleteFunction.bind(this),
5571
- options
5572
- );
5573
- return true;
5574
- },
5575
- "Generates auto-tasks for database schema"
5576
- ).then(
5577
- CadenzaService.createUniqueMetaTask(
5578
- "Join table tasks",
5579
- () => {
5580
- return true;
5581
- }
5582
- ).emits("meta.database.setup_done")
5583
- )
5584
- )
5585
- )
5586
- )
5587
- )
5588
- )
5589
- )
5590
- )
5591
- )
5592
- )
5593
- )
5594
- )
5595
- )
5596
- )
5597
- )
5598
- ],
5599
- "Initializes the database service with schema parsing and task/signal generation"
5343
+ CadenzaService.createMetaTask(
5344
+ "Route PostgresActor setup requests",
5345
+ (ctx) => {
5346
+ const serviceName = String(ctx.options?.serviceName ?? ctx.serviceName ?? "");
5347
+ if (!serviceName) {
5348
+ return ctx;
5349
+ }
5350
+ const registration = this.registrationsByService.get(serviceName);
5351
+ if (!registration) {
5352
+ return ctx;
5353
+ }
5354
+ CadenzaService.emit(`meta.postgres_actor.setup_requested.${registration.actorToken}`, ctx);
5355
+ return ctx;
5356
+ },
5357
+ "Routes generic database init requests to actor-scoped setup signal.",
5358
+ { isMeta: true, isSubMeta: true }
5600
5359
  ).doOn("meta.database_init_requested");
5601
5360
  }
5602
5361
  static get instance() {
@@ -5604,632 +5363,1013 @@ var DatabaseController = class _DatabaseController {
5604
5363
  return this._instance;
5605
5364
  }
5606
5365
  reset() {
5607
- this.dbClient.end();
5366
+ for (const registration of this.registrationsByService.values()) {
5367
+ const runtimeState = registration.actor.getRuntimeState(registration.actorKey);
5368
+ if (runtimeState?.pool) {
5369
+ runtimeState.pool.end().catch(() => void 0);
5370
+ }
5371
+ }
5372
+ this.registrationsByService.clear();
5373
+ this.adminDbClient.end().catch(() => void 0);
5608
5374
  }
5609
- /**
5610
- * Asynchronously retrieves a database client from the connection pool with additional logging and timeout capabilities.
5611
- * The method modifies the client instance by adding timeout tracking and logging functionality to ensure
5612
- * the client is not held for an extended period and track the last executed query for debugging purposes.
5613
- *
5614
- * @return {Promise<PoolClient>} A promise resolving to a database client from the pool with enhanced behavior for query tracking and timeout handling.
5615
- */
5616
- async getClient() {
5617
- const client = await this.dbClient.connect();
5618
- const query = client.query;
5619
- const release = client.release;
5620
- const timeout = setTimeout(() => {
5621
- CadenzaService.log(
5622
- "CRITICAL: A database client has been checked out for more than 5 seconds!",
5623
- {
5624
- clientId: client.uuid,
5625
- query: client.lastQuery,
5626
- databaseName: this.databaseName
5627
- },
5628
- "critical"
5629
- );
5630
- }, 5e3);
5631
- client.query = (...args) => {
5632
- client.lastQuery = args;
5633
- return query.apply(client, args);
5375
+ createPostgresActor(serviceName, schema, options) {
5376
+ const existing = this.registrationsByService.get(serviceName);
5377
+ if (existing) {
5378
+ return existing;
5379
+ }
5380
+ const actorName = `${serviceName}PostgresActor`;
5381
+ const actorToken = normalizeIntentToken(actorName);
5382
+ const actorKey = String(options.databaseName ?? snakeCase(serviceName));
5383
+ const optionTimeout = typeof options.timeoutMs === "number" ? Number(options.timeoutMs) : Number(options.timeout);
5384
+ const safetyPolicy = {
5385
+ statementTimeoutMs: normalizePositiveInteger(
5386
+ optionTimeout,
5387
+ normalizePositiveInteger(Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15e3), 15e3)
5388
+ ),
5389
+ retryCount: normalizePositiveInteger(options.retryCount, 3, 0),
5390
+ retryDelayMs: normalizePositiveInteger(Number(process.env.DATABASE_RETRY_DELAY_MS ?? 100), 100),
5391
+ retryDelayMaxMs: normalizePositiveInteger(
5392
+ Number(process.env.DATABASE_RETRY_DELAY_MAX_MS ?? 1e3),
5393
+ 1e3
5394
+ ),
5395
+ retryDelayFactor: Number(process.env.DATABASE_RETRY_DELAY_FACTOR ?? 2)
5634
5396
  };
5635
- client.release = () => {
5636
- clearTimeout(timeout);
5637
- client.query = query;
5638
- client.release = release;
5639
- return release.apply(client);
5397
+ const actor = CadenzaService.createActor(
5398
+ {
5399
+ name: actorName,
5400
+ description: "Specialized PostgresActor owning pool runtime state and schema-driven DB task generation.",
5401
+ defaultKey: actorKey,
5402
+ keyResolver: (input) => typeof input.databaseName === "string" ? input.databaseName : void 0,
5403
+ loadPolicy: "eager",
5404
+ writeContract: "overwrite",
5405
+ initState: {
5406
+ actorName,
5407
+ actorToken,
5408
+ serviceName,
5409
+ databaseName: actorKey,
5410
+ status: "idle",
5411
+ schemaVersion: Number(schema.version ?? 1),
5412
+ setupStartedAt: null,
5413
+ setupCompletedAt: null,
5414
+ lastHealthCheckAt: null,
5415
+ lastError: null,
5416
+ tables: Object.keys(schema.tables ?? {}),
5417
+ safetyPolicy
5418
+ }
5419
+ },
5420
+ { isMeta: Boolean(options.isMeta) }
5421
+ );
5422
+ const registration = {
5423
+ serviceName,
5424
+ databaseName: actorKey,
5425
+ actorName,
5426
+ actorToken,
5427
+ actorKey,
5428
+ actor,
5429
+ schema,
5430
+ options,
5431
+ tasksGenerated: false,
5432
+ intentNames: /* @__PURE__ */ new Set()
5640
5433
  };
5641
- return client;
5434
+ this.registrationsByService.set(serviceName, registration);
5435
+ this.registerSetupTask(registration);
5436
+ return registration;
5642
5437
  }
5643
- async waitForDatabase(transaction, client, context) {
5644
- for (let i = 0; i < 10; i++) {
5645
- try {
5646
- return await transaction(client, context);
5647
- } catch (err) {
5648
- if (err && err.message.includes("does not exist")) {
5649
- CadenzaService.log("Waiting for database to be ready...");
5650
- await new Promise((res) => setTimeout(res, 1e3));
5651
- } else {
5652
- CadenzaService.log(
5653
- "Database query errored",
5654
- { error: err, context },
5655
- "warning"
5438
+ registerSetupTask(registration) {
5439
+ const setupSignal = `meta.postgres_actor.setup_requested.${registration.actorToken}`;
5440
+ CadenzaService.createMetaTask(
5441
+ `Setup ${registration.actorName}`,
5442
+ registration.actor.task(
5443
+ async ({ input, state, runtimeState, setState, setRuntimeState, emit }) => {
5444
+ const requestedDatabaseName = String(
5445
+ input.options?.databaseName ?? input.databaseName ?? registration.databaseName
5656
5446
  );
5657
- return { rows: [] };
5658
- }
5447
+ if (requestedDatabaseName !== registration.databaseName) {
5448
+ return input;
5449
+ }
5450
+ setState({
5451
+ ...state,
5452
+ status: "initializing",
5453
+ setupStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
5454
+ lastError: null
5455
+ });
5456
+ const priorRuntimePool = runtimeState?.pool ?? null;
5457
+ if (priorRuntimePool) {
5458
+ await priorRuntimePool.end().catch(() => void 0);
5459
+ }
5460
+ try {
5461
+ await this.createDatabaseIfMissing(requestedDatabaseName);
5462
+ const pool = this.createTargetPool(
5463
+ requestedDatabaseName,
5464
+ state.safetyPolicy.statementTimeoutMs
5465
+ );
5466
+ await this.checkPoolHealth(pool, state.safetyPolicy);
5467
+ this.validateSchema({
5468
+ schema: registration.schema,
5469
+ options: registration.options
5470
+ });
5471
+ const sortedTables = this.sortTablesByReferences({
5472
+ schema: registration.schema
5473
+ }).sortedTables;
5474
+ const ddlStatements = this.buildSchemaDdlStatements(
5475
+ registration.schema,
5476
+ sortedTables
5477
+ );
5478
+ await this.applyDdlStatements(pool, ddlStatements);
5479
+ if (!registration.tasksGenerated) {
5480
+ this.generateDatabaseTasks(registration);
5481
+ registration.tasksGenerated = true;
5482
+ }
5483
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
5484
+ setRuntimeState({
5485
+ pool,
5486
+ ready: true,
5487
+ pendingQueries: 0,
5488
+ lastHealthCheckAt: Date.now(),
5489
+ lastError: null
5490
+ });
5491
+ setState({
5492
+ ...state,
5493
+ status: "ready",
5494
+ setupCompletedAt: nowIso,
5495
+ lastHealthCheckAt: nowIso,
5496
+ lastError: null,
5497
+ tables: Object.keys(registration.schema.tables ?? {})
5498
+ });
5499
+ emit("meta.database.setup_done", {
5500
+ serviceName: registration.serviceName,
5501
+ databaseName: registration.databaseName,
5502
+ actorName: registration.actorName,
5503
+ __success: true
5504
+ });
5505
+ return {
5506
+ ...input,
5507
+ __success: true,
5508
+ actorName: registration.actorName,
5509
+ databaseName: registration.databaseName
5510
+ };
5511
+ } catch (error) {
5512
+ const message = errorMessage(error);
5513
+ setRuntimeState({
5514
+ pool: null,
5515
+ ready: false,
5516
+ pendingQueries: 0,
5517
+ lastHealthCheckAt: runtimeState?.lastHealthCheckAt ?? null,
5518
+ lastError: message
5519
+ });
5520
+ setState({
5521
+ ...state,
5522
+ status: "error",
5523
+ setupCompletedAt: (/* @__PURE__ */ new Date()).toISOString(),
5524
+ lastError: message
5525
+ });
5526
+ throw error;
5527
+ }
5528
+ },
5529
+ { mode: "write" }
5530
+ ),
5531
+ "Initializes PostgresActor runtime pool, applies schema, and generates CRUD tasks/intents.",
5532
+ { isMeta: true }
5533
+ ).doOn(setupSignal);
5534
+ }
5535
+ createTargetPool(databaseName, statementTimeoutMs) {
5536
+ const connectionString = this.buildDatabaseConnectionString(databaseName);
5537
+ return new Pool({
5538
+ connectionString,
5539
+ statement_timeout: statementTimeoutMs,
5540
+ query_timeout: statementTimeoutMs,
5541
+ ssl: {
5542
+ rejectUnauthorized: false
5543
+ }
5544
+ });
5545
+ }
5546
+ buildDatabaseConnectionString(databaseName) {
5547
+ const base = process.env.DATABASE_ADDRESS ?? "";
5548
+ if (!base) {
5549
+ throw new Error("DATABASE_ADDRESS environment variable is required");
5550
+ }
5551
+ try {
5552
+ const parsed = new URL(base);
5553
+ parsed.pathname = `/${databaseName}`;
5554
+ if (!parsed.searchParams.has("sslmode")) {
5555
+ parsed.searchParams.set("sslmode", "disable");
5556
+ }
5557
+ return parsed.toString();
5558
+ } catch {
5559
+ const lastSlashIndex = base.lastIndexOf("/");
5560
+ if (lastSlashIndex === -1) {
5561
+ throw new Error("DATABASE_ADDRESS must be a valid postgres connection string");
5659
5562
  }
5563
+ const root = base.slice(0, lastSlashIndex + 1);
5564
+ return `${root}${databaseName}?sslmode=disable`;
5660
5565
  }
5661
- throw new Error(`Timeout waiting for database to be ready`);
5662
5566
  }
5663
- /**
5664
- * Sorts database tables based on their reference dependencies using a topological sort.
5665
- *
5666
- * Tables are reordered such that dependent tables appear later in the list
5667
- * to ensure a dependency hierarchy. If cycles are detected in the dependency graph,
5668
- * they will be noted but the process will not stop. Unreferenced tables are included at the end.
5669
- *
5670
- * @param {Object} ctx - The context object containing the database schema definition and table metadata.
5671
- * ctx.schema {Object} - The schema definition object.
5672
- * ctx.schema.tables {Object} - A mapping of table names to table definitions.
5673
- * Each table definition may contain `fields` (with `references` info)
5674
- * and `foreignKeys` indicating cross-table relationships.
5675
- *
5676
- * @return {Object} - The modified context object with an additional property:
5677
- * sortedTables {string[]} - An array of table names sorted in dependency order.
5678
- * hasCycles {boolean} - Indicates if the dependency graph contains cycles.
5679
- */
5680
- sortTablesByReferences(ctx) {
5681
- const schema = ctx.schema;
5682
- const graph = /* @__PURE__ */ new Map();
5683
- const allTables = Object.keys(schema.tables);
5684
- allTables.forEach((table) => graph.set(table, /* @__PURE__ */ new Set()));
5685
- for (const [tableName, table] of Object.entries(schema.tables)) {
5686
- for (const field of Object.values(table.fields)) {
5687
- if (field.references) {
5688
- const [refTable] = field.references.split("(");
5689
- if (refTable !== tableName && allTables.includes(refTable)) {
5690
- graph.get(refTable)?.add(tableName);
5691
- }
5692
- }
5693
- }
5694
- if (table.foreignKeys) {
5695
- for (const foreignKey of table.foreignKeys) {
5696
- const refTable = foreignKey.tableName;
5697
- if (refTable !== tableName && allTables.includes(refTable)) {
5698
- graph.get(refTable)?.add(tableName);
5699
- }
5700
- }
5701
- }
5567
+ async createDatabaseIfMissing(databaseName) {
5568
+ if (!isSqlIdentifier(databaseName)) {
5569
+ throw new Error(
5570
+ `Invalid database name '${databaseName}'. Names must contain only lowercase alphanumerics and underscores and cannot start with a number.`
5571
+ );
5702
5572
  }
5703
- const visited = /* @__PURE__ */ new Set();
5704
- const tempMark = /* @__PURE__ */ new Set();
5705
- const sorted = [];
5706
- let hasCycles = false;
5707
- function visit(table) {
5708
- if (tempMark.has(table)) {
5709
- hasCycles = true;
5573
+ try {
5574
+ await this.adminDbClient.query(`CREATE DATABASE ${databaseName}`);
5575
+ CadenzaService.log(`Database ${databaseName} created.`);
5576
+ } catch (error) {
5577
+ if (error?.code === "42P04") {
5578
+ CadenzaService.log(`Database ${databaseName} already exists.`);
5710
5579
  return;
5711
5580
  }
5712
- if (visited.has(table)) return;
5713
- tempMark.add(table);
5714
- for (const dep of graph.get(table) || []) {
5715
- visit(dep);
5716
- }
5717
- tempMark.delete(table);
5718
- visited.add(table);
5719
- sorted.push(table);
5581
+ throw new Error(`Failed to create database '${databaseName}': ${errorMessage(error)}`);
5720
5582
  }
5721
- for (const table of allTables) {
5722
- if (!visited.has(table)) {
5723
- visit(table);
5724
- }
5583
+ }
5584
+ async checkPoolHealth(pool, safetyPolicy) {
5585
+ await this.runWithRetries(
5586
+ async () => {
5587
+ await this.withTimeout(
5588
+ () => pool.query("SELECT 1 as health"),
5589
+ safetyPolicy.statementTimeoutMs,
5590
+ "Database health check timed out"
5591
+ );
5592
+ },
5593
+ safetyPolicy,
5594
+ "Health check"
5595
+ );
5596
+ }
5597
+ getPoolOrThrow(registration) {
5598
+ const runtimeState = registration.actor.getRuntimeState(
5599
+ registration.actorKey
5600
+ );
5601
+ if (!runtimeState || !runtimeState.ready || !runtimeState.pool) {
5602
+ throw new Error(
5603
+ `PostgresActor '${registration.actorName}' is not ready. Ensure setup completed before running DB tasks.`
5604
+ );
5725
5605
  }
5726
- for (const table of allTables) {
5727
- if (!visited.has(table)) {
5728
- sorted.push(table);
5606
+ return runtimeState.pool;
5607
+ }
5608
+ async withTimeout(work, timeoutMs, timeoutMessage) {
5609
+ let timeoutHandle = null;
5610
+ try {
5611
+ return await Promise.race([
5612
+ work(),
5613
+ new Promise((_, reject) => {
5614
+ timeoutHandle = setTimeout(() => {
5615
+ reject(new Error(timeoutMessage));
5616
+ }, timeoutMs);
5617
+ })
5618
+ ]);
5619
+ } finally {
5620
+ if (timeoutHandle) {
5621
+ clearTimeout(timeoutHandle);
5729
5622
  }
5730
5623
  }
5731
- sorted.reverse();
5732
- return { ...ctx, sortedTables: sorted, hasCycles };
5733
5624
  }
5734
- /**
5735
- * Asynchronously creates an iterator that splits the provided tables from the schema.
5736
- *
5737
- * @param {Object} ctx - The context object containing the necessary data.
5738
- * @param {string[]} ctx.sortedTables - An array of table names sorted in a specific order.
5739
- * @param {Object} ctx.schema - The schema object that includes table definitions.
5740
- * @param {Object} [ctx.options={}] - Optional configuration options for processing tables.
5741
- *
5742
- * @return {AsyncGenerator} An asynchronous generator that yields objects containing the table definition, metadata, and other context details.
5743
- */
5744
- async *splitTables(ctx) {
5745
- const { sortedTables, schema, options = {} } = ctx;
5746
- for (const tableName of sortedTables) {
5747
- const table = schema.tables[tableName];
5748
- yield { ddl: [], table, tableName, schema, options, sortedTables };
5625
+ async runWithRetries(work, safetyPolicy, operationLabel) {
5626
+ const attempts = normalizePositiveInteger(safetyPolicy.retryCount, 0, 0) + 1;
5627
+ let delayMs = normalizePositiveInteger(safetyPolicy.retryDelayMs, 100);
5628
+ const maxDelayMs = normalizePositiveInteger(safetyPolicy.retryDelayMaxMs, 1e3);
5629
+ const factor = Number.isFinite(safetyPolicy.retryDelayFactor) ? Math.max(1, safetyPolicy.retryDelayFactor) : 1;
5630
+ let lastError;
5631
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
5632
+ try {
5633
+ return await work();
5634
+ } catch (error) {
5635
+ lastError = error;
5636
+ const transient = isTransientDatabaseError(error);
5637
+ if (!transient || attempt >= attempts) {
5638
+ break;
5639
+ }
5640
+ CadenzaService.log(
5641
+ `${operationLabel} failed with transient error. Retrying...`,
5642
+ {
5643
+ attempt,
5644
+ attempts,
5645
+ delayMs,
5646
+ error: errorMessage(error)
5647
+ },
5648
+ "warning"
5649
+ );
5650
+ await sleep(delayMs);
5651
+ delayMs = Math.min(Math.trunc(delayMs * factor), maxDelayMs);
5652
+ }
5749
5653
  }
5654
+ throw lastError;
5750
5655
  }
5751
- /**
5752
- * Converts the keys of objects in an array to camelCase format.
5753
- *
5754
- * @param {Array<any>} rows - An array of objects whose keys should be converted to camelCase.
5755
- * @return {Array<any>} A new array of objects with their keys converted to camelCase.
5756
- */
5757
- toCamelCase(rows) {
5758
- return rows.map((row) => {
5759
- const camelCasedRow = {};
5760
- for (const [key, value] of Object.entries(row)) {
5761
- camelCasedRow[camelCase(key)] = value;
5762
- }
5763
- return camelCasedRow;
5764
- });
5656
+ async executeWithTransaction(pool, transaction, callback) {
5657
+ if (!transaction) {
5658
+ return callback(pool);
5659
+ }
5660
+ const client = await pool.connect();
5661
+ try {
5662
+ await client.query("BEGIN");
5663
+ const result = await callback(client);
5664
+ await client.query("COMMIT");
5665
+ return result;
5666
+ } catch (error) {
5667
+ await client.query("ROLLBACK");
5668
+ throw error;
5669
+ } finally {
5670
+ client.release();
5671
+ }
5765
5672
  }
5766
- /**
5767
- * Executes a query against a specified database table with given parameters.
5768
- *
5769
- * @param {string} tableName - The name of the database table to query.
5770
- * @param {DbOperationPayload} context - An object containing query parameters such as filters, fields, joins, sort, limit, and offset.
5771
- * @return {Promise<any>} A promise that resolves with the query result, including rows, row count, and metadata, or an error object if the query fails.
5772
- */
5773
- async queryFunction(tableName, context) {
5673
+ async queryFunction(registration, tableName, context) {
5774
5674
  const {
5775
5675
  filter = {},
5776
5676
  fields = [],
5777
5677
  joins = {},
5778
5678
  sort = {},
5779
5679
  limit,
5780
- offset
5680
+ offset,
5681
+ queryMode = "rows",
5682
+ aggregates = [],
5683
+ groupBy = []
5781
5684
  } = context;
5782
- let sql = `SELECT ${fields.length ? fields.join(", ") : "*"} FROM ${tableName}`;
5685
+ const pool = this.getPoolOrThrow(registration);
5686
+ const statementTimeoutMs = this.resolveSafetyPolicy(registration).statementTimeoutMs;
5687
+ const resolvedMode = queryMode;
5688
+ const aggregateDefinitions = Array.isArray(aggregates) ? aggregates : [];
5689
+ const groupByFields = Array.isArray(groupBy) ? groupBy : [];
5783
5690
  const params = [];
5784
- if (Object.keys(filter).length > 0) {
5785
- sql += " " + this.buildWhereClause(filter, params);
5786
- }
5787
- if (Object.keys(joins).length > 0) {
5788
- sql += " " + this.buildJoinClause(joins);
5691
+ const whereClause = Object.keys(filter).length > 0 ? this.buildWhereClause(filter, params) : "";
5692
+ const joinClause = Object.keys(joins).length > 0 ? this.buildJoinClause(joins) : "";
5693
+ let sql;
5694
+ if (resolvedMode === "count") {
5695
+ sql = `SELECT COUNT(*)::bigint AS count FROM ${tableName} ${joinClause} ${whereClause}`;
5696
+ } else if (resolvedMode === "exists") {
5697
+ sql = `SELECT EXISTS(SELECT 1 FROM ${tableName} ${joinClause} ${whereClause}) AS exists`;
5698
+ } else if (resolvedMode === "aggregate") {
5699
+ if (aggregateDefinitions.length === 0) {
5700
+ throw new Error("Aggregate queries require at least one aggregate definition");
5701
+ }
5702
+ const aggregateExpressions = aggregateDefinitions.map(
5703
+ (aggregate, index) => {
5704
+ const fn = String(aggregate.fn ?? "").toLowerCase();
5705
+ if (!isSupportedAggregateFunction(fn)) {
5706
+ throw new Error(`Unsupported aggregate function '${aggregate.fn}'`);
5707
+ }
5708
+ const hasField = typeof aggregate.field === "string" && aggregate.field.trim().length > 0;
5709
+ if (fn !== "count" && !hasField) {
5710
+ throw new Error(`Aggregate '${fn}' requires a field`);
5711
+ }
5712
+ const fieldExpression = hasField ? snakeCase(String(aggregate.field)) : "*";
5713
+ const distinctPrefix = aggregate.distinct ? "DISTINCT " : "";
5714
+ const expression = fn === "count" && !hasField ? "COUNT(*)" : `${fn.toUpperCase()}(${distinctPrefix}${fieldExpression})`;
5715
+ const alias = buildAggregateAlias(aggregate, index);
5716
+ return `${expression} AS ${alias}`;
5717
+ }
5718
+ );
5719
+ const groupByExpressions = groupByFields.map((field) => snakeCase(field));
5720
+ const selectExpressions = [...groupByExpressions, ...aggregateExpressions];
5721
+ sql = `SELECT ${selectExpressions.join(", ")} FROM ${tableName} ${joinClause} ${whereClause}`;
5722
+ if (groupByExpressions.length > 0) {
5723
+ sql += ` GROUP BY ${groupByExpressions.join(", ")}`;
5724
+ }
5725
+ } else {
5726
+ sql = `SELECT ${fields.length ? fields.map(snakeCase).join(", ") : "*"} FROM ${tableName} ${joinClause} ${whereClause}`;
5789
5727
  }
5790
- if (Object.keys(sort).length > 0) {
5791
- sql += " ORDER BY " + Object.entries(sort).map(([field, direction]) => `${field} ${direction}`).join(", ");
5728
+ if (Object.keys(sort).length > 0 && resolvedMode !== "count" && resolvedMode !== "exists") {
5729
+ sql += " ORDER BY " + Object.entries(sort).map(([field, direction]) => `${snakeCase(field)} ${direction}`).join(", ");
5792
5730
  }
5793
- if (limit !== void 0) {
5731
+ if (resolvedMode === "one") {
5732
+ sql += ` LIMIT $${params.length + 1}`;
5733
+ params.push(1);
5734
+ } else if (resolvedMode !== "count" && resolvedMode !== "exists" && limit !== void 0) {
5794
5735
  sql += ` LIMIT $${params.length + 1}`;
5795
5736
  params.push(limit);
5796
5737
  }
5797
- if (offset !== void 0) {
5738
+ if (resolvedMode !== "count" && resolvedMode !== "exists" && offset !== void 0) {
5798
5739
  sql += ` OFFSET $${params.length + 1}`;
5799
5740
  params.push(offset);
5800
5741
  }
5801
5742
  try {
5802
- const result = await this.dbClient.query(sql, params);
5743
+ const result = await this.withTimeout(
5744
+ () => pool.query(sql, params),
5745
+ statementTimeoutMs,
5746
+ `Query timeout on table ${tableName}`
5747
+ );
5803
5748
  const rows = this.toCamelCase(result.rows);
5749
+ const rowCount = Number(result.rowCount ?? 0);
5750
+ if (resolvedMode === "count") {
5751
+ return {
5752
+ count: Number(rows[0]?.count ?? 0),
5753
+ rowCount: Number(rows[0]?.count ?? 0),
5754
+ __success: true
5755
+ };
5756
+ }
5757
+ if (resolvedMode === "exists") {
5758
+ const exists = Boolean(rows[0]?.exists);
5759
+ return {
5760
+ exists,
5761
+ rowCount: exists ? 1 : 0,
5762
+ __success: true
5763
+ };
5764
+ }
5765
+ if (resolvedMode === "one") {
5766
+ return {
5767
+ [`${camelCase(tableName)}`]: rows[0] ?? null,
5768
+ rowCount,
5769
+ __success: true
5770
+ };
5771
+ }
5772
+ if (resolvedMode === "aggregate") {
5773
+ return {
5774
+ aggregates: rows,
5775
+ rowCount,
5776
+ __success: true
5777
+ };
5778
+ }
5804
5779
  return {
5805
5780
  [`${camelCase(tableName)}s`]: rows,
5806
- rowCount: result.rowCount,
5807
- __success: true,
5808
- ...context
5781
+ rowCount,
5782
+ __success: true
5809
5783
  };
5810
5784
  } catch (error) {
5811
5785
  return {
5812
- ...context,
5786
+ rowCount: 0,
5813
5787
  errored: true,
5814
- __error: `Query failed: ${error.message}`,
5788
+ __error: `Query failed: ${errorMessage(error)}`,
5815
5789
  __success: false
5816
5790
  };
5817
5791
  }
5818
5792
  }
5819
- /**
5820
- * Inserts data into the specified database table with optional conflict handling.
5821
- *
5822
- * @param {string} tableName - The name of the target database table.
5823
- * @param {DbOperationPayload} context - The context containing data to insert, transaction settings, field mappings, conflict resolution options, and other configurations.
5824
- * - `data` (object | array): The data to be inserted into the database.
5825
- * - `transaction` (boolean): Specifies whether the operation should use a transaction. Defaults to true.
5826
- * - `fields` (array): The fields to return in the result after insertion.
5827
- * - `onConflict` (object): Options for handling conflicts on insert.
5828
- * - `target` (array): Columns to determine conflicts.
5829
- * - `action` (object): Specifies the action to take on conflict, such as updating specified fields.
5830
- * - `awaitExists` (object): Specifies foreign key references to wait for to ensure existence before insertion.
5831
- *
5832
- * @return {Promise<any>} A promise resolving to the result of the database insert operation, including the inserted rows, the row count, and metadata indicating success or error.
5833
- */
5834
- async insertFunction(tableName, context) {
5793
+ async insertFunction(registration, tableName, context) {
5835
5794
  const { data, transaction = true, fields = [], onConflict } = context;
5836
5795
  if (!data || Array.isArray(data) && data.length === 0) {
5837
- return { errored: true, __error: "No data provided for insert" };
5796
+ return {
5797
+ rowCount: 0,
5798
+ errored: true,
5799
+ __error: "No data provided for insert",
5800
+ __success: false
5801
+ };
5838
5802
  }
5839
- let resultContext = {};
5840
- const client = transaction ? await this.getClient() : this.dbClient;
5803
+ const pool = this.getPoolOrThrow(registration);
5804
+ const safetyPolicy = this.resolveSafetyPolicy(registration);
5841
5805
  try {
5842
- if (transaction) await client.query("BEGIN");
5843
- const resolvedData = await this.resolveNestedData(data, tableName);
5844
- const isBatch = Array.isArray(resolvedData);
5845
- const rows = isBatch ? resolvedData : [resolvedData];
5846
- const sql = `INSERT INTO ${tableName} (${Object.keys(rows[0]).map(snakeCase).join(", ")}) VALUES `;
5847
- const values = rows.map(
5848
- (row) => `(${Object.values(row).map((value, i) => {
5849
- if (typeof value === "object" && value?.__effect) {
5850
- if (value.__effect === "increment") {
5851
- return `${Object.keys(row)[i]} + 1`;
5852
- }
5853
- if (value.__effect === "decrement") {
5854
- return `${Object.keys(row)[i]} - 1`;
5855
- }
5856
- if (value.__effect === "set") {
5857
- return `${Object.keys(row)[i]} = ${value.__value}`;
5858
- }
5806
+ const resultContext = await this.runWithRetries(
5807
+ async () => this.executeWithTransaction(pool, Boolean(transaction), async (client) => {
5808
+ const resolvedData = await this.resolveNestedData(
5809
+ registration,
5810
+ data,
5811
+ tableName
5812
+ );
5813
+ const rows = Array.isArray(resolvedData) ? resolvedData : [resolvedData];
5814
+ if (rows.length === 0) {
5815
+ throw new Error("No rows available for insert after resolving data");
5859
5816
  }
5860
- return `$${i + 1}`;
5861
- }).join(", ")})`
5862
- ).join(", ");
5863
- const params = rows.flatMap((row) => Object.values(row));
5864
- let onConflictSql = "";
5865
- if (onConflict) {
5866
- const { target, action } = onConflict;
5867
- onConflictSql += ` ON CONFLICT (${target.join(", ")})`;
5868
- if (action.do === "update") {
5869
- if (!action.set || Object.keys(action.set).length === 0) {
5870
- throw new Error("Update action requires 'set' fields");
5817
+ const keys = Object.keys(rows[0]);
5818
+ const sqlPrefix = `INSERT INTO ${tableName} (${keys.map((key) => snakeCase(key)).join(", ")}) VALUES `;
5819
+ const params = [];
5820
+ const placeholders = rows.map((row) => {
5821
+ const tuple = keys.map((key) => {
5822
+ params.push(row[key]);
5823
+ return `$${params.length}`;
5824
+ }).join(", ");
5825
+ return `(${tuple})`;
5826
+ }).join(", ");
5827
+ let onConflictSql = "";
5828
+ if (onConflict) {
5829
+ onConflictSql = this.buildOnConflictClause(onConflict, params);
5871
5830
  }
5872
- const setClauses = Object.entries(action.set).map(
5873
- ([field, value]) => `${field} = ${value === "excluded" ? "excluded." + field : `$${params.length + 1}`}`
5874
- ).join(", ");
5875
- params.push(
5876
- ...Object.values(action.set).filter(
5877
- (v) => typeof v !== "string" || !v.startsWith("excluded.")
5878
- )
5831
+ const sql = `${sqlPrefix}${placeholders}${onConflictSql} RETURNING ${fields.length ? fields.map(snakeCase).join(", ") : "*"}`;
5832
+ const result = await this.withTimeout(
5833
+ () => client.query(sql, params),
5834
+ safetyPolicy.statementTimeoutMs,
5835
+ `Insert timeout on table ${tableName}`
5879
5836
  );
5880
- onConflictSql += ` DO UPDATE SET ${setClauses}`;
5881
- if (action.where) onConflictSql += ` WHERE ${action.where}`;
5882
- } else {
5883
- onConflictSql += ` DO NOTHING`;
5884
- }
5885
- }
5886
- const result = await client.query(
5887
- `${sql} ${values}${onConflictSql} RETURNING ${fields.length ? fields.join(", ") : "*"}`,
5888
- params
5837
+ const resultRows = this.toCamelCase(result.rows);
5838
+ return {
5839
+ [`${camelCase(tableName)}${rows.length > 1 ? "s" : ""}`]: rows.length > 1 ? resultRows : resultRows[0] ?? null,
5840
+ rowCount: result.rowCount,
5841
+ __success: true
5842
+ };
5843
+ }),
5844
+ safetyPolicy,
5845
+ `Insert ${tableName}`
5889
5846
  );
5890
- if (transaction) await client.query("COMMIT");
5891
- const resultRows = this.toCamelCase(result.rows);
5892
- resultContext = {
5893
- [`${camelCase(tableName)}${isBatch ? "s" : ""}`]: isBatch ? resultRows : resultRows[0],
5894
- rowCount: result.rowCount,
5895
- __success: true
5896
- };
5847
+ return resultContext;
5897
5848
  } catch (error) {
5898
- if (transaction) await client.query("ROLLBACK");
5899
- if (error.message.includes("violates unique constraint")) {
5900
- resultContext = {
5901
- [`${camelCase(tableName)}`]: null,
5902
- __success: false
5903
- };
5904
- } else {
5905
- resultContext = {
5906
- ...context,
5907
- errored: true,
5908
- __error: `Insert failed: ${error.message}`,
5909
- __success: false
5910
- };
5911
- }
5912
- } finally {
5913
- if (transaction && client) {
5914
- client.release();
5915
- }
5849
+ return {
5850
+ rowCount: 0,
5851
+ errored: true,
5852
+ __error: `Insert failed: ${errorMessage(error)}`,
5853
+ __success: false
5854
+ };
5916
5855
  }
5917
- return resultContext;
5918
5856
  }
5919
- /**
5920
- * Updates a database table with the provided data and filter conditions.
5921
- *
5922
- * @param {string} tableName - The name of the database table to update.
5923
- * @param {DbOperationPayload} context - The payload for the update operation, which includes:
5924
- * - data: The data to update in the table.
5925
- * - filter: The conditions to identify the rows to update (default is an empty object).
5926
- * - transaction: Whether the operation should run within a database transaction (default is true).
5927
- * @return {Promise<any>} Returns a Promise resolving to an object that includes:
5928
- * - The updated data if the update is successful.
5929
- * - In case of error:
5930
- * - Error details.
5931
- * - The SQL query and parameters if applicable.
5932
- * - A flag indicating if the update succeeded or failed.
5933
- */
5934
- async updateFunction(tableName, context) {
5857
+ async updateFunction(registration, tableName, context) {
5935
5858
  const { data, filter = {}, transaction = true } = context;
5936
5859
  if (!data || Object.keys(data).length === 0) {
5937
5860
  return {
5861
+ rowCount: 0,
5938
5862
  errored: true,
5939
- __error: `No data provided for update of ${tableName}`
5863
+ __error: `No data provided for update of ${tableName}`,
5864
+ __success: false
5940
5865
  };
5941
5866
  }
5942
- let resultContext = {};
5943
- const client = transaction ? await this.getClient() : this.dbClient;
5867
+ const pool = this.getPoolOrThrow(registration);
5868
+ const safetyPolicy = this.resolveSafetyPolicy(registration);
5944
5869
  try {
5945
- if (transaction) await client.query("BEGIN");
5946
- const resolvedData = await this.resolveNestedData(data, tableName);
5947
- const params = Object.values(resolvedData);
5948
- let offset = 0;
5949
- const setClause = Object.entries(Object.keys(resolvedData)).map(([i, key]) => {
5950
- const value = resolvedData[key];
5951
- const offsetIndex = parseInt(i) - offset;
5952
- if (value.__effect === "increment") {
5953
- params.splice(offsetIndex, 1);
5954
- offset++;
5955
- return `${snakeCase(key)} = ${snakeCase(key)} + 1`;
5956
- }
5957
- if (value.__effect === "decrement") {
5958
- params.splice(offsetIndex, 1);
5959
- offset++;
5960
- return `${snakeCase(key)} = ${snakeCase(key)} - 1`;
5961
- }
5962
- if (value.__effect === "set") {
5963
- params.splice(offsetIndex, 1);
5964
- offset++;
5965
- return `${snakeCase(key)} = ${value.__value}`;
5966
- }
5967
- return `${snakeCase(key)} = $${offsetIndex + 1}`;
5968
- }).join(", ");
5969
- const whereClause = this.buildWhereClause(filter, params);
5970
- const sql = `UPDATE ${tableName} SET ${setClause} ${whereClause} RETURNING *;`;
5971
- const result = await client.query(sql, params);
5972
- if (transaction) await client.query("COMMIT");
5973
- const rows = this.toCamelCase(result.rows);
5974
- if (rows.length === 0) {
5975
- resultContext = {
5976
- sql,
5977
- params,
5978
- __success: false
5979
- };
5980
- } else {
5981
- resultContext = {
5982
- [`${camelCase(tableName)}`]: rows[0],
5983
- __success: true
5984
- };
5985
- }
5870
+ return await this.runWithRetries(
5871
+ async () => this.executeWithTransaction(pool, Boolean(transaction), async (client) => {
5872
+ const resolvedData = await this.resolveNestedData(
5873
+ registration,
5874
+ data,
5875
+ tableName
5876
+ );
5877
+ const params = Object.values(resolvedData);
5878
+ let offset = 0;
5879
+ const setClause = Object.keys(resolvedData).map((key, index) => {
5880
+ const value = resolvedData[key];
5881
+ const offsetIndex = index - offset;
5882
+ if (value?.__effect === "increment") {
5883
+ params.splice(offsetIndex, 1);
5884
+ offset += 1;
5885
+ return `${snakeCase(key)} = ${snakeCase(key)} + 1`;
5886
+ }
5887
+ if (value?.__effect === "decrement") {
5888
+ params.splice(offsetIndex, 1);
5889
+ offset += 1;
5890
+ return `${snakeCase(key)} = ${snakeCase(key)} - 1`;
5891
+ }
5892
+ if (value?.__effect === "set") {
5893
+ params.splice(offsetIndex, 1);
5894
+ offset += 1;
5895
+ return `${snakeCase(key)} = ${value.__value}`;
5896
+ }
5897
+ return `${snakeCase(key)} = $${offsetIndex + 1}`;
5898
+ }).join(", ");
5899
+ const whereClause = this.buildWhereClause(filter, params);
5900
+ const sql = `UPDATE ${tableName} SET ${setClause} ${whereClause} RETURNING *`;
5901
+ const result = await this.withTimeout(
5902
+ () => client.query(sql, params),
5903
+ safetyPolicy.statementTimeoutMs,
5904
+ `Update timeout on table ${tableName}`
5905
+ );
5906
+ const rows = this.toCamelCase(result.rows);
5907
+ const rowCount = Number(result.rowCount ?? 0);
5908
+ return {
5909
+ [`${camelCase(tableName)}`]: rows[0] ?? null,
5910
+ rowCount,
5911
+ __success: rowCount > 0
5912
+ };
5913
+ }),
5914
+ safetyPolicy,
5915
+ `Update ${tableName}`
5916
+ );
5986
5917
  } catch (error) {
5987
- if (transaction) await client.query("ROLLBACK");
5988
- resultContext = {
5989
- ...context,
5918
+ return {
5919
+ rowCount: 0,
5990
5920
  errored: true,
5991
- __error: `Update failed: ${error.message}`,
5921
+ __error: `Update failed: ${errorMessage(error)}`,
5992
5922
  __success: false
5993
5923
  };
5994
- } finally {
5995
- if (transaction && client) {
5996
- client.release();
5997
- }
5998
5924
  }
5999
- return resultContext;
6000
5925
  }
6001
- /**
6002
- * Deletes a record from the specified database table based on the given filter criteria.
6003
- *
6004
- * @param {string} tableName - The name of the database table from which records should be deleted.
6005
- * @param {DbOperationPayload} context - The context for the operation, including filter conditions and transaction settings.
6006
- * @param {Object} context.filter - The filter criteria to identify the records to delete.
6007
- * @param {boolean} [context.transaction=true] - Indicates if the operation should be executed within a transaction.
6008
- * @return {Promise<any>} A promise that resolves to an object containing information about the deleted record
6009
- * or an error object if the delete operation fails.
6010
- */
6011
- async deleteFunction(tableName, context) {
5926
+ async deleteFunction(registration, tableName, context) {
6012
5927
  const { filter = {}, transaction = true } = context;
6013
5928
  if (Object.keys(filter).length === 0) {
6014
- return { errored: true, __error: "No filter provided for delete" };
5929
+ return {
5930
+ rowCount: 0,
5931
+ errored: true,
5932
+ __error: "No filter provided for delete",
5933
+ __success: false
5934
+ };
6015
5935
  }
6016
- let resultContext = {};
6017
- const client = transaction ? await this.getClient() : this.dbClient;
5936
+ const pool = this.getPoolOrThrow(registration);
5937
+ const safetyPolicy = this.resolveSafetyPolicy(registration);
6018
5938
  try {
6019
- if (transaction) await client.query("BEGIN");
6020
- const params = [];
6021
- const whereClause = this.buildWhereClause(filter, params);
6022
- const sql = `DELETE FROM ${tableName} ${whereClause} RETURNING *`;
6023
- const result = await client.query(sql, params);
6024
- if (transaction) await client.query("COMMIT");
6025
- const rows = this.toCamelCase(result.rows);
6026
- resultContext = {
6027
- [`${camelCase(tableName)}`]: rows[0],
6028
- __success: true
6029
- };
5939
+ return await this.runWithRetries(
5940
+ async () => this.executeWithTransaction(pool, Boolean(transaction), async (client) => {
5941
+ const params = [];
5942
+ const whereClause = this.buildWhereClause(filter, params);
5943
+ const sql = `DELETE FROM ${tableName} ${whereClause} RETURNING *`;
5944
+ const result = await this.withTimeout(
5945
+ () => client.query(sql, params),
5946
+ safetyPolicy.statementTimeoutMs,
5947
+ `Delete timeout on table ${tableName}`
5948
+ );
5949
+ const rows = this.toCamelCase(result.rows);
5950
+ return {
5951
+ [`${camelCase(tableName)}`]: rows[0] ?? null,
5952
+ rowCount: result.rowCount,
5953
+ __success: true
5954
+ };
5955
+ }),
5956
+ safetyPolicy,
5957
+ `Delete ${tableName}`
5958
+ );
6030
5959
  } catch (error) {
6031
- if (transaction) await client.query("ROLLBACK");
6032
- resultContext = {
5960
+ return {
5961
+ rowCount: 0,
6033
5962
  errored: true,
6034
- __error: `Delete failed: ${error.message}`,
6035
- __errors: { delete: error.message },
5963
+ __error: `Delete failed: ${errorMessage(error)}`,
6036
5964
  __success: false
6037
5965
  };
6038
- } finally {
6039
- if (transaction && client) {
6040
- client.release();
5966
+ }
5967
+ }
5968
+ resolveSafetyPolicy(registration) {
5969
+ const durableState = registration.actor.getState(
5970
+ registration.actorKey
5971
+ );
5972
+ return {
5973
+ statementTimeoutMs: normalizePositiveInteger(
5974
+ durableState.safetyPolicy?.statementTimeoutMs,
5975
+ 15e3
5976
+ ),
5977
+ retryCount: normalizePositiveInteger(durableState.safetyPolicy?.retryCount, 3, 0),
5978
+ retryDelayMs: normalizePositiveInteger(durableState.safetyPolicy?.retryDelayMs, 100),
5979
+ retryDelayMaxMs: normalizePositiveInteger(
5980
+ durableState.safetyPolicy?.retryDelayMaxMs,
5981
+ 1e3
5982
+ ),
5983
+ retryDelayFactor: Number.isFinite(durableState.safetyPolicy?.retryDelayFactor) ? Math.max(1, Number(durableState.safetyPolicy?.retryDelayFactor)) : 1
5984
+ };
5985
+ }
5986
+ buildOnConflictClause(onConflict, params) {
5987
+ const { target, action } = onConflict;
5988
+ let sql = ` ON CONFLICT (${target.map(snakeCase).join(", ")})`;
5989
+ if (action.do === "update") {
5990
+ if (!action.set || Object.keys(action.set).length === 0) {
5991
+ throw new Error("Update action requires 'set' fields");
5992
+ }
5993
+ const assignments = Object.entries(action.set).map(([field, value]) => {
5994
+ if (typeof value === "string" && value === "excluded") {
5995
+ return `${snakeCase(field)} = excluded.${snakeCase(field)}`;
5996
+ }
5997
+ params.push(value);
5998
+ return `${snakeCase(field)} = $${params.length}`;
5999
+ });
6000
+ sql += ` DO UPDATE SET ${assignments.join(", ")}`;
6001
+ if (action.where) {
6002
+ sql += ` WHERE ${action.where}`;
6041
6003
  }
6004
+ return sql;
6042
6005
  }
6043
- return resultContext;
6006
+ sql += " DO NOTHING";
6007
+ return sql;
6044
6008
  }
6045
6009
  /**
6046
- * Constructs a SQL WHERE clause based on the provided filter object.
6047
- * Builds parameterized queries to prevent SQL injection, appending parameters
6048
- * to the provided params array and utilizing placeholders.
6049
- *
6050
- * @param {Object} filter - An object representing the filtering conditions with
6051
- * keys as column names and values as their corresponding
6052
- * desired values. Values can also be arrays for `IN` queries.
6053
- * @param {any[]} params - An array for storing parameterized values, which will be
6054
- * populated with the filter values for the constructed SQL clause.
6055
- * @return {string} The constructed SQL WHERE clause as a string. If no conditions
6056
- * are provided, an empty string is returned.
6010
+ * Validates database schema structure and content.
6057
6011
  */
6058
- buildWhereClause(filter, params) {
6059
- const conditions = [];
6060
- for (const [key, value] of Object.entries(filter)) {
6061
- if (value !== void 0) {
6062
- if (Array.isArray(value)) {
6063
- conditions.push(
6064
- `${snakeCase(key)} IN (${value.map((v) => {
6065
- const val = `$${params.length + 1}`;
6066
- params.push(v);
6067
- return val;
6068
- }).join(", ")})`
6012
+ validateSchema(ctx) {
6013
+ const schema = ctx.schema;
6014
+ if (!schema?.tables || typeof schema.tables !== "object") {
6015
+ throw new Error("Invalid schema: missing or invalid tables");
6016
+ }
6017
+ for (const [tableName, table] of Object.entries(schema.tables)) {
6018
+ if (!isSqlIdentifier(tableName)) {
6019
+ throw new Error(
6020
+ `Invalid table name ${tableName}. Table names must use lowercase snake_case identifiers.`
6021
+ );
6022
+ }
6023
+ if (!table.fields || typeof table.fields !== "object") {
6024
+ throw new Error(`Invalid table ${tableName}: missing fields`);
6025
+ }
6026
+ for (const [fieldName, field] of Object.entries(table.fields)) {
6027
+ if (!isSqlIdentifier(fieldName)) {
6028
+ throw new Error(
6029
+ `Invalid field name ${fieldName} for ${tableName}. Field names must use lowercase snake_case identifiers.`
6030
+ );
6031
+ }
6032
+ if (!SCHEMA_TYPES.includes(field.type)) {
6033
+ throw new Error(`Invalid type ${field.type} for ${tableName}.${fieldName}`);
6034
+ }
6035
+ if (field.references && !field.references.match(/^[\w]+\([\w]+\)$/)) {
6036
+ throw new Error(
6037
+ `Invalid reference ${field.references} for ${tableName}.${fieldName}`
6069
6038
  );
6070
- } else {
6071
- conditions.push(`${snakeCase(key)} = $${params.length + 1}`);
6072
- params.push(value);
6039
+ }
6040
+ }
6041
+ for (const operation of ["query", "insert", "update", "delete"]) {
6042
+ const customIntents = table.customIntents?.[operation] ?? [];
6043
+ if (!Array.isArray(customIntents)) {
6044
+ throw new Error(
6045
+ `Invalid customIntents.${operation} for table ${tableName}: expected array`
6046
+ );
6047
+ }
6048
+ for (const customIntent of customIntents) {
6049
+ const parsed = readCustomIntentConfig(
6050
+ customIntent
6051
+ );
6052
+ validateIntentName(String(parsed.intent ?? ""));
6073
6053
  }
6074
6054
  }
6075
6055
  }
6076
- return conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
6056
+ return true;
6077
6057
  }
6078
- /**
6079
- * Constructs a SQL join clause from a given set of join definitions.
6080
- *
6081
- * @param {Record<string, JoinDefinition>} joins - An object where keys are table names
6082
- * and values are definitions of join conditions.
6083
- * @return {string} The constructed SQL join clause as a string.
6084
- */
6085
- buildJoinClause(joins) {
6086
- let joinSql = "";
6087
- for (const [table, join] of Object.entries(joins)) {
6088
- joinSql += ` LEFT JOIN ${snakeCase(table)} ${join.alias} ON ${join.on}`;
6089
- if (join.joins) joinSql += " " + this.buildJoinClause(join.joins);
6058
+ sortTablesByReferences(ctx) {
6059
+ const schema = ctx.schema;
6060
+ const graph = /* @__PURE__ */ new Map();
6061
+ const allTables = Object.keys(schema.tables);
6062
+ allTables.forEach((table) => graph.set(table, /* @__PURE__ */ new Set()));
6063
+ for (const [tableName, table] of Object.entries(schema.tables)) {
6064
+ for (const field of Object.values(table.fields)) {
6065
+ if (field.references) {
6066
+ const [refTable] = field.references.split("(");
6067
+ if (refTable !== tableName && allTables.includes(refTable)) {
6068
+ graph.get(refTable)?.add(tableName);
6069
+ }
6070
+ }
6071
+ }
6072
+ if (table.foreignKeys) {
6073
+ for (const foreignKey of table.foreignKeys) {
6074
+ const refTable = foreignKey.tableName;
6075
+ if (refTable !== tableName && allTables.includes(refTable)) {
6076
+ graph.get(refTable)?.add(tableName);
6077
+ }
6078
+ }
6079
+ }
6090
6080
  }
6091
- return joinSql;
6081
+ const visited = /* @__PURE__ */ new Set();
6082
+ const tempMark = /* @__PURE__ */ new Set();
6083
+ const sorted = [];
6084
+ let hasCycles = false;
6085
+ const visit = (table) => {
6086
+ if (tempMark.has(table)) {
6087
+ hasCycles = true;
6088
+ return;
6089
+ }
6090
+ if (visited.has(table)) return;
6091
+ tempMark.add(table);
6092
+ for (const dependent of graph.get(table) || []) {
6093
+ visit(dependent);
6094
+ }
6095
+ tempMark.delete(table);
6096
+ visited.add(table);
6097
+ sorted.push(table);
6098
+ };
6099
+ for (const table of allTables) {
6100
+ if (!visited.has(table)) {
6101
+ visit(table);
6102
+ }
6103
+ }
6104
+ for (const table of allTables) {
6105
+ if (!visited.has(table)) {
6106
+ sorted.push(table);
6107
+ }
6108
+ }
6109
+ sorted.reverse();
6110
+ return { ...ctx, sortedTables: sorted, hasCycles };
6092
6111
  }
6093
- /**
6094
- * Recursively resolves nested data structure by processing special operations and transforming the data accordingly.
6095
- * Handles specific object structures with sub-operations, strings with specific commands, and other nested objects.
6096
- *
6097
- * @param {any} data The initial data to be resolved, which can be an object, array, or primitive value.
6098
- * @param {string} tableName The name of the table associated with the data, used contextually for operation resolution.
6099
- * @return {Promise<any>} A promise that resolves to the fully processed data structure with all nested elements resolved.
6100
- */
6101
- async resolveNestedData(data, tableName) {
6102
- if (Array.isArray(data))
6103
- return Promise.all(data.map((d) => this.resolveNestedData(d, tableName)));
6104
- if (typeof data !== "object" || data === null) return data;
6105
- const resolved = { ...data };
6106
- for (const [key, value] of Object.entries(data)) {
6107
- if (typeof value === "object" && value !== null && "subOperation" in value) {
6108
- const subOp = value;
6109
- resolved[key] = await this.executeSubOperation(subOp);
6110
- } else if (typeof value === "string" && ["increment", "decrement", "set"].includes(value)) {
6111
- resolved[key] = { __effect: value };
6112
- } else if (typeof value === "object") {
6113
- resolved[key] = await this.resolveNestedData(value, tableName);
6112
+ buildSchemaDdlStatements(schema, sortedTables) {
6113
+ const ddl = [];
6114
+ for (const tableName of sortedTables) {
6115
+ const table = schema.tables[tableName];
6116
+ const fieldDefs = Object.entries(table.fields).map(([fieldName, field]) => this.fieldDefinitionToSql(fieldName, field)).join(", ");
6117
+ ddl.push(`CREATE TABLE IF NOT EXISTS ${tableName} (${fieldDefs});`);
6118
+ for (const indexFields of table.indexes ?? []) {
6119
+ ddl.push(
6120
+ `CREATE INDEX IF NOT EXISTS idx_${tableName}_${indexFields.join("_")} ON ${tableName} (${indexFields.map(snakeCase).join(", ")});`
6121
+ );
6122
+ }
6123
+ if (table.primaryKey) {
6124
+ ddl.push(
6125
+ `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS pk_${tableName}_${table.primaryKey.join("_")};`,
6126
+ `ALTER TABLE ${tableName} ADD CONSTRAINT pk_${tableName}_${table.primaryKey.join("_")} PRIMARY KEY (${table.primaryKey.map(snakeCase).join(", ")});`
6127
+ );
6128
+ }
6129
+ for (const uniqueFields of table.uniqueConstraints ?? []) {
6130
+ ddl.push(
6131
+ `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS uq_${tableName}_${uniqueFields.join("_")};`,
6132
+ `ALTER TABLE ${tableName} ADD CONSTRAINT uq_${tableName}_${uniqueFields.join("_")} UNIQUE (${uniqueFields.map(snakeCase).join(", ")});`
6133
+ );
6134
+ }
6135
+ for (const foreignKey of table.foreignKeys ?? []) {
6136
+ const fkName = `fk_${tableName}_${foreignKey.fields.join("_")}`;
6137
+ ddl.push(
6138
+ `ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${fkName};`,
6139
+ `ALTER TABLE ${tableName} ADD CONSTRAINT ${fkName} FOREIGN KEY (${foreignKey.fields.map(snakeCase).join(", ")}) REFERENCES ${foreignKey.tableName} (${foreignKey.referenceFields.map(snakeCase).join(", ")});`
6140
+ );
6141
+ }
6142
+ for (const [triggerName, trigger] of Object.entries(table.triggers ?? {})) {
6143
+ ddl.push(
6144
+ `CREATE OR REPLACE TRIGGER ${triggerName} ${trigger.when} ${trigger.event} ON ${tableName} FOR EACH STATEMENT EXECUTE FUNCTION ${trigger.function};`
6145
+ );
6146
+ }
6147
+ if (table.initialData) {
6148
+ ddl.push(
6149
+ `INSERT INTO ${tableName} (${table.initialData.fields.map(snakeCase).join(", ")}) VALUES ${table.initialData.data.map(
6150
+ (row) => `(${row.map((value) => {
6151
+ if (value === void 0) return "NULL";
6152
+ if (value === null) return "NULL";
6153
+ if (typeof value === "number") return String(value);
6154
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
6155
+ const stringValue = String(value);
6156
+ return `'${stringValue.replace(/'/g, "''")}'`;
6157
+ }).join(", ")})`
6158
+ ).join(", ")} ON CONFLICT DO NOTHING;`
6159
+ );
6114
6160
  }
6115
6161
  }
6116
- return resolved;
6162
+ return ddl;
6117
6163
  }
6118
- /**
6119
- * Executes a sub-operation against the database, such as an insert or query operation.
6120
- *
6121
- * @param {SubOperation} op - The operation to be executed. Contains details such as the type of sub-operation
6122
- * (e.g., "insert" or "query"), the target table, data to be inserted, filters for querying, fields to be retrieved, etc.
6123
- * @return {Promise<any>} A promise that resolves with the result of the operation.
6124
- * For "insert", the result will include the inserted row or a partial response for uuid conflicts.
6125
- * For "query", the result will include the first row that matches the query condition. If no result is found,
6126
- * resolves with an empty object.
6127
- * @throws Throws an error if the operation fails. Rolls back the transaction in case of an error.
6128
- */
6129
- async executeSubOperation(op) {
6130
- const client = await this.getClient();
6131
- try {
6132
- await client.query("BEGIN");
6133
- let result;
6134
- if (op.subOperation === "insert") {
6135
- const resolvedData = await this.resolveNestedData(op.data, op.table);
6136
- const sql = `INSERT INTO ${op.table} (${Object.keys(resolvedData).map((k) => snakeCase(k)).join(", ")}) VALUES (${Object.values(resolvedData).map((_, i) => `$${i + 1}`).join(", ")}) ON CONFLICT DO NOTHING RETURNING ${op.return ?? "*"}`;
6137
- result = await client.query(sql, Object.values(resolvedData));
6138
- result = result.rows[0]?.[op.return ?? "uuid"];
6139
- if (!result) {
6140
- result = op.return && op.return in resolvedData ? resolvedData[op.return] : resolvedData["uuid"];
6141
- }
6142
- } else if (op.subOperation === "query") {
6143
- const params = [];
6144
- const whereClause = this.buildWhereClause(op.filter || {}, params);
6145
- const sql = `SELECT ${op.fields?.join(", ") || "*"} FROM ${op.table} ${whereClause} LIMIT 1`;
6146
- result = (await client.query(sql, params)).rows[0]?.[op.return ?? "uuid"];
6164
+ fieldDefinitionToSql(fieldName, field) {
6165
+ let definition = `${snakeCase(fieldName)} ${field.type.toUpperCase()}`;
6166
+ if (field.type === "varchar") {
6167
+ definition += `(${field.constraints?.maxLength ?? 255})`;
6168
+ }
6169
+ if (field.type === "decimal") {
6170
+ definition += `(${field.constraints?.precision ?? 10},${field.constraints?.scale ?? 2})`;
6171
+ }
6172
+ if (field.primary) definition += " PRIMARY KEY";
6173
+ if (field.unique) definition += " UNIQUE";
6174
+ if (field.default !== void 0) {
6175
+ definition += ` DEFAULT ${field.default === "" ? "''" : String(field.default)}`;
6176
+ }
6177
+ if (field.required && !field.nullable) definition += " NOT NULL";
6178
+ if (field.nullable) definition += " NULL";
6179
+ if (field.generated) {
6180
+ definition += ` GENERATED ALWAYS AS ${field.generated.toUpperCase()} STORED`;
6181
+ }
6182
+ if (field.references) {
6183
+ definition += ` REFERENCES ${field.references} ON DELETE ${field.onDelete || "CASCADE"}`;
6184
+ }
6185
+ if (field.constraints?.check) {
6186
+ definition += ` CHECK (${field.constraints.check})`;
6187
+ }
6188
+ return definition;
6189
+ }
6190
+ async applyDdlStatements(pool, statements) {
6191
+ for (const sql of statements) {
6192
+ try {
6193
+ await pool.query(sql);
6194
+ } catch (error) {
6195
+ CadenzaService.log(
6196
+ "Error applying DDL statement",
6197
+ {
6198
+ sql,
6199
+ error: errorMessage(error)
6200
+ },
6201
+ "error"
6202
+ );
6203
+ throw error;
6147
6204
  }
6148
- await client.query("COMMIT");
6149
- return result || {};
6150
- } catch (error) {
6151
- await client.query("ROLLBACK");
6152
- throw error;
6153
- } finally {
6154
- client.release();
6155
6205
  }
6156
6206
  }
6157
- /**
6158
- * Creates a database task configured for specific operations such as query, insert, update, or delete on a given table.
6159
- *
6160
- * @param {DbOperationType} op - The type of database operation to perform (e.g., "query", "insert", "update", "delete").
6161
- * @param {string} tableName - The name of the table on which the operation will be performed.
6162
- * @param {TableDefinition} table - The table definition that includes configurations such as custom signal triggers and emissions.
6163
- * @param {function(string, AnyObject): Promise<any>} queryFunction - The function to execute the database operation. It takes the table name and a context object as arguments and returns a promise.
6164
- * @param {ServerOptions} options - The options for configuring the server context and metadata behavior.
6165
- * @return {void} This function does not return a value, but it registers a database task for the specified operation.
6166
- */
6167
- createDatabaseTask(op, tableName, table, queryFunction, options) {
6168
- const opAction = op === "query" ? "queried" : op === "insert" ? "inserted" : op === "update" ? "updated" : op === "delete" ? "deleted" : "";
6169
- const defaultSignal = `global.${options.isMeta ? "meta." : ""}${tableName}.${opAction}`;
6207
+ generateDatabaseTasks(registration) {
6208
+ for (const [tableName, table] of Object.entries(registration.schema.tables)) {
6209
+ this.createDatabaseTask(registration, "query", tableName, table);
6210
+ this.createDatabaseTask(registration, "insert", tableName, table);
6211
+ this.createDatabaseTask(registration, "update", tableName, table);
6212
+ this.createDatabaseTask(registration, "delete", tableName, table);
6213
+ this.createDatabaseMacroTasks(registration, tableName, table);
6214
+ }
6215
+ }
6216
+ createDatabaseMacroTasks(registration, tableName, table) {
6217
+ const querySchema = this.getInputSchema("query", tableName, table);
6218
+ const insertSchema = this.getInputSchema("insert", tableName, table);
6219
+ const queryMacroOperations = [
6220
+ "count",
6221
+ "exists",
6222
+ "one",
6223
+ "aggregate"
6224
+ ];
6225
+ for (const macroOperation of queryMacroOperations) {
6226
+ const intentName = `${macroOperation}-pg-${registration.actorToken}-${tableName}`;
6227
+ if (registration.intentNames.has(intentName)) {
6228
+ throw new Error(
6229
+ `Duplicate macro intent '${intentName}' detected for table '${tableName}' in actor '${registration.actorName}'`
6230
+ );
6231
+ }
6232
+ registration.intentNames.add(intentName);
6233
+ CadenzaService.defineIntent({
6234
+ name: intentName,
6235
+ description: `Macro ${macroOperation} operation for table ${tableName}`,
6236
+ input: querySchema
6237
+ });
6238
+ CadenzaService.createThrottledTask(
6239
+ `${macroOperation.toUpperCase()} ${tableName}`,
6240
+ registration.actor.task(
6241
+ async ({ input }) => {
6242
+ const payload = typeof input.queryData === "object" && input.queryData ? input.queryData : input;
6243
+ const result = await this.queryFunction(registration, tableName, {
6244
+ ...payload,
6245
+ queryMode: macroOperation
6246
+ });
6247
+ return {
6248
+ ...input,
6249
+ ...result
6250
+ };
6251
+ },
6252
+ { mode: "read" }
6253
+ ),
6254
+ (context) => context?.__metadata?.__executionTraceId ?? context?.__executionTraceId ?? "default",
6255
+ `Macro ${macroOperation} task for ${tableName}`,
6256
+ {
6257
+ isMeta: registration.options.isMeta,
6258
+ isSubMeta: registration.options.isMeta,
6259
+ validateInputContext: registration.options.securityProfile !== "low",
6260
+ inputSchema: querySchema
6261
+ }
6262
+ ).respondsTo(intentName);
6263
+ }
6264
+ const upsertIntentName = `upsert-pg-${registration.actorToken}-${tableName}`;
6265
+ if (registration.intentNames.has(upsertIntentName)) {
6266
+ throw new Error(
6267
+ `Duplicate macro intent '${upsertIntentName}' detected for table '${tableName}' in actor '${registration.actorName}'`
6268
+ );
6269
+ }
6270
+ registration.intentNames.add(upsertIntentName);
6271
+ CadenzaService.defineIntent({
6272
+ name: upsertIntentName,
6273
+ description: `Macro upsert operation for table ${tableName}`,
6274
+ input: insertSchema
6275
+ });
6276
+ CadenzaService.createThrottledTask(
6277
+ `UPSERT ${tableName}`,
6278
+ registration.actor.task(
6279
+ async ({ input }) => {
6280
+ const payload = typeof input.queryData === "object" && input.queryData ? input.queryData : input;
6281
+ if (!payload.onConflict) {
6282
+ return {
6283
+ ...input,
6284
+ errored: true,
6285
+ __success: false,
6286
+ __error: `Macro upsert requires 'onConflict' payload for table '${tableName}'`
6287
+ };
6288
+ }
6289
+ const result = await this.insertFunction(registration, tableName, payload);
6290
+ return {
6291
+ ...input,
6292
+ ...result
6293
+ };
6294
+ },
6295
+ { mode: "write" }
6296
+ ),
6297
+ (context) => context?.__metadata?.__executionTraceId ?? context?.__executionTraceId ?? "default",
6298
+ `Macro upsert task for ${tableName}`,
6299
+ {
6300
+ isMeta: registration.options.isMeta,
6301
+ isSubMeta: registration.options.isMeta,
6302
+ validateInputContext: registration.options.securityProfile !== "low",
6303
+ inputSchema: insertSchema
6304
+ }
6305
+ ).respondsTo(upsertIntentName);
6306
+ }
6307
+ createDatabaseTask(registration, op, tableName, table) {
6308
+ const opAction = op === "query" ? "queried" : op === "insert" ? "inserted" : op === "update" ? "updated" : "deleted";
6309
+ const defaultSignal = `global.${registration.options.isMeta ? "meta." : ""}${tableName}.${opAction}`;
6170
6310
  const taskName = `${op.charAt(0).toUpperCase() + op.slice(1)} ${tableName}`;
6171
6311
  const schema = this.getInputSchema(op, tableName, table);
6172
- const task = CadenzaService.createThrottledTask(
6173
- taskName,
6174
- async (context, emit) => {
6312
+ const databaseTaskFunction = registration.actor.task(
6313
+ async ({ input, emit }) => {
6314
+ let context = { ...input };
6315
+ let payloadModifiedByTriggers = false;
6175
6316
  for (const action of Object.keys(table.customSignals?.triggers ?? {})) {
6176
- const triggerConditions = (
6177
- // @ts-ignore
6178
- table.customSignals?.triggers?.[action].filter(
6179
- (trigger) => trigger.condition
6180
- )
6181
- );
6182
- for (const triggerCondition of triggerConditions ?? []) {
6183
- if (triggerCondition.condition && !triggerCondition.condition(context)) {
6317
+ const triggerDefinitions = table.customSignals?.triggers?.[action];
6318
+ for (const trigger of triggerDefinitions ?? []) {
6319
+ if (typeof trigger === "string") {
6320
+ continue;
6321
+ }
6322
+ if (trigger.condition && !trigger.condition(context)) {
6184
6323
  return {
6185
6324
  failed: true,
6186
- error: `Condition for signal trigger failed: ${triggerCondition.signal}`
6325
+ __success: false,
6326
+ __error: `Condition for signal trigger failed: ${trigger.signal}`
6187
6327
  };
6188
6328
  }
6189
- }
6190
- const triggerQueryData = (
6191
- // @ts-ignore
6192
- table.customSignals?.triggers?.[action].filter(
6193
- (trigger) => trigger.queryData
6194
- )
6195
- );
6196
- for (const queryData of triggerQueryData ?? []) {
6197
- if (context.queryData) {
6329
+ if (trigger.queryData) {
6198
6330
  context.queryData = {
6199
- ...context.queryData,
6200
- ...queryData
6201
- };
6202
- } else {
6203
- context = {
6204
- ...context,
6205
- ...queryData
6331
+ ...context.queryData ?? {},
6332
+ ...trigger.queryData
6206
6333
  };
6334
+ payloadModifiedByTriggers = true;
6207
6335
  }
6208
6336
  }
6209
6337
  }
6210
- try {
6211
- const result = await queryFunction(
6212
- tableName,
6213
- context.queryData ?? context
6214
- );
6215
- context = {
6216
- ...context,
6217
- ...result
6218
- };
6219
- } catch (e) {
6220
- CadenzaService.log(
6221
- "Database task errored.",
6222
- { taskName, error: e },
6223
- "error"
6224
- );
6225
- throw e;
6338
+ const operationPayload = typeof context.queryData === "object" && context.queryData ? context.queryData : context;
6339
+ this.validateOperationPayload(
6340
+ registration,
6341
+ op,
6342
+ tableName,
6343
+ table,
6344
+ operationPayload,
6345
+ {
6346
+ enforceFieldAllowlist: registration.options.securityProfile === "low" || payloadModifiedByTriggers
6347
+ }
6348
+ );
6349
+ let result;
6350
+ if (op === "query") {
6351
+ result = await this.queryFunction(registration, tableName, operationPayload);
6352
+ } else if (op === "insert") {
6353
+ result = await this.insertFunction(registration, tableName, operationPayload);
6354
+ } else if (op === "update") {
6355
+ result = await this.updateFunction(registration, tableName, operationPayload);
6356
+ } else {
6357
+ result = await this.deleteFunction(registration, tableName, operationPayload);
6226
6358
  }
6359
+ context = {
6360
+ ...context,
6361
+ ...result
6362
+ };
6227
6363
  if (!context.errored) {
6228
6364
  for (const signal of table.customSignals?.emissions?.[op] ?? []) {
6365
+ if (typeof signal === "string") {
6366
+ emit(signal, context);
6367
+ continue;
6368
+ }
6229
6369
  if (signal.condition && !signal.condition(context)) {
6230
6370
  continue;
6231
6371
  }
6232
- emit(signal.signal ?? signal, context);
6372
+ emit(signal.signal, context);
6233
6373
  }
6234
6374
  }
6235
6375
  if (tableName !== "system_log" && context.errored) {
@@ -6259,49 +6399,263 @@ var DatabaseController = class _DatabaseController {
6259
6399
  delete context.offset;
6260
6400
  return context;
6261
6401
  },
6402
+ { mode: op === "query" ? "read" : "write" }
6403
+ );
6404
+ const task = CadenzaService.createThrottledTask(
6405
+ taskName,
6406
+ databaseTaskFunction,
6262
6407
  (context) => context?.__metadata?.__executionTraceId ?? context?.__executionTraceId ?? "default",
6263
- `Auto-generated ${op} task for ${tableName}`,
6408
+ `Auto-generated ${op} task for ${tableName} (PostgresActor)`,
6264
6409
  {
6265
- isMeta: options.isMeta,
6266
- isSubMeta: options.isMeta,
6267
- validateInputContext: options.securityProfile !== "low",
6410
+ isMeta: registration.options.isMeta,
6411
+ isSubMeta: registration.options.isMeta,
6412
+ validateInputContext: registration.options.securityProfile !== "low",
6268
6413
  inputSchema: schema
6269
6414
  }
6270
6415
  ).doOn(
6271
- ...table.customSignals?.triggers?.[op]?.map((signal) => {
6272
- return typeof signal === "string" ? signal : signal.signal;
6273
- }) ?? []
6416
+ ...table.customSignals?.triggers?.[op]?.map(
6417
+ (signal) => typeof signal === "string" ? signal : signal.signal
6418
+ ) ?? []
6274
6419
  ).emits(defaultSignal).attachSignal(
6275
- ...table.customSignals?.emissions?.[op]?.map((signal) => {
6276
- return typeof signal === "string" ? signal : signal.signal;
6277
- }) ?? []
6420
+ ...table.customSignals?.emissions?.[op]?.map(
6421
+ (signal) => typeof signal === "string" ? signal : signal.signal
6422
+ ) ?? []
6278
6423
  );
6279
- if (op === "query") {
6280
- const { intents, warnings } = resolveTableQueryIntents(
6281
- CadenzaService.serviceRegistry?.serviceName,
6282
- tableName,
6283
- table,
6284
- schema
6285
- );
6286
- for (const warning of warnings) {
6287
- CadenzaService.log(
6288
- "Skipped custom query intent registration.",
6289
- {
6290
- tableName,
6291
- warning
6292
- },
6293
- "warning"
6424
+ const { intents } = resolveTableOperationIntents(
6425
+ registration.actorName,
6426
+ tableName,
6427
+ table,
6428
+ op,
6429
+ schema
6430
+ );
6431
+ for (const intent of intents) {
6432
+ if (registration.intentNames.has(intent.name)) {
6433
+ throw new Error(
6434
+ `Duplicate auto/custom intent '${intent.name}' detected while generating ${op} task for table '${tableName}' in actor '${registration.actorName}'`
6294
6435
  );
6295
6436
  }
6296
- for (const intent of intents) {
6297
- CadenzaService.defineIntent({
6298
- name: intent.name,
6299
- description: intent.description,
6300
- input: intent.input
6301
- });
6437
+ registration.intentNames.add(intent.name);
6438
+ CadenzaService.defineIntent({
6439
+ name: intent.name,
6440
+ description: intent.description,
6441
+ input: intent.input
6442
+ });
6443
+ }
6444
+ task.respondsTo(...intents.map((intent) => intent.name));
6445
+ }
6446
+ validateOperationPayload(registration, operation, tableName, table, payload, options) {
6447
+ const allowedFields = new Set(Object.keys(table.fields));
6448
+ const resolvedMode = payload.queryMode ?? "rows";
6449
+ if (!["rows", "count", "exists", "one", "aggregate"].includes(resolvedMode)) {
6450
+ throw new Error(`Unsupported queryMode '${String(payload.queryMode)}'`);
6451
+ }
6452
+ const assertAllowedField = (fieldName, label) => {
6453
+ if (!allowedFields.has(fieldName)) {
6454
+ throw new Error(
6455
+ `Invalid field '${fieldName}' in ${label} for ${operation} on ${tableName}`
6456
+ );
6457
+ }
6458
+ };
6459
+ const aggregateDefinitions = Array.isArray(payload.aggregates) ? payload.aggregates : [];
6460
+ const aggregateSortAllowlist = /* @__PURE__ */ new Set();
6461
+ if (resolvedMode === "aggregate") {
6462
+ if (aggregateDefinitions.length === 0) {
6463
+ throw new Error(
6464
+ `Aggregate queryMode requires at least one aggregate on table '${tableName}'`
6465
+ );
6466
+ }
6467
+ for (const groupField of payload.groupBy ?? []) {
6468
+ assertAllowedField(groupField, "groupBy");
6469
+ aggregateSortAllowlist.add(groupField);
6470
+ }
6471
+ for (const [index, aggregate] of aggregateDefinitions.entries()) {
6472
+ if (!isSupportedAggregateFunction(aggregate.fn)) {
6473
+ throw new Error(
6474
+ `Unsupported aggregate function '${String(aggregate.fn)}' on table '${tableName}'`
6475
+ );
6476
+ }
6477
+ if (aggregate.fn !== "count" && !aggregate.field) {
6478
+ throw new Error(
6479
+ `Aggregate '${aggregate.fn}' requires field on table '${tableName}'`
6480
+ );
6481
+ }
6482
+ if (aggregate.field) {
6483
+ assertAllowedField(aggregate.field, "aggregates.field");
6484
+ }
6485
+ aggregateSortAllowlist.add(buildAggregateAlias(aggregate, index));
6486
+ }
6487
+ } else if (aggregateDefinitions.length > 0 || (payload.groupBy ?? []).length > 0) {
6488
+ throw new Error(
6489
+ `aggregates/groupBy payload requires queryMode='aggregate' on table '${tableName}'`
6490
+ );
6491
+ }
6492
+ if (options.enforceFieldAllowlist) {
6493
+ if (payload.fields) {
6494
+ for (const field of payload.fields) {
6495
+ assertAllowedField(field, "fields");
6496
+ }
6497
+ }
6498
+ if (payload.filter) {
6499
+ for (const field of Object.keys(payload.filter)) {
6500
+ assertAllowedField(field, "filter");
6501
+ }
6502
+ }
6503
+ if (payload.data) {
6504
+ const rows = resolveDataRows(payload.data);
6505
+ for (const row of rows) {
6506
+ for (const field of Object.keys(row)) {
6507
+ assertAllowedField(field, "data");
6508
+ }
6509
+ }
6510
+ }
6511
+ }
6512
+ if (payload.sort) {
6513
+ for (const field of Object.keys(payload.sort)) {
6514
+ if (resolvedMode === "aggregate" && aggregateSortAllowlist.has(field)) {
6515
+ continue;
6516
+ }
6517
+ assertAllowedField(field, "sort");
6518
+ }
6519
+ }
6520
+ if (payload.onConflict) {
6521
+ for (const conflictField of payload.onConflict.target ?? []) {
6522
+ assertAllowedField(conflictField, "onConflict.target");
6523
+ }
6524
+ for (const setField of Object.keys(payload.onConflict.action?.set ?? {})) {
6525
+ assertAllowedField(setField, "onConflict.action.set");
6526
+ }
6527
+ }
6528
+ if (payload.joins) {
6529
+ this.validateJoinPayload(registration.schema, payload.joins);
6530
+ }
6531
+ }
6532
+ validateJoinPayload(schema, joins) {
6533
+ for (const [joinTableName, joinDefinition] of Object.entries(joins)) {
6534
+ if (!schema.tables[joinTableName]) {
6535
+ throw new Error(`Invalid join table '${joinTableName}'. Table does not exist in schema.`);
6536
+ }
6537
+ const joinTable = schema.tables[joinTableName];
6538
+ for (const field of joinDefinition.fields ?? []) {
6539
+ if (!joinTable.fields[field]) {
6540
+ throw new Error(
6541
+ `Invalid join field '${field}' on joined table '${joinTableName}'`
6542
+ );
6543
+ }
6544
+ }
6545
+ if (joinDefinition.filter) {
6546
+ for (const filterField of Object.keys(joinDefinition.filter)) {
6547
+ if (!joinTable.fields[filterField]) {
6548
+ throw new Error(
6549
+ `Invalid join filter field '${filterField}' on joined table '${joinTableName}'`
6550
+ );
6551
+ }
6552
+ }
6553
+ }
6554
+ if (joinDefinition.joins) {
6555
+ this.validateJoinPayload(schema, joinDefinition.joins);
6556
+ }
6557
+ }
6558
+ }
6559
+ toCamelCase(rows) {
6560
+ return rows.map((row) => {
6561
+ const camelCasedRow = {};
6562
+ for (const [key, value] of Object.entries(row)) {
6563
+ camelCasedRow[camelCase(key)] = value;
6564
+ }
6565
+ return camelCasedRow;
6566
+ });
6567
+ }
6568
+ buildWhereClause(filter, params) {
6569
+ const conditions = [];
6570
+ for (const [key, value] of Object.entries(filter)) {
6571
+ if (value !== void 0) {
6572
+ if (Array.isArray(value)) {
6573
+ const placeholders = value.map((entry) => {
6574
+ params.push(entry);
6575
+ return `$${params.length}`;
6576
+ }).join(", ");
6577
+ conditions.push(`${snakeCase(key)} IN (${placeholders})`);
6578
+ } else {
6579
+ params.push(value);
6580
+ conditions.push(`${snakeCase(key)} = $${params.length}`);
6581
+ }
6582
+ }
6583
+ }
6584
+ return conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
6585
+ }
6586
+ buildJoinClause(joins) {
6587
+ let joinSql = "";
6588
+ for (const [table, join] of Object.entries(joins)) {
6589
+ const alias = join.alias ? ` ${join.alias}` : "";
6590
+ joinSql += ` LEFT JOIN ${snakeCase(table)}${alias} ON ${join.on}`;
6591
+ if (join.joins) joinSql += " " + this.buildJoinClause(join.joins);
6592
+ }
6593
+ return joinSql;
6594
+ }
6595
+ async resolveNestedData(registration, data, tableName) {
6596
+ if (Array.isArray(data)) {
6597
+ return Promise.all(
6598
+ data.map((entry) => this.resolveNestedData(registration, entry, tableName))
6599
+ );
6600
+ }
6601
+ if (typeof data !== "object" || data === null) {
6602
+ return data;
6603
+ }
6604
+ const resolved = { ...data };
6605
+ for (const [key, value] of Object.entries(data)) {
6606
+ if (typeof value === "object" && value !== null && "subOperation" in value) {
6607
+ const subOperation = value;
6608
+ resolved[key] = await this.executeSubOperation(registration, subOperation);
6609
+ } else if (typeof value === "string" && ["increment", "decrement", "set"].includes(value)) {
6610
+ resolved[key] = { __effect: value };
6611
+ } else if (typeof value === "object" && value !== null) {
6612
+ resolved[key] = await this.resolveNestedData(registration, value, tableName);
6302
6613
  }
6303
- task.respondsTo(...intents.map((intent) => intent.name));
6304
6614
  }
6615
+ return resolved;
6616
+ }
6617
+ async executeSubOperation(registration, operation) {
6618
+ const targetTableName = operation.table;
6619
+ if (!registration.schema.tables[targetTableName]) {
6620
+ throw new Error(
6621
+ `Sub-operation table '${targetTableName}' does not exist in actor schema`
6622
+ );
6623
+ }
6624
+ const pool = this.getPoolOrThrow(registration);
6625
+ const safetyPolicy = this.resolveSafetyPolicy(registration);
6626
+ return this.executeWithTransaction(pool, true, async (client) => {
6627
+ if (operation.subOperation === "insert") {
6628
+ const resolvedData = await this.resolveNestedData(
6629
+ registration,
6630
+ operation.data,
6631
+ operation.table
6632
+ );
6633
+ const row = ensurePlainObject(resolvedData, "sub-operation insert data");
6634
+ const keys = Object.keys(row);
6635
+ const params = Object.values(row);
6636
+ const sql2 = `INSERT INTO ${operation.table} (${keys.map((key) => snakeCase(key)).join(", ")}) VALUES (${params.map((_, index) => `$${index + 1}`).join(", ")}) ON CONFLICT DO NOTHING RETURNING ${operation.return ?? "*"}`;
6637
+ const result2 = await this.withTimeout(
6638
+ () => client.query(sql2, params),
6639
+ safetyPolicy.statementTimeoutMs,
6640
+ `Sub-operation insert timeout on table ${operation.table}`
6641
+ );
6642
+ const returnKey2 = operation.return ?? "uuid";
6643
+ if (result2.rows[0]?.[returnKey2] !== void 0) {
6644
+ return result2.rows[0][returnKey2];
6645
+ }
6646
+ return row[returnKey2] ?? row.uuid ?? {};
6647
+ }
6648
+ const queryParams = [];
6649
+ const whereClause = this.buildWhereClause(operation.filter || {}, queryParams);
6650
+ const sql = `SELECT ${(operation.fields ?? ["*"]).map((field) => field === "*" ? field : snakeCase(field)).join(", ")} FROM ${operation.table} ${whereClause} LIMIT 1`;
6651
+ const result = await this.withTimeout(
6652
+ () => client.query(sql, queryParams),
6653
+ safetyPolicy.statementTimeoutMs,
6654
+ `Sub-operation query timeout on table ${operation.table}`
6655
+ );
6656
+ const returnKey = operation.return ?? "uuid";
6657
+ return result.rows[0]?.[returnKey] ?? {};
6658
+ });
6305
6659
  }
6306
6660
  getInputSchema(op, tableName, table) {
6307
6661
  const inputSchema = {
@@ -6320,66 +6674,43 @@ var DatabaseController = class _DatabaseController {
6320
6674
  }
6321
6675
  inputSchema.properties.transaction = getTransactionSchema();
6322
6676
  inputSchema.properties.queryData.properties.transaction = inputSchema.properties.transaction;
6323
- switch (op) {
6324
- case "insert":
6325
- inputSchema.properties.data = getInsertDataSchemaFromTable(
6326
- table,
6327
- tableName
6328
- );
6329
- inputSchema.properties.queryData.properties.data = inputSchema.properties.data;
6330
- inputSchema.properties.batch = getQueryBatchSchemaFromTable();
6331
- inputSchema.properties.queryData.properties.batch = inputSchema.properties.batch;
6332
- inputSchema.properties.onConflict = getQueryOnConflictSchemaFromTable(
6333
- table,
6334
- tableName
6335
- );
6336
- inputSchema.properties.queryData.properties.onConflict = inputSchema.properties.onConflict;
6337
- break;
6338
- case "query":
6339
- inputSchema.properties.filter = getQueryFilterSchemaFromTable(
6340
- table,
6341
- tableName
6342
- );
6343
- inputSchema.properties.queryData.properties.filter = inputSchema.properties.filter;
6344
- inputSchema.properties.fields = getQueryFieldsSchemaFromTable(
6345
- table,
6346
- tableName
6347
- );
6348
- inputSchema.properties.queryData.properties.fields = inputSchema.properties.fields;
6349
- inputSchema.properties.joins = getQueryJoinsSchemaFromTable(
6350
- table,
6351
- tableName
6352
- );
6353
- inputSchema.properties.queryData.properties.joins = inputSchema.properties.joins;
6354
- inputSchema.properties.sort = getQuerySortSchemaFromTable(
6355
- table,
6356
- tableName
6357
- );
6358
- inputSchema.properties.queryData.properties.sort = inputSchema.properties.sort;
6359
- inputSchema.properties.limit = getQueryLimitSchemaFromTable();
6360
- inputSchema.properties.queryData.properties.limit = inputSchema.properties.limit;
6361
- inputSchema.properties.offset = getQueryOffsetSchemaFromTable();
6362
- inputSchema.properties.queryData.properties.offset = inputSchema.properties.offset;
6363
- break;
6364
- case "update":
6365
- inputSchema.properties.filter = getQueryFilterSchemaFromTable(
6366
- table,
6367
- tableName
6368
- );
6369
- inputSchema.properties.queryData.properties.filter = inputSchema.properties.filter;
6370
- inputSchema.properties.fields = getQueryFieldsSchemaFromTable(
6371
- table,
6372
- tableName
6373
- );
6374
- inputSchema.properties.queryData.properties.fields = inputSchema.properties.fields;
6375
- break;
6376
- case "delete":
6377
- inputSchema.properties.filter = getQueryFilterSchemaFromTable(
6378
- table,
6379
- tableName
6380
- );
6381
- inputSchema.properties.queryData.properties.filter = inputSchema.properties.filter;
6382
- break;
6677
+ if (op === "insert" || op === "update") {
6678
+ inputSchema.properties.data = getInsertDataSchemaFromTable(table, tableName);
6679
+ inputSchema.properties.queryData.properties.data = inputSchema.properties.data;
6680
+ }
6681
+ if (op === "insert") {
6682
+ inputSchema.properties.batch = getQueryBatchSchemaFromTable();
6683
+ inputSchema.properties.queryData.properties.batch = inputSchema.properties.batch;
6684
+ inputSchema.properties.onConflict = getQueryOnConflictSchemaFromTable(
6685
+ table,
6686
+ tableName
6687
+ );
6688
+ inputSchema.properties.queryData.properties.onConflict = inputSchema.properties.onConflict;
6689
+ }
6690
+ if (op === "query" || op === "update" || op === "delete") {
6691
+ inputSchema.properties.filter = getQueryFilterSchemaFromTable(table, tableName);
6692
+ inputSchema.properties.queryData.properties.filter = inputSchema.properties.filter;
6693
+ }
6694
+ if (op === "query") {
6695
+ inputSchema.properties.queryMode = getQueryModeSchema();
6696
+ inputSchema.properties.queryData.properties.queryMode = inputSchema.properties.queryMode;
6697
+ inputSchema.properties.fields = getQueryFieldsSchemaFromTable(table, tableName);
6698
+ inputSchema.properties.queryData.properties.fields = inputSchema.properties.fields;
6699
+ inputSchema.properties.joins = getQueryJoinsSchemaFromTable(table, tableName);
6700
+ inputSchema.properties.queryData.properties.joins = inputSchema.properties.joins;
6701
+ inputSchema.properties.sort = getQuerySortSchemaFromTable(table, tableName);
6702
+ inputSchema.properties.queryData.properties.sort = inputSchema.properties.sort;
6703
+ inputSchema.properties.aggregates = getQueryAggregatesSchemaFromTable(
6704
+ table,
6705
+ tableName
6706
+ );
6707
+ inputSchema.properties.queryData.properties.aggregates = inputSchema.properties.aggregates;
6708
+ inputSchema.properties.groupBy = getQueryGroupBySchemaFromTable(table, tableName);
6709
+ inputSchema.properties.queryData.properties.groupBy = inputSchema.properties.groupBy;
6710
+ inputSchema.properties.limit = getQueryLimitSchemaFromTable();
6711
+ inputSchema.properties.queryData.properties.limit = inputSchema.properties.limit;
6712
+ inputSchema.properties.offset = getQueryOffsetSchemaFromTable();
6713
+ inputSchema.properties.queryData.properties.offset = inputSchema.properties.offset;
6383
6714
  }
6384
6715
  return inputSchema;
6385
6716
  }
@@ -6389,13 +6720,13 @@ function getInsertDataSchemaFromTable(table, tableName) {
6389
6720
  type: "object",
6390
6721
  properties: {
6391
6722
  ...Object.fromEntries(
6392
- Object.entries(table.fields).map((field) => {
6723
+ Object.entries(table.fields).map(([fieldName, field]) => {
6393
6724
  return [
6394
- field[0],
6725
+ fieldName,
6395
6726
  {
6396
6727
  value: {
6397
- type: tableFieldTypeToSchemaType(field[1].type),
6398
- description: `Inferred from field '${field[0]}' of type [${field[1].type}] on table ${tableName}.`
6728
+ type: tableFieldTypeToSchemaType(field.type),
6729
+ description: `Inferred from field '${fieldName}' of type [${field.type}] on table ${tableName}.`
6399
6730
  },
6400
6731
  effect: {
6401
6732
  type: "string",
@@ -6444,7 +6775,7 @@ function getInsertDataSchemaFromTable(table, tableName) {
6444
6775
  })
6445
6776
  )
6446
6777
  },
6447
- required: Object.entries(table.fields).filter((field) => field[1].required || field[1].primary).map((field) => field[0]),
6778
+ required: Object.entries(table.fields).filter(([, field]) => field.required || field.primary).map(([fieldName]) => fieldName),
6448
6779
  strict: true
6449
6780
  };
6450
6781
  return {
@@ -6460,18 +6791,18 @@ function getQueryFilterSchemaFromTable(table, tableName) {
6460
6791
  type: "object",
6461
6792
  properties: {
6462
6793
  ...Object.fromEntries(
6463
- Object.entries(table.fields).map((field) => {
6794
+ Object.entries(table.fields).map(([fieldName, field]) => {
6464
6795
  return [
6465
- field[0],
6796
+ fieldName,
6466
6797
  {
6467
6798
  value: {
6468
- type: tableFieldTypeToSchemaType(field[1].type),
6469
- description: `Inferred from field '${field[0]}' of type [${field[1].type}] on table ${tableName}.`
6799
+ type: tableFieldTypeToSchemaType(field.type),
6800
+ description: `Inferred from field '${fieldName}' of type [${field.type}] on table ${tableName}.`
6470
6801
  },
6471
6802
  in: {
6472
6803
  type: "array",
6473
6804
  items: {
6474
- type: tableFieldTypeToSchemaType(field[1].type)
6805
+ type: tableFieldTypeToSchemaType(field.type)
6475
6806
  }
6476
6807
  }
6477
6808
  }
@@ -6480,7 +6811,7 @@ function getQueryFilterSchemaFromTable(table, tableName) {
6480
6811
  )
6481
6812
  },
6482
6813
  strict: true,
6483
- description: `Inferred from table '${tableName}' on database service ${CadenzaService.serviceRegistry?.serviceName ?? "unknown-service"}.`
6814
+ description: `Inferred from table '${tableName}' on postgres actor table contract.`
6484
6815
  };
6485
6816
  }
6486
6817
  function getQueryFieldsSchemaFromTable(table, tableName) {
@@ -6492,106 +6823,100 @@ function getQueryFieldsSchemaFromTable(table, tableName) {
6492
6823
  oneOf: Object.keys(table.fields)
6493
6824
  }
6494
6825
  },
6495
- description: `Inferred from table '${tableName}' on database service ${CadenzaService.serviceRegistry?.serviceName ?? "unknown-service"}.`
6826
+ description: `Inferred field projection from table '${tableName}'.`
6496
6827
  };
6497
6828
  }
6498
- function getQueryJoinsSchemaFromTable(table, tableName) {
6829
+ function getQueryModeSchema() {
6499
6830
  return {
6500
- type: "object",
6501
- properties: {
6502
- ...Object.fromEntries(
6503
- Object.entries(table.fields).map((field) => {
6504
- return [
6505
- field[0],
6506
- {
6507
- type: "object",
6508
- properties: {
6509
- on: {
6510
- type: "string"
6511
- },
6512
- fields: {
6513
- type: "array",
6514
- items: {
6515
- type: "string"
6516
- }
6517
- },
6518
- filter: {
6519
- type: "object"
6520
- },
6521
- returnAs: {
6522
- type: "string",
6523
- constraints: {
6524
- oneOf: ["array", "object"]
6525
- }
6526
- },
6527
- alias: {
6528
- type: "string"
6529
- },
6530
- joins: {
6531
- type: "object"
6532
- }
6533
- },
6534
- required: ["on", "fields"],
6535
- strict: true
6536
- }
6537
- ];
6538
- })
6539
- )
6831
+ type: "string",
6832
+ constraints: {
6833
+ oneOf: ["rows", "count", "exists", "one", "aggregate"]
6834
+ }
6835
+ };
6836
+ }
6837
+ function getQueryAggregatesSchemaFromTable(table, tableName) {
6838
+ return {
6839
+ type: "array",
6840
+ items: {
6841
+ type: "object",
6842
+ properties: {
6843
+ fn: {
6844
+ type: "string",
6845
+ constraints: {
6846
+ oneOf: ["count", "sum", "avg", "min", "max"]
6847
+ }
6848
+ },
6849
+ field: {
6850
+ type: "string",
6851
+ constraints: {
6852
+ oneOf: Object.keys(table.fields)
6853
+ }
6854
+ },
6855
+ as: {
6856
+ type: "string"
6857
+ },
6858
+ distinct: {
6859
+ type: "boolean"
6860
+ }
6861
+ },
6862
+ required: ["fn"],
6863
+ strict: true
6540
6864
  },
6541
- strict: true,
6542
- description: `Inferred from table '${tableName}' on database service ${CadenzaService.serviceRegistry?.serviceName ?? "unknown-service"}.`
6865
+ description: `Aggregate definitions inferred from table '${tableName}'.`
6543
6866
  };
6544
6867
  }
6545
- function getQuerySortSchemaFromTable(table, tableName) {
6868
+ function getQueryGroupBySchemaFromTable(table, tableName) {
6546
6869
  return {
6547
- type: "object",
6548
- properties: {
6549
- ...Object.fromEntries(
6550
- Object.entries(table.fields).map((field) => {
6551
- return [
6552
- field[0],
6553
- {
6554
- type: "string",
6555
- constraints: {
6556
- oneOf: ["asc", "desc"]
6557
- }
6558
- }
6559
- ];
6560
- })
6561
- )
6870
+ type: "array",
6871
+ items: {
6872
+ type: "string",
6873
+ constraints: {
6874
+ oneOf: Object.keys(table.fields)
6875
+ }
6562
6876
  },
6563
- strict: true,
6564
- description: `Inferred from table '${tableName}' on database service ${CadenzaService.serviceRegistry?.serviceName ?? "unknown-service"}.`
6877
+ description: `Group by fields inferred from table '${tableName}'.`
6878
+ };
6879
+ }
6880
+ function getQueryJoinsSchemaFromTable(_table, tableName) {
6881
+ return {
6882
+ type: "object",
6883
+ description: `Join definitions for table '${tableName}'.`
6884
+ };
6885
+ }
6886
+ function getQuerySortSchemaFromTable(_table, tableName) {
6887
+ return {
6888
+ type: "object",
6889
+ strict: false,
6890
+ description: `Sort definition for table '${tableName}'. Keys are validated at runtime against allowlists and aggregate aliases.`
6565
6891
  };
6566
6892
  }
6567
6893
  function getQueryLimitSchemaFromTable() {
6568
6894
  return {
6569
6895
  type: "number",
6570
6896
  constraints: {
6571
- min: 1
6572
- },
6573
- description: "Limit for query results"
6897
+ min: 0,
6898
+ max: 1e3
6899
+ }
6574
6900
  };
6575
6901
  }
6576
6902
  function getQueryOffsetSchemaFromTable() {
6577
6903
  return {
6578
6904
  type: "number",
6579
6905
  constraints: {
6580
- min: 0
6581
- },
6582
- description: "Offset for query results"
6906
+ min: 0,
6907
+ max: 1e6
6908
+ }
6583
6909
  };
6584
6910
  }
6585
- function getTransactionSchema() {
6911
+ function getQueryBatchSchemaFromTable() {
6586
6912
  return {
6587
- type: "boolean",
6588
- description: "Whether to run the query in a transaction"
6913
+ type: "boolean"
6589
6914
  };
6590
6915
  }
6591
- function getQueryBatchSchemaFromTable() {
6916
+ function getTransactionSchema() {
6592
6917
  return {
6593
6918
  type: "boolean",
6594
- description: "Whether to run the query in batch mode"
6919
+ description: "Execute the operation in a transaction."
6595
6920
  };
6596
6921
  }
6597
6922
  function getQueryOnConflictSchemaFromTable(table, tableName) {
@@ -6617,55 +6942,43 @@ function getQueryOnConflictSchemaFromTable(table, tableName) {
6617
6942
  }
6618
6943
  },
6619
6944
  set: {
6620
- type: "object",
6621
- properties: {
6622
- ...Object.fromEntries(
6623
- Object.entries(table.fields).map((field) => {
6624
- return [
6625
- field[0],
6626
- {
6627
- type: tableFieldTypeToSchemaType(field[1].type),
6628
- description: `Inferred from field '${field[0]}' of type [${field[1].type}] on table ${tableName}.`
6629
- }
6630
- ];
6631
- })
6632
- )
6633
- }
6945
+ type: "object"
6634
6946
  },
6635
6947
  where: {
6636
6948
  type: "string"
6637
6949
  }
6638
- },
6639
- required: ["do"]
6950
+ }
6640
6951
  }
6641
6952
  },
6642
- required: ["target", "action"],
6643
- strict: true
6953
+ strict: true,
6954
+ description: `Conflict strategy for inserts on table '${tableName}'.`
6644
6955
  };
6645
6956
  }
6646
6957
  function tableFieldTypeToSchemaType(type) {
6647
6958
  switch (type) {
6648
6959
  case "varchar":
6649
6960
  case "text":
6650
- case "jsonb":
6651
6961
  case "uuid":
6962
+ case "timestamp":
6652
6963
  case "date":
6653
6964
  case "geo_point":
6654
- case "bytea":
6655
6965
  return "string";
6656
6966
  case "int":
6657
6967
  case "bigint":
6658
6968
  case "decimal":
6659
- case "timestamp":
6660
6969
  return "number";
6661
6970
  case "boolean":
6662
6971
  return "boolean";
6663
6972
  case "array":
6664
6973
  return "array";
6665
6974
  case "object":
6975
+ case "jsonb":
6666
6976
  return "object";
6977
+ case "bytea":
6978
+ return "string";
6979
+ default:
6980
+ return "any";
6667
6981
  }
6668
- return "any";
6669
6982
  }
6670
6983
 
6671
6984
  // src/Cadenza.ts
@@ -8241,16 +8554,16 @@ var CadenzaService = class {
8241
8554
  this.createCadenzaService(serviceName, description, options);
8242
8555
  }
8243
8556
  /**
8244
- * Creates and initializes a database service with the provided name, schema, and configuration options.
8245
- * This method is not supported in a browser environment and will log a warning if called in such an environment.
8557
+ * Creates and initializes a PostgresActor-backed database service.
8558
+ * This is the canonical API for schema-driven postgres setup in cadenza-service.
8246
8559
  *
8247
- * @param {string} name - The name of the database service to be created.
8248
- * @param {DatabaseSchemaDefinition} schema - The schema definition for the database service.
8249
- * @param {string} [description=""] - An optional description of the database service.
8250
- * @param {ServerOptions & DatabaseOptions} [options={}] - Optional configuration settings for the database and server.
8251
- * @return {void} This method does not return a value.
8560
+ * @param {string} name - Logical actor/service name.
8561
+ * @param {DatabaseSchemaDefinition} schema - Database schema definition.
8562
+ * @param {string} [description=""] - Optional human-readable description.
8563
+ * @param {ServerOptions & DatabaseOptions} [options={}] - Server/database runtime options.
8564
+ * @return {void}
8252
8565
  */
8253
- static createDatabaseService(name, schema, description = "", options = {}) {
8566
+ static createPostgresActor(name, schema, description = "", options = {}) {
8254
8567
  if (isBrowser) {
8255
8568
  console.warn(
8256
8569
  "Database service creation is not supported in the browser. Use the CadenzaDB service instead."
@@ -8260,7 +8573,7 @@ var CadenzaService = class {
8260
8573
  if (this.serviceCreated) return;
8261
8574
  this.bootstrap();
8262
8575
  this.serviceRegistry.serviceName = name;
8263
- DatabaseController.instance;
8576
+ const databaseController = DatabaseController.instance;
8264
8577
  options = {
8265
8578
  loadBalance: true,
8266
8579
  useSocket: true,
@@ -8281,14 +8594,23 @@ var CadenzaService = class {
8281
8594
  isDatabase: true,
8282
8595
  ...options
8283
8596
  };
8284
- console.log("Creating database service", options);
8597
+ const registration = databaseController.createPostgresActor(
8598
+ name,
8599
+ schema,
8600
+ options
8601
+ );
8602
+ console.log("Creating PostgresActor", {
8603
+ serviceName: name,
8604
+ actorName: registration.actorName,
8605
+ options
8606
+ });
8285
8607
  this.emit("meta.database_init_requested", {
8286
8608
  schema,
8287
8609
  databaseName: options.databaseName,
8288
8610
  options
8289
8611
  });
8290
8612
  this.createMetaTask("Set database connection", () => {
8291
- this.createMetaTask("Insert database service", (_, emit) => {
8613
+ this.createMetaTask("Insert database service bridge", (_, emit) => {
8292
8614
  emit("global.meta.created_database_service", {
8293
8615
  data: {
8294
8616
  service_name: name,
@@ -8299,12 +8621,33 @@ var CadenzaService = class {
8299
8621
  });
8300
8622
  this.log("Database service created", {
8301
8623
  name,
8302
- isMeta: options.isMeta
8624
+ isMeta: options.isMeta,
8625
+ actorName: registration.actorName
8303
8626
  });
8304
8627
  }).doOn("meta.service_registry.service_inserted");
8305
8628
  this.createCadenzaService(name, description, options);
8306
8629
  }).doOn("meta.database.setup_done");
8307
8630
  }
8631
+ /**
8632
+ * Creates a meta PostgresActor service.
8633
+ *
8634
+ * @param {string} name - Logical actor/service name.
8635
+ * @param {DatabaseSchemaDefinition} schema - Database schema definition.
8636
+ * @param {string} [description=""] - Optional description.
8637
+ * @param {ServerOptions & DatabaseOptions} [options={}] - Optional server/database options.
8638
+ * @return {void}
8639
+ */
8640
+ static createMetaPostgresActor(name, schema, description = "", options = {}) {
8641
+ this.bootstrap();
8642
+ options.isMeta = true;
8643
+ this.createPostgresActor(name, schema, description, options);
8644
+ }
8645
+ /**
8646
+ * Legacy compatibility wrapper. Prefer {@link createPostgresActor}.
8647
+ */
8648
+ static createDatabaseService(name, schema, description = "", options = {}) {
8649
+ this.createPostgresActor(name, schema, description, options);
8650
+ }
8308
8651
  /**
8309
8652
  * Creates a meta database service with the specified configuration.
8310
8653
  *
@@ -8315,9 +8658,7 @@ var CadenzaService = class {
8315
8658
  * @return {void} - This method does not return a value.
8316
8659
  */
8317
8660
  static createMetaDatabaseService(name, schema, description = "", options = {}) {
8318
- this.bootstrap();
8319
- options.isMeta = true;
8320
- this.createDatabaseService(name, schema, description, options);
8661
+ this.createMetaPostgresActor(name, schema, description, options);
8321
8662
  }
8322
8663
  static createActor(spec, options = {}) {
8323
8664
  this.bootstrap();