postgresdk 0.18.29 → 0.19.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/cli.js CHANGED
@@ -492,6 +492,14 @@ var require_config = __commonJS(() => {
492
492
  import { mkdir, writeFile, readFile, readdir, unlink } from "fs/promises";
493
493
  import { dirname, join } from "path";
494
494
  import { existsSync } from "fs";
495
+ function isVectorType(pgType) {
496
+ const t = pgType.toLowerCase();
497
+ return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
498
+ }
499
+ function isJsonbType(pgType) {
500
+ const t = pgType.toLowerCase();
501
+ return t === "json" || t === "jsonb";
502
+ }
495
503
  async function writeFilesIfChanged(files) {
496
504
  let written = 0;
497
505
  let unchanged = 0;
@@ -890,9 +898,10 @@ function generateResourceWithSDK(table, model, graph, config) {
890
898
  const hasSinglePK = table.pk.length === 1;
891
899
  const pkField = hasSinglePK ? table.pk[0] : "id";
892
900
  const enums = model.enums || {};
893
- const overrides = config?.softDeleteColumnOverrides;
894
- const resolvedSoftDeleteCol = overrides && tableName in overrides ? overrides[tableName] ?? null : config?.softDeleteColumn ?? null;
901
+ const overrides = config?.delete?.softDeleteColumnOverrides;
902
+ const resolvedSoftDeleteCol = overrides && tableName in overrides ? overrides[tableName] ?? null : config?.delete?.softDeleteColumn ?? null;
895
903
  const softDeleteCol = resolvedSoftDeleteCol && table.columns.some((c) => c.name === resolvedSoftDeleteCol) ? resolvedSoftDeleteCol : null;
904
+ const exposeHardDelete = config?.delete?.exposeHardDelete ?? true;
896
905
  const sdkMethods = [];
897
906
  const endpoints = [];
898
907
  sdkMethods.push({
@@ -968,16 +977,47 @@ const withDeleted = await sdk.${tableName}.getByPk('id', { includeSoftDeleted: t
968
977
  }
969
978
  if (hasSinglePK) {
970
979
  sdkMethods.push({
971
- name: "delete",
972
- signature: `delete(${pkField}: string): Promise<${Type}>`,
973
- description: `Delete a ${tableName}`,
974
- example: `const deleted = await sdk.${tableName}.delete('id');`,
975
- correspondsTo: `DELETE ${basePath}/:${pkField}`
980
+ name: "upsert",
981
+ signature: `upsert(args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} }): Promise<${Type}>`,
982
+ description: `Insert or update a ${tableName} based on a conflict target. The 'where' keys define the unique conflict columns (must be a unique constraint). 'create' is used if no conflict; 'update' is applied if a conflict occurs.`,
983
+ example: `const result = await sdk.${tableName}.upsert({
984
+ where: { ${pkField}: 'some-id' },
985
+ create: { ${generateExampleFields(table, "create")} },
986
+ update: { ${generateExampleFields(table, "update")} },
987
+ });`,
988
+ correspondsTo: `POST ${basePath}/upsert`
989
+ });
990
+ endpoints.push({
991
+ method: "POST",
992
+ path: `${basePath}/upsert`,
993
+ description: `Upsert ${tableName} — insert if no conflict on 'where' columns, update otherwise`,
994
+ requestBody: `{ where: Update${Type}; create: Insert${Type}; update: Update${Type} }`,
995
+ responseBody: `${Type}`
976
996
  });
997
+ }
998
+ if (hasSinglePK) {
999
+ if (softDeleteCol) {
1000
+ sdkMethods.push({
1001
+ name: "softDelete",
1002
+ signature: `softDelete(${pkField}: string): Promise<${Type}>`,
1003
+ description: `Soft-delete a ${tableName} (sets ${softDeleteCol})`,
1004
+ example: `const deleted = await sdk.${tableName}.softDelete('id');`,
1005
+ correspondsTo: `DELETE ${basePath}/:${pkField}`
1006
+ });
1007
+ }
1008
+ if (!softDeleteCol || exposeHardDelete) {
1009
+ sdkMethods.push({
1010
+ name: "hardDelete",
1011
+ signature: `hardDelete(${pkField}: string): Promise<${Type}>`,
1012
+ description: `Permanently delete a ${tableName}`,
1013
+ example: `const deleted = await sdk.${tableName}.hardDelete('id');`,
1014
+ correspondsTo: softDeleteCol ? `DELETE ${basePath}/:${pkField}?hard=true` : `DELETE ${basePath}/:${pkField}`
1015
+ });
1016
+ }
977
1017
  endpoints.push({
978
1018
  method: "DELETE",
979
1019
  path: `${basePath}/:${pkField}`,
980
- description: `Delete ${tableName}`,
1020
+ description: softDeleteCol ? `Delete ${tableName} (soft-delete by default${exposeHardDelete ? "; add ?hard=true for permanent deletion" : ""})` : `Delete ${tableName}`,
981
1021
  responseBody: `${Type}`
982
1022
  });
983
1023
  }
@@ -1475,7 +1515,7 @@ function extractConfigFields(configContent) {
1475
1515
  if (connectionMatch) {
1476
1516
  fields.push({
1477
1517
  key: "connectionString",
1478
- value: connectionMatch[1]?.trim(),
1518
+ value: connectionMatch[1]?.trim().replace(/\s*\/\/.*$/, ""),
1479
1519
  description: "PostgreSQL connection string",
1480
1520
  isRequired: true,
1481
1521
  isCommented: false
@@ -1495,7 +1535,7 @@ function extractConfigFields(configContent) {
1495
1535
  const outClientMatch = configContent.match(/^\s*(\/\/)?\s*outClient:\s*"(.+)"/m);
1496
1536
  if (outDirMatch) {
1497
1537
  let value = outDirMatch[2]?.trim() || "";
1498
- value = value.replace(/,\s*$/, "");
1538
+ value = value.replace(/\s*\/\/.*$/, "").replace(/,\s*$/, "");
1499
1539
  fields.push({
1500
1540
  key: "outDir",
1501
1541
  value,
@@ -1513,13 +1553,13 @@ function extractConfigFields(configContent) {
1513
1553
  isCommented
1514
1554
  });
1515
1555
  }
1516
- const softDeleteMatch = configContent.match(/^\s*(\/\/)?\s*softDeleteColumn:\s*(.+),?$/m);
1517
- if (softDeleteMatch) {
1556
+ const deleteBlock = extractComplexBlock(configContent, "delete");
1557
+ if (deleteBlock) {
1518
1558
  fields.push({
1519
- key: "softDeleteColumn",
1520
- value: softDeleteMatch[2]?.trim().replace(/,$/, "").replace(/["']/g, ""),
1521
- description: "Column name for soft deletes",
1522
- isCommented: !!softDeleteMatch[1]
1559
+ key: "delete",
1560
+ value: deleteBlock.content,
1561
+ description: "Delete configuration (soft/hard delete behavior)",
1562
+ isCommented: deleteBlock.isCommented
1523
1563
  });
1524
1564
  }
1525
1565
  const depthMatch = configContent.match(/^\s*(\/\/)?\s*(includeMethodsDepth|includeDepthLimit):\s*(\d+)/m);
@@ -1594,11 +1634,11 @@ function extractConfigFields(configContent) {
1594
1634
  isCommented: pullBlock.isCommented
1595
1635
  });
1596
1636
  }
1597
- const pullTokenMatch = configContent.match(/^\s*(\/\/)?\s*pullToken:\s*(.+),?$/m);
1637
+ const pullTokenMatch = configContent.match(/^[ \t]{0,3}(\/\/)?\s*pullToken:\s*(.+),?$/m);
1598
1638
  if (pullTokenMatch) {
1599
1639
  fields.push({
1600
1640
  key: "pullToken",
1601
- value: pullTokenMatch[2]?.trim().replace(/,$/, ""),
1641
+ value: pullTokenMatch[2]?.trim().replace(/\s*\/\/.*$/, "").replace(/,$/, ""),
1602
1642
  description: "Token for protecting /_psdk/* endpoints",
1603
1643
  isCommented: !!pullTokenMatch[1]
1604
1644
  });
@@ -1701,12 +1741,12 @@ export default {
1701
1741
  // ========== ADVANCED OPTIONS ==========
1702
1742
 
1703
1743
  /**
1704
- * Column name for soft deletes. When set, DELETE operations will update
1705
- * this column instead of removing rows.
1706
- * @default null (hard deletes)
1707
- * @example "deleted_at"
1744
+ * Delete configuration (soft/hard delete behavior).
1745
+ * When softDeleteColumn is set, DELETE operations update that column instead of removing rows.
1746
+ * Set exposeHardDelete: false to prevent permanent deletion via the API.
1747
+ * @default undefined (hard deletes only)
1708
1748
  */
1709
- ${getFieldLine("softDeleteColumn", existingFields, mergeStrategy, "null", userChoices)}
1749
+ ${getComplexBlockLine("delete", existingFields, mergeStrategy, userChoices)}
1710
1750
 
1711
1751
  /**
1712
1752
  * How to type numeric columns in TypeScript
@@ -1795,6 +1835,12 @@ export default {
1795
1835
  `;
1796
1836
  return template;
1797
1837
  }
1838
+ function wrapValue(value) {
1839
+ if (typeof value === "string" && !value.startsWith('"') && !value.startsWith("{") && !value.startsWith("[")) {
1840
+ return `"${value}"`;
1841
+ }
1842
+ return String(value);
1843
+ }
1798
1844
  function getFieldValue(key, existingFields, mergeStrategy, userChoices) {
1799
1845
  const existing = existingFields.find((f) => f.key === key);
1800
1846
  if (mergeStrategy === "keep-existing" && existing && !existing.isCommented) {
@@ -1821,31 +1867,29 @@ function getFieldLine(key, existingFields, mergeStrategy, defaultValue, userChoi
1821
1867
  const shouldUseExisting = mergeStrategy === "keep-existing" && existing && !existing.isCommented || mergeStrategy === "interactive" && userChoices?.get(key) === "keep" && existing && !existing.isCommented;
1822
1868
  const shouldUseNew = mergeStrategy === "use-defaults" || mergeStrategy === "interactive" && userChoices?.get(key) === "new";
1823
1869
  if (shouldUseExisting && existing) {
1824
- let value = existing.value;
1825
- if (typeof value === "string" && !value.startsWith('"') && !value.startsWith("{") && !value.startsWith("[")) {
1826
- value = `"${value}"`;
1827
- }
1828
- return `${key}: ${value},`;
1870
+ return `${key}: ${wrapValue(existing.value)},`;
1829
1871
  }
1830
1872
  if (shouldUseNew) {
1831
1873
  return `${key}: ${defaultValue},`;
1832
1874
  }
1833
- return `// ${key}: ${defaultValue},`;
1875
+ return `// ${key}: ${existing ? wrapValue(existing.value) : defaultValue},`;
1834
1876
  }
1835
1877
  function getComplexBlockLine(key, existingFields, mergeStrategy, userChoices) {
1836
1878
  const existing = existingFields.find((f) => f.key === key);
1837
1879
  const shouldUseExisting = mergeStrategy === "keep-existing" && existing && !existing.isCommented || mergeStrategy === "interactive" && userChoices?.get(key) === "keep" && existing && !existing.isCommented;
1838
- const shouldUseNew = mergeStrategy === "use-defaults" || mergeStrategy === "interactive" && userChoices?.get(key) === "new";
1839
1880
  if (shouldUseExisting && existing) {
1840
1881
  return `${key}: ${existing.value},`;
1841
1882
  }
1842
- if (shouldUseNew) {
1843
- return getDefaultComplexBlock(key);
1844
- }
1845
1883
  return getDefaultComplexBlock(key);
1846
1884
  }
1847
1885
  function getDefaultComplexBlock(key) {
1848
1886
  switch (key) {
1887
+ case "delete":
1888
+ return `// delete: {
1889
+ // softDeleteColumn: "deleted_at",
1890
+ // exposeHardDelete: true, // default: true
1891
+ // // softDeleteColumnOverrides: { audit_logs: null }
1892
+ // },`;
1849
1893
  case "tests":
1850
1894
  return `// tests: {
1851
1895
  // generate: true,
@@ -1973,30 +2017,6 @@ async function initCommand(args) {
1973
2017
  }
1974
2018
  userChoices.set(field.key, choice);
1975
2019
  }
1976
- const newOptions = [
1977
- { key: "tests", description: "Enable test generation" },
1978
- { key: "auth", description: "Add authentication" },
1979
- { key: "pullToken", description: "Add SDK endpoint protection" },
1980
- { key: "pull", description: "Configure SDK distribution" }
1981
- ];
1982
- const existingKeys = new Set(existingFields.map((f) => f.key));
1983
- const missingOptions = newOptions.filter((opt) => !existingKeys.has(opt.key));
1984
- if (missingOptions.length > 0) {
1985
- console.log(`
1986
- \uD83D\uDCE6 New configuration options available:
1987
- `);
1988
- for (const option of missingOptions) {
1989
- const { addOption } = await prompts2({
1990
- type: "confirm",
1991
- name: "addOption",
1992
- message: `Add ${option.description} configuration? (commented out by default)`,
1993
- initial: false
1994
- });
1995
- if (addOption) {
1996
- userChoices.set(option.key, "add-commented");
1997
- }
1998
- }
1999
- }
2000
2020
  }
2001
2021
  const backupPath = configPath + ".backup." + Date.now();
2002
2022
  copyFileSync(configPath, backupPath);
@@ -2137,13 +2157,14 @@ export default {
2137
2157
  // ========== ADVANCED OPTIONS ==========
2138
2158
 
2139
2159
  /**
2140
- * Column name for soft deletes. When set, DELETE operations will update
2141
- * this column instead of removing rows.
2142
- *
2143
- * Default: null (hard deletes)
2144
- * Example: "deleted_at"
2160
+ * Delete configuration (soft/hard delete behavior).
2161
+ * When softDeleteColumn is set, DELETE operations update that column instead of removing rows.
2162
+ * Set exposeHardDelete: false to prevent permanent deletion via the API.
2145
2163
  */
2146
- // softDeleteColumn: null,
2164
+ // delete: {
2165
+ // softDeleteColumn: "deleted_at",
2166
+ // exposeHardDelete: true,
2167
+ // },
2147
2168
 
2148
2169
  /**
2149
2170
  * How to type numeric columns in TypeScript
@@ -2712,7 +2733,7 @@ function emitIncludeResolver(graph, useJsExtensions) {
2712
2733
  const edges = graph[table] || {};
2713
2734
  const edgeEntries = Object.entries(edges);
2714
2735
  if (edgeEntries.length === 0) {
2715
- out += `export type ${Type}WithIncludes<TInclude extends ${Type}IncludeSpec> = Select${Type};
2736
+ out += `export type ${Type}WithIncludes<_TInclude extends ${Type}IncludeSpec> = Select${Type};
2716
2737
 
2717
2738
  `;
2718
2739
  continue;
@@ -2858,6 +2879,18 @@ ${insertFields}
2858
2879
  });
2859
2880
 
2860
2881
  export const Update${Type}Schema = Insert${Type}Schema.partial();
2882
+
2883
+ export const Upsert${Type}Schema = z.object({
2884
+ where: Update${Type}Schema.refine(
2885
+ (d) => Object.keys(d).length > 0,
2886
+ { message: "where must specify at least one column" }
2887
+ ),
2888
+ create: Insert${Type}Schema,
2889
+ update: Update${Type}Schema.refine(
2890
+ (d) => Object.keys(d).length > 0,
2891
+ { message: "update must specify at least one column" }
2892
+ ),
2893
+ });
2861
2894
  `;
2862
2895
  }
2863
2896
 
@@ -2951,13 +2984,6 @@ export interface PaginatedResponse<T> {
2951
2984
 
2952
2985
  // src/emit-routes-hono.ts
2953
2986
  init_utils();
2954
- function isVectorType(pgType) {
2955
- const t = pgType.toLowerCase();
2956
- return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
2957
- }
2958
- function isJsonbType(pgType) {
2959
- return pgType.toLowerCase() === "json" || pgType.toLowerCase() === "jsonb";
2960
- }
2961
2987
  function emitHonoRoutes(table, _graph, opts) {
2962
2988
  const fileTableName = table.name;
2963
2989
  const Type = pascal(table.name);
@@ -2971,6 +2997,7 @@ function emitHonoRoutes(table, _graph, opts) {
2971
2997
  const hasCompositePk = safePkCols.length > 1;
2972
2998
  const pkPath = hasCompositePk ? safePkCols.map((c) => `:${c}`).join("/") : `:${safePkCols[0]}`;
2973
2999
  const softDel = opts.softDeleteColumn && table.columns.some((c) => c.name === opts.softDeleteColumn) ? opts.softDeleteColumn : null;
3000
+ const exposeHard = !!(softDel && opts.exposeHardDelete);
2974
3001
  const getPkParams = hasCompositePk ? `const pkValues = [${safePkCols.map((c) => `c.req.param("${c}")`).join(", ")}];` : `const pkValues = [c.req.param("${safePkCols[0]}")];`;
2975
3002
  const hasAuth = opts.authStrategy && opts.authStrategy !== "none";
2976
3003
  const ext = opts.useJsExtensions ? ".js" : "";
@@ -2987,7 +3014,7 @@ function emitHonoRoutes(table, _graph, opts) {
2987
3014
  import { Hono } from "hono";
2988
3015
  import type { Context } from "hono";
2989
3016
  import { z } from "zod";
2990
- import { Insert${Type}Schema, Update${Type}Schema } from "../zod/${fileTableName}${ext}";
3017
+ import { Insert${Type}Schema, Update${Type}Schema, Upsert${Type}Schema } from "../zod/${fileTableName}${ext}";
2991
3018
  import { loadIncludes } from "../include-loader${ext}";
2992
3019
  import * as coreOps from "../core/operations${ext}";
2993
3020
  ${authImport}
@@ -2996,10 +3023,12 @@ const columnEnum = z.enum([${columnNames}]);
2996
3023
 
2997
3024
  const createSchema = Insert${Type}Schema;
2998
3025
  const updateSchema = Update${Type}Schema;
3026
+ const upsertSchema = Upsert${Type}Schema;
2999
3027
 
3000
3028
  const deleteSchema = z.object({
3001
3029
  select: z.array(z.string()).min(1).optional(),
3002
- exclude: z.array(z.string()).min(1).optional()
3030
+ exclude: z.array(z.string()).min(1).optional(),${exposeHard ? `
3031
+ hard: z.boolean().optional(),` : ""}
3003
3032
  }).strict().refine(
3004
3033
  (data) => !(data.select && data.exclude),
3005
3034
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -3222,6 +3251,39 @@ ${hasAuth ? `
3222
3251
  }, result.status as any);
3223
3252
  });
3224
3253
 
3254
+ // UPSERT
3255
+ app.post(\`\${base}/upsert\`, async (c) => {
3256
+ const body = await c.req.json().catch(() => ({}));
3257
+ const parsed = upsertSchema.safeParse(body);
3258
+
3259
+ if (!parsed.success) {
3260
+ const issues = parsed.error.flatten();
3261
+ return c.json({ error: "Invalid body", issues }, 400);
3262
+ }
3263
+
3264
+ const selectParam = c.req.query("select");
3265
+ const excludeParam = c.req.query("exclude");
3266
+ const select = selectParam ? selectParam.split(",") : undefined;
3267
+ const exclude = excludeParam ? excludeParam.split(",") : undefined;
3268
+
3269
+ if (select && exclude) {
3270
+ return c.json({ error: "Cannot specify both 'select' and 'exclude' parameters" }, 400);
3271
+ }
3272
+
3273
+ if (deps.onRequest) {
3274
+ await deps.onRequest(c, deps.pg);
3275
+ }
3276
+
3277
+ const ctx = { ...baseCtx, select, exclude };
3278
+ const result = await coreOps.upsertRecord(ctx, parsed.data);
3279
+
3280
+ if (result.error) {
3281
+ return c.json({ error: result.error }, result.status as any);
3282
+ }
3283
+
3284
+ return c.json(result.data, result.status as any);
3285
+ });
3286
+
3225
3287
  // UPDATE
3226
3288
  app.patch(\`\${base}/${pkPath}\`, async (c) => {
3227
3289
  ${getPkParams}
@@ -3261,13 +3323,15 @@ ${hasAuth ? `
3261
3323
  app.delete(\`\${base}/${pkPath}\`, async (c) => {
3262
3324
  ${getPkParams}
3263
3325
 
3264
- // Parse query params for select/exclude
3326
+ // Parse query params for select/exclude${exposeHard ? " and hard" : ""}
3265
3327
  const selectParam = c.req.query("select");
3266
3328
  const excludeParam = c.req.query("exclude");
3267
3329
  const queryData: any = {};
3268
3330
  if (selectParam) queryData.select = selectParam.split(",");
3269
3331
  if (excludeParam) queryData.exclude = excludeParam.split(",");
3270
-
3332
+ ${exposeHard ? ` const hardParam = c.req.query("hard");
3333
+ if (hardParam !== undefined) queryData.hard = hardParam === "true";
3334
+ ` : ""}
3271
3335
  const queryParsed = deleteSchema.safeParse(queryData);
3272
3336
  if (!queryParsed.success) {
3273
3337
  const issues = queryParsed.error.flatten();
@@ -3279,7 +3343,7 @@ ${hasAuth ? `
3279
3343
  }
3280
3344
 
3281
3345
  const ctx = { ...baseCtx, select: queryParsed.data.select, exclude: queryParsed.data.exclude };
3282
- const result = await coreOps.deleteRecord(ctx, pkValues);
3346
+ const result = await coreOps.deleteRecord(ctx, pkValues${exposeHard ? ", { hard: queryParsed.data.hard }" : ""});
3283
3347
 
3284
3348
  if (result.error) {
3285
3349
  return c.json({ error: result.error }, result.status as any);
@@ -3294,14 +3358,6 @@ ${hasAuth ? `
3294
3358
  // src/emit-client.ts
3295
3359
  init_utils();
3296
3360
  init_emit_include_methods();
3297
- function isVectorType2(pgType) {
3298
- const t = pgType.toLowerCase();
3299
- return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
3300
- }
3301
- function isJsonbType2(pgType) {
3302
- const t = pgType.toLowerCase();
3303
- return t === "json" || t === "jsonb";
3304
- }
3305
3361
  function toIncludeParamName(relationKey) {
3306
3362
  return `${relationKey}Include`;
3307
3363
  }
@@ -3324,10 +3380,12 @@ function emitClient(table, graph, opts, model) {
3324
3380
  const Type = pascal(table.name);
3325
3381
  const ext = opts.useJsExtensions ? ".js" : "";
3326
3382
  const trigramParamType = `{ field: string; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: string[]; strategy?: "greatest" | "concat"; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number } | { fields: Array<{ field: string; weight: number }>; query: string; metric?: "similarity" | "wordSimilarity" | "strictWordSimilarity"; threshold?: number }`;
3327
- const hasVectorColumns = table.columns.some((c) => isVectorType2(c.pgType));
3328
- const hasJsonbColumns = table.columns.some((c) => isJsonbType2(c.pgType));
3383
+ const hasVectorColumns = table.columns.some((c) => isVectorType(c.pgType));
3384
+ const hasJsonbColumns = table.columns.some((c) => isJsonbType(c.pgType));
3385
+ const hasSoftDelete = !!opts.softDeleteColumn;
3386
+ const exposeHard = !hasSoftDelete || (opts.exposeHardDelete ?? true);
3329
3387
  if (process.env.SDK_DEBUG) {
3330
- const vectorCols = table.columns.filter((c) => isVectorType2(c.pgType));
3388
+ const vectorCols = table.columns.filter((c) => isVectorType(c.pgType));
3331
3389
  if (vectorCols.length > 0) {
3332
3390
  console.log(`[DEBUG] Table ${table.name}: Found ${vectorCols.length} vector columns:`, vectorCols.map((c) => `${c.name} (${c.pgType})`));
3333
3391
  }
@@ -3343,15 +3401,19 @@ function emitClient(table, graph, opts, model) {
3343
3401
  skipJunctionTables: opts.skipJunctionTables ?? true
3344
3402
  }, allTables);
3345
3403
  const importedTypes = new Set;
3404
+ const usedIncludeSpecTypes = new Set([table.name]);
3346
3405
  importedTypes.add(table.name);
3347
3406
  for (const method of includeMethods) {
3348
3407
  for (const target of method.targets) {
3349
3408
  importedTypes.add(target);
3350
3409
  }
3410
+ const pattern = analyzeIncludeSpec(method.includeSpec);
3411
+ if (pattern.type === "nested" && method.targets[0]) {
3412
+ usedIncludeSpecTypes.add(method.targets[0]);
3413
+ }
3351
3414
  }
3352
3415
  const typeImports = `import type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${table.name}${ext}";`;
3353
- const includeSpecTypes = [table.name, ...Array.from(importedTypes).filter((t) => t !== table.name)];
3354
- const includeSpecImport = `import type { ${includeSpecTypes.map((t) => `${pascal(t)}IncludeSpec`).join(", ")} } from "./include-spec${ext}";`;
3416
+ const includeSpecImport = `import type { ${Array.from(usedIncludeSpecTypes).map((t) => `${pascal(t)}IncludeSpec`).join(", ")} } from "./include-spec${ext}";`;
3355
3417
  const includeResolverImport = `import type { ${Type}WithIncludes } from "./include-resolver${ext}";`;
3356
3418
  const otherTableImports = [];
3357
3419
  for (const target of Array.from(importedTypes)) {
@@ -3553,6 +3615,54 @@ function emitClient(table, graph, opts, model) {
3553
3615
  `;
3554
3616
  }
3555
3617
  }
3618
+ const buildDeleteMethod = (methodName, setHardTrue) => {
3619
+ const actionName = methodName === "softDelete" ? "Soft-delete" : "Hard-delete (permanently remove)";
3620
+ const hardLine = setHardTrue ? `
3621
+ queryParams.set('hard', 'true');` : "";
3622
+ const pksLabel = hasCompositePk ? "s" : "";
3623
+ const G = hasJsonbColumns ? `<TJsonb extends Partial<Select${Type}> = {}>` : "";
3624
+ const RBase = hasJsonbColumns ? `Select${Type}<TJsonb>` : `Select${Type}`;
3625
+ const RPart = hasJsonbColumns ? `Partial<Select${Type}<TJsonb>>` : `Partial<Select${Type}>`;
3626
+ return ` /**
3627
+ ` + ` * ${actionName} a ${table.name} record by primary key with field selection
3628
+ ` + ` * @param pk - The primary key value${pksLabel}
3629
+ ` + ` * @param options - Select specific fields to return
3630
+ ` + ` * @returns The deleted record with only selected fields if found, null otherwise
3631
+ ` + ` */
3632
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options: { select: string[] }): Promise<${RPart} | null>;
3633
+ ` + ` /**
3634
+ ` + ` * ${actionName} a ${table.name} record by primary key with field exclusion
3635
+ ` + ` * @param pk - The primary key value${pksLabel}
3636
+ ` + ` * @param options - Exclude specific fields from return
3637
+ ` + ` * @returns The deleted record without excluded fields if found, null otherwise
3638
+ ` + ` */
3639
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options: { exclude: string[] }): Promise<${RPart} | null>;
3640
+ ` + ` /**
3641
+ ` + ` * ${actionName} a ${table.name} record by primary key
3642
+ ` + ` * @param pk - The primary key value${pksLabel}
3643
+ ` + ` * @returns The deleted record with all fields if found, null otherwise
3644
+ ` + ` */
3645
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<${RBase} | null>;
3646
+ ` + ` async ${methodName}${G}(
3647
+ ` + ` pk: ${pkType},
3648
+ ` + ` options?: { select?: string[]; exclude?: string[] }
3649
+ ` + ` ): Promise<${RBase} | ${RPart} | null> {
3650
+ ` + ` const path = ${pkPathExpr};
3651
+ ` + ` const queryParams = new URLSearchParams();${hardLine}
3652
+ ` + ` if (options?.select) queryParams.set('select', options.select.join(','));
3653
+ ` + ` if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
3654
+ ` + ` const query = queryParams.toString();
3655
+ ` + " const url = query ? `${this.resource}/${path}?${query}` : `${this.resource}/${path}`;\n" + ` return this.del<${RBase} | null>(url);
3656
+ ` + ` }`;
3657
+ };
3658
+ const deleteMethodParts = [];
3659
+ if (hasSoftDelete)
3660
+ deleteMethodParts.push(buildDeleteMethod("softDelete", false));
3661
+ if (exposeHard)
3662
+ deleteMethodParts.push(buildDeleteMethod("hardDelete", hasSoftDelete));
3663
+ const deleteMethodsCode = deleteMethodParts.join(`
3664
+
3665
+ `);
3556
3666
  return `/**
3557
3667
  * AUTO-GENERATED FILE - DO NOT EDIT
3558
3668
  *
@@ -3562,6 +3672,7 @@ function emitClient(table, graph, opts, model) {
3562
3672
  * To make changes, modify your schema or configuration and regenerate.
3563
3673
  */
3564
3674
  import { BaseClient } from "./base-client${ext}";
3675
+ import type { TxOp } from "./base-client${ext}";
3565
3676
  import type { Where } from "./where-types${ext}";
3566
3677
  import type { PaginatedResponse } from "./types/shared${ext}";
3567
3678
  ${typeImports}
@@ -3643,6 +3754,78 @@ ${hasJsonbColumns ? ` /**
3643
3754
  return this.post<Select${Type}>(url, data);
3644
3755
  }`}
3645
3756
 
3757
+ ${hasJsonbColumns ? ` /**
3758
+ * Upsert a ${table.name} record with field selection
3759
+ */
3760
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
3761
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
3762
+ options: { select: string[] }
3763
+ ): Promise<Partial<Select${Type}<TJsonb>>>;
3764
+ /**
3765
+ * Upsert a ${table.name} record with field exclusion
3766
+ */
3767
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
3768
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
3769
+ options: { exclude: string[] }
3770
+ ): Promise<Partial<Select${Type}<TJsonb>>>;
3771
+ /**
3772
+ * Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
3773
+ * @param args.where - Conflict target column(s) (must be a unique constraint)
3774
+ * @param args.create - Full insert data used when no conflict occurs
3775
+ * @param args.update - Partial data applied when a conflict occurs
3776
+ * @returns The resulting record
3777
+ */
3778
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
3779
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
3780
+ options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
3781
+ ): Promise<Select${Type}<TJsonb>>;
3782
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
3783
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
3784
+ options?: { select?: string[]; exclude?: string[] }
3785
+ ): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>> {
3786
+ const queryParams = new URLSearchParams();
3787
+ if (options?.select) queryParams.set('select', options.select.join(','));
3788
+ if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
3789
+ const query = queryParams.toString();
3790
+ const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
3791
+ return this.post<Select${Type}<TJsonb>>(url, args);
3792
+ }` : ` /**
3793
+ * Upsert a ${table.name} record with field selection
3794
+ */
3795
+ async upsert(
3796
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
3797
+ options: { select: string[] }
3798
+ ): Promise<Partial<Select${Type}>>;
3799
+ /**
3800
+ * Upsert a ${table.name} record with field exclusion
3801
+ */
3802
+ async upsert(
3803
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
3804
+ options: { exclude: string[] }
3805
+ ): Promise<Partial<Select${Type}>>;
3806
+ /**
3807
+ * Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
3808
+ * @param args.where - Conflict target column(s) (must be a unique constraint)
3809
+ * @param args.create - Full insert data used when no conflict occurs
3810
+ * @param args.update - Partial data applied when a conflict occurs
3811
+ * @returns The resulting record
3812
+ */
3813
+ async upsert(
3814
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
3815
+ options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
3816
+ ): Promise<Select${Type}>;
3817
+ async upsert(
3818
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
3819
+ options?: { select?: string[]; exclude?: string[] }
3820
+ ): Promise<Select${Type} | Partial<Select${Type}>> {
3821
+ const queryParams = new URLSearchParams();
3822
+ if (options?.select) queryParams.set('select', options.select.join(','));
3823
+ if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
3824
+ const query = queryParams.toString();
3825
+ const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
3826
+ return this.post<Select${Type}>(url, args);
3827
+ }`}
3828
+
3646
3829
  ${hasJsonbColumns ? ` /**
3647
3830
  * Get a ${table.name} record by primary key with field selection
3648
3831
  * @param pk - The primary key value${hasCompositePk ? "s" : ""}
@@ -3983,72 +4166,27 @@ ${hasJsonbColumns ? ` /**
3983
4166
  return this.patch<Select${Type} | null>(url, patch);
3984
4167
  }`}
3985
4168
 
3986
- ${hasJsonbColumns ? ` /**
3987
- * Delete a ${table.name} record by primary key with field selection
3988
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3989
- * @param options - Select specific fields to return
3990
- * @returns The deleted record with only selected fields if found, null otherwise
3991
- */
3992
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options: { select: string[] }): Promise<Partial<Select${Type}<TJsonb>> | null>;
3993
- /**
3994
- * Delete a ${table.name} record by primary key with field exclusion
3995
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3996
- * @param options - Exclude specific fields from return
3997
- * @returns The deleted record without excluded fields if found, null otherwise
3998
- */
3999
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options: { exclude: string[] }): Promise<Partial<Select${Type}<TJsonb>> | null>;
4000
- /**
4001
- * Delete a ${table.name} record by primary key
4002
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
4003
- * @returns The deleted record with all fields if found, null otherwise
4004
- * @example
4005
- * // With JSONB type override:
4006
- * const user = await client.delete<{ metadata: Metadata }>('user-id');
4007
- */
4008
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type}<TJsonb> | null>;
4009
- async delete<TJsonb extends Partial<Select${Type}> = {}>(
4010
- pk: ${pkType},
4011
- options?: { select?: string[]; exclude?: string[] }
4012
- ): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>> | null> {
4013
- const path = ${pkPathExpr};
4014
- const queryParams = new URLSearchParams();
4015
- if (options?.select) queryParams.set('select', options.select.join(','));
4016
- if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
4017
- const query = queryParams.toString();
4018
- const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
4019
- return this.del<Select${Type}<TJsonb> | null>(url);
4020
- }` : ` /**
4021
- * Delete a ${table.name} record by primary key with field selection
4022
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
4023
- * @param options - Select specific fields to return
4024
- * @returns The deleted record with only selected fields if found, null otherwise
4025
- */
4026
- async delete(pk: ${pkType}, options: { select: string[] }): Promise<Partial<Select${Type}> | null>;
4027
- /**
4028
- * Delete a ${table.name} record by primary key with field exclusion
4029
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
4030
- * @param options - Exclude specific fields from return
4031
- * @returns The deleted record without excluded fields if found, null otherwise
4032
- */
4033
- async delete(pk: ${pkType}, options: { exclude: string[] }): Promise<Partial<Select${Type}> | null>;
4034
- /**
4035
- * Delete a ${table.name} record by primary key
4036
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
4037
- * @returns The deleted record with all fields if found, null otherwise
4038
- */
4039
- async delete(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type} | null>;
4040
- async delete(
4041
- pk: ${pkType},
4042
- options?: { select?: string[]; exclude?: string[] }
4043
- ): Promise<Select${Type} | Partial<Select${Type}> | null> {
4044
- const path = ${pkPathExpr};
4045
- const queryParams = new URLSearchParams();
4046
- if (options?.select) queryParams.set('select', options.select.join(','));
4047
- if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
4048
- const query = queryParams.toString();
4049
- const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
4050
- return this.del<Select${Type} | null>(url);
4051
- }`}
4169
+ ${deleteMethodsCode}
4170
+
4171
+ /** Build a lazy CREATE descriptor for use with sdk.$transaction([...]) */
4172
+ $create(data: Insert${Type}): TxOp<Select${Type}> {
4173
+ return { _table: "${table.name}", _op: "create", _data: data as Record<string, unknown> };
4174
+ }
4175
+
4176
+ /** Build a lazy UPDATE descriptor for use with sdk.$transaction([...]) */
4177
+ $update(pk: ${pkType}, data: Update${Type}): TxOp<Select${Type} | null> {
4178
+ return { _table: "${table.name}", _op: "update", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"}, _data: data as Record<string, unknown> };
4179
+ }
4180
+
4181
+ /** Build a lazy DELETE descriptor for use with sdk.$transaction([...]) */
4182
+ $delete(pk: ${pkType}): TxOp<Select${Type} | null> {
4183
+ return { _table: "${table.name}", _op: "delete", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"} };
4184
+ }
4185
+
4186
+ /** Build a lazy UPSERT descriptor for use with sdk.$transaction([...]) */
4187
+ $upsert(args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} }): TxOp<Select${Type}> {
4188
+ return { _table: "${table.name}", _op: "upsert", _data: args as Record<string, unknown> };
4189
+ }
4052
4190
  ${includeMethodsCode}}
4053
4191
  `;
4054
4192
  }
@@ -4063,7 +4201,9 @@ function emitClientIndex(tables, useJsExtensions, graph, includeOpts) {
4063
4201
  * To make changes, modify your schema or configuration and regenerate.
4064
4202
  */
4065
4203
  `;
4066
- out += `import type { AuthConfig } from "./base-client${ext}";
4204
+ out += `import { BaseClient } from "./base-client${ext}";
4205
+ `;
4206
+ out += `import type { AuthConfig, TxOp } from "./base-client${ext}";
4067
4207
  `;
4068
4208
  for (const t of tables) {
4069
4209
  out += `import { ${pascal(t.name)}Client } from "./${t.name}${ext}";
@@ -4079,7 +4219,7 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
4079
4219
  `;
4080
4220
  out += ` */
4081
4221
  `;
4082
- out += `export class SDK {
4222
+ out += `export class SDK extends BaseClient {
4083
4223
  `;
4084
4224
  for (const t of tables) {
4085
4225
  out += ` public ${t.name}: ${pascal(t.name)}Client;
@@ -4089,17 +4229,101 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
4089
4229
  constructor(cfg: { baseUrl: string; fetch?: typeof fetch; auth?: AuthConfig }) {
4090
4230
  `;
4091
4231
  out += ` const f = cfg.fetch ?? fetch;
4232
+ `;
4233
+ out += ` super(cfg.baseUrl, f, cfg.auth);
4092
4234
  `;
4093
4235
  for (const t of tables) {
4094
4236
  out += ` this.${t.name} = new ${pascal(t.name)}Client(cfg.baseUrl, f, cfg.auth);
4095
4237
  `;
4096
4238
  }
4097
4239
  out += ` }
4240
+ `;
4241
+ out += `
4242
+ `;
4243
+ out += ` /**
4244
+ `;
4245
+ out += ` * Execute multiple operations atomically in one PostgreSQL transaction.
4246
+ `;
4247
+ out += ` * All ops are validated before BEGIN is issued — fail-fast on bad input.
4248
+ `;
4249
+ out += ` *
4250
+ `;
4251
+ out += ` * @example
4252
+ `;
4253
+ out += ` * const [order, user] = await sdk.$transaction([
4254
+ `;
4255
+ out += ` * sdk.orders.$create({ user_id: 1, total: 99 }),
4256
+ `;
4257
+ out += ` * sdk.users.$update('1', { last_order_at: new Date().toISOString() }),
4258
+ `;
4259
+ out += ` * ]);
4260
+ `;
4261
+ out += ` */
4262
+ `;
4263
+ out += ` async $transaction<const T extends readonly TxOp<unknown>[]>(
4264
+ `;
4265
+ out += ` ops: [...T]
4266
+ `;
4267
+ out += ` ): Promise<{ [K in keyof T]: T[K] extends TxOp<infer R> ? R : never }> {
4268
+ `;
4269
+ out += ` const payload = ops.map(op => ({
4270
+ `;
4271
+ out += ` op: op._op,
4272
+ `;
4273
+ out += ` table: op._table,
4274
+ `;
4275
+ out += ` ...(op._data !== undefined ? { data: op._data } : {}),
4276
+ `;
4277
+ out += ` ...(op._pk !== undefined ? { pk: op._pk } : {}),
4278
+ `;
4279
+ out += ` }));
4280
+ `;
4281
+ out += `
4282
+ `;
4283
+ out += ` const res = await this.fetchFn(\`\${this.baseUrl}/v1/transaction\`, {
4284
+ `;
4285
+ out += ` method: "POST",
4286
+ `;
4287
+ out += ` headers: await this.headers(true),
4288
+ `;
4289
+ out += ` body: JSON.stringify({ ops: payload }),
4290
+ `;
4291
+ out += ` });
4292
+ `;
4293
+ out += `
4294
+ `;
4295
+ out += ` if (!res.ok) {
4296
+ `;
4297
+ out += ` let errBody: Record<string, unknown> = {};
4298
+ `;
4299
+ out += ` try { errBody = await res.json() as Record<string, unknown>; } catch {}
4300
+ `;
4301
+ out += ` const err = Object.assign(
4302
+ `;
4303
+ out += ` new Error((errBody.error as string | undefined) ?? \`$transaction failed: \${res.status}\`),
4304
+ `;
4305
+ out += ` { failedAt: errBody.failedAt as number | undefined, issues: errBody.issues }
4306
+ `;
4307
+ out += ` );
4308
+ `;
4309
+ out += ` throw err;
4310
+ `;
4311
+ out += ` }
4312
+ `;
4313
+ out += `
4314
+ `;
4315
+ out += ` const json = await res.json() as { results: unknown[] };
4316
+ `;
4317
+ out += ` return json.results as unknown as { [K in keyof T]: T[K] extends TxOp<infer R> ? R : never };
4318
+ `;
4319
+ out += ` }
4098
4320
  `;
4099
4321
  out += `}
4100
4322
 
4101
4323
  `;
4102
4324
  out += `export { BaseClient } from "./base-client${ext}";
4325
+ `;
4326
+ out += `export type { TxOp } from "./base-client${ext}";
4103
4327
  `;
4104
4328
  out += `
4105
4329
  // Include specification types for custom queries
@@ -4300,15 +4524,29 @@ export abstract class BaseClient {
4300
4524
  method: "DELETE",
4301
4525
  headers: await this.headers(),
4302
4526
  });
4303
-
4527
+
4304
4528
  if (res.status === 404) {
4305
4529
  return null as T;
4306
4530
  }
4307
-
4531
+
4308
4532
  await this.okOrThrow(res, "DELETE", path);
4309
4533
  return (await res.json()) as T;
4310
4534
  }
4311
4535
  }
4536
+
4537
+ /**
4538
+ * Lazy operation descriptor returned by $create/$update/$delete.
4539
+ * \`__resultType\` is a phantom field — never assigned at runtime, exists only
4540
+ * so TypeScript can infer the correct tuple element type inside \`$transaction\`.
4541
+ */
4542
+ export type TxOp<T = unknown> = {
4543
+ readonly _table: string;
4544
+ readonly _op: "create" | "update" | "delete" | "upsert";
4545
+ readonly _data?: Record<string, unknown>;
4546
+ readonly _pk?: string | Record<string, unknown>;
4547
+ /** @internal */
4548
+ readonly __resultType?: T;
4549
+ };
4312
4550
  `;
4313
4551
  }
4314
4552
 
@@ -4852,14 +5090,14 @@ function tsTypeFor(pgType, opts, enums) {
4852
5090
  return "number[]";
4853
5091
  return "string";
4854
5092
  }
4855
- function isJsonbType3(pgType) {
5093
+ function isJsonbType2(pgType) {
4856
5094
  const t = pgType.toLowerCase();
4857
5095
  return t === "json" || t === "jsonb";
4858
5096
  }
4859
5097
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
4860
5098
  function emitTypes(table, opts, enums) {
4861
5099
  const Type = pascal2(table.name);
4862
- const hasJsonbColumns = table.columns.some((col) => isJsonbType3(col.pgType));
5100
+ const hasJsonbColumns = table.columns.some((col) => isJsonbType2(col.pgType));
4863
5101
  const insertFields = table.columns.map((col) => {
4864
5102
  const base = tsTypeFor(col.pgType, opts, enums);
4865
5103
  const optional = col.hasDefault || col.nullable ? "?" : "";
@@ -5310,9 +5548,12 @@ export async function authMiddleware(c: Context, next: Next) {
5310
5548
 
5311
5549
  // src/emit-router-hono.ts
5312
5550
  init_utils();
5313
- function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
5551
+ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken, opts) {
5314
5552
  const tableNames = tables.map((t) => t.name).sort();
5315
5553
  const ext = useJsExtensions ? ".js" : "";
5554
+ const apiPathPrefix = opts?.apiPathPrefix ?? "/v1";
5555
+ const softDeleteCols = opts?.softDeleteCols ?? {};
5556
+ const includeMethodsDepth = opts?.includeMethodsDepth ?? 2;
5316
5557
  let resolvedPullToken;
5317
5558
  let pullTokenEnvVar;
5318
5559
  if (pullToken) {
@@ -5338,6 +5579,88 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
5338
5579
  const Type = pascal(name);
5339
5580
  return `export { register${Type}Routes } from "./routes/${name}${ext}";`;
5340
5581
  }).join(`
5582
+ `);
5583
+ const txSchemaImports = tableNames.map((name) => {
5584
+ const Type = pascal(name);
5585
+ return `import { Insert${Type}Schema, Update${Type}Schema } from "./zod/${name}${ext}";`;
5586
+ }).join(`
5587
+ `);
5588
+ function txRouteBlock(appVar) {
5589
+ const authLine = hasAuth ? ` ${appVar}.use(\`${apiPathPrefix}/transaction\`, authMiddleware);
5590
+ ` : "";
5591
+ return `${authLine} ${appVar}.post(\`${apiPathPrefix}/transaction\`, async (c) => {
5592
+ const body = await c.req.json().catch(() => ({}));
5593
+ const rawOps: unknown[] = Array.isArray(body.ops) ? body.ops : [];
5594
+
5595
+ if (rawOps.length === 0) {
5596
+ return c.json({ error: "ops must be a non-empty array" }, 400);
5597
+ }
5598
+
5599
+ // Validate all ops against their table schemas BEFORE opening a transaction
5600
+ const validatedOps: coreOps.TransactionOperation[] = [];
5601
+ for (let i = 0; i < rawOps.length; i++) {
5602
+ const item = rawOps[i] as any;
5603
+ const entry = TABLE_TX_METADATA[item?.table as string];
5604
+ if (!entry) {
5605
+ return c.json({ error: \`Unknown table "\${item?.table}" at index \${i}\`, failedAt: i }, 400);
5606
+ }
5607
+ if (item.op === "create") {
5608
+ const parsed = entry.insertSchema.safeParse(item.data ?? {});
5609
+ if (!parsed.success) {
5610
+ return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
5611
+ }
5612
+ validatedOps.push({ op: "create", table: item.table, data: parsed.data });
5613
+ } else if (item.op === "update") {
5614
+ const parsed = entry.updateSchema.safeParse(item.data ?? {});
5615
+ if (!parsed.success) {
5616
+ return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
5617
+ }
5618
+ if (item.pk == null) {
5619
+ return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
5620
+ }
5621
+ validatedOps.push({ op: "update", table: item.table, pk: item.pk, data: parsed.data });
5622
+ } else if (item.op === "delete") {
5623
+ if (item.pk == null) {
5624
+ return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
5625
+ }
5626
+ validatedOps.push({ op: "delete", table: item.table, pk: item.pk });
5627
+ } else {
5628
+ return c.json({ error: \`Unknown op "\${item?.op}" at index \${i}\`, failedAt: i }, 400);
5629
+ }
5630
+ }
5631
+
5632
+ const onBegin = deps.onRequest
5633
+ ? (txClient: typeof deps.pg) => deps.onRequest!(c, txClient)
5634
+ : undefined;
5635
+
5636
+ const result = await coreOps.executeTransaction(deps.pg, validatedOps, TABLE_TX_METADATA, onBegin);
5637
+
5638
+ if (!result.ok) {
5639
+ return c.json({ error: result.error, failedAt: result.failedAt }, 400);
5640
+ }
5641
+ return c.json({ results: result.results.map(r => r.data) }, 200);
5642
+ });`;
5643
+ }
5644
+ const txMetadataEntries = tables.slice().sort((a, b) => a.name.localeCompare(b.name)).map((t) => {
5645
+ const rawPk = t.pk;
5646
+ const pkCols = Array.isArray(rawPk) ? rawPk : rawPk ? [rawPk] : ["id"];
5647
+ const softDel = softDeleteCols[t.name] ?? null;
5648
+ const allCols = t.columns.map((c) => `"${c.name}"`).join(", ");
5649
+ const vecCols = t.columns.filter((c) => isVectorType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
5650
+ const jsonCols = t.columns.filter((c) => isJsonbType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
5651
+ const Type = pascal(t.name);
5652
+ return ` "${t.name}": {
5653
+ table: "${t.name}",
5654
+ pkColumns: ${JSON.stringify(pkCols)},
5655
+ softDeleteColumn: ${softDel ? `"${softDel}"` : "null"},
5656
+ allColumnNames: [${allCols}],
5657
+ vectorColumns: [${vecCols}],
5658
+ jsonbColumns: [${jsonCols}],
5659
+ includeMethodsDepth: ${includeMethodsDepth},
5660
+ insertSchema: Insert${Type}Schema,
5661
+ updateSchema: Update${Type}Schema,
5662
+ }`;
5663
+ }).join(`,
5341
5664
  `);
5342
5665
  return `/**
5343
5666
  * AUTO-GENERATED FILE - DO NOT EDIT
@@ -5351,8 +5674,27 @@ import { Hono } from "hono";
5351
5674
  import type { Context } from "hono";
5352
5675
  import { SDK_MANIFEST } from "./sdk-bundle${ext}";
5353
5676
  import { getContract } from "./contract${ext}";
5677
+ import * as coreOps from "./core/operations${ext}";
5678
+ ${txSchemaImports}
5354
5679
  ${imports}
5355
- ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
5680
+ ${hasAuth ? `import { authMiddleware } from "./auth${ext}";
5681
+ export { authMiddleware };` : ""}
5682
+
5683
+ /** Discriminated result from safeParse — mirrors Zod's actual return shape */
5684
+ type SchemaParseResult =
5685
+ | { success: true; data: Record<string, unknown> }
5686
+ | { success: false; error: { flatten: () => unknown } };
5687
+
5688
+ /** Registry entry — core metadata + Zod schemas for request validation */
5689
+ interface TxTableRegistry extends coreOps.TransactionTableMetadata {
5690
+ insertSchema: { safeParse: (v: unknown) => SchemaParseResult };
5691
+ updateSchema: { safeParse: (v: unknown) => SchemaParseResult };
5692
+ }
5693
+
5694
+ // Registry used by POST /v1/transaction — maps table name to metadata + Zod schemas
5695
+ const TABLE_TX_METADATA: Record<string, TxTableRegistry> = {
5696
+ ${txMetadataEntries}
5697
+ };
5356
5698
 
5357
5699
  /**
5358
5700
  * Creates a Hono router with all generated routes that can be mounted into your existing app.
@@ -5422,7 +5764,7 @@ ${pullToken ? `
5422
5764
  if (!expectedToken) {
5423
5765
  // Token not configured in environment - reject request
5424
5766
  return c.json({
5425
- error: "SDK endpoints are protected but pullToken environment variable not set. ${pullTokenEnvVar ? `Set ${pullTokenEnvVar} in your environment or remove pullToken from config.` : "Set the pullToken environment variable or remove pullToken from config."}"
5767
+ error: "SDK endpoints are protected but pullToken environment variable not set. ${pullTokenEnvVar ? `Set ${pullTokenEnvVar} in your environment, or remove pullToken from config.` : "Remove pullToken from config or set the expected environment variable."}"
5426
5768
  }, 500);
5427
5769
  }
5428
5770
 
@@ -5439,6 +5781,9 @@ ${pullToken ? `
5439
5781
  await next();
5440
5782
  });
5441
5783
  ` : ""}
5784
+ // Transaction endpoint — executes multiple operations atomically
5785
+ ${txRouteBlock("router")}
5786
+
5442
5787
  // SDK distribution endpoints
5443
5788
  router.get("/_psdk/sdk/manifest", (c) => {
5444
5789
  return c.json({
@@ -5522,6 +5867,7 @@ export function registerAllRoutes(
5522
5867
  }
5523
5868
  ) {
5524
5869
  ${registrations.replace(/router/g, "app")}
5870
+ ${txRouteBlock("app")}
5525
5871
  }
5526
5872
 
5527
5873
  // Individual route registrations (for selective use)
@@ -5566,8 +5912,6 @@ function emitCoreOperations() {
5566
5912
  * These functions handle the actual database logic and can be used by any framework adapter.
5567
5913
  */
5568
5914
 
5569
- import type { z } from "zod";
5570
-
5571
5915
  export interface DatabaseClient {
5572
5916
  query: (text: string, params?: any[]) => Promise<{ rows: any[] }>;
5573
5917
  }
@@ -5711,6 +6055,96 @@ export async function createRecord(
5711
6055
  }
5712
6056
  }
5713
6057
 
6058
+ /**
6059
+ * UPSERT operation - Insert or update a record based on a conflict target
6060
+ */
6061
+ export async function upsertRecord(
6062
+ ctx: OperationContext,
6063
+ args: {
6064
+ where: Record<string, any>; // conflict target columns (keys only matter)
6065
+ create: Record<string, any>; // full insert data
6066
+ update: Record<string, any>; // update data on conflict
6067
+ }
6068
+ ): Promise<{ data?: any; error?: string; issues?: any; status: number }> {
6069
+ try {
6070
+ const { where, create: createData, update: updateData } = args;
6071
+
6072
+ const conflictCols = Object.keys(where);
6073
+ if (!conflictCols.length) {
6074
+ return { error: "where must specify at least one column", status: 400 };
6075
+ }
6076
+
6077
+ const insertCols = Object.keys(createData);
6078
+ const insertVals = Object.values(createData);
6079
+ if (!insertCols.length) {
6080
+ return { error: "No fields provided in create", status: 400 };
6081
+ }
6082
+
6083
+ // Filter PK columns from update (mirrors updateRecord behaviour)
6084
+ const filteredUpdate = Object.fromEntries(
6085
+ Object.entries(updateData).filter(([k]) => !ctx.pkColumns.includes(k))
6086
+ );
6087
+ if (!Object.keys(filteredUpdate).length) {
6088
+ return { error: "update must include at least one non-PK field", status: 400 };
6089
+ }
6090
+
6091
+ // Serialise JSONB/vector values for create (same pattern as createRecord)
6092
+ const preparedInsertVals = insertVals.map((v, i) =>
6093
+ v !== null && v !== undefined && typeof v === 'object' &&
6094
+ (ctx.jsonbColumns?.includes(insertCols[i]!) || ctx.vectorColumns?.includes(insertCols[i]!))
6095
+ ? JSON.stringify(v) : v
6096
+ );
6097
+
6098
+ // Serialise JSONB/vector values for update
6099
+ const updateCols = Object.keys(filteredUpdate);
6100
+ const updateVals = Object.values(filteredUpdate);
6101
+ const preparedUpdateVals = updateVals.map((v, i) =>
6102
+ v !== null && v !== undefined && typeof v === 'object' &&
6103
+ (ctx.jsonbColumns?.includes(updateCols[i]!) || ctx.vectorColumns?.includes(updateCols[i]!))
6104
+ ? JSON.stringify(v) : v
6105
+ );
6106
+
6107
+ const placeholders = insertCols.map((_, i) => \`$\${i + 1}\`).join(', ');
6108
+ const conflictSQL = conflictCols.map(c => \`"\${c}"\`).join(', ');
6109
+ const setSql = updateCols.map((k, i) => \`"\${k}" = $\${insertCols.length + i + 1}\`).join(', ');
6110
+ const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
6111
+
6112
+ const text = \`INSERT INTO "\${ctx.table}" (\${insertCols.map(c => \`"\${c}"\`).join(', ')})
6113
+ VALUES (\${placeholders})
6114
+ ON CONFLICT (\${conflictSQL}) DO UPDATE SET \${setSql}
6115
+ RETURNING \${returningClause}\`;
6116
+ const params = [...preparedInsertVals, ...preparedUpdateVals];
6117
+
6118
+ log.debug("UPSERT SQL:", text, "params:", params);
6119
+ const { rows } = await ctx.pg.query(text, params);
6120
+ const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
6121
+
6122
+ if (!parsedRows[0]) {
6123
+ return { data: null, status: 500 };
6124
+ }
6125
+
6126
+ return { data: parsedRows[0], status: 200 };
6127
+ } catch (e: any) {
6128
+ const errorMsg = e?.message ?? "";
6129
+ const isJsonError = errorMsg.includes("invalid input syntax for type json");
6130
+ if (isJsonError) {
6131
+ log.error(\`UPSERT \${ctx.table} - Invalid JSON input detected!\`);
6132
+ log.error("Input args that caused error:", JSON.stringify(args, null, 2));
6133
+ log.error("Filtered update data (sent to DB):", JSON.stringify(Object.fromEntries(
6134
+ Object.entries(args.update).filter(([k]) => !ctx.pkColumns.includes(k))
6135
+ ), null, 2));
6136
+ log.error("PostgreSQL error:", errorMsg);
6137
+ } else {
6138
+ log.error(\`UPSERT \${ctx.table} error:\`, e?.stack ?? e);
6139
+ }
6140
+ return {
6141
+ error: e?.message ?? "Internal error",
6142
+ ...(DEBUG ? { stack: e?.stack } : {}),
6143
+ status: 500
6144
+ };
6145
+ }
6146
+ }
6147
+
5714
6148
  /**
5715
6149
  * READ operation - Get a record by primary key
5716
6150
  */
@@ -6387,7 +6821,8 @@ export async function updateRecord(
6387
6821
  */
6388
6822
  export async function deleteRecord(
6389
6823
  ctx: OperationContext,
6390
- pkValues: any[]
6824
+ pkValues: any[],
6825
+ opts?: { hard?: boolean }
6391
6826
  ): Promise<{ data?: any; error?: string; status: number }> {
6392
6827
  try {
6393
6828
  const hasCompositePk = ctx.pkColumns.length > 1;
@@ -6396,11 +6831,12 @@ export async function deleteRecord(
6396
6831
  : \`"\${ctx.pkColumns[0]}" = $1\`;
6397
6832
 
6398
6833
  const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
6399
- const text = ctx.softDeleteColumn
6834
+ const doSoftDelete = ctx.softDeleteColumn && !opts?.hard;
6835
+ const text = doSoftDelete
6400
6836
  ? \`UPDATE "\${ctx.table}" SET "\${ctx.softDeleteColumn}" = NOW() WHERE \${wherePkSql} RETURNING \${returningClause}\`
6401
6837
  : \`DELETE FROM "\${ctx.table}" WHERE \${wherePkSql} RETURNING \${returningClause}\`;
6402
6838
 
6403
- log.debug(\`DELETE \${ctx.softDeleteColumn ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
6839
+ log.debug(\`DELETE \${doSoftDelete ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
6404
6840
  const { rows } = await ctx.pg.query(text, pkValues);
6405
6841
  const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
6406
6842
 
@@ -6411,12 +6847,123 @@ export async function deleteRecord(
6411
6847
  return { data: parsedRows[0], status: 200 };
6412
6848
  } catch (e: any) {
6413
6849
  log.error(\`DELETE \${ctx.table} error:\`, e?.stack ?? e);
6414
- return {
6415
- error: e?.message ?? "Internal error",
6850
+ return {
6851
+ error: e?.message ?? "Internal error",
6416
6852
  ...(DEBUG ? { stack: e?.stack } : {}),
6417
- status: 500
6853
+ status: 500
6418
6854
  };
6419
6855
  }
6856
+ }
6857
+
6858
+ /**
6859
+ * Static metadata for a table used during transaction execution.
6860
+ * Mirrors the fields needed to construct an OperationContext (minus the pg client,
6861
+ * which is injected at transaction time).
6862
+ */
6863
+ export interface TransactionTableMetadata {
6864
+ table: string;
6865
+ pkColumns: string[];
6866
+ softDeleteColumn: string | null;
6867
+ allColumnNames: string[];
6868
+ vectorColumns: string[];
6869
+ jsonbColumns: string[];
6870
+ includeMethodsDepth: number;
6871
+ }
6872
+
6873
+ export type TransactionOperation =
6874
+ | { op: "create"; table: string; data: Record<string, unknown> }
6875
+ | { op: "update"; table: string; pk: string | Record<string, unknown>; data: Record<string, unknown> }
6876
+ | { op: "delete"; table: string; pk: string | Record<string, unknown> }
6877
+ | { op: "upsert"; table: string; data: { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> } };
6878
+
6879
+ /**
6880
+ * Executes a list of operations atomically inside a single PostgreSQL transaction.
6881
+ *
6882
+ * - When \`pg\` has a \`.connect()\` method (Pool), acquires a dedicated connection.
6883
+ * - Otherwise uses \`pg\` directly (already a single connected Client).
6884
+ * - Calls \`onBegin(txClient)\` after BEGIN and before any operations (for SET LOCAL etc.).
6885
+ * - Any status >= 400 or thrown error triggers ROLLBACK.
6886
+ * - \`failedAt: -1\` indicates an unexpected exception (e.g. connectivity failure).
6887
+ */
6888
+ export async function executeTransaction(
6889
+ pg: DatabaseClient & { connect?: () => Promise<DatabaseClient & { release?: () => void }> },
6890
+ ops: TransactionOperation[],
6891
+ metadata: Record<string, TransactionTableMetadata>,
6892
+ onBegin?: (txClient: DatabaseClient) => Promise<void>
6893
+ ): Promise<
6894
+ | { ok: true; results: Array<{ data: unknown }> }
6895
+ | { ok: false; error: string; failedAt: number }
6896
+ > {
6897
+ // Fail-fast: validate all table names before touching the DB
6898
+ for (let i = 0; i < ops.length; i++) {
6899
+ if (!metadata[ops[i]!.table]) {
6900
+ return { ok: false, error: \`Unknown table "\${ops[i]!.table}"\`, failedAt: i };
6901
+ }
6902
+ }
6903
+
6904
+ // Pool gives a dedicated connection; plain Client is used directly
6905
+ const isPool = typeof (pg as any).connect === "function";
6906
+ const txClient: DatabaseClient & { release?: () => void } = isPool
6907
+ ? await (pg as any).connect()
6908
+ : pg;
6909
+
6910
+ try {
6911
+ await txClient.query("BEGIN");
6912
+ if (onBegin) await onBegin(txClient);
6913
+
6914
+ const results: Array<{ data: unknown }> = [];
6915
+
6916
+ for (let i = 0; i < ops.length; i++) {
6917
+ const op = ops[i]!;
6918
+ const meta = metadata[op.table]!;
6919
+ const ctx: OperationContext = {
6920
+ pg: txClient,
6921
+ table: meta.table,
6922
+ pkColumns: meta.pkColumns,
6923
+ softDeleteColumn: meta.softDeleteColumn,
6924
+ allColumnNames: meta.allColumnNames,
6925
+ vectorColumns: meta.vectorColumns,
6926
+ jsonbColumns: meta.jsonbColumns,
6927
+ includeMethodsDepth: meta.includeMethodsDepth,
6928
+ };
6929
+
6930
+ let result: { data?: unknown; error?: string; status: number };
6931
+
6932
+ if (op.op === "create") {
6933
+ result = await createRecord(ctx, op.data);
6934
+ } else if (op.op === "upsert") {
6935
+ result = await upsertRecord(ctx, op.data);
6936
+ } else {
6937
+ const pkValues = Array.isArray(op.pk)
6938
+ ? op.pk
6939
+ : typeof op.pk === "object" && op.pk !== null
6940
+ ? meta.pkColumns.map(c => (op.pk as Record<string, unknown>)[c])
6941
+ : [op.pk];
6942
+ result = op.op === "update"
6943
+ ? await updateRecord(ctx, pkValues as string[], op.data)
6944
+ : await deleteRecord(ctx, pkValues as string[]);
6945
+ }
6946
+
6947
+ if (result.status >= 400) {
6948
+ try { await txClient.query("ROLLBACK"); } catch {}
6949
+ txClient.release?.();
6950
+ return {
6951
+ ok: false,
6952
+ error: result.error ?? \`Op \${i} returned status \${result.status}\`,
6953
+ failedAt: i,
6954
+ };
6955
+ }
6956
+ results.push({ data: result.data ?? null });
6957
+ }
6958
+
6959
+ await txClient.query("COMMIT");
6960
+ txClient.release?.();
6961
+ return { ok: true, results };
6962
+ } catch (e: unknown) {
6963
+ try { await txClient.query("ROLLBACK"); } catch {}
6964
+ txClient.release?.();
6965
+ return { ok: false, error: (e instanceof Error ? e.message : String(e)) ?? "Transaction error", failedAt: -1 };
6966
+ }
6420
6967
  }`;
6421
6968
  }
6422
6969
 
@@ -6848,7 +7395,7 @@ function generateForeignKeySetup(table, model, clientPath) {
6848
7395
  // Clean up parent ${foreignTableName} record
6849
7396
  if (${foreignTableName}Id) {
6850
7397
  try {
6851
- await sdk.${foreignTableName}.delete(${foreignTableName}Id);
7398
+ await sdk.${foreignTableName}.hardDelete(${foreignTableName}Id);
6852
7399
  } catch (e) {
6853
7400
  // Parent might already be deleted due to cascading
6854
7401
  }
@@ -6865,7 +7412,7 @@ function generateForeignKeySetup(table, model, clientPath) {
6865
7412
  // Clean up parent ${foreignTableName} record
6866
7413
  if (${foreignTableName}Key) {
6867
7414
  try {
6868
- await sdk.${foreignTableName}.delete(${foreignTableName}Key);
7415
+ await sdk.${foreignTableName}.hardDelete(${foreignTableName}Key);
6869
7416
  } catch (e) {
6870
7417
  // Parent might already be deleted due to cascading
6871
7418
  }
@@ -7130,19 +7677,34 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
7130
7677
  console.warn('No ID from create test, skipping update test');
7131
7678
  return;
7132
7679
  }
7133
-
7680
+
7134
7681
  const updateData: Update${Type} = ${updateData};
7135
7682
  const updated = await sdk.${table.name}.update(createdId, updateData);
7136
7683
  expect(updated).toBeDefined();
7684
+ });
7685
+
7686
+ it('should upsert (update path) ${table.name}', async () => {
7687
+ if (!createdId) {
7688
+ console.warn('No ID from create test, skipping upsert test');
7689
+ return;
7690
+ }
7691
+ // where: use PK as conflict target; create includes PK so conflict is guaranteed
7692
+ const result = await sdk.${table.name}.upsert({
7693
+ where: { ${table.pk[0]}: createdId },
7694
+ create: { ${table.pk[0]}: createdId, ...${sampleData} },
7695
+ update: ${updateData},
7696
+ });
7697
+ expect(result).toBeDefined();
7698
+ expect(result.${table.pk[0]}).toBe(createdId);
7137
7699
  });` : ""}
7138
-
7700
+
7139
7701
  it('should delete ${table.name}', async () => {
7140
7702
  if (!createdId) {
7141
7703
  console.warn('No ID from create test, skipping delete test');
7142
7704
  return;
7143
7705
  }
7144
7706
 
7145
- const deleted = await sdk.${table.name}.delete(createdId);
7707
+ const deleted = await sdk.${table.name}.hardDelete(createdId);
7146
7708
  expect(deleted).toBeDefined();
7147
7709
 
7148
7710
  // Verify deletion
@@ -7159,10 +7721,16 @@ var __filename2 = fileURLToPath(import.meta.url);
7159
7721
  var __dirname2 = dirname2(__filename2);
7160
7722
  var { version: CLI_VERSION } = JSON.parse(readFileSync(join2(__dirname2, "../package.json"), "utf-8"));
7161
7723
  function resolveSoftDeleteColumn(cfg, tableName) {
7162
- const overrides = cfg.softDeleteColumnOverrides;
7724
+ const del = cfg.delete;
7725
+ if (!del)
7726
+ return null;
7727
+ const overrides = del.softDeleteColumnOverrides;
7163
7728
  if (overrides && tableName in overrides)
7164
7729
  return overrides[tableName] ?? null;
7165
- return cfg.softDeleteColumn ?? null;
7730
+ return del.softDeleteColumn ?? null;
7731
+ }
7732
+ function resolveExposeHardDelete(cfg) {
7733
+ return cfg.delete?.exposeHardDelete ?? true;
7166
7734
  }
7167
7735
  async function generate(configPath, options) {
7168
7736
  if (!existsSync2(configPath)) {
@@ -7252,6 +7820,7 @@ async function generate(configPath, options) {
7252
7820
  path: join2(serverDir, "core", "operations.ts"),
7253
7821
  content: emitCoreOperations()
7254
7822
  });
7823
+ const exposeHardDelete = resolveExposeHardDelete(cfg);
7255
7824
  if (process.env.SDK_DEBUG) {
7256
7825
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
7257
7826
  }
@@ -7269,6 +7838,7 @@ async function generate(configPath, options) {
7269
7838
  if (serverFramework === "hono") {
7270
7839
  routeContent = emitHonoRoutes(table, graph, {
7271
7840
  softDeleteColumn: softDeleteCols[table.name] ?? null,
7841
+ exposeHardDelete,
7272
7842
  includeMethodsDepth: cfg.includeMethodsDepth || 2,
7273
7843
  authStrategy: getAuthStrategy(normalizedAuth),
7274
7844
  useJsExtensions: cfg.useJsExtensions,
@@ -7284,6 +7854,8 @@ async function generate(configPath, options) {
7284
7854
  files.push({
7285
7855
  path: join2(clientDir, `${table.name}.ts`),
7286
7856
  content: emitClient(table, graph, {
7857
+ softDeleteColumn: softDeleteCols[table.name] ?? null,
7858
+ exposeHardDelete,
7287
7859
  useJsExtensions: cfg.useJsExtensionsClient,
7288
7860
  includeMethodsDepth: cfg.includeMethodsDepth ?? 2,
7289
7861
  skipJunctionTables: cfg.skipJunctionTables ?? true
@@ -7297,7 +7869,11 @@ async function generate(configPath, options) {
7297
7869
  if (serverFramework === "hono") {
7298
7870
  files.push({
7299
7871
  path: join2(serverDir, "router.ts"),
7300
- content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken)
7872
+ content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken, {
7873
+ apiPathPrefix: cfg.apiPathPrefix || "/v1",
7874
+ softDeleteCols: Object.fromEntries(Object.values(model.tables).map((t) => [t.name, resolveSoftDeleteColumn(cfg, t.name)])),
7875
+ includeMethodsDepth: cfg.includeMethodsDepth ?? 2
7876
+ })
7301
7877
  });
7302
7878
  }
7303
7879
  const { generateUnifiedContract: generateUnifiedContract2, generateUnifiedContractMarkdown: generateUnifiedContractMarkdown2 } = await Promise.resolve().then(() => (init_emit_sdk_contract(), exports_emit_sdk_contract));