saltcorn-db-code 0.1.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/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Saltcorn DB Code
2
+
3
+ Saltcorn DB Code is a Saltcorn plugin for inspecting and managing PostgreSQL database code objects from the Saltcorn UI.
4
+
5
+ - List functions and procedures in the current tenant schema.
6
+ - View routine metadata.
7
+ - View the full SQL definition returned by PostgreSQL.
8
+ - Filter a shared routine list by function or stored procedure.
9
+ - Create functions and stored procedures with structured forms and Saltcorn's code editor for the body.
10
+ - Create routines from full DDL when you already have the exact PostgreSQL SQL.
11
+ - Edit routines from their current PostgreSQL DDL.
12
+ - Delete routines with confirmation and without default `CASCADE`.
13
+ - Call routines from Saltcorn events/workflows through the `DB_Routine` action.
14
+ - Restrict access to Saltcorn administrators.
15
+ - Show a clear unsupported-database message on SQLite.
16
+
17
+ TODO
18
+
19
+ - Storeds as Action from triggers, callable from scheduled or API calls
20
+
21
+ ## Some Screenshots
22
+
23
+ ![Main screen](./screenshots/main.png)
24
+
25
+ ![New function screen](./screenshots/new_function.png)
26
+
27
+ ![Test functions screen](./screenshots/test_functions.png)
28
+
29
+
30
+ ## Local installation during development
31
+
32
+ From the Saltcorn checkout:
33
+
34
+ ```bash
35
+ cd /home/devgiu/dev/saltcorn
36
+ ./packages/saltcorn-cli/bin/saltcorn dev:localize-plugin db-code /home/devgiu/dev/saltcorn-db-code
37
+ ```
38
+
39
+ After loading the plugin, create a Saltcorn view with the `DBCodeConsole` view pattern. This is the recommended entrypoint because it behaves like other Saltcorn plugin consoles and can be added to menus normally.
40
+
41
+ Alternative direct route for development:
42
+
43
+ ```txt
44
+ /db-code
45
+ ```
46
+
47
+ Administrators can add either the created view URL, usually `/view/<view-name>`, or the direct `/db-code` route to a normal Saltcorn menu link.
48
+
49
+ ## Development commands
50
+
51
+ ```bash
52
+ npm test
53
+ npm run lint
54
+ ```
55
+
56
+ ## Scope
57
+
58
+ The first milestone is read-only PostgreSQL routines. Creation, editing, deletion, execution, configuration, and additional database object types are tracked in `PLAN.md` and `docs/TODO.md`.
package/index.js ADDED
@@ -0,0 +1,41 @@
1
+ const listRoute = require("./routes/list");
2
+ const showRoute = require("./routes/show");
3
+ const placeholder = require("./routes/placeholder");
4
+ const { executeFormRoute, executePostRoute } = require("./routes/execute");
5
+ const { createFormRoute, createPostRoute, createProcedureFormRoute, createProcedurePostRoute, createDdlFormRoute, createDdlPostRoute } = require("./routes/create");
6
+ const { editFormRoute, editPostRoute } = require("./routes/edit");
7
+ const { deleteFormRoute, deletePostRoute } = require("./routes/delete");
8
+ const { generateRoutineSqlRoute } = require("./routes/ai");
9
+ const { exportFormRoute, exportPostRoute } = require("./routes/export");
10
+ const { importFormRoute, importPostRoute, importExecuteRoute } = require("./routes/import");
11
+ const { DB_Routine } = require("./lib/db-routine-action");
12
+ const dbCodeConsole = require("./viewtemplates/db-code-console");
13
+
14
+ module.exports = {
15
+ sc_plugin_api_version: 1,
16
+ plugin_name: "db-code",
17
+ viewtemplates: [dbCodeConsole],
18
+ actions: { DB_Routine },
19
+ routes: [
20
+ { url: "/db-code", method: "get", callback: listRoute },
21
+ { url: "/db-code/routine/:oid", method: "get", callback: showRoute },
22
+ { url: "/db-code/new", method: "get", callback: createFormRoute },
23
+ { url: "/db-code/new", method: "post", callback: createPostRoute },
24
+ { url: "/db-code/new-procedure", method: "get", callback: createProcedureFormRoute },
25
+ { url: "/db-code/new-procedure", method: "post", callback: createProcedurePostRoute },
26
+ { url: "/db-code/new-ddl", method: "get", callback: createDdlFormRoute },
27
+ { url: "/db-code/new-ddl", method: "post", callback: createDdlPostRoute },
28
+ { url: "/db-code/routine/:oid/edit", method: "get", callback: editFormRoute },
29
+ { url: "/db-code/routine/:oid/edit", method: "post", callback: editPostRoute },
30
+ { url: "/db-code/routine/:oid/delete", method: "get", callback: deleteFormRoute },
31
+ { url: "/db-code/routine/:oid/delete", method: "post", callback: deletePostRoute },
32
+ { url: "/db-code/routine/:oid/execute", method: "get", callback: executeFormRoute },
33
+ { url: "/db-code/routine/:oid/execute", method: "post", callback: executePostRoute },
34
+ { url: "/db-code/ai/generate-sql", method: "post", callback: generateRoutineSqlRoute },
35
+ { url: "/db-code/export", method: "get", callback: exportFormRoute },
36
+ { url: "/db-code/export", method: "post", callback: exportPostRoute },
37
+ { url: "/db-code/import", method: "get", callback: importFormRoute },
38
+ { url: "/db-code/import", method: "post", callback: importPostRoute },
39
+ { url: "/db-code/import/execute", method: "post", callback: importExecuteRoute }
40
+ ]
41
+ };
@@ -0,0 +1,42 @@
1
+ function getPath(source, path) {
2
+ if (!path) return undefined;
3
+ return String(path).split(".").reduce((acc, part) => (acc == null ? undefined : acc[part]), source);
4
+ }
5
+
6
+ function resolveTemplateValue(value, context) {
7
+ if (Array.isArray(value)) return value.map((item) => resolveTemplateValue(item, context));
8
+ if (value && typeof value === "object") {
9
+ return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, resolveTemplateValue(val, context)]));
10
+ }
11
+ if (typeof value !== "string") return value;
12
+ const exact = value.match(/^\s*\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}\s*$/);
13
+ if (exact) {
14
+ const key = exact[1];
15
+ if (key.startsWith("row.")) return getPath(context.row, key.slice(4));
16
+ if (key.startsWith("user.")) return getPath(context.user, key.slice(5));
17
+ if (key.startsWith("configuration.")) return getPath(context.configuration, key.slice(14));
18
+ return getPath(context.row, key) ?? getPath(context.configuration, key) ?? getPath(context.user, key);
19
+ }
20
+ return value.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (_, key) => {
21
+ const resolved = key.startsWith("row.")
22
+ ? getPath(context.row, key.slice(4))
23
+ : key.startsWith("user.")
24
+ ? getPath(context.user, key.slice(5))
25
+ : key.startsWith("configuration.")
26
+ ? getPath(context.configuration, key.slice(14))
27
+ : getPath(context.row, key) ?? getPath(context.configuration, key) ?? getPath(context.user, key);
28
+ return resolved == null ? "" : String(resolved);
29
+ });
30
+ }
31
+
32
+ function parseArgumentsJson(argumentsJson, context, opts = {}) {
33
+ const text = String(argumentsJson || "").trim();
34
+ if (!text) return [];
35
+ const parsed = JSON.parse(text);
36
+ if (!Array.isArray(parsed) && !(opts.allowObject && parsed && typeof parsed === "object")) {
37
+ throw new Error("DB_Routine arguments JSON must be an array.");
38
+ }
39
+ return resolveTemplateValue(parsed, context);
40
+ }
41
+
42
+ module.exports = { getPath, resolveTemplateValue, parseArgumentsJson };
package/lib/auth.js ADDED
@@ -0,0 +1,18 @@
1
+ function isAdmin(req) {
2
+ return Boolean(req && req.user && req.user.role_id === 1);
3
+ }
4
+
5
+ function requireAdmin(req, res) {
6
+ if (isAdmin(req)) return true;
7
+ if (!req.user && res && typeof res.redirect === "function") {
8
+ res.redirect(`/auth/login?dest=${encodeURIComponent(req.originalUrl || req.url || "/db-code")}`);
9
+ return false;
10
+ }
11
+ if (res && typeof res.status === "function") res.status(403);
12
+ if (res && typeof res.send === "function") {
13
+ res.send("Forbidden: DB Code is available to administrators only.");
14
+ }
15
+ return false;
16
+ }
17
+
18
+ module.exports = { isAdmin, requireAdmin };
@@ -0,0 +1,146 @@
1
+ const db = require("@saltcorn/data/db");
2
+ const { listRoutines, getRoutineByOid } = require("./introspection");
3
+ const { quoteIdent } = require("./sql-builders");
4
+ const { routineArity } = require("./routine-args");
5
+
6
+ function placeholders(count) {
7
+ return Array.from({ length: count }, (_, index) => `$${index + 1}`).join(", ");
8
+ }
9
+
10
+ async function callRoutine({ routineOid, argumentsJson, row, user, configuration }) {
11
+ const routine = await getRoutineByOid(routineOid);
12
+ if (!routine) throw new Error("Configured DB routine was not found in the current tenant schema.");
13
+ if (!["function", "procedure"].includes(routine.kind)) throw new Error("DB_Routine only supports functions and procedures.");
14
+
15
+ const values = parseAndValidateArgs(argumentsJson, routine, { row, user, configuration });
16
+ const routineRef = `${quoteIdent(routine.schema)}.${quoteIdent(routine.name)}`;
17
+ if (routine.kind === "procedure") {
18
+ const result = await db.query(`CALL ${routineRef}(${placeholders(values.length)})`, values);
19
+ return { success: true, kind: routine.kind, routine: routine.name, rowCount: result.rowCount || 0 };
20
+ }
21
+
22
+ const result = await db.query(`SELECT * FROM ${routineRef}(${placeholders(values.length)})`, values);
23
+ return { success: true, kind: routine.kind, routine: routine.name, rows: result.rows || [], rowCount: result.rowCount || 0 };
24
+ }
25
+
26
+ function resolveTemplateValue(value, context) {
27
+ if (Array.isArray(value)) return value.map((item) => resolveTemplateValue(item, context));
28
+ if (value && typeof value === "object") {
29
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveTemplateValue(v, context)]));
30
+ }
31
+ if (typeof value !== "string") return value;
32
+ return value.replace(/\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}/g, (match) => match);
33
+ }
34
+
35
+ function parseAndValidateArgs(argumentsJson, routine, context) {
36
+ const text = String(argumentsJson || "").trim();
37
+ const { required, total } = routineArity(routine);
38
+
39
+ if (!text) {
40
+ if (required > 0) {
41
+ throw new Error(
42
+ `Routine "${routine.name}" requires ${required} argument${required > 1 ? "s" : ""} (${routine.identity_arguments}) but none were provided.`
43
+ );
44
+ }
45
+ return [];
46
+ }
47
+
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ } catch (e) {
52
+ throw new Error(`Arguments JSON is not valid JSON: ${e.message}`);
53
+ }
54
+
55
+ if (Array.isArray(parsed)) {
56
+ const count = parsed.length;
57
+ if (count < required) {
58
+ throw new Error(
59
+ `Routine "${routine.name}" requires at least ${required} argument${required > 1 ? "s" : ""} (${routine.identity_arguments}) but ${count} were provided.`
60
+ );
61
+ }
62
+ if (count > total) {
63
+ throw new Error(
64
+ `Routine "${routine.name}" accepts at most ${total} argument${total > 1 ? "s" : ""} (${routine.identity_arguments}) but ${count} were provided.`
65
+ );
66
+ }
67
+ return resolveTemplateValue(parsed, context);
68
+ }
69
+
70
+ if (parsed && typeof parsed === "object") {
71
+ if (required === 0 && total === 0) {
72
+ throw new Error(`Routine "${routine.name}" takes no arguments.`);
73
+ }
74
+ return resolveTemplateValue(parsed, context);
75
+ }
76
+
77
+ throw new Error("Arguments JSON must be an array or object.");
78
+ }
79
+
80
+ async function routineOptions() {
81
+ const routines = await listRoutines();
82
+ return routines
83
+ .filter((routine) => ["function", "procedure"].includes(routine.kind))
84
+ .map((routine) => ({
85
+ value: String(routine.oid),
86
+ label: `${routine.kind}: ${routine.name}(${routine.identity_arguments || ""})`
87
+ }));
88
+ }
89
+
90
+ const DB_Routine = {
91
+ namespace: "Database",
92
+ description: "Call a PostgreSQL function or stored procedure from the current tenant schema",
93
+ configFields: async () => [
94
+ {
95
+ name: "routine_oid",
96
+ label: "DB routine",
97
+ type: "String",
98
+ input_type: "select",
99
+ required: true,
100
+ options: await routineOptions(),
101
+ sublabel: "Select a function or stored procedure from the current tenant schema."
102
+ },
103
+ {
104
+ name: "arguments_json",
105
+ label: "Arguments JSON",
106
+ input_type: "code",
107
+ type: "String",
108
+ required: false,
109
+ attributes: { mode: "application/json", nojoins: true },
110
+ sublabel: "JSON array (positional) or JSON object (named). Supports templates like {{row.id}}, {{user.email}}, {{configuration.some_value}}. Leave blank only for zero-argument routines.",
111
+ validator(s) {
112
+ if (!s || !String(s).trim()) return true;
113
+ try {
114
+ const parsed = JSON.parse(s);
115
+ if (!Array.isArray(parsed) && (parsed === null || typeof parsed !== "object")) {
116
+ return "Arguments must be a JSON array or object.";
117
+ }
118
+ return true;
119
+ } catch (e) {
120
+ return `Invalid JSON: ${e.message}`;
121
+ }
122
+ }
123
+ }
124
+ ],
125
+ configuration_summary: (cfg = {}) => `Call DB routine OID ${cfg.routine_oid || "not selected"}`,
126
+ run: async ({ row, user, configuration }) => {
127
+ try {
128
+ if (!configuration?.routine_oid) {
129
+ return {
130
+ error: "DB_Routine is not configured: no routine selected. Open the trigger/action configuration and select a DB routine."
131
+ };
132
+ }
133
+ return await callRoutine({
134
+ routineOid: configuration.routine_oid,
135
+ argumentsJson: configuration.arguments_json,
136
+ row,
137
+ user,
138
+ configuration
139
+ });
140
+ } catch (error) {
141
+ return { error: error.message || "DB_Routine execution failed." };
142
+ }
143
+ }
144
+ };
145
+
146
+ module.exports = { DB_Routine, callRoutine };
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Export/import helpers for database routines.
3
+ *
4
+ * Pack format:
5
+ * {
6
+ * format: "saltcorn-db-code-routines",
7
+ * version: 1,
8
+ * exported_at: "2026-05-09T...",
9
+ * source_schema: "public",
10
+ * routines: [
11
+ * {
12
+ * name: "hello",
13
+ * kind: "function",
14
+ * identity_arguments: "",
15
+ * arguments: "",
16
+ * result_type: "text",
17
+ * language: "sql",
18
+ * volatility: "IMMUTABLE",
19
+ * security_definer: false,
20
+ * input_args: [],
21
+ * description: "...",
22
+ * ddl: "CREATE OR REPLACE FUNCTION ..."
23
+ * }
24
+ * ]
25
+ * }
26
+ */
27
+
28
+ const { escapeHtml } = require("./html");
29
+
30
+ const PACK_FORMAT = "saltcorn-db-code-routines";
31
+ const PACK_VERSION = 1;
32
+
33
+ /**
34
+ * Build an export pack from an array of routine rows (as returned by listRoutines).
35
+ * Each routine's `definition` (from pg_get_functiondef) is used as the DDL.
36
+ */
37
+ function buildExportPack(routines, schema) {
38
+ return {
39
+ format: PACK_FORMAT,
40
+ version: PACK_VERSION,
41
+ exported_at: new Date().toISOString(),
42
+ source_schema: schema,
43
+ routines: routines.map((r) => ({
44
+ name: r.name,
45
+ kind: r.kind,
46
+ identity_arguments: r.identity_arguments || "",
47
+ arguments: r.arguments || "",
48
+ result_type: r.result_type || null,
49
+ language: r.language || "plpgsql",
50
+ volatility: r.volatility || "VOLATILE",
51
+ security_definer: !!r.prosecdef,
52
+ input_args: r.input_args || [],
53
+ description: r.description || null,
54
+ ddl: r.definition || "",
55
+ })),
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Validate an imported pack object.
61
+ * Returns { valid: true, pack } or { valid: false, error: "..." }.
62
+ */
63
+ function validateImportPack(raw) {
64
+ if (!raw || typeof raw !== "object") {
65
+ return { valid: false, error: "Invalid file: not a JSON object." };
66
+ }
67
+ if (raw.format !== PACK_FORMAT) {
68
+ return { valid: false, error: `Unrecognised format "${raw.format || "(missing)"}". Expected "${PACK_FORMAT}".` };
69
+ }
70
+ if ((raw.version || 0) > PACK_VERSION) {
71
+ return { valid: false, error: `Pack version ${raw.version} is newer than supported version ${PACK_VERSION}. Please update the DB Code plugin.` };
72
+ }
73
+ if (!Array.isArray(raw.routines)) {
74
+ return { valid: false, error: "Pack has no routines array." };
75
+ }
76
+ for (let i = 0; i < raw.routines.length; i++) {
77
+ const r = raw.routines[i];
78
+ if (!r.name) return { valid: false, error: `Routine #${i + 1} has no name.` };
79
+ if (!r.kind) return { valid: false, error: `Routine "${r.name}" has no kind.` };
80
+ if (!r.ddl) return { valid: false, error: `Routine "${r.name}" has no DDL.` };
81
+ }
82
+ return { valid: true, pack: raw };
83
+ }
84
+
85
+ /**
86
+ * Remap schema references in a DDL string from oldSchema to newSchema.
87
+ * Simple string replacement of "oldSchema". and oldSchema. patterns.
88
+ */
89
+ function remapSchemaInDdl(ddl, oldSchema, newSchema) {
90
+ if (!ddl || oldSchema === newSchema) return ddl;
91
+ const escapedOld = oldSchema.replace(/"/g, '""');
92
+ // Replace quoted and unquoted schema references
93
+ let result = ddl;
94
+ result = result.replace(new RegExp(`"${escapedOld.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\.`, "g"), `"${newSchema.replace(/"/g, '""')}".`);
95
+ result = result.replace(new RegExp(`(?<!")\\b${oldSchema.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.(?!")`, "g"), `${newSchema}.`);
96
+ return result;
97
+ }
98
+
99
+ /**
100
+ * Render an HTML table previewing the routines in a pack.
101
+ */
102
+ function renderPackPreview(pack) {
103
+ const routineRows = pack.routines.map((r, i) => {
104
+ const kindIcon = r.kind === "procedure" ? "fas fa-cogs" : "fas fa-cube";
105
+ const kindLabel = r.kind === "procedure" ? "Stored procedure" : "Function";
106
+ const kindClass = r.kind === "procedure" ? "bg-warning text-dark" : "bg-primary";
107
+ return `<tr>
108
+ <td><input class="form-check-input" type="checkbox" name="import_${i}" value="1" checked></td>
109
+ <td><span class="badge ${kindClass}"><i class="${kindIcon} me-1"></i>${escapeHtml(kindLabel)}</span></td>
110
+ <td><code>${escapeHtml(r.name)}</code></td>
111
+ <td><code>${escapeHtml(r.identity_arguments || "none")}</code></td>
112
+ <td><code>${escapeHtml(r.result_type || "—")}</code></td>
113
+ <td><span class="badge bg-light text-dark border">${escapeHtml(r.language || "")}</span></td>
114
+ </tr>`;
115
+ }).join("");
116
+
117
+ return `<div class="table-responsive"><table class="table table-sm table-hover align-middle mb-0">
118
+ <thead><tr>
119
+ <th><input class="form-check-input" type="checkbox" id="importToggleAll" checked></th>
120
+ <th>Type</th><th>Name</th><th>Arguments</th><th>Returns</th><th>Language</th>
121
+ </tr></thead>
122
+ <tbody>${routineRows}</tbody>
123
+ </table></div>
124
+ <div class="small text-muted mt-2">${pack.routines.length} routine${pack.routines.length !== 1 ? "s" : ""} · exported from schema <code>${escapeHtml(pack.source_schema || "?")}</code></div>
125
+ <script>
126
+ (function(){
127
+ var toggle = document.getElementById('importToggleAll');
128
+ if (!toggle) return;
129
+ var boxes = document.querySelectorAll('input[name^="import_"]');
130
+ toggle.addEventListener('change', function(){
131
+ boxes.forEach(function(b){ b.checked = toggle.checked; });
132
+ });
133
+ })();
134
+ </script>`;
135
+ }
136
+
137
+ module.exports = { PACK_FORMAT, PACK_VERSION, buildExportPack, validateImportPack, remapSchemaInDdl, renderPackPreview };
package/lib/html.js ADDED
@@ -0,0 +1,16 @@
1
+ function escapeHtml(value) {
2
+ return String(value ?? "")
3
+ .replace(/&/g, "&amp;")
4
+ .replace(/</g, "&lt;")
5
+ .replace(/>/g, "&gt;")
6
+ .replace(/"/g, "&quot;")
7
+ .replace(/'/g, "&#39;");
8
+ }
9
+
10
+ function page(req, res, title, body) {
11
+ const html = `<div class="container mt-4 db-code-plugin"><h1>${escapeHtml(title)}</h1>${body}</div>`;
12
+ if (res && typeof res.sendWrap === "function") return res.sendWrap(title, html);
13
+ return res.send(`<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><link rel="stylesheet" href="/static_assets/bootstrap.min.css"></head><body>${html}</body></html>`);
14
+ }
15
+
16
+ module.exports = { escapeHtml, page };
@@ -0,0 +1,87 @@
1
+ const db = require("@saltcorn/data/db");
2
+
3
+ const ROUTINE_SELECT = `
4
+ SELECT
5
+ p.oid,
6
+ n.nspname AS schema,
7
+ p.proname AS name,
8
+ p.prokind,
9
+ CASE p.prokind
10
+ WHEN 'f' THEN 'function'
11
+ WHEN 'p' THEN 'procedure'
12
+ WHEN 'a' THEN 'aggregate'
13
+ WHEN 'w' THEN 'window'
14
+ END AS kind,
15
+ pg_get_function_identity_arguments(p.oid) AS identity_arguments,
16
+ pg_get_function_arguments(p.oid) AS arguments,
17
+ pg_get_function_result(p.oid) AS result_type,
18
+ pg_get_functiondef(p.oid) AS definition,
19
+ l.lanname AS language,
20
+ p.provolatile,
21
+ p.pronargs,
22
+ p.pronargdefaults,
23
+ COALESCE(argmeta.input_args, '[]'::json) AS input_args,
24
+ CASE p.provolatile
25
+ WHEN 'i' THEN 'IMMUTABLE'
26
+ WHEN 's' THEN 'STABLE'
27
+ WHEN 'v' THEN 'VOLATILE'
28
+ END AS volatility,
29
+ p.prosecdef,
30
+ obj_description(p.oid, 'pg_proc') AS description
31
+ FROM pg_proc p
32
+ JOIN pg_namespace n ON n.oid = p.pronamespace
33
+ JOIN pg_language l ON l.oid = p.prolang
34
+ LEFT JOIN LATERAL (
35
+ SELECT json_agg(
36
+ json_build_object(
37
+ 'position', u.ord,
38
+ 'name', CASE
39
+ WHEN p.proargnames IS NOT NULL AND array_length(p.proargnames, 1) >= u.ord THEN p.proargnames[u.ord]
40
+ ELSE NULL
41
+ END,
42
+ 'type', format_type(u.type_oid, NULL)
43
+ )
44
+ ORDER BY u.ord
45
+ ) AS input_args
46
+ FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(type_oid, ord)
47
+ ) argmeta ON true
48
+ `;
49
+
50
+ function ensurePostgres() {
51
+ if (db.isSQLite) {
52
+ throw new Error("DB Code only supports PostgreSQL. SQLite does not support persistent stored routines.");
53
+ }
54
+ }
55
+
56
+ function currentTenantSchema() {
57
+ if (typeof db.getTenantSchema !== "function") throw new Error("Cannot resolve the current Saltcorn tenant schema.");
58
+ return db.getTenantSchema();
59
+ }
60
+
61
+ async function listRoutines() {
62
+ ensurePostgres();
63
+ const schema = currentTenantSchema();
64
+ const { rows } = await db.query(
65
+ `${ROUTINE_SELECT}
66
+ WHERE n.nspname = $1
67
+ ORDER BY p.proname, identity_arguments`,
68
+ [schema]
69
+ );
70
+ return rows;
71
+ }
72
+
73
+ async function getRoutineByOid(oid) {
74
+ ensurePostgres();
75
+ const schema = currentTenantSchema();
76
+ const parsedOid = Number.parseInt(oid, 10);
77
+ if (!Number.isInteger(parsedOid) || parsedOid <= 0) throw new Error("Invalid routine OID.");
78
+ const { rows } = await db.query(
79
+ `${ROUTINE_SELECT}
80
+ WHERE n.nspname = $1 AND p.oid = $2
81
+ LIMIT 1`,
82
+ [schema, parsedOid]
83
+ );
84
+ return rows[0] || null;
85
+ }
86
+
87
+ module.exports = { ensurePostgres, currentTenantSchema, listRoutines, getRoutineByOid };