@dypai-ai/mcp 1.0.9 → 1.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/package.json +5 -2
- package/src/index.js +231 -66
- package/src/tools/codegen.js +362 -0
- package/src/tools/deploy.js +27 -97
- package/src/tools/domains.js +49 -52
- package/src/tools/enrich.js +17 -0
- package/src/tools/frontend.js +93 -0
- package/src/tools/project-context.js +84 -0
- package/src/tools/proxy.js +14 -6
- package/src/tools/sql-side-effects.js +97 -0
- package/src/tools/sync/codec.js +185 -0
- package/src/tools/sync/describe.js +149 -0
- package/src/tools/sync/diff.js +83 -0
- package/src/tools/sync/index.js +18 -0
- package/src/tools/sync/planner.js +426 -0
- package/src/tools/sync/pull.js +414 -0
- package/src/tools/sync/push.js +411 -0
- package/src/tools/sync/schema-dump.js +96 -0
- package/src/tools/sync/test-endpoint.js +210 -0
- package/src/tools/sync/test.js +343 -0
- package/src/tools/sync/transforms.js +157 -0
- package/src/tools/sync/validate.js +567 -0
- package/src/tools/trace-summarize.js +178 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_push — apply the local ./dypai/ state to the remote project.
|
|
3
|
+
*
|
|
4
|
+
* Reads local YAML + referenced files, computes a plan against remote,
|
|
5
|
+
* and applies it via create_endpoint / update_endpoint / delete_endpoint.
|
|
6
|
+
*
|
|
7
|
+
* By default refuses to delete remote endpoints that aren't in local (safe).
|
|
8
|
+
* Pass delete_orphans: true to opt in.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { resolve as resolvePath } from "path"
|
|
12
|
+
import { proxyToolCall } from "../proxy.js"
|
|
13
|
+
import {
|
|
14
|
+
fetchRemoteState,
|
|
15
|
+
readLocalState,
|
|
16
|
+
readLocalStateSnapshot,
|
|
17
|
+
readLocalConfig,
|
|
18
|
+
readLocalRealtime,
|
|
19
|
+
fetchRemoteRealtime,
|
|
20
|
+
computePlan,
|
|
21
|
+
localToCanonical,
|
|
22
|
+
} from "./planner.js"
|
|
23
|
+
import { runValidation } from "./validate.js"
|
|
24
|
+
import { regenerateTypes } from "../codegen.js"
|
|
25
|
+
|
|
26
|
+
// ─── Apply helpers ─────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run an async worker over an array with bounded concurrency. Workers never
|
|
30
|
+
* throw (they handle their own errors via the push errors[] accumulator),
|
|
31
|
+
* so we can await them all safely without Promise.all propagating rejections.
|
|
32
|
+
*/
|
|
33
|
+
async function runWithConcurrency(items, concurrency, worker) {
|
|
34
|
+
if (!items.length) return
|
|
35
|
+
const queue = items.slice()
|
|
36
|
+
const pumps = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
37
|
+
while (queue.length) {
|
|
38
|
+
const item = queue.shift()
|
|
39
|
+
await worker(item)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
await Promise.all(pumps)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function endpointPayload(row) {
|
|
46
|
+
// The remote schemas reject null values for optional fields — only include
|
|
47
|
+
// what's actually set. workflow_code is always required.
|
|
48
|
+
const p = {
|
|
49
|
+
name: row.name,
|
|
50
|
+
method: row.method,
|
|
51
|
+
workflow_code: row.workflow_code,
|
|
52
|
+
is_tool: !!row.is_tool,
|
|
53
|
+
allowed_roles: row.allowed_roles || [],
|
|
54
|
+
}
|
|
55
|
+
if (row.description) p.description = row.description
|
|
56
|
+
if (row.tool_description) p.tool_description = row.tool_description
|
|
57
|
+
if (row.group_id) p.group_id = row.group_id
|
|
58
|
+
if (row.input) p.input = row.input
|
|
59
|
+
if (row.output) p.output = row.output
|
|
60
|
+
return p
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function applyCreate(canonicalRow, projectId) {
|
|
64
|
+
return proxyToolCall("create_endpoint", {
|
|
65
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
66
|
+
...endpointPayload(canonicalRow),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function applyUpdate(canonicalRow, endpointId, projectId) {
|
|
71
|
+
return proxyToolCall("update_endpoint", {
|
|
72
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
73
|
+
endpoint_id: endpointId,
|
|
74
|
+
updates: endpointPayload(canonicalRow),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function applyDelete(endpointId, projectId) {
|
|
79
|
+
return proxyToolCall("delete_endpoint", {
|
|
80
|
+
...(projectId ? { project_id: projectId } : {}),
|
|
81
|
+
endpoint_id: endpointId,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Realtime policies sync ────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reconcile local realtime.yaml with system.realtime_policies remotely.
|
|
89
|
+
* Returns a summary of what was applied (or would be applied in dry_run).
|
|
90
|
+
*/
|
|
91
|
+
async function syncRealtimePolicies(projectId, rootDir, dryRun = false) {
|
|
92
|
+
const local = await readLocalRealtime(rootDir)
|
|
93
|
+
if (!local) return { skipped: true, reason: "no realtime.yaml found" }
|
|
94
|
+
|
|
95
|
+
const remote = await fetchRemoteRealtime(projectId)
|
|
96
|
+
if (remote === null) {
|
|
97
|
+
return { skipped: true, reason: "engine has no system.realtime_policies table yet (backward compat)" }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const key = (p) => `${p.target_type}::${p.target_name}`
|
|
101
|
+
const remoteMap = Object.fromEntries(remote.map(p => [key(p), p]))
|
|
102
|
+
const localMap = Object.fromEntries(local.rows.map(p => [key(p), p]))
|
|
103
|
+
|
|
104
|
+
const toUpsert = []
|
|
105
|
+
const toDelete = []
|
|
106
|
+
for (const k of Object.keys(localMap)) {
|
|
107
|
+
const l = localMap[k]
|
|
108
|
+
const r = remoteMap[k]
|
|
109
|
+
if (!r || !policiesEqual(l, r)) toUpsert.push(l)
|
|
110
|
+
}
|
|
111
|
+
for (const k of Object.keys(remoteMap)) {
|
|
112
|
+
if (!localMap[k]) toDelete.push(remoteMap[k])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (dryRun) {
|
|
116
|
+
return { upsert: toUpsert.length, delete: toDelete.length, dry_run: true }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// execute_sql on the remote is parameterless (only takes a raw `sql` string).
|
|
120
|
+
// To avoid hand-escaping values, we embed the rows as JSON and let Postgres
|
|
121
|
+
// parse them via jsonb_to_recordset — JSON.stringify handles quote escaping
|
|
122
|
+
// correctly, and we only need to double single-quotes for the SQL literal.
|
|
123
|
+
if (toUpsert.length > 0) {
|
|
124
|
+
const rowsJson = JSON.stringify(toUpsert.map(p => ({
|
|
125
|
+
target_type: p.target_type,
|
|
126
|
+
target_name: p.target_name,
|
|
127
|
+
subscribe_filter: p.subscribe_filter,
|
|
128
|
+
events: p.events,
|
|
129
|
+
required_role: p.required_role,
|
|
130
|
+
auth_required: p.auth_required,
|
|
131
|
+
}))).replace(/'/g, "''") // escape for SQL string literal
|
|
132
|
+
|
|
133
|
+
await proxyToolCall("execute_sql", {
|
|
134
|
+
project_id: projectId,
|
|
135
|
+
sql: `
|
|
136
|
+
INSERT INTO system.realtime_policies
|
|
137
|
+
(target_type, target_name, subscribe_filter, events, required_role, auth_required, updated_at)
|
|
138
|
+
SELECT target_type, target_name, subscribe_filter, events, required_role, auth_required, now()
|
|
139
|
+
FROM jsonb_to_recordset('${rowsJson}'::jsonb) AS r(
|
|
140
|
+
target_type text,
|
|
141
|
+
target_name text,
|
|
142
|
+
subscribe_filter text,
|
|
143
|
+
events text[],
|
|
144
|
+
required_role text,
|
|
145
|
+
auth_required boolean
|
|
146
|
+
)
|
|
147
|
+
ON CONFLICT (target_type, target_name) DO UPDATE SET
|
|
148
|
+
subscribe_filter = EXCLUDED.subscribe_filter,
|
|
149
|
+
events = EXCLUDED.events,
|
|
150
|
+
required_role = EXCLUDED.required_role,
|
|
151
|
+
auth_required = EXCLUDED.auth_required,
|
|
152
|
+
updated_at = now()
|
|
153
|
+
`,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (toDelete.length > 0) {
|
|
158
|
+
// Build a safe tuple list. target_type is always 'table' or 'channel', target_name is user-provided.
|
|
159
|
+
const pairs = toDelete.map(p =>
|
|
160
|
+
`('${p.target_type}', '${String(p.target_name).replace(/'/g, "''")}')`
|
|
161
|
+
).join(",")
|
|
162
|
+
await proxyToolCall("execute_sql", {
|
|
163
|
+
project_id: projectId,
|
|
164
|
+
sql: `
|
|
165
|
+
DELETE FROM system.realtime_policies
|
|
166
|
+
WHERE (target_type, target_name) IN (${pairs})
|
|
167
|
+
`,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { upsert: toUpsert.length, delete: toDelete.length }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function policiesEqual(a, b) {
|
|
175
|
+
return a.target_type === b.target_type
|
|
176
|
+
&& a.target_name === b.target_name
|
|
177
|
+
&& (a.subscribe_filter || null) === (b.subscribe_filter || null)
|
|
178
|
+
&& JSON.stringify(a.events || []) === JSON.stringify(b.events || [])
|
|
179
|
+
&& (a.required_role || null) === (b.required_role || null)
|
|
180
|
+
&& !!a.auth_required === !!b.auth_required
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Tool ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export const dypaiPushTool = {
|
|
186
|
+
name: "dypai_push",
|
|
187
|
+
description:
|
|
188
|
+
"Apply the local ./dypai/ state to the remote project: creates, updates, and optionally deletes endpoints. " +
|
|
189
|
+
"ALWAYS run dypai_diff first to preview the plan. This tool mutates remote state. " +
|
|
190
|
+
"By default, endpoints in remote but missing locally are kept (safe). Pass delete_orphans: true to delete them.",
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: "object",
|
|
193
|
+
properties: {
|
|
194
|
+
project_id: {
|
|
195
|
+
type: "string",
|
|
196
|
+
description: "Project UUID. Optional if your token is project-scoped.",
|
|
197
|
+
},
|
|
198
|
+
root_dir: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Root of the dypai/ folder (default: ./dypai).",
|
|
201
|
+
default: "./dypai",
|
|
202
|
+
},
|
|
203
|
+
delete_orphans: {
|
|
204
|
+
type: "boolean",
|
|
205
|
+
description: "If true, endpoints in remote but missing locally get deleted. Default: false.",
|
|
206
|
+
default: false,
|
|
207
|
+
},
|
|
208
|
+
dry_run: {
|
|
209
|
+
type: "boolean",
|
|
210
|
+
description: "If true, compute the plan and return it without applying. Same as dypai_diff.",
|
|
211
|
+
default: false,
|
|
212
|
+
},
|
|
213
|
+
force: {
|
|
214
|
+
type: "boolean",
|
|
215
|
+
description: "Skip conflict detection. Use only if you know the remote changes you'd overwrite. Default: false.",
|
|
216
|
+
default: false,
|
|
217
|
+
},
|
|
218
|
+
skip_validation: {
|
|
219
|
+
type: "boolean",
|
|
220
|
+
description: "Skip the dypai_validate pre-flight check. Use only when you know the validator is wrong. Default: false.",
|
|
221
|
+
default: false,
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async execute({ project_id, root_dir = "./dypai", delete_orphans = false, dry_run = false, force = false, skip_validation = false } = {}) {
|
|
227
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
228
|
+
|
|
229
|
+
// Resolve the target project via dypai.config.yaml if the caller didn't
|
|
230
|
+
// pass an explicit project_id. If both are provided and disagree, refuse —
|
|
231
|
+
// this is the primary guard against an agent hallucinating a project_id.
|
|
232
|
+
const config = await readLocalConfig(rootDir)
|
|
233
|
+
const configProjectId = config?.project_id || null
|
|
234
|
+
const targetProjectId = project_id || configProjectId
|
|
235
|
+
|
|
236
|
+
// Pre-flight validation (lint). Blocks the push unless skip_validation is set.
|
|
237
|
+
if (!skip_validation && !dry_run) {
|
|
238
|
+
try {
|
|
239
|
+
const validation = await runValidation(rootDir, project_id || configProjectId)
|
|
240
|
+
if (!validation.success) {
|
|
241
|
+
return {
|
|
242
|
+
success: false,
|
|
243
|
+
applied: false,
|
|
244
|
+
reason: "validation_failed",
|
|
245
|
+
summary: validation.summary,
|
|
246
|
+
diagnostics: validation.diagnostics.filter(d => d.severity === "error").slice(0, 20),
|
|
247
|
+
hint: `${validation.summary.errors} error(s) would cause runtime failures. Fix them, or retry with skip_validation: true to override.`,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} catch (e) {
|
|
251
|
+
// Don't block the push on a validator crash — just warn
|
|
252
|
+
console.error(`[dypai_push] validator crashed: ${e.message}`)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (project_id && configProjectId && project_id !== configProjectId) {
|
|
257
|
+
return {
|
|
258
|
+
success: false,
|
|
259
|
+
applied: false,
|
|
260
|
+
reason: "project_id_mismatch",
|
|
261
|
+
expected_project_id: configProjectId,
|
|
262
|
+
provided_project_id: project_id,
|
|
263
|
+
hint:
|
|
264
|
+
`This dypai/ folder is pinned to project ${configProjectId} (${config?.project_name || "unknown"}). ` +
|
|
265
|
+
`You passed ${project_id}. Refusing to push to a different project. ` +
|
|
266
|
+
`If retargeting is intentional, edit dypai.config.yaml first.`,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const [local, remote, stateSnapshot] = await Promise.all([
|
|
271
|
+
readLocalState(rootDir),
|
|
272
|
+
fetchRemoteState(targetProjectId),
|
|
273
|
+
readLocalStateSnapshot(rootDir),
|
|
274
|
+
])
|
|
275
|
+
|
|
276
|
+
if (local.errors.length) {
|
|
277
|
+
return {
|
|
278
|
+
success: false,
|
|
279
|
+
phase: "read_local",
|
|
280
|
+
errors: local.errors,
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
285
|
+
deleteOrphans: delete_orphans,
|
|
286
|
+
stateSnapshot,
|
|
287
|
+
})
|
|
288
|
+
const totalChanges = plan.create.length + plan.update.length + plan.delete.length
|
|
289
|
+
|
|
290
|
+
// Block push on conflicts unless forced
|
|
291
|
+
const conflicts = (plan.warnings || []).filter(w => w.type === "remote_changed_since_pull")
|
|
292
|
+
if (conflicts.length && !force) {
|
|
293
|
+
return {
|
|
294
|
+
success: false,
|
|
295
|
+
applied: false,
|
|
296
|
+
reason: "conflicts_detected",
|
|
297
|
+
conflicts,
|
|
298
|
+
hint: "Run dypai_pull to refresh local state, resolve any overlap, then push again. To override, pass force=true.",
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (dry_run || totalChanges === 0) {
|
|
303
|
+
return {
|
|
304
|
+
success: true,
|
|
305
|
+
applied: false,
|
|
306
|
+
reason: totalChanges === 0 ? "no_changes" : "dry_run",
|
|
307
|
+
summary: summaryFromPlan(plan),
|
|
308
|
+
plan,
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Apply in order: creates → updates → deletes.
|
|
313
|
+
// Within each phase we run with bounded concurrency (5) so pushing 50
|
|
314
|
+
// endpoints doesn't take 50 * 200ms = 10s — drops to ~2s on typical RTT.
|
|
315
|
+
const CONCURRENCY = 5
|
|
316
|
+
const applied = { created: [], updated: [], deleted: [] }
|
|
317
|
+
const errors = []
|
|
318
|
+
|
|
319
|
+
await runWithConcurrency(plan.create, CONCURRENCY, async (item) => {
|
|
320
|
+
try {
|
|
321
|
+
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
322
|
+
await applyCreate(canonical, targetProjectId)
|
|
323
|
+
applied.created.push(item.name)
|
|
324
|
+
} catch (e) {
|
|
325
|
+
errors.push({ op: "create", endpoint: item.name, error: e.message })
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
await runWithConcurrency(plan.update, CONCURRENCY, async (item) => {
|
|
330
|
+
try {
|
|
331
|
+
const canonical = localToCanonical(local.byName[item.name], remote.mapsCtx)
|
|
332
|
+
const endpointId = remote.byName[item.name]?.id
|
|
333
|
+
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
334
|
+
await applyUpdate(canonical, endpointId, targetProjectId)
|
|
335
|
+
applied.updated.push({ name: item.name, changed_fields: item.changedFields })
|
|
336
|
+
} catch (e) {
|
|
337
|
+
errors.push({ op: "update", endpoint: item.name, error: e.message })
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
await runWithConcurrency(plan.delete, CONCURRENCY, async (item) => {
|
|
342
|
+
try {
|
|
343
|
+
const endpointId = remote.byName[item.name]?.id
|
|
344
|
+
if (!endpointId) throw new Error("endpoint_id missing from remote state")
|
|
345
|
+
await applyDelete(endpointId, targetProjectId)
|
|
346
|
+
applied.deleted.push(item.name)
|
|
347
|
+
} catch (e) {
|
|
348
|
+
errors.push({ op: "delete", endpoint: item.name, error: e.message })
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Also reconcile realtime policies if realtime.yaml exists
|
|
353
|
+
let realtime = null
|
|
354
|
+
try {
|
|
355
|
+
realtime = await syncRealtimePolicies(targetProjectId, rootDir, false)
|
|
356
|
+
} catch (e) {
|
|
357
|
+
errors.push({ op: "realtime", error: e.message })
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const changedNames = [
|
|
361
|
+
...applied.created,
|
|
362
|
+
...applied.updated.map(u => u.name),
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
// Regenerate TS types if endpoints changed (input/output schemas may have shifted).
|
|
366
|
+
let codegen
|
|
367
|
+
if (changedNames.length > 0) {
|
|
368
|
+
try { codegen = await regenerateTypes(rootDir) }
|
|
369
|
+
catch (e) { codegen = { skipped: true, reason: e.message } }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
success: errors.length === 0,
|
|
374
|
+
applied: true,
|
|
375
|
+
summary: {
|
|
376
|
+
created: applied.created.length,
|
|
377
|
+
updated: applied.updated.length,
|
|
378
|
+
deleted: applied.deleted.length,
|
|
379
|
+
unchanged: plan.unchanged.length,
|
|
380
|
+
errors: errors.length,
|
|
381
|
+
realtime: realtime || { skipped: true, reason: "no realtime.yaml" },
|
|
382
|
+
codegen: codegen?.regenerated
|
|
383
|
+
? { files: codegen.files_written.length }
|
|
384
|
+
: (codegen?.reason || "no changes to regenerate"),
|
|
385
|
+
},
|
|
386
|
+
details: applied,
|
|
387
|
+
errors: errors.length ? errors : undefined,
|
|
388
|
+
// Only one next_step — and only when it's non-obvious
|
|
389
|
+
next_step: errors.length
|
|
390
|
+
? "Fix the offending YAMLs and push again."
|
|
391
|
+
: changedNames.length
|
|
392
|
+
? `Test changed endpoint(s) with test_workflow: ${changedNames.slice(0, 3).join(", ")}${changedNames.length > 3 ? "…" : ""}`
|
|
393
|
+
: undefined,
|
|
394
|
+
hint: errors.some(e => /credential/i.test(e.error))
|
|
395
|
+
? "A referenced credential doesn't exist remotely. Create it in the dashboard (same name), then retry."
|
|
396
|
+
: errors.some(e => /validation/i.test(e.error))
|
|
397
|
+
? "Field shape mismatch. Inspect the YAML of the failed endpoint."
|
|
398
|
+
: undefined,
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function summaryFromPlan(plan) {
|
|
404
|
+
return {
|
|
405
|
+
create: plan.create.length,
|
|
406
|
+
update: plan.update.length,
|
|
407
|
+
delete: plan.delete.length,
|
|
408
|
+
unchanged: plan.unchanged.length,
|
|
409
|
+
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
410
|
+
}
|
|
411
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dump of the public schema as DDL-looking SQL (CREATE TABLE statements with
|
|
3
|
+
* columns + PKs + FK comments). Shared by dypai_pull and the auto-refresh
|
|
4
|
+
* triggered by execute_sql when it detects schema-affecting DDL.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { proxyToolCall } from "../proxy.js"
|
|
8
|
+
|
|
9
|
+
async function execSql(projectId, sql) {
|
|
10
|
+
const args = projectId ? { project_id: projectId, sql } : { sql }
|
|
11
|
+
const result = await proxyToolCall("execute_sql", args)
|
|
12
|
+
if (result?.error) throw new Error(`SQL error: ${result.error}`)
|
|
13
|
+
if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
|
|
14
|
+
return result.rows
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatColumnType(c) {
|
|
18
|
+
if (c.data_type === "character varying") {
|
|
19
|
+
return c.character_maximum_length ? `varchar(${c.character_maximum_length})` : "varchar"
|
|
20
|
+
}
|
|
21
|
+
if (c.data_type === "USER-DEFINED") return c.udt_name || "user_defined"
|
|
22
|
+
if (c.data_type === "ARRAY") return `${c.udt_name || "text"}`.replace(/^_/, "") + "[]"
|
|
23
|
+
return c.data_type
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function dumpPublicSchema(projectId) {
|
|
27
|
+
const [columns, pks, fks] = await Promise.all([
|
|
28
|
+
execSql(projectId, `
|
|
29
|
+
SELECT table_name, column_name, data_type, is_nullable, column_default,
|
|
30
|
+
character_maximum_length, udt_name, ordinal_position
|
|
31
|
+
FROM information_schema.columns
|
|
32
|
+
WHERE table_schema = 'public'
|
|
33
|
+
ORDER BY table_name, ordinal_position
|
|
34
|
+
`),
|
|
35
|
+
execSql(projectId, `
|
|
36
|
+
SELECT tc.table_name, kcu.column_name
|
|
37
|
+
FROM information_schema.table_constraints tc
|
|
38
|
+
JOIN information_schema.key_column_usage kcu
|
|
39
|
+
ON tc.constraint_schema = kcu.constraint_schema
|
|
40
|
+
AND tc.constraint_name = kcu.constraint_name
|
|
41
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
|
|
42
|
+
`),
|
|
43
|
+
execSql(projectId, `
|
|
44
|
+
SELECT tc.table_name, kcu.column_name,
|
|
45
|
+
ccu.table_name AS ref_table, ccu.column_name AS ref_column
|
|
46
|
+
FROM information_schema.table_constraints tc
|
|
47
|
+
JOIN information_schema.key_column_usage kcu
|
|
48
|
+
ON tc.constraint_schema = kcu.constraint_schema
|
|
49
|
+
AND tc.constraint_name = kcu.constraint_name
|
|
50
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
51
|
+
ON tc.constraint_schema = ccu.constraint_schema
|
|
52
|
+
AND tc.constraint_name = ccu.constraint_name
|
|
53
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
|
|
54
|
+
`),
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
const byTable = {}
|
|
58
|
+
for (const c of columns) {
|
|
59
|
+
if (!byTable[c.table_name]) byTable[c.table_name] = []
|
|
60
|
+
byTable[c.table_name].push(c)
|
|
61
|
+
}
|
|
62
|
+
const pkSet = new Set(pks.map(p => `${p.table_name}.${p.column_name}`))
|
|
63
|
+
const fksByTable = {}
|
|
64
|
+
for (const f of fks) {
|
|
65
|
+
if (!fksByTable[f.table_name]) fksByTable[f.table_name] = []
|
|
66
|
+
fksByTable[f.table_name].push(f)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines = [
|
|
70
|
+
"-- Auto-generated — do NOT edit by hand.",
|
|
71
|
+
"-- Regenerated on every dypai_pull and after schema-affecting execute_sql (CREATE/ALTER/DROP/RENAME TABLE).",
|
|
72
|
+
"",
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const tableNames = Object.keys(byTable).sort()
|
|
76
|
+
for (const table of tableNames) {
|
|
77
|
+
const cols = byTable[table]
|
|
78
|
+
lines.push(`CREATE TABLE public.${table} (`)
|
|
79
|
+
const colLines = cols.map(c => {
|
|
80
|
+
const type = formatColumnType(c)
|
|
81
|
+
const nullable = c.is_nullable === "NO" ? " NOT NULL" : ""
|
|
82
|
+
const pk = pkSet.has(`${table}.${c.column_name}`) ? " PRIMARY KEY" : ""
|
|
83
|
+
const dflt = c.column_default ? ` DEFAULT ${c.column_default}` : ""
|
|
84
|
+
return ` ${c.column_name} ${type}${nullable}${pk}${dflt}`
|
|
85
|
+
})
|
|
86
|
+
lines.push(colLines.join(",\n"))
|
|
87
|
+
lines.push(");")
|
|
88
|
+
|
|
89
|
+
const tableFks = fksByTable[table] || []
|
|
90
|
+
for (const fk of tableFks) {
|
|
91
|
+
lines.push(`-- FK: ${fk.column_name} -> public.${fk.ref_table}(${fk.ref_column})`)
|
|
92
|
+
}
|
|
93
|
+
lines.push("")
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n")
|
|
96
|
+
}
|