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