postgresdk 0.18.30 → 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/LICENSE +1 -1
- package/README.md +59 -8
- package/dist/cli-config-utils.d.ts +2 -1
- package/dist/cli.js +749 -175
- package/dist/emit-client.d.ts +2 -0
- package/dist/emit-router-hono.d.ts +5 -1
- package/dist/emit-routes-hono.d.ts +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +714 -120
- package/dist/types.d.ts +10 -3
- package/dist/utils.d.ts +4 -0
- package/package.json +2 -2
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: "
|
|
972
|
-
signature: `
|
|
973
|
-
description: `
|
|
974
|
-
example: `const
|
|
975
|
-
|
|
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
|
|
1517
|
-
if (
|
|
1556
|
+
const deleteBlock = extractComplexBlock(configContent, "delete");
|
|
1557
|
+
if (deleteBlock) {
|
|
1518
1558
|
fields.push({
|
|
1519
|
-
key: "
|
|
1520
|
-
value:
|
|
1521
|
-
description: "
|
|
1522
|
-
isCommented:
|
|
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(
|
|
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
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
* @
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
*
|
|
2141
|
-
*
|
|
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
|
-
//
|
|
2164
|
+
// delete: {
|
|
2165
|
+
// softDeleteColumn: "deleted_at",
|
|
2166
|
+
// exposeHardDelete: true,
|
|
2167
|
+
// },
|
|
2147
2168
|
|
|
2148
2169
|
/**
|
|
2149
2170
|
* How to type numeric columns in TypeScript
|
|
@@ -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) =>
|
|
3328
|
-
const hasJsonbColumns = table.columns.some((c) =>
|
|
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) =>
|
|
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
|
}
|
|
@@ -3557,6 +3615,54 @@ function emitClient(table, graph, opts, model) {
|
|
|
3557
3615
|
`;
|
|
3558
3616
|
}
|
|
3559
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
|
+
`);
|
|
3560
3666
|
return `/**
|
|
3561
3667
|
* AUTO-GENERATED FILE - DO NOT EDIT
|
|
3562
3668
|
*
|
|
@@ -3566,6 +3672,7 @@ function emitClient(table, graph, opts, model) {
|
|
|
3566
3672
|
* To make changes, modify your schema or configuration and regenerate.
|
|
3567
3673
|
*/
|
|
3568
3674
|
import { BaseClient } from "./base-client${ext}";
|
|
3675
|
+
import type { TxOp } from "./base-client${ext}";
|
|
3569
3676
|
import type { Where } from "./where-types${ext}";
|
|
3570
3677
|
import type { PaginatedResponse } from "./types/shared${ext}";
|
|
3571
3678
|
${typeImports}
|
|
@@ -3647,6 +3754,78 @@ ${hasJsonbColumns ? ` /**
|
|
|
3647
3754
|
return this.post<Select${Type}>(url, data);
|
|
3648
3755
|
}`}
|
|
3649
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
|
+
|
|
3650
3829
|
${hasJsonbColumns ? ` /**
|
|
3651
3830
|
* Get a ${table.name} record by primary key with field selection
|
|
3652
3831
|
* @param pk - The primary key value${hasCompositePk ? "s" : ""}
|
|
@@ -3987,72 +4166,27 @@ ${hasJsonbColumns ? ` /**
|
|
|
3987
4166
|
return this.patch<Select${Type} | null>(url, patch);
|
|
3988
4167
|
}`}
|
|
3989
4168
|
|
|
3990
|
-
${
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
/**
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
*/
|
|
4012
|
-
async delete<TJsonb extends Partial<Select${Type}> = {}>(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type}<TJsonb> | null>;
|
|
4013
|
-
async delete<TJsonb extends Partial<Select${Type}> = {}>(
|
|
4014
|
-
pk: ${pkType},
|
|
4015
|
-
options?: { select?: string[]; exclude?: string[] }
|
|
4016
|
-
): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>> | null> {
|
|
4017
|
-
const path = ${pkPathExpr};
|
|
4018
|
-
const queryParams = new URLSearchParams();
|
|
4019
|
-
if (options?.select) queryParams.set('select', options.select.join(','));
|
|
4020
|
-
if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
|
|
4021
|
-
const query = queryParams.toString();
|
|
4022
|
-
const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
|
|
4023
|
-
return this.del<Select${Type}<TJsonb> | null>(url);
|
|
4024
|
-
}` : ` /**
|
|
4025
|
-
* Delete a ${table.name} record by primary key with field selection
|
|
4026
|
-
* @param pk - The primary key value${hasCompositePk ? "s" : ""}
|
|
4027
|
-
* @param options - Select specific fields to return
|
|
4028
|
-
* @returns The deleted record with only selected fields if found, null otherwise
|
|
4029
|
-
*/
|
|
4030
|
-
async delete(pk: ${pkType}, options: { select: string[] }): Promise<Partial<Select${Type}> | null>;
|
|
4031
|
-
/**
|
|
4032
|
-
* Delete a ${table.name} record by primary key with field exclusion
|
|
4033
|
-
* @param pk - The primary key value${hasCompositePk ? "s" : ""}
|
|
4034
|
-
* @param options - Exclude specific fields from return
|
|
4035
|
-
* @returns The deleted record without excluded fields if found, null otherwise
|
|
4036
|
-
*/
|
|
4037
|
-
async delete(pk: ${pkType}, options: { exclude: string[] }): Promise<Partial<Select${Type}> | null>;
|
|
4038
|
-
/**
|
|
4039
|
-
* Delete a ${table.name} record by primary key
|
|
4040
|
-
* @param pk - The primary key value${hasCompositePk ? "s" : ""}
|
|
4041
|
-
* @returns The deleted record with all fields if found, null otherwise
|
|
4042
|
-
*/
|
|
4043
|
-
async delete(pk: ${pkType}, options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>): Promise<Select${Type} | null>;
|
|
4044
|
-
async delete(
|
|
4045
|
-
pk: ${pkType},
|
|
4046
|
-
options?: { select?: string[]; exclude?: string[] }
|
|
4047
|
-
): Promise<Select${Type} | Partial<Select${Type}> | null> {
|
|
4048
|
-
const path = ${pkPathExpr};
|
|
4049
|
-
const queryParams = new URLSearchParams();
|
|
4050
|
-
if (options?.select) queryParams.set('select', options.select.join(','));
|
|
4051
|
-
if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
|
|
4052
|
-
const query = queryParams.toString();
|
|
4053
|
-
const url = query ? \`\${this.resource}/\${path}?\${query}\` : \`\${this.resource}/\${path}\`;
|
|
4054
|
-
return this.del<Select${Type} | null>(url);
|
|
4055
|
-
}`}
|
|
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
|
+
}
|
|
4056
4190
|
${includeMethodsCode}}
|
|
4057
4191
|
`;
|
|
4058
4192
|
}
|
|
@@ -4067,7 +4201,9 @@ function emitClientIndex(tables, useJsExtensions, graph, includeOpts) {
|
|
|
4067
4201
|
* To make changes, modify your schema or configuration and regenerate.
|
|
4068
4202
|
*/
|
|
4069
4203
|
`;
|
|
4070
|
-
out += `import
|
|
4204
|
+
out += `import { BaseClient } from "./base-client${ext}";
|
|
4205
|
+
`;
|
|
4206
|
+
out += `import type { AuthConfig, TxOp } from "./base-client${ext}";
|
|
4071
4207
|
`;
|
|
4072
4208
|
for (const t of tables) {
|
|
4073
4209
|
out += `import { ${pascal(t.name)}Client } from "./${t.name}${ext}";
|
|
@@ -4083,7 +4219,7 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
|
|
|
4083
4219
|
`;
|
|
4084
4220
|
out += ` */
|
|
4085
4221
|
`;
|
|
4086
|
-
out += `export class SDK {
|
|
4222
|
+
out += `export class SDK extends BaseClient {
|
|
4087
4223
|
`;
|
|
4088
4224
|
for (const t of tables) {
|
|
4089
4225
|
out += ` public ${t.name}: ${pascal(t.name)}Client;
|
|
@@ -4093,17 +4229,101 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
|
|
|
4093
4229
|
constructor(cfg: { baseUrl: string; fetch?: typeof fetch; auth?: AuthConfig }) {
|
|
4094
4230
|
`;
|
|
4095
4231
|
out += ` const f = cfg.fetch ?? fetch;
|
|
4232
|
+
`;
|
|
4233
|
+
out += ` super(cfg.baseUrl, f, cfg.auth);
|
|
4096
4234
|
`;
|
|
4097
4235
|
for (const t of tables) {
|
|
4098
4236
|
out += ` this.${t.name} = new ${pascal(t.name)}Client(cfg.baseUrl, f, cfg.auth);
|
|
4099
4237
|
`;
|
|
4100
4238
|
}
|
|
4101
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 += ` }
|
|
4102
4320
|
`;
|
|
4103
4321
|
out += `}
|
|
4104
4322
|
|
|
4105
4323
|
`;
|
|
4106
4324
|
out += `export { BaseClient } from "./base-client${ext}";
|
|
4325
|
+
`;
|
|
4326
|
+
out += `export type { TxOp } from "./base-client${ext}";
|
|
4107
4327
|
`;
|
|
4108
4328
|
out += `
|
|
4109
4329
|
// Include specification types for custom queries
|
|
@@ -4304,15 +4524,29 @@ export abstract class BaseClient {
|
|
|
4304
4524
|
method: "DELETE",
|
|
4305
4525
|
headers: await this.headers(),
|
|
4306
4526
|
});
|
|
4307
|
-
|
|
4527
|
+
|
|
4308
4528
|
if (res.status === 404) {
|
|
4309
4529
|
return null as T;
|
|
4310
4530
|
}
|
|
4311
|
-
|
|
4531
|
+
|
|
4312
4532
|
await this.okOrThrow(res, "DELETE", path);
|
|
4313
4533
|
return (await res.json()) as T;
|
|
4314
4534
|
}
|
|
4315
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
|
+
};
|
|
4316
4550
|
`;
|
|
4317
4551
|
}
|
|
4318
4552
|
|
|
@@ -4856,14 +5090,14 @@ function tsTypeFor(pgType, opts, enums) {
|
|
|
4856
5090
|
return "number[]";
|
|
4857
5091
|
return "string";
|
|
4858
5092
|
}
|
|
4859
|
-
function
|
|
5093
|
+
function isJsonbType2(pgType) {
|
|
4860
5094
|
const t = pgType.toLowerCase();
|
|
4861
5095
|
return t === "json" || t === "jsonb";
|
|
4862
5096
|
}
|
|
4863
5097
|
var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
|
|
4864
5098
|
function emitTypes(table, opts, enums) {
|
|
4865
5099
|
const Type = pascal2(table.name);
|
|
4866
|
-
const hasJsonbColumns = table.columns.some((col) =>
|
|
5100
|
+
const hasJsonbColumns = table.columns.some((col) => isJsonbType2(col.pgType));
|
|
4867
5101
|
const insertFields = table.columns.map((col) => {
|
|
4868
5102
|
const base = tsTypeFor(col.pgType, opts, enums);
|
|
4869
5103
|
const optional = col.hasDefault || col.nullable ? "?" : "";
|
|
@@ -5314,9 +5548,12 @@ export async function authMiddleware(c: Context, next: Next) {
|
|
|
5314
5548
|
|
|
5315
5549
|
// src/emit-router-hono.ts
|
|
5316
5550
|
init_utils();
|
|
5317
|
-
function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
|
|
5551
|
+
function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken, opts) {
|
|
5318
5552
|
const tableNames = tables.map((t) => t.name).sort();
|
|
5319
5553
|
const ext = useJsExtensions ? ".js" : "";
|
|
5554
|
+
const apiPathPrefix = opts?.apiPathPrefix ?? "/v1";
|
|
5555
|
+
const softDeleteCols = opts?.softDeleteCols ?? {};
|
|
5556
|
+
const includeMethodsDepth = opts?.includeMethodsDepth ?? 2;
|
|
5320
5557
|
let resolvedPullToken;
|
|
5321
5558
|
let pullTokenEnvVar;
|
|
5322
5559
|
if (pullToken) {
|
|
@@ -5342,6 +5579,88 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
|
|
|
5342
5579
|
const Type = pascal(name);
|
|
5343
5580
|
return `export { register${Type}Routes } from "./routes/${name}${ext}";`;
|
|
5344
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(`,
|
|
5345
5664
|
`);
|
|
5346
5665
|
return `/**
|
|
5347
5666
|
* AUTO-GENERATED FILE - DO NOT EDIT
|
|
@@ -5355,8 +5674,27 @@ import { Hono } from "hono";
|
|
|
5355
5674
|
import type { Context } from "hono";
|
|
5356
5675
|
import { SDK_MANIFEST } from "./sdk-bundle${ext}";
|
|
5357
5676
|
import { getContract } from "./contract${ext}";
|
|
5677
|
+
import * as coreOps from "./core/operations${ext}";
|
|
5678
|
+
${txSchemaImports}
|
|
5358
5679
|
${imports}
|
|
5359
|
-
${hasAuth ? `
|
|
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
|
+
};
|
|
5360
5698
|
|
|
5361
5699
|
/**
|
|
5362
5700
|
* Creates a Hono router with all generated routes that can be mounted into your existing app.
|
|
@@ -5426,7 +5764,7 @@ ${pullToken ? `
|
|
|
5426
5764
|
if (!expectedToken) {
|
|
5427
5765
|
// Token not configured in environment - reject request
|
|
5428
5766
|
return c.json({
|
|
5429
|
-
error: "SDK endpoints are protected but pullToken environment variable not set. ${pullTokenEnvVar ? `Set ${pullTokenEnvVar} in your environment 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."}"
|
|
5430
5768
|
}, 500);
|
|
5431
5769
|
}
|
|
5432
5770
|
|
|
@@ -5443,6 +5781,9 @@ ${pullToken ? `
|
|
|
5443
5781
|
await next();
|
|
5444
5782
|
});
|
|
5445
5783
|
` : ""}
|
|
5784
|
+
// Transaction endpoint — executes multiple operations atomically
|
|
5785
|
+
${txRouteBlock("router")}
|
|
5786
|
+
|
|
5446
5787
|
// SDK distribution endpoints
|
|
5447
5788
|
router.get("/_psdk/sdk/manifest", (c) => {
|
|
5448
5789
|
return c.json({
|
|
@@ -5526,6 +5867,7 @@ export function registerAllRoutes(
|
|
|
5526
5867
|
}
|
|
5527
5868
|
) {
|
|
5528
5869
|
${registrations.replace(/router/g, "app")}
|
|
5870
|
+
${txRouteBlock("app")}
|
|
5529
5871
|
}
|
|
5530
5872
|
|
|
5531
5873
|
// Individual route registrations (for selective use)
|
|
@@ -5713,6 +6055,96 @@ export async function createRecord(
|
|
|
5713
6055
|
}
|
|
5714
6056
|
}
|
|
5715
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
|
+
|
|
5716
6148
|
/**
|
|
5717
6149
|
* READ operation - Get a record by primary key
|
|
5718
6150
|
*/
|
|
@@ -6389,7 +6821,8 @@ export async function updateRecord(
|
|
|
6389
6821
|
*/
|
|
6390
6822
|
export async function deleteRecord(
|
|
6391
6823
|
ctx: OperationContext,
|
|
6392
|
-
pkValues: any[]
|
|
6824
|
+
pkValues: any[],
|
|
6825
|
+
opts?: { hard?: boolean }
|
|
6393
6826
|
): Promise<{ data?: any; error?: string; status: number }> {
|
|
6394
6827
|
try {
|
|
6395
6828
|
const hasCompositePk = ctx.pkColumns.length > 1;
|
|
@@ -6398,11 +6831,12 @@ export async function deleteRecord(
|
|
|
6398
6831
|
: \`"\${ctx.pkColumns[0]}" = $1\`;
|
|
6399
6832
|
|
|
6400
6833
|
const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
|
|
6401
|
-
const
|
|
6834
|
+
const doSoftDelete = ctx.softDeleteColumn && !opts?.hard;
|
|
6835
|
+
const text = doSoftDelete
|
|
6402
6836
|
? \`UPDATE "\${ctx.table}" SET "\${ctx.softDeleteColumn}" = NOW() WHERE \${wherePkSql} RETURNING \${returningClause}\`
|
|
6403
6837
|
: \`DELETE FROM "\${ctx.table}" WHERE \${wherePkSql} RETURNING \${returningClause}\`;
|
|
6404
6838
|
|
|
6405
|
-
log.debug(\`DELETE \${
|
|
6839
|
+
log.debug(\`DELETE \${doSoftDelete ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
|
|
6406
6840
|
const { rows } = await ctx.pg.query(text, pkValues);
|
|
6407
6841
|
const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
|
|
6408
6842
|
|
|
@@ -6413,12 +6847,123 @@ export async function deleteRecord(
|
|
|
6413
6847
|
return { data: parsedRows[0], status: 200 };
|
|
6414
6848
|
} catch (e: any) {
|
|
6415
6849
|
log.error(\`DELETE \${ctx.table} error:\`, e?.stack ?? e);
|
|
6416
|
-
return {
|
|
6417
|
-
error: e?.message ?? "Internal error",
|
|
6850
|
+
return {
|
|
6851
|
+
error: e?.message ?? "Internal error",
|
|
6418
6852
|
...(DEBUG ? { stack: e?.stack } : {}),
|
|
6419
|
-
status: 500
|
|
6853
|
+
status: 500
|
|
6420
6854
|
};
|
|
6421
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
|
+
}
|
|
6422
6967
|
}`;
|
|
6423
6968
|
}
|
|
6424
6969
|
|
|
@@ -6850,7 +7395,7 @@ function generateForeignKeySetup(table, model, clientPath) {
|
|
|
6850
7395
|
// Clean up parent ${foreignTableName} record
|
|
6851
7396
|
if (${foreignTableName}Id) {
|
|
6852
7397
|
try {
|
|
6853
|
-
await sdk.${foreignTableName}.
|
|
7398
|
+
await sdk.${foreignTableName}.hardDelete(${foreignTableName}Id);
|
|
6854
7399
|
} catch (e) {
|
|
6855
7400
|
// Parent might already be deleted due to cascading
|
|
6856
7401
|
}
|
|
@@ -6867,7 +7412,7 @@ function generateForeignKeySetup(table, model, clientPath) {
|
|
|
6867
7412
|
// Clean up parent ${foreignTableName} record
|
|
6868
7413
|
if (${foreignTableName}Key) {
|
|
6869
7414
|
try {
|
|
6870
|
-
await sdk.${foreignTableName}.
|
|
7415
|
+
await sdk.${foreignTableName}.hardDelete(${foreignTableName}Key);
|
|
6871
7416
|
} catch (e) {
|
|
6872
7417
|
// Parent might already be deleted due to cascading
|
|
6873
7418
|
}
|
|
@@ -7132,19 +7677,34 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
|
|
|
7132
7677
|
console.warn('No ID from create test, skipping update test');
|
|
7133
7678
|
return;
|
|
7134
7679
|
}
|
|
7135
|
-
|
|
7680
|
+
|
|
7136
7681
|
const updateData: Update${Type} = ${updateData};
|
|
7137
7682
|
const updated = await sdk.${table.name}.update(createdId, updateData);
|
|
7138
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);
|
|
7139
7699
|
});` : ""}
|
|
7140
|
-
|
|
7700
|
+
|
|
7141
7701
|
it('should delete ${table.name}', async () => {
|
|
7142
7702
|
if (!createdId) {
|
|
7143
7703
|
console.warn('No ID from create test, skipping delete test');
|
|
7144
7704
|
return;
|
|
7145
7705
|
}
|
|
7146
7706
|
|
|
7147
|
-
const deleted = await sdk.${table.name}.
|
|
7707
|
+
const deleted = await sdk.${table.name}.hardDelete(createdId);
|
|
7148
7708
|
expect(deleted).toBeDefined();
|
|
7149
7709
|
|
|
7150
7710
|
// Verify deletion
|
|
@@ -7161,10 +7721,16 @@ var __filename2 = fileURLToPath(import.meta.url);
|
|
|
7161
7721
|
var __dirname2 = dirname2(__filename2);
|
|
7162
7722
|
var { version: CLI_VERSION } = JSON.parse(readFileSync(join2(__dirname2, "../package.json"), "utf-8"));
|
|
7163
7723
|
function resolveSoftDeleteColumn(cfg, tableName) {
|
|
7164
|
-
const
|
|
7724
|
+
const del = cfg.delete;
|
|
7725
|
+
if (!del)
|
|
7726
|
+
return null;
|
|
7727
|
+
const overrides = del.softDeleteColumnOverrides;
|
|
7165
7728
|
if (overrides && tableName in overrides)
|
|
7166
7729
|
return overrides[tableName] ?? null;
|
|
7167
|
-
return
|
|
7730
|
+
return del.softDeleteColumn ?? null;
|
|
7731
|
+
}
|
|
7732
|
+
function resolveExposeHardDelete(cfg) {
|
|
7733
|
+
return cfg.delete?.exposeHardDelete ?? true;
|
|
7168
7734
|
}
|
|
7169
7735
|
async function generate(configPath, options) {
|
|
7170
7736
|
if (!existsSync2(configPath)) {
|
|
@@ -7254,6 +7820,7 @@ async function generate(configPath, options) {
|
|
|
7254
7820
|
path: join2(serverDir, "core", "operations.ts"),
|
|
7255
7821
|
content: emitCoreOperations()
|
|
7256
7822
|
});
|
|
7823
|
+
const exposeHardDelete = resolveExposeHardDelete(cfg);
|
|
7257
7824
|
if (process.env.SDK_DEBUG) {
|
|
7258
7825
|
console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
|
|
7259
7826
|
}
|
|
@@ -7271,6 +7838,7 @@ async function generate(configPath, options) {
|
|
|
7271
7838
|
if (serverFramework === "hono") {
|
|
7272
7839
|
routeContent = emitHonoRoutes(table, graph, {
|
|
7273
7840
|
softDeleteColumn: softDeleteCols[table.name] ?? null,
|
|
7841
|
+
exposeHardDelete,
|
|
7274
7842
|
includeMethodsDepth: cfg.includeMethodsDepth || 2,
|
|
7275
7843
|
authStrategy: getAuthStrategy(normalizedAuth),
|
|
7276
7844
|
useJsExtensions: cfg.useJsExtensions,
|
|
@@ -7286,6 +7854,8 @@ async function generate(configPath, options) {
|
|
|
7286
7854
|
files.push({
|
|
7287
7855
|
path: join2(clientDir, `${table.name}.ts`),
|
|
7288
7856
|
content: emitClient(table, graph, {
|
|
7857
|
+
softDeleteColumn: softDeleteCols[table.name] ?? null,
|
|
7858
|
+
exposeHardDelete,
|
|
7289
7859
|
useJsExtensions: cfg.useJsExtensionsClient,
|
|
7290
7860
|
includeMethodsDepth: cfg.includeMethodsDepth ?? 2,
|
|
7291
7861
|
skipJunctionTables: cfg.skipJunctionTables ?? true
|
|
@@ -7299,7 +7869,11 @@ async function generate(configPath, options) {
|
|
|
7299
7869
|
if (serverFramework === "hono") {
|
|
7300
7870
|
files.push({
|
|
7301
7871
|
path: join2(serverDir, "router.ts"),
|
|
7302
|
-
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
|
+
})
|
|
7303
7877
|
});
|
|
7304
7878
|
}
|
|
7305
7879
|
const { generateUnifiedContract: generateUnifiedContract2, generateUnifiedContractMarkdown: generateUnifiedContractMarkdown2 } = await Promise.resolve().then(() => (init_emit_sdk_contract(), exports_emit_sdk_contract));
|