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/index.js CHANGED
@@ -491,6 +491,14 @@ var require_config = __commonJS(() => {
491
491
  import { mkdir, writeFile, readFile, readdir, unlink } from "fs/promises";
492
492
  import { dirname, join } from "path";
493
493
  import { existsSync } from "fs";
494
+ function isVectorType(pgType) {
495
+ const t = pgType.toLowerCase();
496
+ return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
497
+ }
498
+ function isJsonbType(pgType) {
499
+ const t = pgType.toLowerCase();
500
+ return t === "json" || t === "jsonb";
501
+ }
494
502
  async function writeFilesIfChanged(files) {
495
503
  let written = 0;
496
504
  let unchanged = 0;
@@ -889,9 +897,10 @@ function generateResourceWithSDK(table, model, graph, config) {
889
897
  const hasSinglePK = table.pk.length === 1;
890
898
  const pkField = hasSinglePK ? table.pk[0] : "id";
891
899
  const enums = model.enums || {};
892
- const overrides = config?.softDeleteColumnOverrides;
893
- const resolvedSoftDeleteCol = overrides && tableName in overrides ? overrides[tableName] ?? null : config?.softDeleteColumn ?? null;
900
+ const overrides = config?.delete?.softDeleteColumnOverrides;
901
+ const resolvedSoftDeleteCol = overrides && tableName in overrides ? overrides[tableName] ?? null : config?.delete?.softDeleteColumn ?? null;
894
902
  const softDeleteCol = resolvedSoftDeleteCol && table.columns.some((c) => c.name === resolvedSoftDeleteCol) ? resolvedSoftDeleteCol : null;
903
+ const exposeHardDelete = config?.delete?.exposeHardDelete ?? true;
895
904
  const sdkMethods = [];
896
905
  const endpoints = [];
897
906
  sdkMethods.push({
@@ -967,16 +976,47 @@ const withDeleted = await sdk.${tableName}.getByPk('id', { includeSoftDeleted: t
967
976
  }
968
977
  if (hasSinglePK) {
969
978
  sdkMethods.push({
970
- name: "delete",
971
- signature: `delete(${pkField}: string): Promise<${Type}>`,
972
- description: `Delete a ${tableName}`,
973
- example: `const deleted = await sdk.${tableName}.delete('id');`,
974
- correspondsTo: `DELETE ${basePath}/:${pkField}`
979
+ name: "upsert",
980
+ signature: `upsert(args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} }): Promise<${Type}>`,
981
+ 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.`,
982
+ example: `const result = await sdk.${tableName}.upsert({
983
+ where: { ${pkField}: 'some-id' },
984
+ create: { ${generateExampleFields(table, "create")} },
985
+ update: { ${generateExampleFields(table, "update")} },
986
+ });`,
987
+ correspondsTo: `POST ${basePath}/upsert`
975
988
  });
989
+ endpoints.push({
990
+ method: "POST",
991
+ path: `${basePath}/upsert`,
992
+ description: `Upsert ${tableName} — insert if no conflict on 'where' columns, update otherwise`,
993
+ requestBody: `{ where: Update${Type}; create: Insert${Type}; update: Update${Type} }`,
994
+ responseBody: `${Type}`
995
+ });
996
+ }
997
+ if (hasSinglePK) {
998
+ if (softDeleteCol) {
999
+ sdkMethods.push({
1000
+ name: "softDelete",
1001
+ signature: `softDelete(${pkField}: string): Promise<${Type}>`,
1002
+ description: `Soft-delete a ${tableName} (sets ${softDeleteCol})`,
1003
+ example: `const deleted = await sdk.${tableName}.softDelete('id');`,
1004
+ correspondsTo: `DELETE ${basePath}/:${pkField}`
1005
+ });
1006
+ }
1007
+ if (!softDeleteCol || exposeHardDelete) {
1008
+ sdkMethods.push({
1009
+ name: "hardDelete",
1010
+ signature: `hardDelete(${pkField}: string): Promise<${Type}>`,
1011
+ description: `Permanently delete a ${tableName}`,
1012
+ example: `const deleted = await sdk.${tableName}.hardDelete('id');`,
1013
+ correspondsTo: softDeleteCol ? `DELETE ${basePath}/:${pkField}?hard=true` : `DELETE ${basePath}/:${pkField}`
1014
+ });
1015
+ }
976
1016
  endpoints.push({
977
1017
  method: "DELETE",
978
1018
  path: `${basePath}/:${pkField}`,
979
- description: `Delete ${tableName}`,
1019
+ description: softDeleteCol ? `Delete ${tableName} (soft-delete by default${exposeHardDelete ? "; add ?hard=true for permanent deletion" : ""})` : `Delete ${tableName}`,
980
1020
  responseBody: `${Type}`
981
1021
  });
982
1022
  }
@@ -1733,7 +1773,7 @@ function emitIncludeResolver(graph, useJsExtensions) {
1733
1773
  const edges = graph[table] || {};
1734
1774
  const edgeEntries = Object.entries(edges);
1735
1775
  if (edgeEntries.length === 0) {
1736
- out += `export type ${Type}WithIncludes<TInclude extends ${Type}IncludeSpec> = Select${Type};
1776
+ out += `export type ${Type}WithIncludes<_TInclude extends ${Type}IncludeSpec> = Select${Type};
1737
1777
 
1738
1778
  `;
1739
1779
  continue;
@@ -1879,6 +1919,18 @@ ${insertFields}
1879
1919
  });
1880
1920
 
1881
1921
  export const Update${Type}Schema = Insert${Type}Schema.partial();
1922
+
1923
+ export const Upsert${Type}Schema = z.object({
1924
+ where: Update${Type}Schema.refine(
1925
+ (d) => Object.keys(d).length > 0,
1926
+ { message: "where must specify at least one column" }
1927
+ ),
1928
+ create: Insert${Type}Schema,
1929
+ update: Update${Type}Schema.refine(
1930
+ (d) => Object.keys(d).length > 0,
1931
+ { message: "update must specify at least one column" }
1932
+ ),
1933
+ });
1882
1934
  `;
1883
1935
  }
1884
1936
 
@@ -1972,13 +2024,6 @@ export interface PaginatedResponse<T> {
1972
2024
 
1973
2025
  // src/emit-routes-hono.ts
1974
2026
  init_utils();
1975
- function isVectorType(pgType) {
1976
- const t = pgType.toLowerCase();
1977
- return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
1978
- }
1979
- function isJsonbType(pgType) {
1980
- return pgType.toLowerCase() === "json" || pgType.toLowerCase() === "jsonb";
1981
- }
1982
2027
  function emitHonoRoutes(table, _graph, opts) {
1983
2028
  const fileTableName = table.name;
1984
2029
  const Type = pascal(table.name);
@@ -1992,6 +2037,7 @@ function emitHonoRoutes(table, _graph, opts) {
1992
2037
  const hasCompositePk = safePkCols.length > 1;
1993
2038
  const pkPath = hasCompositePk ? safePkCols.map((c) => `:${c}`).join("/") : `:${safePkCols[0]}`;
1994
2039
  const softDel = opts.softDeleteColumn && table.columns.some((c) => c.name === opts.softDeleteColumn) ? opts.softDeleteColumn : null;
2040
+ const exposeHard = !!(softDel && opts.exposeHardDelete);
1995
2041
  const getPkParams = hasCompositePk ? `const pkValues = [${safePkCols.map((c) => `c.req.param("${c}")`).join(", ")}];` : `const pkValues = [c.req.param("${safePkCols[0]}")];`;
1996
2042
  const hasAuth = opts.authStrategy && opts.authStrategy !== "none";
1997
2043
  const ext = opts.useJsExtensions ? ".js" : "";
@@ -2008,7 +2054,7 @@ function emitHonoRoutes(table, _graph, opts) {
2008
2054
  import { Hono } from "hono";
2009
2055
  import type { Context } from "hono";
2010
2056
  import { z } from "zod";
2011
- import { Insert${Type}Schema, Update${Type}Schema } from "../zod/${fileTableName}${ext}";
2057
+ import { Insert${Type}Schema, Update${Type}Schema, Upsert${Type}Schema } from "../zod/${fileTableName}${ext}";
2012
2058
  import { loadIncludes } from "../include-loader${ext}";
2013
2059
  import * as coreOps from "../core/operations${ext}";
2014
2060
  ${authImport}
@@ -2017,10 +2063,12 @@ const columnEnum = z.enum([${columnNames}]);
2017
2063
 
2018
2064
  const createSchema = Insert${Type}Schema;
2019
2065
  const updateSchema = Update${Type}Schema;
2066
+ const upsertSchema = Upsert${Type}Schema;
2020
2067
 
2021
2068
  const deleteSchema = z.object({
2022
2069
  select: z.array(z.string()).min(1).optional(),
2023
- exclude: z.array(z.string()).min(1).optional()
2070
+ exclude: z.array(z.string()).min(1).optional(),${exposeHard ? `
2071
+ hard: z.boolean().optional(),` : ""}
2024
2072
  }).strict().refine(
2025
2073
  (data) => !(data.select && data.exclude),
2026
2074
  { message: "Cannot specify both 'select' and 'exclude' parameters" }
@@ -2243,6 +2291,39 @@ ${hasAuth ? `
2243
2291
  }, result.status as any);
2244
2292
  });
2245
2293
 
2294
+ // UPSERT
2295
+ app.post(\`\${base}/upsert\`, async (c) => {
2296
+ const body = await c.req.json().catch(() => ({}));
2297
+ const parsed = upsertSchema.safeParse(body);
2298
+
2299
+ if (!parsed.success) {
2300
+ const issues = parsed.error.flatten();
2301
+ return c.json({ error: "Invalid body", issues }, 400);
2302
+ }
2303
+
2304
+ const selectParam = c.req.query("select");
2305
+ const excludeParam = c.req.query("exclude");
2306
+ const select = selectParam ? selectParam.split(",") : undefined;
2307
+ const exclude = excludeParam ? excludeParam.split(",") : undefined;
2308
+
2309
+ if (select && exclude) {
2310
+ return c.json({ error: "Cannot specify both 'select' and 'exclude' parameters" }, 400);
2311
+ }
2312
+
2313
+ if (deps.onRequest) {
2314
+ await deps.onRequest(c, deps.pg);
2315
+ }
2316
+
2317
+ const ctx = { ...baseCtx, select, exclude };
2318
+ const result = await coreOps.upsertRecord(ctx, parsed.data);
2319
+
2320
+ if (result.error) {
2321
+ return c.json({ error: result.error }, result.status as any);
2322
+ }
2323
+
2324
+ return c.json(result.data, result.status as any);
2325
+ });
2326
+
2246
2327
  // UPDATE
2247
2328
  app.patch(\`\${base}/${pkPath}\`, async (c) => {
2248
2329
  ${getPkParams}
@@ -2282,13 +2363,15 @@ ${hasAuth ? `
2282
2363
  app.delete(\`\${base}/${pkPath}\`, async (c) => {
2283
2364
  ${getPkParams}
2284
2365
 
2285
- // Parse query params for select/exclude
2366
+ // Parse query params for select/exclude${exposeHard ? " and hard" : ""}
2286
2367
  const selectParam = c.req.query("select");
2287
2368
  const excludeParam = c.req.query("exclude");
2288
2369
  const queryData: any = {};
2289
2370
  if (selectParam) queryData.select = selectParam.split(",");
2290
2371
  if (excludeParam) queryData.exclude = excludeParam.split(",");
2291
-
2372
+ ${exposeHard ? ` const hardParam = c.req.query("hard");
2373
+ if (hardParam !== undefined) queryData.hard = hardParam === "true";
2374
+ ` : ""}
2292
2375
  const queryParsed = deleteSchema.safeParse(queryData);
2293
2376
  if (!queryParsed.success) {
2294
2377
  const issues = queryParsed.error.flatten();
@@ -2300,7 +2383,7 @@ ${hasAuth ? `
2300
2383
  }
2301
2384
 
2302
2385
  const ctx = { ...baseCtx, select: queryParsed.data.select, exclude: queryParsed.data.exclude };
2303
- const result = await coreOps.deleteRecord(ctx, pkValues);
2386
+ const result = await coreOps.deleteRecord(ctx, pkValues${exposeHard ? ", { hard: queryParsed.data.hard }" : ""});
2304
2387
 
2305
2388
  if (result.error) {
2306
2389
  return c.json({ error: result.error }, result.status as any);
@@ -2315,14 +2398,6 @@ ${hasAuth ? `
2315
2398
  // src/emit-client.ts
2316
2399
  init_utils();
2317
2400
  init_emit_include_methods();
2318
- function isVectorType2(pgType) {
2319
- const t = pgType.toLowerCase();
2320
- return t === "vector" || t === "halfvec" || t === "sparsevec" || t === "bit";
2321
- }
2322
- function isJsonbType2(pgType) {
2323
- const t = pgType.toLowerCase();
2324
- return t === "json" || t === "jsonb";
2325
- }
2326
2401
  function toIncludeParamName(relationKey) {
2327
2402
  return `${relationKey}Include`;
2328
2403
  }
@@ -2345,10 +2420,12 @@ function emitClient(table, graph, opts, model) {
2345
2420
  const Type = pascal(table.name);
2346
2421
  const ext = opts.useJsExtensions ? ".js" : "";
2347
2422
  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 }`;
2348
- const hasVectorColumns = table.columns.some((c) => isVectorType2(c.pgType));
2349
- const hasJsonbColumns = table.columns.some((c) => isJsonbType2(c.pgType));
2423
+ const hasVectorColumns = table.columns.some((c) => isVectorType(c.pgType));
2424
+ const hasJsonbColumns = table.columns.some((c) => isJsonbType(c.pgType));
2425
+ const hasSoftDelete = !!opts.softDeleteColumn;
2426
+ const exposeHard = !hasSoftDelete || (opts.exposeHardDelete ?? true);
2350
2427
  if (process.env.SDK_DEBUG) {
2351
- const vectorCols = table.columns.filter((c) => isVectorType2(c.pgType));
2428
+ const vectorCols = table.columns.filter((c) => isVectorType(c.pgType));
2352
2429
  if (vectorCols.length > 0) {
2353
2430
  console.log(`[DEBUG] Table ${table.name}: Found ${vectorCols.length} vector columns:`, vectorCols.map((c) => `${c.name} (${c.pgType})`));
2354
2431
  }
@@ -2364,15 +2441,19 @@ function emitClient(table, graph, opts, model) {
2364
2441
  skipJunctionTables: opts.skipJunctionTables ?? true
2365
2442
  }, allTables);
2366
2443
  const importedTypes = new Set;
2444
+ const usedIncludeSpecTypes = new Set([table.name]);
2367
2445
  importedTypes.add(table.name);
2368
2446
  for (const method of includeMethods) {
2369
2447
  for (const target of method.targets) {
2370
2448
  importedTypes.add(target);
2371
2449
  }
2450
+ const pattern = analyzeIncludeSpec(method.includeSpec);
2451
+ if (pattern.type === "nested" && method.targets[0]) {
2452
+ usedIncludeSpecTypes.add(method.targets[0]);
2453
+ }
2372
2454
  }
2373
2455
  const typeImports = `import type { Insert${Type}, Update${Type}, Select${Type} } from "./types/${table.name}${ext}";`;
2374
- const includeSpecTypes = [table.name, ...Array.from(importedTypes).filter((t) => t !== table.name)];
2375
- const includeSpecImport = `import type { ${includeSpecTypes.map((t) => `${pascal(t)}IncludeSpec`).join(", ")} } from "./include-spec${ext}";`;
2456
+ const includeSpecImport = `import type { ${Array.from(usedIncludeSpecTypes).map((t) => `${pascal(t)}IncludeSpec`).join(", ")} } from "./include-spec${ext}";`;
2376
2457
  const includeResolverImport = `import type { ${Type}WithIncludes } from "./include-resolver${ext}";`;
2377
2458
  const otherTableImports = [];
2378
2459
  for (const target of Array.from(importedTypes)) {
@@ -2574,6 +2655,54 @@ function emitClient(table, graph, opts, model) {
2574
2655
  `;
2575
2656
  }
2576
2657
  }
2658
+ const buildDeleteMethod = (methodName, setHardTrue) => {
2659
+ const actionName = methodName === "softDelete" ? "Soft-delete" : "Hard-delete (permanently remove)";
2660
+ const hardLine = setHardTrue ? `
2661
+ queryParams.set('hard', 'true');` : "";
2662
+ const pksLabel = hasCompositePk ? "s" : "";
2663
+ const G = hasJsonbColumns ? `<TJsonb extends Partial<Select${Type}> = {}>` : "";
2664
+ const RBase = hasJsonbColumns ? `Select${Type}<TJsonb>` : `Select${Type}`;
2665
+ const RPart = hasJsonbColumns ? `Partial<Select${Type}<TJsonb>>` : `Partial<Select${Type}>`;
2666
+ return ` /**
2667
+ ` + ` * ${actionName} a ${table.name} record by primary key with field selection
2668
+ ` + ` * @param pk - The primary key value${pksLabel}
2669
+ ` + ` * @param options - Select specific fields to return
2670
+ ` + ` * @returns The deleted record with only selected fields if found, null otherwise
2671
+ ` + ` */
2672
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options: { select: string[] }): Promise<${RPart} | null>;
2673
+ ` + ` /**
2674
+ ` + ` * ${actionName} a ${table.name} record by primary key with field exclusion
2675
+ ` + ` * @param pk - The primary key value${pksLabel}
2676
+ ` + ` * @param options - Exclude specific fields from return
2677
+ ` + ` * @returns The deleted record without excluded fields if found, null otherwise
2678
+ ` + ` */
2679
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options: { exclude: string[] }): Promise<${RPart} | null>;
2680
+ ` + ` /**
2681
+ ` + ` * ${actionName} a ${table.name} record by primary key
2682
+ ` + ` * @param pk - The primary key value${pksLabel}
2683
+ ` + ` * @returns The deleted record with all fields if found, null otherwise
2684
+ ` + ` */
2685
+ ` + ` async ${methodName}${G}(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<${RBase} | null>;
2686
+ ` + ` async ${methodName}${G}(
2687
+ ` + ` pk: ${pkType},
2688
+ ` + ` options?: { select?: string[]; exclude?: string[] }
2689
+ ` + ` ): Promise<${RBase} | ${RPart} | null> {
2690
+ ` + ` const path = ${pkPathExpr};
2691
+ ` + ` const queryParams = new URLSearchParams();${hardLine}
2692
+ ` + ` if (options?.select) queryParams.set('select', options.select.join(','));
2693
+ ` + ` if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
2694
+ ` + ` const query = queryParams.toString();
2695
+ ` + " const url = query ? `${this.resource}/${path}?${query}` : `${this.resource}/${path}`;\n" + ` return this.del<${RBase} | null>(url);
2696
+ ` + ` }`;
2697
+ };
2698
+ const deleteMethodParts = [];
2699
+ if (hasSoftDelete)
2700
+ deleteMethodParts.push(buildDeleteMethod("softDelete", false));
2701
+ if (exposeHard)
2702
+ deleteMethodParts.push(buildDeleteMethod("hardDelete", hasSoftDelete));
2703
+ const deleteMethodsCode = deleteMethodParts.join(`
2704
+
2705
+ `);
2577
2706
  return `/**
2578
2707
  * AUTO-GENERATED FILE - DO NOT EDIT
2579
2708
  *
@@ -2583,6 +2712,7 @@ function emitClient(table, graph, opts, model) {
2583
2712
  * To make changes, modify your schema or configuration and regenerate.
2584
2713
  */
2585
2714
  import { BaseClient } from "./base-client${ext}";
2715
+ import type { TxOp } from "./base-client${ext}";
2586
2716
  import type { Where } from "./where-types${ext}";
2587
2717
  import type { PaginatedResponse } from "./types/shared${ext}";
2588
2718
  ${typeImports}
@@ -2664,6 +2794,78 @@ ${hasJsonbColumns ? ` /**
2664
2794
  return this.post<Select${Type}>(url, data);
2665
2795
  }`}
2666
2796
 
2797
+ ${hasJsonbColumns ? ` /**
2798
+ * Upsert a ${table.name} record with field selection
2799
+ */
2800
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
2801
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
2802
+ options: { select: string[] }
2803
+ ): Promise<Partial<Select${Type}<TJsonb>>>;
2804
+ /**
2805
+ * Upsert a ${table.name} record with field exclusion
2806
+ */
2807
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
2808
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
2809
+ options: { exclude: string[] }
2810
+ ): Promise<Partial<Select${Type}<TJsonb>>>;
2811
+ /**
2812
+ * Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
2813
+ * @param args.where - Conflict target column(s) (must be a unique constraint)
2814
+ * @param args.create - Full insert data used when no conflict occurs
2815
+ * @param args.update - Partial data applied when a conflict occurs
2816
+ * @returns The resulting record
2817
+ */
2818
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
2819
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
2820
+ options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
2821
+ ): Promise<Select${Type}<TJsonb>>;
2822
+ async upsert<TJsonb extends Partial<Select${Type}> = {}>(
2823
+ args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
2824
+ options?: { select?: string[]; exclude?: string[] }
2825
+ ): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>> {
2826
+ const queryParams = new URLSearchParams();
2827
+ if (options?.select) queryParams.set('select', options.select.join(','));
2828
+ if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
2829
+ const query = queryParams.toString();
2830
+ const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
2831
+ return this.post<Select${Type}<TJsonb>>(url, args);
2832
+ }` : ` /**
2833
+ * Upsert a ${table.name} record with field selection
2834
+ */
2835
+ async upsert(
2836
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
2837
+ options: { select: string[] }
2838
+ ): Promise<Partial<Select${Type}>>;
2839
+ /**
2840
+ * Upsert a ${table.name} record with field exclusion
2841
+ */
2842
+ async upsert(
2843
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
2844
+ options: { exclude: string[] }
2845
+ ): Promise<Partial<Select${Type}>>;
2846
+ /**
2847
+ * Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
2848
+ * @param args.where - Conflict target column(s) (must be a unique constraint)
2849
+ * @param args.create - Full insert data used when no conflict occurs
2850
+ * @param args.update - Partial data applied when a conflict occurs
2851
+ * @returns The resulting record
2852
+ */
2853
+ async upsert(
2854
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
2855
+ options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
2856
+ ): Promise<Select${Type}>;
2857
+ async upsert(
2858
+ args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
2859
+ options?: { select?: string[]; exclude?: string[] }
2860
+ ): Promise<Select${Type} | Partial<Select${Type}>> {
2861
+ const queryParams = new URLSearchParams();
2862
+ if (options?.select) queryParams.set('select', options.select.join(','));
2863
+ if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
2864
+ const query = queryParams.toString();
2865
+ const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
2866
+ return this.post<Select${Type}>(url, args);
2867
+ }`}
2868
+
2667
2869
  ${hasJsonbColumns ? ` /**
2668
2870
  * Get a ${table.name} record by primary key with field selection
2669
2871
  * @param pk - The primary key value${hasCompositePk ? "s" : ""}
@@ -3004,72 +3206,27 @@ ${hasJsonbColumns ? ` /**
3004
3206
  return this.patch<Select${Type} | null>(url, patch);
3005
3207
  }`}
3006
3208
 
3007
- ${hasJsonbColumns ? ` /**
3008
- * Delete a ${table.name} record by primary key with field selection
3009
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3010
- * @param options - Select specific fields to return
3011
- * @returns The deleted record with only selected fields if found, null otherwise
3012
- */
3013
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options: { select: string[] }): Promise<Partial<Select${Type}<TJsonb>> | null>;
3014
- /**
3015
- * Delete a ${table.name} record by primary key with field exclusion
3016
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3017
- * @param options - Exclude specific fields from return
3018
- * @returns The deleted record without excluded fields if found, null otherwise
3019
- */
3020
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options: { exclude: string[] }): Promise<Partial<Select${Type}<TJsonb>> | null>;
3021
- /**
3022
- * Delete a ${table.name} record by primary key
3023
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3024
- * @returns The deleted record with all fields if found, null otherwise
3025
- * @example
3026
- * // With JSONB type override:
3027
- * const user = await client.delete<{ metadata: Metadata }>('user-id');
3028
- */
3029
- async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type}<TJsonb> | null>;
3030
- async delete<TJsonb extends Partial<Select${Type}> = {}>(
3031
- pk: ${pkType},
3032
- options?: { select?: string[]; exclude?: string[] }
3033
- ): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>> | null> {
3034
- const path = ${pkPathExpr};
3035
- const queryParams = new URLSearchParams();
3036
- if (options?.select) queryParams.set('select', options.select.join(','));
3037
- if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
3038
- const query = queryParams.toString();
3039
- const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
3040
- return this.del<Select${Type}<TJsonb> | null>(url);
3041
- }` : ` /**
3042
- * Delete a ${table.name} record by primary key with field selection
3043
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3044
- * @param options - Select specific fields to return
3045
- * @returns The deleted record with only selected fields if found, null otherwise
3046
- */
3047
- async delete(pk: ${pkType}, options: { select: string[] }): Promise<Partial<Select${Type}> | null>;
3048
- /**
3049
- * Delete a ${table.name} record by primary key with field exclusion
3050
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3051
- * @param options - Exclude specific fields from return
3052
- * @returns The deleted record without excluded fields if found, null otherwise
3053
- */
3054
- async delete(pk: ${pkType}, options: { exclude: string[] }): Promise<Partial<Select${Type}> | null>;
3055
- /**
3056
- * Delete a ${table.name} record by primary key
3057
- * @param pk - The primary key value${hasCompositePk ? "s" : ""}
3058
- * @returns The deleted record with all fields if found, null otherwise
3059
- */
3060
- async delete(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type} | null>;
3061
- async delete(
3062
- pk: ${pkType},
3063
- options?: { select?: string[]; exclude?: string[] }
3064
- ): Promise<Select${Type} | Partial<Select${Type}> | null> {
3065
- const path = ${pkPathExpr};
3066
- const queryParams = new URLSearchParams();
3067
- if (options?.select) queryParams.set('select', options.select.join(','));
3068
- if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
3069
- const query = queryParams.toString();
3070
- const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
3071
- return this.del<Select${Type} | null>(url);
3072
- }`}
3209
+ ${deleteMethodsCode}
3210
+
3211
+ /** Build a lazy CREATE descriptor for use with sdk.$transaction([...]) */
3212
+ $create(data: Insert${Type}): TxOp<Select${Type}> {
3213
+ return { _table: "${table.name}", _op: "create", _data: data as Record<string, unknown> };
3214
+ }
3215
+
3216
+ /** Build a lazy UPDATE descriptor for use with sdk.$transaction([...]) */
3217
+ $update(pk: ${pkType}, data: Update${Type}): TxOp<Select${Type} | null> {
3218
+ return { _table: "${table.name}", _op: "update", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"}, _data: data as Record<string, unknown> };
3219
+ }
3220
+
3221
+ /** Build a lazy DELETE descriptor for use with sdk.$transaction([...]) */
3222
+ $delete(pk: ${pkType}): TxOp<Select${Type} | null> {
3223
+ return { _table: "${table.name}", _op: "delete", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"} };
3224
+ }
3225
+
3226
+ /** Build a lazy UPSERT descriptor for use with sdk.$transaction([...]) */
3227
+ $upsert(args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} }): TxOp<Select${Type}> {
3228
+ return { _table: "${table.name}", _op: "upsert", _data: args as Record<string, unknown> };
3229
+ }
3073
3230
  ${includeMethodsCode}}
3074
3231
  `;
3075
3232
  }
@@ -3084,7 +3241,9 @@ function emitClientIndex(tables, useJsExtensions, graph, includeOpts) {
3084
3241
  * To make changes, modify your schema or configuration and regenerate.
3085
3242
  */
3086
3243
  `;
3087
- out += `import type { AuthConfig } from "./base-client${ext}";
3244
+ out += `import { BaseClient } from "./base-client${ext}";
3245
+ `;
3246
+ out += `import type { AuthConfig, TxOp } from "./base-client${ext}";
3088
3247
  `;
3089
3248
  for (const t of tables) {
3090
3249
  out += `import { ${pascal(t.name)}Client } from "./${t.name}${ext}";
@@ -3100,7 +3259,7 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
3100
3259
  `;
3101
3260
  out += ` */
3102
3261
  `;
3103
- out += `export class SDK {
3262
+ out += `export class SDK extends BaseClient {
3104
3263
  `;
3105
3264
  for (const t of tables) {
3106
3265
  out += ` public ${t.name}: ${pascal(t.name)}Client;
@@ -3110,17 +3269,101 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
3110
3269
  constructor(cfg: { baseUrl: string; fetch?: typeof fetch; auth?: AuthConfig }) {
3111
3270
  `;
3112
3271
  out += ` const f = cfg.fetch ?? fetch;
3272
+ `;
3273
+ out += ` super(cfg.baseUrl, f, cfg.auth);
3113
3274
  `;
3114
3275
  for (const t of tables) {
3115
3276
  out += ` this.${t.name} = new ${pascal(t.name)}Client(cfg.baseUrl, f, cfg.auth);
3116
3277
  `;
3117
3278
  }
3118
3279
  out += ` }
3280
+ `;
3281
+ out += `
3282
+ `;
3283
+ out += ` /**
3284
+ `;
3285
+ out += ` * Execute multiple operations atomically in one PostgreSQL transaction.
3286
+ `;
3287
+ out += ` * All ops are validated before BEGIN is issued — fail-fast on bad input.
3288
+ `;
3289
+ out += ` *
3290
+ `;
3291
+ out += ` * @example
3292
+ `;
3293
+ out += ` * const [order, user] = await sdk.$transaction([
3294
+ `;
3295
+ out += ` * sdk.orders.$create({ user_id: 1, total: 99 }),
3296
+ `;
3297
+ out += ` * sdk.users.$update('1', { last_order_at: new Date().toISOString() }),
3298
+ `;
3299
+ out += ` * ]);
3300
+ `;
3301
+ out += ` */
3302
+ `;
3303
+ out += ` async $transaction<const T extends readonly TxOp<unknown>[]>(
3304
+ `;
3305
+ out += ` ops: [...T]
3306
+ `;
3307
+ out += ` ): Promise<{ [K in keyof T]: T[K] extends TxOp<infer R> ? R : never }> {
3308
+ `;
3309
+ out += ` const payload = ops.map(op => ({
3310
+ `;
3311
+ out += ` op: op._op,
3312
+ `;
3313
+ out += ` table: op._table,
3314
+ `;
3315
+ out += ` ...(op._data !== undefined ? { data: op._data } : {}),
3316
+ `;
3317
+ out += ` ...(op._pk !== undefined ? { pk: op._pk } : {}),
3318
+ `;
3319
+ out += ` }));
3320
+ `;
3321
+ out += `
3322
+ `;
3323
+ out += ` const res = await this.fetchFn(\`\${this.baseUrl}/v1/transaction\`, {
3324
+ `;
3325
+ out += ` method: "POST",
3326
+ `;
3327
+ out += ` headers: await this.headers(true),
3328
+ `;
3329
+ out += ` body: JSON.stringify({ ops: payload }),
3330
+ `;
3331
+ out += ` });
3332
+ `;
3333
+ out += `
3334
+ `;
3335
+ out += ` if (!res.ok) {
3336
+ `;
3337
+ out += ` let errBody: Record<string, unknown> = {};
3338
+ `;
3339
+ out += ` try { errBody = await res.json() as Record<string, unknown>; } catch {}
3340
+ `;
3341
+ out += ` const err = Object.assign(
3342
+ `;
3343
+ out += ` new Error((errBody.error as string | undefined) ?? \`$transaction failed: \${res.status}\`),
3344
+ `;
3345
+ out += ` { failedAt: errBody.failedAt as number | undefined, issues: errBody.issues }
3346
+ `;
3347
+ out += ` );
3348
+ `;
3349
+ out += ` throw err;
3350
+ `;
3351
+ out += ` }
3352
+ `;
3353
+ out += `
3354
+ `;
3355
+ out += ` const json = await res.json() as { results: unknown[] };
3356
+ `;
3357
+ out += ` return json.results as unknown as { [K in keyof T]: T[K] extends TxOp<infer R> ? R : never };
3358
+ `;
3359
+ out += ` }
3119
3360
  `;
3120
3361
  out += `}
3121
3362
 
3122
3363
  `;
3123
3364
  out += `export { BaseClient } from "./base-client${ext}";
3365
+ `;
3366
+ out += `export type { TxOp } from "./base-client${ext}";
3124
3367
  `;
3125
3368
  out += `
3126
3369
  // Include specification types for custom queries
@@ -3321,15 +3564,29 @@ export abstract class BaseClient {
3321
3564
  method: "DELETE",
3322
3565
  headers: await this.headers(),
3323
3566
  });
3324
-
3567
+
3325
3568
  if (res.status === 404) {
3326
3569
  return null as T;
3327
3570
  }
3328
-
3571
+
3329
3572
  await this.okOrThrow(res, "DELETE", path);
3330
3573
  return (await res.json()) as T;
3331
3574
  }
3332
3575
  }
3576
+
3577
+ /**
3578
+ * Lazy operation descriptor returned by $create/$update/$delete.
3579
+ * \`__resultType\` is a phantom field — never assigned at runtime, exists only
3580
+ * so TypeScript can infer the correct tuple element type inside \`$transaction\`.
3581
+ */
3582
+ export type TxOp<T = unknown> = {
3583
+ readonly _table: string;
3584
+ readonly _op: "create" | "update" | "delete" | "upsert";
3585
+ readonly _data?: Record<string, unknown>;
3586
+ readonly _pk?: string | Record<string, unknown>;
3587
+ /** @internal */
3588
+ readonly __resultType?: T;
3589
+ };
3333
3590
  `;
3334
3591
  }
3335
3592
 
@@ -3873,14 +4130,14 @@ function tsTypeFor(pgType, opts, enums) {
3873
4130
  return "number[]";
3874
4131
  return "string";
3875
4132
  }
3876
- function isJsonbType3(pgType) {
4133
+ function isJsonbType2(pgType) {
3877
4134
  const t = pgType.toLowerCase();
3878
4135
  return t === "json" || t === "jsonb";
3879
4136
  }
3880
4137
  var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
3881
4138
  function emitTypes(table, opts, enums) {
3882
4139
  const Type = pascal2(table.name);
3883
- const hasJsonbColumns = table.columns.some((col) => isJsonbType3(col.pgType));
4140
+ const hasJsonbColumns = table.columns.some((col) => isJsonbType2(col.pgType));
3884
4141
  const insertFields = table.columns.map((col) => {
3885
4142
  const base = tsTypeFor(col.pgType, opts, enums);
3886
4143
  const optional = col.hasDefault || col.nullable ? "?" : "";
@@ -4331,9 +4588,12 @@ export async function authMiddleware(c: Context, next: Next) {
4331
4588
 
4332
4589
  // src/emit-router-hono.ts
4333
4590
  init_utils();
4334
- function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
4591
+ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken, opts) {
4335
4592
  const tableNames = tables.map((t) => t.name).sort();
4336
4593
  const ext = useJsExtensions ? ".js" : "";
4594
+ const apiPathPrefix = opts?.apiPathPrefix ?? "/v1";
4595
+ const softDeleteCols = opts?.softDeleteCols ?? {};
4596
+ const includeMethodsDepth = opts?.includeMethodsDepth ?? 2;
4337
4597
  let resolvedPullToken;
4338
4598
  let pullTokenEnvVar;
4339
4599
  if (pullToken) {
@@ -4359,6 +4619,88 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
4359
4619
  const Type = pascal(name);
4360
4620
  return `export { register${Type}Routes } from "./routes/${name}${ext}";`;
4361
4621
  }).join(`
4622
+ `);
4623
+ const txSchemaImports = tableNames.map((name) => {
4624
+ const Type = pascal(name);
4625
+ return `import { Insert${Type}Schema, Update${Type}Schema } from "./zod/${name}${ext}";`;
4626
+ }).join(`
4627
+ `);
4628
+ function txRouteBlock(appVar) {
4629
+ const authLine = hasAuth ? ` ${appVar}.use(\`${apiPathPrefix}/transaction\`, authMiddleware);
4630
+ ` : "";
4631
+ return `${authLine} ${appVar}.post(\`${apiPathPrefix}/transaction\`, async (c) => {
4632
+ const body = await c.req.json().catch(() => ({}));
4633
+ const rawOps: unknown[] = Array.isArray(body.ops) ? body.ops : [];
4634
+
4635
+ if (rawOps.length === 0) {
4636
+ return c.json({ error: "ops must be a non-empty array" }, 400);
4637
+ }
4638
+
4639
+ // Validate all ops against their table schemas BEFORE opening a transaction
4640
+ const validatedOps: coreOps.TransactionOperation[] = [];
4641
+ for (let i = 0; i < rawOps.length; i++) {
4642
+ const item = rawOps[i] as any;
4643
+ const entry = TABLE_TX_METADATA[item?.table as string];
4644
+ if (!entry) {
4645
+ return c.json({ error: \`Unknown table "\${item?.table}" at index \${i}\`, failedAt: i }, 400);
4646
+ }
4647
+ if (item.op === "create") {
4648
+ const parsed = entry.insertSchema.safeParse(item.data ?? {});
4649
+ if (!parsed.success) {
4650
+ return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
4651
+ }
4652
+ validatedOps.push({ op: "create", table: item.table, data: parsed.data });
4653
+ } else if (item.op === "update") {
4654
+ const parsed = entry.updateSchema.safeParse(item.data ?? {});
4655
+ if (!parsed.success) {
4656
+ return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
4657
+ }
4658
+ if (item.pk == null) {
4659
+ return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
4660
+ }
4661
+ validatedOps.push({ op: "update", table: item.table, pk: item.pk, data: parsed.data });
4662
+ } else if (item.op === "delete") {
4663
+ if (item.pk == null) {
4664
+ return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
4665
+ }
4666
+ validatedOps.push({ op: "delete", table: item.table, pk: item.pk });
4667
+ } else {
4668
+ return c.json({ error: \`Unknown op "\${item?.op}" at index \${i}\`, failedAt: i }, 400);
4669
+ }
4670
+ }
4671
+
4672
+ const onBegin = deps.onRequest
4673
+ ? (txClient: typeof deps.pg) => deps.onRequest!(c, txClient)
4674
+ : undefined;
4675
+
4676
+ const result = await coreOps.executeTransaction(deps.pg, validatedOps, TABLE_TX_METADATA, onBegin);
4677
+
4678
+ if (!result.ok) {
4679
+ return c.json({ error: result.error, failedAt: result.failedAt }, 400);
4680
+ }
4681
+ return c.json({ results: result.results.map(r => r.data) }, 200);
4682
+ });`;
4683
+ }
4684
+ const txMetadataEntries = tables.slice().sort((a, b) => a.name.localeCompare(b.name)).map((t) => {
4685
+ const rawPk = t.pk;
4686
+ const pkCols = Array.isArray(rawPk) ? rawPk : rawPk ? [rawPk] : ["id"];
4687
+ const softDel = softDeleteCols[t.name] ?? null;
4688
+ const allCols = t.columns.map((c) => `"${c.name}"`).join(", ");
4689
+ const vecCols = t.columns.filter((c) => isVectorType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
4690
+ const jsonCols = t.columns.filter((c) => isJsonbType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
4691
+ const Type = pascal(t.name);
4692
+ return ` "${t.name}": {
4693
+ table: "${t.name}",
4694
+ pkColumns: ${JSON.stringify(pkCols)},
4695
+ softDeleteColumn: ${softDel ? `"${softDel}"` : "null"},
4696
+ allColumnNames: [${allCols}],
4697
+ vectorColumns: [${vecCols}],
4698
+ jsonbColumns: [${jsonCols}],
4699
+ includeMethodsDepth: ${includeMethodsDepth},
4700
+ insertSchema: Insert${Type}Schema,
4701
+ updateSchema: Update${Type}Schema,
4702
+ }`;
4703
+ }).join(`,
4362
4704
  `);
4363
4705
  return `/**
4364
4706
  * AUTO-GENERATED FILE - DO NOT EDIT
@@ -4372,8 +4714,27 @@ import { Hono } from "hono";
4372
4714
  import type { Context } from "hono";
4373
4715
  import { SDK_MANIFEST } from "./sdk-bundle${ext}";
4374
4716
  import { getContract } from "./contract${ext}";
4717
+ import * as coreOps from "./core/operations${ext}";
4718
+ ${txSchemaImports}
4375
4719
  ${imports}
4376
- ${hasAuth ? `export { authMiddleware } from "./auth${ext}";` : ""}
4720
+ ${hasAuth ? `import { authMiddleware } from "./auth${ext}";
4721
+ export { authMiddleware };` : ""}
4722
+
4723
+ /** Discriminated result from safeParse — mirrors Zod's actual return shape */
4724
+ type SchemaParseResult =
4725
+ | { success: true; data: Record<string, unknown> }
4726
+ | { success: false; error: { flatten: () => unknown } };
4727
+
4728
+ /** Registry entry — core metadata + Zod schemas for request validation */
4729
+ interface TxTableRegistry extends coreOps.TransactionTableMetadata {
4730
+ insertSchema: { safeParse: (v: unknown) => SchemaParseResult };
4731
+ updateSchema: { safeParse: (v: unknown) => SchemaParseResult };
4732
+ }
4733
+
4734
+ // Registry used by POST /v1/transaction — maps table name to metadata + Zod schemas
4735
+ const TABLE_TX_METADATA: Record<string, TxTableRegistry> = {
4736
+ ${txMetadataEntries}
4737
+ };
4377
4738
 
4378
4739
  /**
4379
4740
  * Creates a Hono router with all generated routes that can be mounted into your existing app.
@@ -4443,7 +4804,7 @@ ${pullToken ? `
4443
4804
  if (!expectedToken) {
4444
4805
  // Token not configured in environment - reject request
4445
4806
  return c.json({
4446
- 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."}"
4807
+ 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."}"
4447
4808
  }, 500);
4448
4809
  }
4449
4810
 
@@ -4460,6 +4821,9 @@ ${pullToken ? `
4460
4821
  await next();
4461
4822
  });
4462
4823
  ` : ""}
4824
+ // Transaction endpoint — executes multiple operations atomically
4825
+ ${txRouteBlock("router")}
4826
+
4463
4827
  // SDK distribution endpoints
4464
4828
  router.get("/_psdk/sdk/manifest", (c) => {
4465
4829
  return c.json({
@@ -4543,6 +4907,7 @@ export function registerAllRoutes(
4543
4907
  }
4544
4908
  ) {
4545
4909
  ${registrations.replace(/router/g, "app")}
4910
+ ${txRouteBlock("app")}
4546
4911
  }
4547
4912
 
4548
4913
  // Individual route registrations (for selective use)
@@ -4587,8 +4952,6 @@ function emitCoreOperations() {
4587
4952
  * These functions handle the actual database logic and can be used by any framework adapter.
4588
4953
  */
4589
4954
 
4590
- import type { z } from "zod";
4591
-
4592
4955
  export interface DatabaseClient {
4593
4956
  query: (text: string, params?: any[]) => Promise<{ rows: any[] }>;
4594
4957
  }
@@ -4732,6 +5095,96 @@ export async function createRecord(
4732
5095
  }
4733
5096
  }
4734
5097
 
5098
+ /**
5099
+ * UPSERT operation - Insert or update a record based on a conflict target
5100
+ */
5101
+ export async function upsertRecord(
5102
+ ctx: OperationContext,
5103
+ args: {
5104
+ where: Record<string, any>; // conflict target columns (keys only matter)
5105
+ create: Record<string, any>; // full insert data
5106
+ update: Record<string, any>; // update data on conflict
5107
+ }
5108
+ ): Promise<{ data?: any; error?: string; issues?: any; status: number }> {
5109
+ try {
5110
+ const { where, create: createData, update: updateData } = args;
5111
+
5112
+ const conflictCols = Object.keys(where);
5113
+ if (!conflictCols.length) {
5114
+ return { error: "where must specify at least one column", status: 400 };
5115
+ }
5116
+
5117
+ const insertCols = Object.keys(createData);
5118
+ const insertVals = Object.values(createData);
5119
+ if (!insertCols.length) {
5120
+ return { error: "No fields provided in create", status: 400 };
5121
+ }
5122
+
5123
+ // Filter PK columns from update (mirrors updateRecord behaviour)
5124
+ const filteredUpdate = Object.fromEntries(
5125
+ Object.entries(updateData).filter(([k]) => !ctx.pkColumns.includes(k))
5126
+ );
5127
+ if (!Object.keys(filteredUpdate).length) {
5128
+ return { error: "update must include at least one non-PK field", status: 400 };
5129
+ }
5130
+
5131
+ // Serialise JSONB/vector values for create (same pattern as createRecord)
5132
+ const preparedInsertVals = insertVals.map((v, i) =>
5133
+ v !== null && v !== undefined && typeof v === 'object' &&
5134
+ (ctx.jsonbColumns?.includes(insertCols[i]!) || ctx.vectorColumns?.includes(insertCols[i]!))
5135
+ ? JSON.stringify(v) : v
5136
+ );
5137
+
5138
+ // Serialise JSONB/vector values for update
5139
+ const updateCols = Object.keys(filteredUpdate);
5140
+ const updateVals = Object.values(filteredUpdate);
5141
+ const preparedUpdateVals = updateVals.map((v, i) =>
5142
+ v !== null && v !== undefined && typeof v === 'object' &&
5143
+ (ctx.jsonbColumns?.includes(updateCols[i]!) || ctx.vectorColumns?.includes(updateCols[i]!))
5144
+ ? JSON.stringify(v) : v
5145
+ );
5146
+
5147
+ const placeholders = insertCols.map((_, i) => \`$\${i + 1}\`).join(', ');
5148
+ const conflictSQL = conflictCols.map(c => \`"\${c}"\`).join(', ');
5149
+ const setSql = updateCols.map((k, i) => \`"\${k}" = $\${insertCols.length + i + 1}\`).join(', ');
5150
+ const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
5151
+
5152
+ const text = \`INSERT INTO "\${ctx.table}" (\${insertCols.map(c => \`"\${c}"\`).join(', ')})
5153
+ VALUES (\${placeholders})
5154
+ ON CONFLICT (\${conflictSQL}) DO UPDATE SET \${setSql}
5155
+ RETURNING \${returningClause}\`;
5156
+ const params = [...preparedInsertVals, ...preparedUpdateVals];
5157
+
5158
+ log.debug("UPSERT SQL:", text, "params:", params);
5159
+ const { rows } = await ctx.pg.query(text, params);
5160
+ const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
5161
+
5162
+ if (!parsedRows[0]) {
5163
+ return { data: null, status: 500 };
5164
+ }
5165
+
5166
+ return { data: parsedRows[0], status: 200 };
5167
+ } catch (e: any) {
5168
+ const errorMsg = e?.message ?? "";
5169
+ const isJsonError = errorMsg.includes("invalid input syntax for type json");
5170
+ if (isJsonError) {
5171
+ log.error(\`UPSERT \${ctx.table} - Invalid JSON input detected!\`);
5172
+ log.error("Input args that caused error:", JSON.stringify(args, null, 2));
5173
+ log.error("Filtered update data (sent to DB):", JSON.stringify(Object.fromEntries(
5174
+ Object.entries(args.update).filter(([k]) => !ctx.pkColumns.includes(k))
5175
+ ), null, 2));
5176
+ log.error("PostgreSQL error:", errorMsg);
5177
+ } else {
5178
+ log.error(\`UPSERT \${ctx.table} error:\`, e?.stack ?? e);
5179
+ }
5180
+ return {
5181
+ error: e?.message ?? "Internal error",
5182
+ ...(DEBUG ? { stack: e?.stack } : {}),
5183
+ status: 500
5184
+ };
5185
+ }
5186
+ }
5187
+
4735
5188
  /**
4736
5189
  * READ operation - Get a record by primary key
4737
5190
  */
@@ -5408,7 +5861,8 @@ export async function updateRecord(
5408
5861
  */
5409
5862
  export async function deleteRecord(
5410
5863
  ctx: OperationContext,
5411
- pkValues: any[]
5864
+ pkValues: any[],
5865
+ opts?: { hard?: boolean }
5412
5866
  ): Promise<{ data?: any; error?: string; status: number }> {
5413
5867
  try {
5414
5868
  const hasCompositePk = ctx.pkColumns.length > 1;
@@ -5417,11 +5871,12 @@ export async function deleteRecord(
5417
5871
  : \`"\${ctx.pkColumns[0]}" = $1\`;
5418
5872
 
5419
5873
  const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
5420
- const text = ctx.softDeleteColumn
5874
+ const doSoftDelete = ctx.softDeleteColumn && !opts?.hard;
5875
+ const text = doSoftDelete
5421
5876
  ? \`UPDATE "\${ctx.table}" SET "\${ctx.softDeleteColumn}" = NOW() WHERE \${wherePkSql} RETURNING \${returningClause}\`
5422
5877
  : \`DELETE FROM "\${ctx.table}" WHERE \${wherePkSql} RETURNING \${returningClause}\`;
5423
5878
 
5424
- log.debug(\`DELETE \${ctx.softDeleteColumn ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
5879
+ log.debug(\`DELETE \${doSoftDelete ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
5425
5880
  const { rows } = await ctx.pg.query(text, pkValues);
5426
5881
  const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
5427
5882
 
@@ -5432,12 +5887,123 @@ export async function deleteRecord(
5432
5887
  return { data: parsedRows[0], status: 200 };
5433
5888
  } catch (e: any) {
5434
5889
  log.error(\`DELETE \${ctx.table} error:\`, e?.stack ?? e);
5435
- return {
5436
- error: e?.message ?? "Internal error",
5890
+ return {
5891
+ error: e?.message ?? "Internal error",
5437
5892
  ...(DEBUG ? { stack: e?.stack } : {}),
5438
- status: 500
5893
+ status: 500
5439
5894
  };
5440
5895
  }
5896
+ }
5897
+
5898
+ /**
5899
+ * Static metadata for a table used during transaction execution.
5900
+ * Mirrors the fields needed to construct an OperationContext (minus the pg client,
5901
+ * which is injected at transaction time).
5902
+ */
5903
+ export interface TransactionTableMetadata {
5904
+ table: string;
5905
+ pkColumns: string[];
5906
+ softDeleteColumn: string | null;
5907
+ allColumnNames: string[];
5908
+ vectorColumns: string[];
5909
+ jsonbColumns: string[];
5910
+ includeMethodsDepth: number;
5911
+ }
5912
+
5913
+ export type TransactionOperation =
5914
+ | { op: "create"; table: string; data: Record<string, unknown> }
5915
+ | { op: "update"; table: string; pk: string | Record<string, unknown>; data: Record<string, unknown> }
5916
+ | { op: "delete"; table: string; pk: string | Record<string, unknown> }
5917
+ | { op: "upsert"; table: string; data: { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> } };
5918
+
5919
+ /**
5920
+ * Executes a list of operations atomically inside a single PostgreSQL transaction.
5921
+ *
5922
+ * - When \`pg\` has a \`.connect()\` method (Pool), acquires a dedicated connection.
5923
+ * - Otherwise uses \`pg\` directly (already a single connected Client).
5924
+ * - Calls \`onBegin(txClient)\` after BEGIN and before any operations (for SET LOCAL etc.).
5925
+ * - Any status >= 400 or thrown error triggers ROLLBACK.
5926
+ * - \`failedAt: -1\` indicates an unexpected exception (e.g. connectivity failure).
5927
+ */
5928
+ export async function executeTransaction(
5929
+ pg: DatabaseClient & { connect?: () => Promise<DatabaseClient & { release?: () => void }> },
5930
+ ops: TransactionOperation[],
5931
+ metadata: Record<string, TransactionTableMetadata>,
5932
+ onBegin?: (txClient: DatabaseClient) => Promise<void>
5933
+ ): Promise<
5934
+ | { ok: true; results: Array<{ data: unknown }> }
5935
+ | { ok: false; error: string; failedAt: number }
5936
+ > {
5937
+ // Fail-fast: validate all table names before touching the DB
5938
+ for (let i = 0; i < ops.length; i++) {
5939
+ if (!metadata[ops[i]!.table]) {
5940
+ return { ok: false, error: \`Unknown table "\${ops[i]!.table}"\`, failedAt: i };
5941
+ }
5942
+ }
5943
+
5944
+ // Pool gives a dedicated connection; plain Client is used directly
5945
+ const isPool = typeof (pg as any).connect === "function";
5946
+ const txClient: DatabaseClient & { release?: () => void } = isPool
5947
+ ? await (pg as any).connect()
5948
+ : pg;
5949
+
5950
+ try {
5951
+ await txClient.query("BEGIN");
5952
+ if (onBegin) await onBegin(txClient);
5953
+
5954
+ const results: Array<{ data: unknown }> = [];
5955
+
5956
+ for (let i = 0; i < ops.length; i++) {
5957
+ const op = ops[i]!;
5958
+ const meta = metadata[op.table]!;
5959
+ const ctx: OperationContext = {
5960
+ pg: txClient,
5961
+ table: meta.table,
5962
+ pkColumns: meta.pkColumns,
5963
+ softDeleteColumn: meta.softDeleteColumn,
5964
+ allColumnNames: meta.allColumnNames,
5965
+ vectorColumns: meta.vectorColumns,
5966
+ jsonbColumns: meta.jsonbColumns,
5967
+ includeMethodsDepth: meta.includeMethodsDepth,
5968
+ };
5969
+
5970
+ let result: { data?: unknown; error?: string; status: number };
5971
+
5972
+ if (op.op === "create") {
5973
+ result = await createRecord(ctx, op.data);
5974
+ } else if (op.op === "upsert") {
5975
+ result = await upsertRecord(ctx, op.data);
5976
+ } else {
5977
+ const pkValues = Array.isArray(op.pk)
5978
+ ? op.pk
5979
+ : typeof op.pk === "object" && op.pk !== null
5980
+ ? meta.pkColumns.map(c => (op.pk as Record<string, unknown>)[c])
5981
+ : [op.pk];
5982
+ result = op.op === "update"
5983
+ ? await updateRecord(ctx, pkValues as string[], op.data)
5984
+ : await deleteRecord(ctx, pkValues as string[]);
5985
+ }
5986
+
5987
+ if (result.status >= 400) {
5988
+ try { await txClient.query("ROLLBACK"); } catch {}
5989
+ txClient.release?.();
5990
+ return {
5991
+ ok: false,
5992
+ error: result.error ?? \`Op \${i} returned status \${result.status}\`,
5993
+ failedAt: i,
5994
+ };
5995
+ }
5996
+ results.push({ data: result.data ?? null });
5997
+ }
5998
+
5999
+ await txClient.query("COMMIT");
6000
+ txClient.release?.();
6001
+ return { ok: true, results };
6002
+ } catch (e: unknown) {
6003
+ try { await txClient.query("ROLLBACK"); } catch {}
6004
+ txClient.release?.();
6005
+ return { ok: false, error: (e instanceof Error ? e.message : String(e)) ?? "Transaction error", failedAt: -1 };
6006
+ }
5441
6007
  }`;
5442
6008
  }
5443
6009
 
@@ -5869,7 +6435,7 @@ function generateForeignKeySetup(table, model, clientPath) {
5869
6435
  // Clean up parent ${foreignTableName} record
5870
6436
  if (${foreignTableName}Id) {
5871
6437
  try {
5872
- await sdk.${foreignTableName}.delete(${foreignTableName}Id);
6438
+ await sdk.${foreignTableName}.hardDelete(${foreignTableName}Id);
5873
6439
  } catch (e) {
5874
6440
  // Parent might already be deleted due to cascading
5875
6441
  }
@@ -5886,7 +6452,7 @@ function generateForeignKeySetup(table, model, clientPath) {
5886
6452
  // Clean up parent ${foreignTableName} record
5887
6453
  if (${foreignTableName}Key) {
5888
6454
  try {
5889
- await sdk.${foreignTableName}.delete(${foreignTableName}Key);
6455
+ await sdk.${foreignTableName}.hardDelete(${foreignTableName}Key);
5890
6456
  } catch (e) {
5891
6457
  // Parent might already be deleted due to cascading
5892
6458
  }
@@ -6151,19 +6717,34 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
6151
6717
  console.warn('No ID from create test, skipping update test');
6152
6718
  return;
6153
6719
  }
6154
-
6720
+
6155
6721
  const updateData: Update${Type} = ${updateData};
6156
6722
  const updated = await sdk.${table.name}.update(createdId, updateData);
6157
6723
  expect(updated).toBeDefined();
6724
+ });
6725
+
6726
+ it('should upsert (update path) ${table.name}', async () => {
6727
+ if (!createdId) {
6728
+ console.warn('No ID from create test, skipping upsert test');
6729
+ return;
6730
+ }
6731
+ // where: use PK as conflict target; create includes PK so conflict is guaranteed
6732
+ const result = await sdk.${table.name}.upsert({
6733
+ where: { ${table.pk[0]}: createdId },
6734
+ create: { ${table.pk[0]}: createdId, ...${sampleData} },
6735
+ update: ${updateData},
6736
+ });
6737
+ expect(result).toBeDefined();
6738
+ expect(result.${table.pk[0]}).toBe(createdId);
6158
6739
  });` : ""}
6159
-
6740
+
6160
6741
  it('should delete ${table.name}', async () => {
6161
6742
  if (!createdId) {
6162
6743
  console.warn('No ID from create test, skipping delete test');
6163
6744
  return;
6164
6745
  }
6165
6746
 
6166
- const deleted = await sdk.${table.name}.delete(createdId);
6747
+ const deleted = await sdk.${table.name}.hardDelete(createdId);
6167
6748
  expect(deleted).toBeDefined();
6168
6749
 
6169
6750
  // Verify deletion
@@ -6180,10 +6761,16 @@ var __filename2 = fileURLToPath(import.meta.url);
6180
6761
  var __dirname2 = dirname2(__filename2);
6181
6762
  var { version: CLI_VERSION } = JSON.parse(readFileSync(join2(__dirname2, "../package.json"), "utf-8"));
6182
6763
  function resolveSoftDeleteColumn(cfg, tableName) {
6183
- const overrides = cfg.softDeleteColumnOverrides;
6764
+ const del = cfg.delete;
6765
+ if (!del)
6766
+ return null;
6767
+ const overrides = del.softDeleteColumnOverrides;
6184
6768
  if (overrides && tableName in overrides)
6185
6769
  return overrides[tableName] ?? null;
6186
- return cfg.softDeleteColumn ?? null;
6770
+ return del.softDeleteColumn ?? null;
6771
+ }
6772
+ function resolveExposeHardDelete(cfg) {
6773
+ return cfg.delete?.exposeHardDelete ?? true;
6187
6774
  }
6188
6775
  async function generate(configPath, options) {
6189
6776
  if (!existsSync2(configPath)) {
@@ -6273,6 +6860,7 @@ async function generate(configPath, options) {
6273
6860
  path: join2(serverDir, "core", "operations.ts"),
6274
6861
  content: emitCoreOperations()
6275
6862
  });
6863
+ const exposeHardDelete = resolveExposeHardDelete(cfg);
6276
6864
  if (process.env.SDK_DEBUG) {
6277
6865
  console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
6278
6866
  }
@@ -6290,6 +6878,7 @@ async function generate(configPath, options) {
6290
6878
  if (serverFramework === "hono") {
6291
6879
  routeContent = emitHonoRoutes(table, graph, {
6292
6880
  softDeleteColumn: softDeleteCols[table.name] ?? null,
6881
+ exposeHardDelete,
6293
6882
  includeMethodsDepth: cfg.includeMethodsDepth || 2,
6294
6883
  authStrategy: getAuthStrategy(normalizedAuth),
6295
6884
  useJsExtensions: cfg.useJsExtensions,
@@ -6305,6 +6894,8 @@ async function generate(configPath, options) {
6305
6894
  files.push({
6306
6895
  path: join2(clientDir, `${table.name}.ts`),
6307
6896
  content: emitClient(table, graph, {
6897
+ softDeleteColumn: softDeleteCols[table.name] ?? null,
6898
+ exposeHardDelete,
6308
6899
  useJsExtensions: cfg.useJsExtensionsClient,
6309
6900
  includeMethodsDepth: cfg.includeMethodsDepth ?? 2,
6310
6901
  skipJunctionTables: cfg.skipJunctionTables ?? true
@@ -6318,7 +6909,11 @@ async function generate(configPath, options) {
6318
6909
  if (serverFramework === "hono") {
6319
6910
  files.push({
6320
6911
  path: join2(serverDir, "router.ts"),
6321
- content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken)
6912
+ content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken, {
6913
+ apiPathPrefix: cfg.apiPathPrefix || "/v1",
6914
+ softDeleteCols: Object.fromEntries(Object.values(model.tables).map((t) => [t.name, resolveSoftDeleteColumn(cfg, t.name)])),
6915
+ includeMethodsDepth: cfg.includeMethodsDepth ?? 2
6916
+ })
6322
6917
  });
6323
6918
  }
6324
6919
  const { generateUnifiedContract: generateUnifiedContract2, generateUnifiedContractMarkdown: generateUnifiedContractMarkdown2 } = await Promise.resolve().then(() => (init_emit_sdk_contract(), exports_emit_sdk_contract));
@@ -6446,5 +7041,6 @@ async function generate(configPath, options) {
6446
7041
  }
6447
7042
  export {
6448
7043
  resolveSoftDeleteColumn,
7044
+ resolveExposeHardDelete,
6449
7045
  generate
6450
7046
  };