postgresdk 0.18.30 → 0.19.1
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 +768 -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 +733 -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,68 @@ 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
|
+
`);
|
|
3666
|
+
const txDeleteParts = [];
|
|
3667
|
+
if (hasSoftDelete)
|
|
3668
|
+
txDeleteParts.push(` /** Build a lazy soft-DELETE descriptor for use with sdk.$transaction([...]) */
|
|
3669
|
+
` + ` $softDelete(pk: ${pkType}): TxOp<Select${Type} | null> {
|
|
3670
|
+
` + ` return { _table: "${table.name}", _op: "softDelete", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"} };
|
|
3671
|
+
` + ` }`);
|
|
3672
|
+
if (exposeHard)
|
|
3673
|
+
txDeleteParts.push(` /** Build a lazy hard-DELETE descriptor for use with sdk.$transaction([...]) */
|
|
3674
|
+
` + ` $hardDelete(pk: ${pkType}): TxOp<Select${Type} | null> {
|
|
3675
|
+
` + ` return { _table: "${table.name}", _op: "hardDelete", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"} };
|
|
3676
|
+
` + ` }`);
|
|
3677
|
+
const txDeleteMethodsCode = txDeleteParts.join(`
|
|
3678
|
+
|
|
3679
|
+
`);
|
|
3560
3680
|
return `/**
|
|
3561
3681
|
* AUTO-GENERATED FILE - DO NOT EDIT
|
|
3562
3682
|
*
|
|
@@ -3566,6 +3686,7 @@ function emitClient(table, graph, opts, model) {
|
|
|
3566
3686
|
* To make changes, modify your schema or configuration and regenerate.
|
|
3567
3687
|
*/
|
|
3568
3688
|
import { BaseClient } from "./base-client${ext}";
|
|
3689
|
+
import type { TxOp } from "./base-client${ext}";
|
|
3569
3690
|
import type { Where } from "./where-types${ext}";
|
|
3570
3691
|
import type { PaginatedResponse } from "./types/shared${ext}";
|
|
3571
3692
|
${typeImports}
|
|
@@ -3647,6 +3768,78 @@ ${hasJsonbColumns ? ` /**
|
|
|
3647
3768
|
return this.post<Select${Type}>(url, data);
|
|
3648
3769
|
}`}
|
|
3649
3770
|
|
|
3771
|
+
${hasJsonbColumns ? ` /**
|
|
3772
|
+
* Upsert a ${table.name} record with field selection
|
|
3773
|
+
*/
|
|
3774
|
+
async upsert<TJsonb extends Partial<Select${Type}> = {}>(
|
|
3775
|
+
args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
|
|
3776
|
+
options: { select: string[] }
|
|
3777
|
+
): Promise<Partial<Select${Type}<TJsonb>>>;
|
|
3778
|
+
/**
|
|
3779
|
+
* Upsert a ${table.name} record with field exclusion
|
|
3780
|
+
*/
|
|
3781
|
+
async upsert<TJsonb extends Partial<Select${Type}> = {}>(
|
|
3782
|
+
args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
|
|
3783
|
+
options: { exclude: string[] }
|
|
3784
|
+
): Promise<Partial<Select${Type}<TJsonb>>>;
|
|
3785
|
+
/**
|
|
3786
|
+
* Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
|
|
3787
|
+
* @param args.where - Conflict target column(s) (must be a unique constraint)
|
|
3788
|
+
* @param args.create - Full insert data used when no conflict occurs
|
|
3789
|
+
* @param args.update - Partial data applied when a conflict occurs
|
|
3790
|
+
* @returns The resulting record
|
|
3791
|
+
*/
|
|
3792
|
+
async upsert<TJsonb extends Partial<Select${Type}> = {}>(
|
|
3793
|
+
args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
|
|
3794
|
+
options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
|
|
3795
|
+
): Promise<Select${Type}<TJsonb>>;
|
|
3796
|
+
async upsert<TJsonb extends Partial<Select${Type}> = {}>(
|
|
3797
|
+
args: { where: Update${Type}<TJsonb>; create: NoInfer<Insert${Type}<TJsonb>>; update: NoInfer<Update${Type}<TJsonb>> },
|
|
3798
|
+
options?: { select?: string[]; exclude?: string[] }
|
|
3799
|
+
): Promise<Select${Type}<TJsonb> | Partial<Select${Type}<TJsonb>>> {
|
|
3800
|
+
const queryParams = new URLSearchParams();
|
|
3801
|
+
if (options?.select) queryParams.set('select', options.select.join(','));
|
|
3802
|
+
if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
|
|
3803
|
+
const query = queryParams.toString();
|
|
3804
|
+
const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
|
|
3805
|
+
return this.post<Select${Type}<TJsonb>>(url, args);
|
|
3806
|
+
}` : ` /**
|
|
3807
|
+
* Upsert a ${table.name} record with field selection
|
|
3808
|
+
*/
|
|
3809
|
+
async upsert(
|
|
3810
|
+
args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
|
|
3811
|
+
options: { select: string[] }
|
|
3812
|
+
): Promise<Partial<Select${Type}>>;
|
|
3813
|
+
/**
|
|
3814
|
+
* Upsert a ${table.name} record with field exclusion
|
|
3815
|
+
*/
|
|
3816
|
+
async upsert(
|
|
3817
|
+
args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
|
|
3818
|
+
options: { exclude: string[] }
|
|
3819
|
+
): Promise<Partial<Select${Type}>>;
|
|
3820
|
+
/**
|
|
3821
|
+
* Upsert a ${table.name} record — insert if no conflict on 'where' columns, update otherwise.
|
|
3822
|
+
* @param args.where - Conflict target column(s) (must be a unique constraint)
|
|
3823
|
+
* @param args.create - Full insert data used when no conflict occurs
|
|
3824
|
+
* @param args.update - Partial data applied when a conflict occurs
|
|
3825
|
+
* @returns The resulting record
|
|
3826
|
+
*/
|
|
3827
|
+
async upsert(
|
|
3828
|
+
args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
|
|
3829
|
+
options?: Omit<{ select?: string[]; exclude?: string[] }, 'select' | 'exclude'>
|
|
3830
|
+
): Promise<Select${Type}>;
|
|
3831
|
+
async upsert(
|
|
3832
|
+
args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} },
|
|
3833
|
+
options?: { select?: string[]; exclude?: string[] }
|
|
3834
|
+
): Promise<Select${Type} | Partial<Select${Type}>> {
|
|
3835
|
+
const queryParams = new URLSearchParams();
|
|
3836
|
+
if (options?.select) queryParams.set('select', options.select.join(','));
|
|
3837
|
+
if (options?.exclude) queryParams.set('exclude', options.exclude.join(','));
|
|
3838
|
+
const query = queryParams.toString();
|
|
3839
|
+
const url = query ? \`\${this.resource}/upsert?\${query}\` : \`\${this.resource}/upsert\`;
|
|
3840
|
+
return this.post<Select${Type}>(url, args);
|
|
3841
|
+
}`}
|
|
3842
|
+
|
|
3650
3843
|
${hasJsonbColumns ? ` /**
|
|
3651
3844
|
* Get a ${table.name} record by primary key with field selection
|
|
3652
3845
|
* @param pk - The primary key value${hasCompositePk ? "s" : ""}
|
|
@@ -3987,72 +4180,24 @@ ${hasJsonbColumns ? ` /**
|
|
|
3987
4180
|
return this.patch<Select${Type} | null>(url, patch);
|
|
3988
4181
|
}`}
|
|
3989
4182
|
|
|
3990
|
-
${
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
/**
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
/**
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
* @example
|
|
4009
|
-
* // With JSONB type override:
|
|
4010
|
-
* const user = await client.delete<{ metadata: Metadata }>('user-id');
|
|
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
|
-
}`}
|
|
4183
|
+
${deleteMethodsCode}
|
|
4184
|
+
|
|
4185
|
+
/** Build a lazy CREATE descriptor for use with sdk.$transaction([...]) */
|
|
4186
|
+
$create(data: Insert${Type}): TxOp<Select${Type}> {
|
|
4187
|
+
return { _table: "${table.name}", _op: "create", _data: data as Record<string, unknown> };
|
|
4188
|
+
}
|
|
4189
|
+
|
|
4190
|
+
/** Build a lazy UPDATE descriptor for use with sdk.$transaction([...]) */
|
|
4191
|
+
$update(pk: ${pkType}, data: Update${Type}): TxOp<Select${Type} | null> {
|
|
4192
|
+
return { _table: "${table.name}", _op: "update", _pk: ${hasCompositePk ? "pk as Record<string, unknown>" : "pk"}, _data: data as Record<string, unknown> };
|
|
4193
|
+
}
|
|
4194
|
+
|
|
4195
|
+
${txDeleteMethodsCode}
|
|
4196
|
+
|
|
4197
|
+
/** Build a lazy UPSERT descriptor for use with sdk.$transaction([...]) */
|
|
4198
|
+
$upsert(args: { where: Update${Type}; create: Insert${Type}; update: Update${Type} }): TxOp<Select${Type}> {
|
|
4199
|
+
return { _table: "${table.name}", _op: "upsert", _data: args as Record<string, unknown> };
|
|
4200
|
+
}
|
|
4056
4201
|
${includeMethodsCode}}
|
|
4057
4202
|
`;
|
|
4058
4203
|
}
|
|
@@ -4067,7 +4212,9 @@ function emitClientIndex(tables, useJsExtensions, graph, includeOpts) {
|
|
|
4067
4212
|
* To make changes, modify your schema or configuration and regenerate.
|
|
4068
4213
|
*/
|
|
4069
4214
|
`;
|
|
4070
|
-
out += `import
|
|
4215
|
+
out += `import { BaseClient } from "./base-client${ext}";
|
|
4216
|
+
`;
|
|
4217
|
+
out += `import type { AuthConfig, TxOp } from "./base-client${ext}";
|
|
4071
4218
|
`;
|
|
4072
4219
|
for (const t of tables) {
|
|
4073
4220
|
out += `import { ${pascal(t.name)}Client } from "./${t.name}${ext}";
|
|
@@ -4083,7 +4230,7 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
|
|
|
4083
4230
|
`;
|
|
4084
4231
|
out += ` */
|
|
4085
4232
|
`;
|
|
4086
|
-
out += `export class SDK {
|
|
4233
|
+
out += `export class SDK extends BaseClient {
|
|
4087
4234
|
`;
|
|
4088
4235
|
for (const t of tables) {
|
|
4089
4236
|
out += ` public ${t.name}: ${pascal(t.name)}Client;
|
|
@@ -4093,17 +4240,101 @@ export type { AuthConfig, HeaderMap, AuthHeadersProvider } from "./base-client${
|
|
|
4093
4240
|
constructor(cfg: { baseUrl: string; fetch?: typeof fetch; auth?: AuthConfig }) {
|
|
4094
4241
|
`;
|
|
4095
4242
|
out += ` const f = cfg.fetch ?? fetch;
|
|
4243
|
+
`;
|
|
4244
|
+
out += ` super(cfg.baseUrl, f, cfg.auth);
|
|
4096
4245
|
`;
|
|
4097
4246
|
for (const t of tables) {
|
|
4098
4247
|
out += ` this.${t.name} = new ${pascal(t.name)}Client(cfg.baseUrl, f, cfg.auth);
|
|
4099
4248
|
`;
|
|
4100
4249
|
}
|
|
4101
4250
|
out += ` }
|
|
4251
|
+
`;
|
|
4252
|
+
out += `
|
|
4253
|
+
`;
|
|
4254
|
+
out += ` /**
|
|
4255
|
+
`;
|
|
4256
|
+
out += ` * Execute multiple operations atomically in one PostgreSQL transaction.
|
|
4257
|
+
`;
|
|
4258
|
+
out += ` * All ops are validated before BEGIN is issued — fail-fast on bad input.
|
|
4259
|
+
`;
|
|
4260
|
+
out += ` *
|
|
4261
|
+
`;
|
|
4262
|
+
out += ` * @example
|
|
4263
|
+
`;
|
|
4264
|
+
out += ` * const [order, user] = await sdk.$transaction([
|
|
4265
|
+
`;
|
|
4266
|
+
out += ` * sdk.orders.$create({ user_id: 1, total: 99 }),
|
|
4267
|
+
`;
|
|
4268
|
+
out += ` * sdk.users.$update('1', { last_order_at: new Date().toISOString() }),
|
|
4269
|
+
`;
|
|
4270
|
+
out += ` * ]);
|
|
4271
|
+
`;
|
|
4272
|
+
out += ` */
|
|
4273
|
+
`;
|
|
4274
|
+
out += ` async $transaction<const T extends readonly TxOp<unknown>[]>(
|
|
4275
|
+
`;
|
|
4276
|
+
out += ` ops: [...T]
|
|
4277
|
+
`;
|
|
4278
|
+
out += ` ): Promise<{ [K in keyof T]: T[K] extends TxOp<infer R> ? R : never }> {
|
|
4279
|
+
`;
|
|
4280
|
+
out += ` const payload = ops.map(op => ({
|
|
4281
|
+
`;
|
|
4282
|
+
out += ` op: op._op,
|
|
4283
|
+
`;
|
|
4284
|
+
out += ` table: op._table,
|
|
4285
|
+
`;
|
|
4286
|
+
out += ` ...(op._data !== undefined ? { data: op._data } : {}),
|
|
4287
|
+
`;
|
|
4288
|
+
out += ` ...(op._pk !== undefined ? { pk: op._pk } : {}),
|
|
4289
|
+
`;
|
|
4290
|
+
out += ` }));
|
|
4291
|
+
`;
|
|
4292
|
+
out += `
|
|
4293
|
+
`;
|
|
4294
|
+
out += ` const res = await this.fetchFn(\`\${this.baseUrl}/v1/transaction\`, {
|
|
4295
|
+
`;
|
|
4296
|
+
out += ` method: "POST",
|
|
4297
|
+
`;
|
|
4298
|
+
out += ` headers: await this.headers(true),
|
|
4299
|
+
`;
|
|
4300
|
+
out += ` body: JSON.stringify({ ops: payload }),
|
|
4301
|
+
`;
|
|
4302
|
+
out += ` });
|
|
4303
|
+
`;
|
|
4304
|
+
out += `
|
|
4305
|
+
`;
|
|
4306
|
+
out += ` if (!res.ok) {
|
|
4307
|
+
`;
|
|
4308
|
+
out += ` let errBody: Record<string, unknown> = {};
|
|
4309
|
+
`;
|
|
4310
|
+
out += ` try { errBody = await res.json() as Record<string, unknown>; } catch {}
|
|
4311
|
+
`;
|
|
4312
|
+
out += ` const err = Object.assign(
|
|
4313
|
+
`;
|
|
4314
|
+
out += ` new Error((errBody.error as string | undefined) ?? \`$transaction failed: \${res.status}\`),
|
|
4315
|
+
`;
|
|
4316
|
+
out += ` { failedAt: errBody.failedAt as number | undefined, issues: errBody.issues }
|
|
4317
|
+
`;
|
|
4318
|
+
out += ` );
|
|
4319
|
+
`;
|
|
4320
|
+
out += ` throw err;
|
|
4321
|
+
`;
|
|
4322
|
+
out += ` }
|
|
4323
|
+
`;
|
|
4324
|
+
out += `
|
|
4325
|
+
`;
|
|
4326
|
+
out += ` const json = await res.json() as { results: unknown[] };
|
|
4327
|
+
`;
|
|
4328
|
+
out += ` return json.results as unknown as { [K in keyof T]: T[K] extends TxOp<infer R> ? R : never };
|
|
4329
|
+
`;
|
|
4330
|
+
out += ` }
|
|
4102
4331
|
`;
|
|
4103
4332
|
out += `}
|
|
4104
4333
|
|
|
4105
4334
|
`;
|
|
4106
4335
|
out += `export { BaseClient } from "./base-client${ext}";
|
|
4336
|
+
`;
|
|
4337
|
+
out += `export type { TxOp } from "./base-client${ext}";
|
|
4107
4338
|
`;
|
|
4108
4339
|
out += `
|
|
4109
4340
|
// Include specification types for custom queries
|
|
@@ -4304,15 +4535,29 @@ export abstract class BaseClient {
|
|
|
4304
4535
|
method: "DELETE",
|
|
4305
4536
|
headers: await this.headers(),
|
|
4306
4537
|
});
|
|
4307
|
-
|
|
4538
|
+
|
|
4308
4539
|
if (res.status === 404) {
|
|
4309
4540
|
return null as T;
|
|
4310
4541
|
}
|
|
4311
|
-
|
|
4542
|
+
|
|
4312
4543
|
await this.okOrThrow(res, "DELETE", path);
|
|
4313
4544
|
return (await res.json()) as T;
|
|
4314
4545
|
}
|
|
4315
4546
|
}
|
|
4547
|
+
|
|
4548
|
+
/**
|
|
4549
|
+
* Lazy operation descriptor returned by $create/$update/$softDelete/$hardDelete/$upsert.
|
|
4550
|
+
* \`__resultType\` is a phantom field — never assigned at runtime, exists only
|
|
4551
|
+
* so TypeScript can infer the correct tuple element type inside \`$transaction\`.
|
|
4552
|
+
*/
|
|
4553
|
+
export type TxOp<T = unknown> = {
|
|
4554
|
+
readonly _table: string;
|
|
4555
|
+
readonly _op: "create" | "update" | "softDelete" | "hardDelete" | "upsert";
|
|
4556
|
+
readonly _data?: Record<string, unknown>;
|
|
4557
|
+
readonly _pk?: string | Record<string, unknown>;
|
|
4558
|
+
/** @internal */
|
|
4559
|
+
readonly __resultType?: T;
|
|
4560
|
+
};
|
|
4316
4561
|
`;
|
|
4317
4562
|
}
|
|
4318
4563
|
|
|
@@ -4856,14 +5101,14 @@ function tsTypeFor(pgType, opts, enums) {
|
|
|
4856
5101
|
return "number[]";
|
|
4857
5102
|
return "string";
|
|
4858
5103
|
}
|
|
4859
|
-
function
|
|
5104
|
+
function isJsonbType2(pgType) {
|
|
4860
5105
|
const t = pgType.toLowerCase();
|
|
4861
5106
|
return t === "json" || t === "jsonb";
|
|
4862
5107
|
}
|
|
4863
5108
|
var pascal2 = (s) => s.split(/[_\s-]+/).map((w) => w?.[0] ? w[0].toUpperCase() + w.slice(1) : "").join("");
|
|
4864
5109
|
function emitTypes(table, opts, enums) {
|
|
4865
5110
|
const Type = pascal2(table.name);
|
|
4866
|
-
const hasJsonbColumns = table.columns.some((col) =>
|
|
5111
|
+
const hasJsonbColumns = table.columns.some((col) => isJsonbType2(col.pgType));
|
|
4867
5112
|
const insertFields = table.columns.map((col) => {
|
|
4868
5113
|
const base = tsTypeFor(col.pgType, opts, enums);
|
|
4869
5114
|
const optional = col.hasDefault || col.nullable ? "?" : "";
|
|
@@ -5314,9 +5559,12 @@ export async function authMiddleware(c: Context, next: Next) {
|
|
|
5314
5559
|
|
|
5315
5560
|
// src/emit-router-hono.ts
|
|
5316
5561
|
init_utils();
|
|
5317
|
-
function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
|
|
5562
|
+
function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken, opts) {
|
|
5318
5563
|
const tableNames = tables.map((t) => t.name).sort();
|
|
5319
5564
|
const ext = useJsExtensions ? ".js" : "";
|
|
5565
|
+
const apiPathPrefix = opts?.apiPathPrefix ?? "/v1";
|
|
5566
|
+
const softDeleteCols = opts?.softDeleteCols ?? {};
|
|
5567
|
+
const includeMethodsDepth = opts?.includeMethodsDepth ?? 2;
|
|
5320
5568
|
let resolvedPullToken;
|
|
5321
5569
|
let pullTokenEnvVar;
|
|
5322
5570
|
if (pullToken) {
|
|
@@ -5342,6 +5590,93 @@ function emitHonoRouter(tables, hasAuth, useJsExtensions, pullToken) {
|
|
|
5342
5590
|
const Type = pascal(name);
|
|
5343
5591
|
return `export { register${Type}Routes } from "./routes/${name}${ext}";`;
|
|
5344
5592
|
}).join(`
|
|
5593
|
+
`);
|
|
5594
|
+
const txSchemaImports = tableNames.map((name) => {
|
|
5595
|
+
const Type = pascal(name);
|
|
5596
|
+
return `import { Insert${Type}Schema, Update${Type}Schema } from "./zod/${name}${ext}";`;
|
|
5597
|
+
}).join(`
|
|
5598
|
+
`);
|
|
5599
|
+
function txRouteBlock(appVar) {
|
|
5600
|
+
const authLine = hasAuth ? ` ${appVar}.use(\`${apiPathPrefix}/transaction\`, authMiddleware);
|
|
5601
|
+
` : "";
|
|
5602
|
+
return `${authLine} ${appVar}.post(\`${apiPathPrefix}/transaction\`, async (c) => {
|
|
5603
|
+
const body = await c.req.json().catch(() => ({}));
|
|
5604
|
+
const rawOps: unknown[] = Array.isArray(body.ops) ? body.ops : [];
|
|
5605
|
+
|
|
5606
|
+
if (rawOps.length === 0) {
|
|
5607
|
+
return c.json({ error: "ops must be a non-empty array" }, 400);
|
|
5608
|
+
}
|
|
5609
|
+
|
|
5610
|
+
// Validate all ops against their table schemas BEFORE opening a transaction
|
|
5611
|
+
const validatedOps: coreOps.TransactionOperation[] = [];
|
|
5612
|
+
for (let i = 0; i < rawOps.length; i++) {
|
|
5613
|
+
const item = rawOps[i] as any;
|
|
5614
|
+
const entry = TABLE_TX_METADATA[item?.table as string];
|
|
5615
|
+
if (!entry) {
|
|
5616
|
+
return c.json({ error: \`Unknown table "\${item?.table}" at index \${i}\`, failedAt: i }, 400);
|
|
5617
|
+
}
|
|
5618
|
+
if (item.op === "create") {
|
|
5619
|
+
const parsed = entry.insertSchema.safeParse(item.data ?? {});
|
|
5620
|
+
if (!parsed.success) {
|
|
5621
|
+
return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
|
|
5622
|
+
}
|
|
5623
|
+
validatedOps.push({ op: "create", table: item.table, data: parsed.data });
|
|
5624
|
+
} else if (item.op === "update") {
|
|
5625
|
+
const parsed = entry.updateSchema.safeParse(item.data ?? {});
|
|
5626
|
+
if (!parsed.success) {
|
|
5627
|
+
return c.json({ error: "Validation failed", issues: parsed.error.flatten(), failedAt: i }, 400);
|
|
5628
|
+
}
|
|
5629
|
+
if (item.pk == null) {
|
|
5630
|
+
return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
|
|
5631
|
+
}
|
|
5632
|
+
validatedOps.push({ op: "update", table: item.table, pk: item.pk, data: parsed.data });
|
|
5633
|
+
} else if (item.op === "softDelete") {
|
|
5634
|
+
if (item.pk == null) {
|
|
5635
|
+
return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
|
|
5636
|
+
}
|
|
5637
|
+
validatedOps.push({ op: "softDelete", table: item.table, pk: item.pk });
|
|
5638
|
+
} else if (item.op === "hardDelete") {
|
|
5639
|
+
if (item.pk == null) {
|
|
5640
|
+
return c.json({ error: \`Missing pk at index \${i}\`, failedAt: i }, 400);
|
|
5641
|
+
}
|
|
5642
|
+
validatedOps.push({ op: "hardDelete", table: item.table, pk: item.pk });
|
|
5643
|
+
} else {
|
|
5644
|
+
return c.json({ error: \`Unknown op "\${item?.op}" at index \${i}\`, failedAt: i }, 400);
|
|
5645
|
+
}
|
|
5646
|
+
}
|
|
5647
|
+
|
|
5648
|
+
const onBegin = deps.onRequest
|
|
5649
|
+
? (txClient: typeof deps.pg) => deps.onRequest!(c, txClient)
|
|
5650
|
+
: undefined;
|
|
5651
|
+
|
|
5652
|
+
const result = await coreOps.executeTransaction(deps.pg, validatedOps, TABLE_TX_METADATA, onBegin);
|
|
5653
|
+
|
|
5654
|
+
if (!result.ok) {
|
|
5655
|
+
return c.json({ error: result.error, failedAt: result.failedAt }, 400);
|
|
5656
|
+
}
|
|
5657
|
+
return c.json({ results: result.results.map(r => r.data) }, 200);
|
|
5658
|
+
});`;
|
|
5659
|
+
}
|
|
5660
|
+
const txMetadataEntries = tables.slice().sort((a, b) => a.name.localeCompare(b.name)).map((t) => {
|
|
5661
|
+
const rawPk = t.pk;
|
|
5662
|
+
const pkCols = Array.isArray(rawPk) ? rawPk : rawPk ? [rawPk] : ["id"];
|
|
5663
|
+
const softDel = softDeleteCols[t.name] ?? null;
|
|
5664
|
+
const allCols = t.columns.map((c) => `"${c.name}"`).join(", ");
|
|
5665
|
+
const vecCols = t.columns.filter((c) => isVectorType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
|
|
5666
|
+
const jsonCols = t.columns.filter((c) => isJsonbType(c.pgType)).map((c) => `"${c.name}"`).join(", ");
|
|
5667
|
+
const Type = pascal(t.name);
|
|
5668
|
+
return ` "${t.name}": {
|
|
5669
|
+
table: "${t.name}",
|
|
5670
|
+
pkColumns: ${JSON.stringify(pkCols)},
|
|
5671
|
+
softDeleteColumn: ${softDel ? `"${softDel}"` : "null"},
|
|
5672
|
+
allColumnNames: [${allCols}],
|
|
5673
|
+
vectorColumns: [${vecCols}],
|
|
5674
|
+
jsonbColumns: [${jsonCols}],
|
|
5675
|
+
includeMethodsDepth: ${includeMethodsDepth},
|
|
5676
|
+
insertSchema: Insert${Type}Schema,
|
|
5677
|
+
updateSchema: Update${Type}Schema,
|
|
5678
|
+
}`;
|
|
5679
|
+
}).join(`,
|
|
5345
5680
|
`);
|
|
5346
5681
|
return `/**
|
|
5347
5682
|
* AUTO-GENERATED FILE - DO NOT EDIT
|
|
@@ -5355,8 +5690,27 @@ import { Hono } from "hono";
|
|
|
5355
5690
|
import type { Context } from "hono";
|
|
5356
5691
|
import { SDK_MANIFEST } from "./sdk-bundle${ext}";
|
|
5357
5692
|
import { getContract } from "./contract${ext}";
|
|
5693
|
+
import * as coreOps from "./core/operations${ext}";
|
|
5694
|
+
${txSchemaImports}
|
|
5358
5695
|
${imports}
|
|
5359
|
-
${hasAuth ? `
|
|
5696
|
+
${hasAuth ? `import { authMiddleware } from "./auth${ext}";
|
|
5697
|
+
export { authMiddleware };` : ""}
|
|
5698
|
+
|
|
5699
|
+
/** Discriminated result from safeParse — mirrors Zod's actual return shape */
|
|
5700
|
+
type SchemaParseResult =
|
|
5701
|
+
| { success: true; data: Record<string, unknown> }
|
|
5702
|
+
| { success: false; error: { flatten: () => unknown } };
|
|
5703
|
+
|
|
5704
|
+
/** Registry entry — core metadata + Zod schemas for request validation */
|
|
5705
|
+
interface TxTableRegistry extends coreOps.TransactionTableMetadata {
|
|
5706
|
+
insertSchema: { safeParse: (v: unknown) => SchemaParseResult };
|
|
5707
|
+
updateSchema: { safeParse: (v: unknown) => SchemaParseResult };
|
|
5708
|
+
}
|
|
5709
|
+
|
|
5710
|
+
// Registry used by POST /v1/transaction — maps table name to metadata + Zod schemas
|
|
5711
|
+
const TABLE_TX_METADATA: Record<string, TxTableRegistry> = {
|
|
5712
|
+
${txMetadataEntries}
|
|
5713
|
+
};
|
|
5360
5714
|
|
|
5361
5715
|
/**
|
|
5362
5716
|
* Creates a Hono router with all generated routes that can be mounted into your existing app.
|
|
@@ -5426,7 +5780,7 @@ ${pullToken ? `
|
|
|
5426
5780
|
if (!expectedToken) {
|
|
5427
5781
|
// Token not configured in environment - reject request
|
|
5428
5782
|
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.` : "
|
|
5783
|
+
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
5784
|
}, 500);
|
|
5431
5785
|
}
|
|
5432
5786
|
|
|
@@ -5443,6 +5797,9 @@ ${pullToken ? `
|
|
|
5443
5797
|
await next();
|
|
5444
5798
|
});
|
|
5445
5799
|
` : ""}
|
|
5800
|
+
// Transaction endpoint — executes multiple operations atomically
|
|
5801
|
+
${txRouteBlock("router")}
|
|
5802
|
+
|
|
5446
5803
|
// SDK distribution endpoints
|
|
5447
5804
|
router.get("/_psdk/sdk/manifest", (c) => {
|
|
5448
5805
|
return c.json({
|
|
@@ -5526,6 +5883,7 @@ export function registerAllRoutes(
|
|
|
5526
5883
|
}
|
|
5527
5884
|
) {
|
|
5528
5885
|
${registrations.replace(/router/g, "app")}
|
|
5886
|
+
${txRouteBlock("app")}
|
|
5529
5887
|
}
|
|
5530
5888
|
|
|
5531
5889
|
// Individual route registrations (for selective use)
|
|
@@ -5713,6 +6071,96 @@ export async function createRecord(
|
|
|
5713
6071
|
}
|
|
5714
6072
|
}
|
|
5715
6073
|
|
|
6074
|
+
/**
|
|
6075
|
+
* UPSERT operation - Insert or update a record based on a conflict target
|
|
6076
|
+
*/
|
|
6077
|
+
export async function upsertRecord(
|
|
6078
|
+
ctx: OperationContext,
|
|
6079
|
+
args: {
|
|
6080
|
+
where: Record<string, any>; // conflict target columns (keys only matter)
|
|
6081
|
+
create: Record<string, any>; // full insert data
|
|
6082
|
+
update: Record<string, any>; // update data on conflict
|
|
6083
|
+
}
|
|
6084
|
+
): Promise<{ data?: any; error?: string; issues?: any; status: number }> {
|
|
6085
|
+
try {
|
|
6086
|
+
const { where, create: createData, update: updateData } = args;
|
|
6087
|
+
|
|
6088
|
+
const conflictCols = Object.keys(where);
|
|
6089
|
+
if (!conflictCols.length) {
|
|
6090
|
+
return { error: "where must specify at least one column", status: 400 };
|
|
6091
|
+
}
|
|
6092
|
+
|
|
6093
|
+
const insertCols = Object.keys(createData);
|
|
6094
|
+
const insertVals = Object.values(createData);
|
|
6095
|
+
if (!insertCols.length) {
|
|
6096
|
+
return { error: "No fields provided in create", status: 400 };
|
|
6097
|
+
}
|
|
6098
|
+
|
|
6099
|
+
// Filter PK columns from update (mirrors updateRecord behaviour)
|
|
6100
|
+
const filteredUpdate = Object.fromEntries(
|
|
6101
|
+
Object.entries(updateData).filter(([k]) => !ctx.pkColumns.includes(k))
|
|
6102
|
+
);
|
|
6103
|
+
if (!Object.keys(filteredUpdate).length) {
|
|
6104
|
+
return { error: "update must include at least one non-PK field", status: 400 };
|
|
6105
|
+
}
|
|
6106
|
+
|
|
6107
|
+
// Serialise JSONB/vector values for create (same pattern as createRecord)
|
|
6108
|
+
const preparedInsertVals = insertVals.map((v, i) =>
|
|
6109
|
+
v !== null && v !== undefined && typeof v === 'object' &&
|
|
6110
|
+
(ctx.jsonbColumns?.includes(insertCols[i]!) || ctx.vectorColumns?.includes(insertCols[i]!))
|
|
6111
|
+
? JSON.stringify(v) : v
|
|
6112
|
+
);
|
|
6113
|
+
|
|
6114
|
+
// Serialise JSONB/vector values for update
|
|
6115
|
+
const updateCols = Object.keys(filteredUpdate);
|
|
6116
|
+
const updateVals = Object.values(filteredUpdate);
|
|
6117
|
+
const preparedUpdateVals = updateVals.map((v, i) =>
|
|
6118
|
+
v !== null && v !== undefined && typeof v === 'object' &&
|
|
6119
|
+
(ctx.jsonbColumns?.includes(updateCols[i]!) || ctx.vectorColumns?.includes(updateCols[i]!))
|
|
6120
|
+
? JSON.stringify(v) : v
|
|
6121
|
+
);
|
|
6122
|
+
|
|
6123
|
+
const placeholders = insertCols.map((_, i) => \`$\${i + 1}\`).join(', ');
|
|
6124
|
+
const conflictSQL = conflictCols.map(c => \`"\${c}"\`).join(', ');
|
|
6125
|
+
const setSql = updateCols.map((k, i) => \`"\${k}" = $\${insertCols.length + i + 1}\`).join(', ');
|
|
6126
|
+
const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
|
|
6127
|
+
|
|
6128
|
+
const text = \`INSERT INTO "\${ctx.table}" (\${insertCols.map(c => \`"\${c}"\`).join(', ')})
|
|
6129
|
+
VALUES (\${placeholders})
|
|
6130
|
+
ON CONFLICT (\${conflictSQL}) DO UPDATE SET \${setSql}
|
|
6131
|
+
RETURNING \${returningClause}\`;
|
|
6132
|
+
const params = [...preparedInsertVals, ...preparedUpdateVals];
|
|
6133
|
+
|
|
6134
|
+
log.debug("UPSERT SQL:", text, "params:", params);
|
|
6135
|
+
const { rows } = await ctx.pg.query(text, params);
|
|
6136
|
+
const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
|
|
6137
|
+
|
|
6138
|
+
if (!parsedRows[0]) {
|
|
6139
|
+
return { data: null, status: 500 };
|
|
6140
|
+
}
|
|
6141
|
+
|
|
6142
|
+
return { data: parsedRows[0], status: 200 };
|
|
6143
|
+
} catch (e: any) {
|
|
6144
|
+
const errorMsg = e?.message ?? "";
|
|
6145
|
+
const isJsonError = errorMsg.includes("invalid input syntax for type json");
|
|
6146
|
+
if (isJsonError) {
|
|
6147
|
+
log.error(\`UPSERT \${ctx.table} - Invalid JSON input detected!\`);
|
|
6148
|
+
log.error("Input args that caused error:", JSON.stringify(args, null, 2));
|
|
6149
|
+
log.error("Filtered update data (sent to DB):", JSON.stringify(Object.fromEntries(
|
|
6150
|
+
Object.entries(args.update).filter(([k]) => !ctx.pkColumns.includes(k))
|
|
6151
|
+
), null, 2));
|
|
6152
|
+
log.error("PostgreSQL error:", errorMsg);
|
|
6153
|
+
} else {
|
|
6154
|
+
log.error(\`UPSERT \${ctx.table} error:\`, e?.stack ?? e);
|
|
6155
|
+
}
|
|
6156
|
+
return {
|
|
6157
|
+
error: e?.message ?? "Internal error",
|
|
6158
|
+
...(DEBUG ? { stack: e?.stack } : {}),
|
|
6159
|
+
status: 500
|
|
6160
|
+
};
|
|
6161
|
+
}
|
|
6162
|
+
}
|
|
6163
|
+
|
|
5716
6164
|
/**
|
|
5717
6165
|
* READ operation - Get a record by primary key
|
|
5718
6166
|
*/
|
|
@@ -6389,7 +6837,8 @@ export async function updateRecord(
|
|
|
6389
6837
|
*/
|
|
6390
6838
|
export async function deleteRecord(
|
|
6391
6839
|
ctx: OperationContext,
|
|
6392
|
-
pkValues: any[]
|
|
6840
|
+
pkValues: any[],
|
|
6841
|
+
opts?: { hard?: boolean }
|
|
6393
6842
|
): Promise<{ data?: any; error?: string; status: number }> {
|
|
6394
6843
|
try {
|
|
6395
6844
|
const hasCompositePk = ctx.pkColumns.length > 1;
|
|
@@ -6398,11 +6847,12 @@ export async function deleteRecord(
|
|
|
6398
6847
|
: \`"\${ctx.pkColumns[0]}" = $1\`;
|
|
6399
6848
|
|
|
6400
6849
|
const returningClause = buildColumnList(ctx.select, ctx.exclude, ctx.allColumnNames);
|
|
6401
|
-
const
|
|
6850
|
+
const doSoftDelete = ctx.softDeleteColumn && !opts?.hard;
|
|
6851
|
+
const text = doSoftDelete
|
|
6402
6852
|
? \`UPDATE "\${ctx.table}" SET "\${ctx.softDeleteColumn}" = NOW() WHERE \${wherePkSql} RETURNING \${returningClause}\`
|
|
6403
6853
|
: \`DELETE FROM "\${ctx.table}" WHERE \${wherePkSql} RETURNING \${returningClause}\`;
|
|
6404
6854
|
|
|
6405
|
-
log.debug(\`DELETE \${
|
|
6855
|
+
log.debug(\`DELETE \${doSoftDelete ? '(soft)' : ''} \${ctx.table} SQL:\`, text, "pk:", pkValues);
|
|
6406
6856
|
const { rows } = await ctx.pg.query(text, pkValues);
|
|
6407
6857
|
const parsedRows = parseVectorColumns(rows, ctx.vectorColumns);
|
|
6408
6858
|
|
|
@@ -6413,12 +6863,126 @@ export async function deleteRecord(
|
|
|
6413
6863
|
return { data: parsedRows[0], status: 200 };
|
|
6414
6864
|
} catch (e: any) {
|
|
6415
6865
|
log.error(\`DELETE \${ctx.table} error:\`, e?.stack ?? e);
|
|
6416
|
-
return {
|
|
6417
|
-
error: e?.message ?? "Internal error",
|
|
6866
|
+
return {
|
|
6867
|
+
error: e?.message ?? "Internal error",
|
|
6418
6868
|
...(DEBUG ? { stack: e?.stack } : {}),
|
|
6419
|
-
status: 500
|
|
6869
|
+
status: 500
|
|
6420
6870
|
};
|
|
6421
6871
|
}
|
|
6872
|
+
}
|
|
6873
|
+
|
|
6874
|
+
/**
|
|
6875
|
+
* Static metadata for a table used during transaction execution.
|
|
6876
|
+
* Mirrors the fields needed to construct an OperationContext (minus the pg client,
|
|
6877
|
+
* which is injected at transaction time).
|
|
6878
|
+
*/
|
|
6879
|
+
export interface TransactionTableMetadata {
|
|
6880
|
+
table: string;
|
|
6881
|
+
pkColumns: string[];
|
|
6882
|
+
softDeleteColumn: string | null;
|
|
6883
|
+
allColumnNames: string[];
|
|
6884
|
+
vectorColumns: string[];
|
|
6885
|
+
jsonbColumns: string[];
|
|
6886
|
+
includeMethodsDepth: number;
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6889
|
+
export type TransactionOperation =
|
|
6890
|
+
| { op: "create"; table: string; data: Record<string, unknown> }
|
|
6891
|
+
| { op: "update"; table: string; pk: string | Record<string, unknown>; data: Record<string, unknown> }
|
|
6892
|
+
| { op: "softDelete"; table: string; pk: string | Record<string, unknown> }
|
|
6893
|
+
| { op: "hardDelete"; table: string; pk: string | Record<string, unknown> }
|
|
6894
|
+
| { op: "upsert"; table: string; data: { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> } };
|
|
6895
|
+
|
|
6896
|
+
/**
|
|
6897
|
+
* Executes a list of operations atomically inside a single PostgreSQL transaction.
|
|
6898
|
+
*
|
|
6899
|
+
* - When \`pg\` has a \`.connect()\` method (Pool), acquires a dedicated connection.
|
|
6900
|
+
* - Otherwise uses \`pg\` directly (already a single connected Client).
|
|
6901
|
+
* - Calls \`onBegin(txClient)\` after BEGIN and before any operations (for SET LOCAL etc.).
|
|
6902
|
+
* - Any status >= 400 or thrown error triggers ROLLBACK.
|
|
6903
|
+
* - \`failedAt: -1\` indicates an unexpected exception (e.g. connectivity failure).
|
|
6904
|
+
*/
|
|
6905
|
+
export async function executeTransaction(
|
|
6906
|
+
pg: DatabaseClient & { connect?: () => Promise<DatabaseClient & { release?: () => void }> },
|
|
6907
|
+
ops: TransactionOperation[],
|
|
6908
|
+
metadata: Record<string, TransactionTableMetadata>,
|
|
6909
|
+
onBegin?: (txClient: DatabaseClient) => Promise<void>
|
|
6910
|
+
): Promise<
|
|
6911
|
+
| { ok: true; results: Array<{ data: unknown }> }
|
|
6912
|
+
| { ok: false; error: string; failedAt: number }
|
|
6913
|
+
> {
|
|
6914
|
+
// Fail-fast: validate all table names before touching the DB
|
|
6915
|
+
for (let i = 0; i < ops.length; i++) {
|
|
6916
|
+
if (!metadata[ops[i]!.table]) {
|
|
6917
|
+
return { ok: false, error: \`Unknown table "\${ops[i]!.table}"\`, failedAt: i };
|
|
6918
|
+
}
|
|
6919
|
+
}
|
|
6920
|
+
|
|
6921
|
+
// Pool gives a dedicated connection; plain Client is used directly
|
|
6922
|
+
const isPool = typeof (pg as any).connect === "function";
|
|
6923
|
+
const txClient: DatabaseClient & { release?: () => void } = isPool
|
|
6924
|
+
? await (pg as any).connect()
|
|
6925
|
+
: pg;
|
|
6926
|
+
|
|
6927
|
+
try {
|
|
6928
|
+
await txClient.query("BEGIN");
|
|
6929
|
+
if (onBegin) await onBegin(txClient);
|
|
6930
|
+
|
|
6931
|
+
const results: Array<{ data: unknown }> = [];
|
|
6932
|
+
|
|
6933
|
+
for (let i = 0; i < ops.length; i++) {
|
|
6934
|
+
const op = ops[i]!;
|
|
6935
|
+
const meta = metadata[op.table]!;
|
|
6936
|
+
const ctx: OperationContext = {
|
|
6937
|
+
pg: txClient,
|
|
6938
|
+
table: meta.table,
|
|
6939
|
+
pkColumns: meta.pkColumns,
|
|
6940
|
+
softDeleteColumn: meta.softDeleteColumn,
|
|
6941
|
+
allColumnNames: meta.allColumnNames,
|
|
6942
|
+
vectorColumns: meta.vectorColumns,
|
|
6943
|
+
jsonbColumns: meta.jsonbColumns,
|
|
6944
|
+
includeMethodsDepth: meta.includeMethodsDepth,
|
|
6945
|
+
};
|
|
6946
|
+
|
|
6947
|
+
let result: { data?: unknown; error?: string; status: number };
|
|
6948
|
+
|
|
6949
|
+
if (op.op === "create") {
|
|
6950
|
+
result = await createRecord(ctx, op.data);
|
|
6951
|
+
} else if (op.op === "upsert") {
|
|
6952
|
+
result = await upsertRecord(ctx, op.data);
|
|
6953
|
+
} else {
|
|
6954
|
+
const pkValues = Array.isArray(op.pk)
|
|
6955
|
+
? op.pk
|
|
6956
|
+
: typeof op.pk === "object" && op.pk !== null
|
|
6957
|
+
? meta.pkColumns.map(c => (op.pk as Record<string, unknown>)[c])
|
|
6958
|
+
: [op.pk];
|
|
6959
|
+
result = op.op === "update"
|
|
6960
|
+
? await updateRecord(ctx, pkValues as string[], op.data)
|
|
6961
|
+
: op.op === "hardDelete"
|
|
6962
|
+
? await deleteRecord(ctx, pkValues as string[], { hard: true })
|
|
6963
|
+
: await deleteRecord(ctx, pkValues as string[]);
|
|
6964
|
+
}
|
|
6965
|
+
|
|
6966
|
+
if (result.status >= 400) {
|
|
6967
|
+
try { await txClient.query("ROLLBACK"); } catch {}
|
|
6968
|
+
txClient.release?.();
|
|
6969
|
+
return {
|
|
6970
|
+
ok: false,
|
|
6971
|
+
error: result.error ?? \`Op \${i} returned status \${result.status}\`,
|
|
6972
|
+
failedAt: i,
|
|
6973
|
+
};
|
|
6974
|
+
}
|
|
6975
|
+
results.push({ data: result.data ?? null });
|
|
6976
|
+
}
|
|
6977
|
+
|
|
6978
|
+
await txClient.query("COMMIT");
|
|
6979
|
+
txClient.release?.();
|
|
6980
|
+
return { ok: true, results };
|
|
6981
|
+
} catch (e: unknown) {
|
|
6982
|
+
try { await txClient.query("ROLLBACK"); } catch {}
|
|
6983
|
+
txClient.release?.();
|
|
6984
|
+
return { ok: false, error: (e instanceof Error ? e.message : String(e)) ?? "Transaction error", failedAt: -1 };
|
|
6985
|
+
}
|
|
6422
6986
|
}`;
|
|
6423
6987
|
}
|
|
6424
6988
|
|
|
@@ -6850,7 +7414,7 @@ function generateForeignKeySetup(table, model, clientPath) {
|
|
|
6850
7414
|
// Clean up parent ${foreignTableName} record
|
|
6851
7415
|
if (${foreignTableName}Id) {
|
|
6852
7416
|
try {
|
|
6853
|
-
await sdk.${foreignTableName}.
|
|
7417
|
+
await sdk.${foreignTableName}.hardDelete(${foreignTableName}Id);
|
|
6854
7418
|
} catch (e) {
|
|
6855
7419
|
// Parent might already be deleted due to cascading
|
|
6856
7420
|
}
|
|
@@ -6867,7 +7431,7 @@ function generateForeignKeySetup(table, model, clientPath) {
|
|
|
6867
7431
|
// Clean up parent ${foreignTableName} record
|
|
6868
7432
|
if (${foreignTableName}Key) {
|
|
6869
7433
|
try {
|
|
6870
|
-
await sdk.${foreignTableName}.
|
|
7434
|
+
await sdk.${foreignTableName}.hardDelete(${foreignTableName}Key);
|
|
6871
7435
|
} catch (e) {
|
|
6872
7436
|
// Parent might already be deleted due to cascading
|
|
6873
7437
|
}
|
|
@@ -7132,19 +7696,34 @@ function generateTestCases(table, sampleData, updateData, hasForeignKeys = false
|
|
|
7132
7696
|
console.warn('No ID from create test, skipping update test');
|
|
7133
7697
|
return;
|
|
7134
7698
|
}
|
|
7135
|
-
|
|
7699
|
+
|
|
7136
7700
|
const updateData: Update${Type} = ${updateData};
|
|
7137
7701
|
const updated = await sdk.${table.name}.update(createdId, updateData);
|
|
7138
7702
|
expect(updated).toBeDefined();
|
|
7703
|
+
});
|
|
7704
|
+
|
|
7705
|
+
it('should upsert (update path) ${table.name}', async () => {
|
|
7706
|
+
if (!createdId) {
|
|
7707
|
+
console.warn('No ID from create test, skipping upsert test');
|
|
7708
|
+
return;
|
|
7709
|
+
}
|
|
7710
|
+
// where: use PK as conflict target; create includes PK so conflict is guaranteed
|
|
7711
|
+
const result = await sdk.${table.name}.upsert({
|
|
7712
|
+
where: { ${table.pk[0]}: createdId },
|
|
7713
|
+
create: { ${table.pk[0]}: createdId, ...${sampleData} },
|
|
7714
|
+
update: ${updateData},
|
|
7715
|
+
});
|
|
7716
|
+
expect(result).toBeDefined();
|
|
7717
|
+
expect(result.${table.pk[0]}).toBe(createdId);
|
|
7139
7718
|
});` : ""}
|
|
7140
|
-
|
|
7719
|
+
|
|
7141
7720
|
it('should delete ${table.name}', async () => {
|
|
7142
7721
|
if (!createdId) {
|
|
7143
7722
|
console.warn('No ID from create test, skipping delete test');
|
|
7144
7723
|
return;
|
|
7145
7724
|
}
|
|
7146
7725
|
|
|
7147
|
-
const deleted = await sdk.${table.name}.
|
|
7726
|
+
const deleted = await sdk.${table.name}.hardDelete(createdId);
|
|
7148
7727
|
expect(deleted).toBeDefined();
|
|
7149
7728
|
|
|
7150
7729
|
// Verify deletion
|
|
@@ -7161,10 +7740,16 @@ var __filename2 = fileURLToPath(import.meta.url);
|
|
|
7161
7740
|
var __dirname2 = dirname2(__filename2);
|
|
7162
7741
|
var { version: CLI_VERSION } = JSON.parse(readFileSync(join2(__dirname2, "../package.json"), "utf-8"));
|
|
7163
7742
|
function resolveSoftDeleteColumn(cfg, tableName) {
|
|
7164
|
-
const
|
|
7743
|
+
const del = cfg.delete;
|
|
7744
|
+
if (!del)
|
|
7745
|
+
return null;
|
|
7746
|
+
const overrides = del.softDeleteColumnOverrides;
|
|
7165
7747
|
if (overrides && tableName in overrides)
|
|
7166
7748
|
return overrides[tableName] ?? null;
|
|
7167
|
-
return
|
|
7749
|
+
return del.softDeleteColumn ?? null;
|
|
7750
|
+
}
|
|
7751
|
+
function resolveExposeHardDelete(cfg) {
|
|
7752
|
+
return cfg.delete?.exposeHardDelete ?? true;
|
|
7168
7753
|
}
|
|
7169
7754
|
async function generate(configPath, options) {
|
|
7170
7755
|
if (!existsSync2(configPath)) {
|
|
@@ -7254,6 +7839,7 @@ async function generate(configPath, options) {
|
|
|
7254
7839
|
path: join2(serverDir, "core", "operations.ts"),
|
|
7255
7840
|
content: emitCoreOperations()
|
|
7256
7841
|
});
|
|
7842
|
+
const exposeHardDelete = resolveExposeHardDelete(cfg);
|
|
7257
7843
|
if (process.env.SDK_DEBUG) {
|
|
7258
7844
|
console.log(`[Index] About to process ${Object.keys(model.tables || {}).length} tables for generation`);
|
|
7259
7845
|
}
|
|
@@ -7271,6 +7857,7 @@ async function generate(configPath, options) {
|
|
|
7271
7857
|
if (serverFramework === "hono") {
|
|
7272
7858
|
routeContent = emitHonoRoutes(table, graph, {
|
|
7273
7859
|
softDeleteColumn: softDeleteCols[table.name] ?? null,
|
|
7860
|
+
exposeHardDelete,
|
|
7274
7861
|
includeMethodsDepth: cfg.includeMethodsDepth || 2,
|
|
7275
7862
|
authStrategy: getAuthStrategy(normalizedAuth),
|
|
7276
7863
|
useJsExtensions: cfg.useJsExtensions,
|
|
@@ -7286,6 +7873,8 @@ async function generate(configPath, options) {
|
|
|
7286
7873
|
files.push({
|
|
7287
7874
|
path: join2(clientDir, `${table.name}.ts`),
|
|
7288
7875
|
content: emitClient(table, graph, {
|
|
7876
|
+
softDeleteColumn: softDeleteCols[table.name] ?? null,
|
|
7877
|
+
exposeHardDelete,
|
|
7289
7878
|
useJsExtensions: cfg.useJsExtensionsClient,
|
|
7290
7879
|
includeMethodsDepth: cfg.includeMethodsDepth ?? 2,
|
|
7291
7880
|
skipJunctionTables: cfg.skipJunctionTables ?? true
|
|
@@ -7299,7 +7888,11 @@ async function generate(configPath, options) {
|
|
|
7299
7888
|
if (serverFramework === "hono") {
|
|
7300
7889
|
files.push({
|
|
7301
7890
|
path: join2(serverDir, "router.ts"),
|
|
7302
|
-
content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken
|
|
7891
|
+
content: emitHonoRouter(Object.values(model.tables), getAuthStrategy(normalizedAuth) !== "none", cfg.useJsExtensions, cfg.pullToken, {
|
|
7892
|
+
apiPathPrefix: cfg.apiPathPrefix || "/v1",
|
|
7893
|
+
softDeleteCols: Object.fromEntries(Object.values(model.tables).map((t) => [t.name, resolveSoftDeleteColumn(cfg, t.name)])),
|
|
7894
|
+
includeMethodsDepth: cfg.includeMethodsDepth ?? 2
|
|
7895
|
+
})
|
|
7303
7896
|
});
|
|
7304
7897
|
}
|
|
7305
7898
|
const { generateUnifiedContract: generateUnifiedContract2, generateUnifiedContractMarkdown: generateUnifiedContractMarkdown2 } = await Promise.resolve().then(() => (init_emit_sdk_contract(), exports_emit_sdk_contract));
|