@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,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dypai_diff — preview what dypai_push would change. Read-only, safe.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolve as resolvePath } from "path"
|
|
6
|
+
import { fetchRemoteState, readLocalState, readLocalStateSnapshot, readLocalConfig, computePlan } from "./planner.js"
|
|
7
|
+
|
|
8
|
+
export const dypaiDiffTool = {
|
|
9
|
+
name: "dypai_diff",
|
|
10
|
+
description:
|
|
11
|
+
"Compare the local ./dypai/ folder against the remote project state without applying anything. " +
|
|
12
|
+
"Returns a plan: endpoints to create, update (with changed fields), delete, and unchanged. " +
|
|
13
|
+
"Always call this before dypai_push to preview changes safely.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
project_id: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "Project UUID. Optional if your token is project-scoped.",
|
|
20
|
+
},
|
|
21
|
+
root_dir: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Root of the dypai/ folder (default: ./dypai).",
|
|
24
|
+
default: "./dypai",
|
|
25
|
+
},
|
|
26
|
+
delete_orphans: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
description: "If true, endpoints present in remote but missing locally are queued for deletion. Default: false (safe).",
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async execute({ project_id, root_dir = "./dypai", delete_orphans = false } = {}) {
|
|
35
|
+
const rootDir = resolvePath(process.cwd(), root_dir)
|
|
36
|
+
|
|
37
|
+
const config = await readLocalConfig(rootDir)
|
|
38
|
+
const targetProjectId = project_id || config?.project_id || null
|
|
39
|
+
|
|
40
|
+
const [local, remote, stateSnapshot] = await Promise.all([
|
|
41
|
+
readLocalState(rootDir),
|
|
42
|
+
fetchRemoteState(targetProjectId),
|
|
43
|
+
readLocalStateSnapshot(rootDir),
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
if (local.errors.length) {
|
|
47
|
+
return {
|
|
48
|
+
success: false,
|
|
49
|
+
phase: "read_local",
|
|
50
|
+
errors: local.errors,
|
|
51
|
+
hint: "Fix the YAML/file errors before running diff again.",
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const plan = computePlan(local, remote, remote.mapsCtx, {
|
|
56
|
+
deleteOrphans: delete_orphans,
|
|
57
|
+
stateSnapshot,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const totalChanges = plan.create.length + plan.update.length + plan.delete.length
|
|
61
|
+
const hasConflicts = (plan.warnings || []).some(w => w.type === "remote_changed_since_pull")
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
summary: {
|
|
66
|
+
create: plan.create.length,
|
|
67
|
+
update: plan.update.length,
|
|
68
|
+
delete: plan.delete.length,
|
|
69
|
+
unchanged: plan.unchanged.length,
|
|
70
|
+
orphans_ignored: plan.orphansIgnored?.length || 0,
|
|
71
|
+
warnings: plan.warnings?.length || 0,
|
|
72
|
+
},
|
|
73
|
+
// Only include plan details when there are actual changes or warnings
|
|
74
|
+
plan: (totalChanges > 0 || plan.warnings?.length) ? plan : undefined,
|
|
75
|
+
// Conflicts and missing creds deserve surfacing; no-op diffs don't
|
|
76
|
+
hint: hasConflicts
|
|
77
|
+
? "Remote changed since your last pull — dypai_pull first, or dypai_push will be blocked."
|
|
78
|
+
: (plan.warnings || []).some(w => w.type === "missing_credential")
|
|
79
|
+
? "Some YAMLs reference credentials missing remotely. Create them in the dashboard before pushing."
|
|
80
|
+
: undefined,
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-first source-of-truth tooling for DYPAI projects.
|
|
3
|
+
*
|
|
4
|
+
* Architecture:
|
|
5
|
+
* transforms.js — bidirectional field transforms (symmetric by construction)
|
|
6
|
+
* codec.js — endpoint-level serialize / deserialize (structural shape)
|
|
7
|
+
* pull.js — DB → filesystem (uses codec.serializeEndpoint)
|
|
8
|
+
* push.js — filesystem → DB (uses codec.deserializeEndpoint) [pending]
|
|
9
|
+
* diff.js — compares both sides without writing [pending]
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { dypaiPullTool } from "./pull.js"
|
|
13
|
+
export { dypaiDiffTool } from "./diff.js"
|
|
14
|
+
export { dypaiPushTool } from "./push.js"
|
|
15
|
+
export { dypaiDescribeTool } from "./describe.js"
|
|
16
|
+
export { dypaiValidateTool } from "./validate.js"
|
|
17
|
+
export { dypaiTestTool } from "./test.js"
|
|
18
|
+
export { dypaiTestEndpointTool } from "./test-endpoint.js"
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planner — the brain behind diff and push.
|
|
3
|
+
*
|
|
4
|
+
* Reads local YAML + referenced files from disk, fetches the remote DB state,
|
|
5
|
+
* and produces a plan: { create, update, delete, unchanged }.
|
|
6
|
+
*
|
|
7
|
+
* Both sides are normalized through the same codec so canvas, default
|
|
8
|
+
* trigger blocks, and other noise don't show up as false-positive diffs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, readdir } from "fs/promises"
|
|
12
|
+
import { join } from "path"
|
|
13
|
+
import YAML from "yaml"
|
|
14
|
+
import { proxyToolCall } from "../proxy.js"
|
|
15
|
+
import { deserializeEndpoint, serializeEndpoint } from "./codec.js"
|
|
16
|
+
|
|
17
|
+
// ─── Remote ────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
async function execSql(projectId, sql) {
|
|
20
|
+
const args = projectId ? { project_id: projectId, sql } : { sql }
|
|
21
|
+
const result = await proxyToolCall("execute_sql", args)
|
|
22
|
+
if (result?.error) throw new Error(`SQL error: ${result.error}`)
|
|
23
|
+
if (!result?.rows) throw new Error(`Unexpected SQL response: ${JSON.stringify(result).slice(0, 300)}`)
|
|
24
|
+
return result.rows
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseMaybeJson(v) {
|
|
28
|
+
if (v == null || typeof v !== "string") return v
|
|
29
|
+
try { return JSON.parse(v) } catch { return v }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hydrateRow(row) {
|
|
33
|
+
return {
|
|
34
|
+
...row,
|
|
35
|
+
workflow_code: parseMaybeJson(row.workflow_code) || {},
|
|
36
|
+
input: parseMaybeJson(row.input),
|
|
37
|
+
output: parseMaybeJson(row.output),
|
|
38
|
+
allowed_roles: parseMaybeJson(row.allowed_roles) || row.allowed_roles || [],
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load .dypai/state.json if it exists. Used for conflict detection on push.
|
|
44
|
+
*/
|
|
45
|
+
export async function readLocalStateSnapshot(rootDir) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = await readFile(join(rootDir, ".dypai", "state.json"), "utf8")
|
|
48
|
+
return JSON.parse(raw)
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load dypai.config.yaml if it exists. This is the committed identity of the
|
|
56
|
+
* project (project_id, project_name). Used to auto-resolve project_id and to
|
|
57
|
+
* guard against pushing to the wrong project by accident.
|
|
58
|
+
*/
|
|
59
|
+
export async function readLocalConfig(rootDir) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await readFile(join(rootDir, "dypai.config.yaml"), "utf8")
|
|
62
|
+
return YAML.parse(raw) || null
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load dypai/realtime.yaml if it exists. Returns { tables: {...}, channels: {...} }
|
|
70
|
+
* flattened into a list of { target_type, target_name, ...policy } rows so the
|
|
71
|
+
* push/diff paths can treat them uniformly.
|
|
72
|
+
*/
|
|
73
|
+
export async function readLocalRealtime(rootDir) {
|
|
74
|
+
try {
|
|
75
|
+
const raw = await readFile(join(rootDir, "realtime.yaml"), "utf8")
|
|
76
|
+
const doc = YAML.parse(raw) || {}
|
|
77
|
+
const rows = []
|
|
78
|
+
for (const [name, p] of Object.entries(doc.tables || {})) {
|
|
79
|
+
rows.push(normalizePolicy("table", name, p))
|
|
80
|
+
}
|
|
81
|
+
for (const [name, p] of Object.entries(doc.channels || {})) {
|
|
82
|
+
rows.push(normalizePolicy("channel", name, p))
|
|
83
|
+
}
|
|
84
|
+
return { rows, doc }
|
|
85
|
+
} catch {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizePolicy(target_type, target_name, p) {
|
|
91
|
+
return {
|
|
92
|
+
target_type,
|
|
93
|
+
target_name,
|
|
94
|
+
subscribe_filter: p?.subscribe_filter ?? null,
|
|
95
|
+
events: Array.isArray(p?.events) ? p.events : null,
|
|
96
|
+
required_role: p?.required_role ?? null,
|
|
97
|
+
auth_required: p?.auth_required !== false, // default true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fetch remote realtime policies from the DB. Tolerates the table not existing. */
|
|
102
|
+
export async function fetchRemoteRealtime(projectId) {
|
|
103
|
+
try {
|
|
104
|
+
return await execSql(projectId, `
|
|
105
|
+
SELECT target_type, target_name, subscribe_filter,
|
|
106
|
+
events, required_role, auth_required
|
|
107
|
+
FROM system.realtime_policies
|
|
108
|
+
ORDER BY target_type, target_name
|
|
109
|
+
`)
|
|
110
|
+
} catch {
|
|
111
|
+
return null // engine doesn't have the table yet
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function fetchRemoteState(projectId) {
|
|
116
|
+
const [endpoints, credentials, groups] = await Promise.all([
|
|
117
|
+
execSql(projectId, `
|
|
118
|
+
SELECT id, name, method, description, workflow_code, input, output,
|
|
119
|
+
allowed_roles, is_tool, tool_description, group_id, updated_at
|
|
120
|
+
FROM system.endpoints
|
|
121
|
+
WHERE is_active = true
|
|
122
|
+
ORDER BY name
|
|
123
|
+
`),
|
|
124
|
+
execSql(projectId, "SELECT id, name, type FROM system.credentials"),
|
|
125
|
+
execSql(projectId, "SELECT id, name FROM system.endpoints_group"),
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
const mapsCtx = {
|
|
129
|
+
credIdToName: Object.fromEntries(credentials.map(c => [c.id, c.name])),
|
|
130
|
+
groupIdToName: Object.fromEntries(groups.map(g => [g.id, g.name])),
|
|
131
|
+
endpointIdToName: Object.fromEntries(endpoints.map(e => [e.id, e.name])),
|
|
132
|
+
credNameToId: Object.fromEntries(credentials.map(c => [c.name, c.id])),
|
|
133
|
+
groupNameToId: Object.fromEntries(groups.map(g => [g.name, g.id])),
|
|
134
|
+
endpointNameToId: Object.fromEntries(endpoints.map(e => [e.name, e.id])),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const byName = {}
|
|
138
|
+
for (const raw of endpoints) {
|
|
139
|
+
byName[raw.name] = hydrateRow(raw)
|
|
140
|
+
}
|
|
141
|
+
return { byName, mapsCtx }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Local ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Walk a doc finding all `*_file` string fields. Used to pre-read contents.
|
|
148
|
+
*/
|
|
149
|
+
function collectFileRefs(doc) {
|
|
150
|
+
const refs = new Set()
|
|
151
|
+
const walk = (node) => {
|
|
152
|
+
if (!node || typeof node !== "object") return
|
|
153
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
154
|
+
for (const [k, v] of Object.entries(node)) {
|
|
155
|
+
if (typeof v === "string" && k.endsWith("_file")) refs.add(v)
|
|
156
|
+
else walk(v)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
walk(doc)
|
|
160
|
+
return Array.from(refs)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Walk endpoints/ recursively. The first-level subfolder name (if any) encodes
|
|
165
|
+
* the endpoint's group. Files directly under endpoints/ have no group.
|
|
166
|
+
*
|
|
167
|
+
* endpoints/foo.yaml → no group
|
|
168
|
+
* endpoints/Admin/foo.yaml → group "Admin"
|
|
169
|
+
* endpoints/Admin/Users/foo.yaml → group "Admin" (deeper nesting ignored for now)
|
|
170
|
+
*/
|
|
171
|
+
async function findEndpointYamls(endpointsDir) {
|
|
172
|
+
const out = [] // { relFromEndpoints, group }
|
|
173
|
+
async function walk(dir, relFromRoot, group) {
|
|
174
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
|
|
175
|
+
for (const e of entries) {
|
|
176
|
+
if (e.isDirectory()) {
|
|
177
|
+
// Only the first-level folder is the group; deeper is flattened into it
|
|
178
|
+
const sub = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
|
|
179
|
+
await walk(join(dir, e.name), sub, group || e.name)
|
|
180
|
+
} else if (e.isFile() && (e.name.endsWith(".yaml") || e.name.endsWith(".yml"))) {
|
|
181
|
+
const rel = relFromRoot ? `${relFromRoot}/${e.name}` : e.name
|
|
182
|
+
out.push({ rel, group })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
await walk(endpointsDir, "", null)
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function readLocalState(rootDir) {
|
|
191
|
+
const endpointsDir = join(rootDir, "endpoints")
|
|
192
|
+
const yamls = await findEndpointYamls(endpointsDir)
|
|
193
|
+
|
|
194
|
+
const byName = {}
|
|
195
|
+
const errors = []
|
|
196
|
+
|
|
197
|
+
for (const { rel, group } of yamls) {
|
|
198
|
+
try {
|
|
199
|
+
const raw = await readFile(join(endpointsDir, rel), "utf8")
|
|
200
|
+
const doc = YAML.parse(raw)
|
|
201
|
+
if (!doc?.name) {
|
|
202
|
+
errors.push({ file: rel, error: "missing 'name' field" })
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Inject group from folder if present and not already set in the YAML
|
|
207
|
+
if (group && !doc.group) doc.group = group
|
|
208
|
+
|
|
209
|
+
// Pre-read all *_file references (paths are relative to dypai/ root)
|
|
210
|
+
const refs = collectFileRefs(doc)
|
|
211
|
+
const contents = await Promise.all(
|
|
212
|
+
refs.map(p => readFile(join(rootDir, p), "utf8").catch(() => {
|
|
213
|
+
errors.push({ file: rel, error: `referenced file not found: ${p}` })
|
|
214
|
+
return ""
|
|
215
|
+
}))
|
|
216
|
+
)
|
|
217
|
+
const fileMap = Object.fromEntries(refs.map((p, i) => [p, contents[i]]))
|
|
218
|
+
|
|
219
|
+
byName[doc.name] = { doc, fileMap }
|
|
220
|
+
} catch (e) {
|
|
221
|
+
errors.push({ file: rel, error: e.message })
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { byName, errors }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Normalization + diff ──────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Produce a canonical (fully-hydrated, DB-shaped) row from a local {doc, fileMap}.
|
|
232
|
+
* This is what we'd send to update_endpoint.
|
|
233
|
+
*/
|
|
234
|
+
export function localToCanonical(entry, mapsCtx) {
|
|
235
|
+
const { doc, fileMap } = entry
|
|
236
|
+
const ctx = { ...mapsCtx, readFile: (p) => fileMap[p] ?? "" }
|
|
237
|
+
return deserializeEndpoint(doc, ctx)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Inline *_file refs back into a doc using a path→content map. Mutates doc.
|
|
242
|
+
*/
|
|
243
|
+
function inlineFileRefs(node, fileMap) {
|
|
244
|
+
if (!node || typeof node !== "object") return
|
|
245
|
+
if (Array.isArray(node)) {
|
|
246
|
+
node.forEach(n => inlineFileRefs(n, fileMap))
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
const inlines = []
|
|
250
|
+
for (const [k, v] of Object.entries(node)) {
|
|
251
|
+
if (typeof v === "string" && k.endsWith("_file") && fileMap[v] !== undefined) {
|
|
252
|
+
inlines.push(k)
|
|
253
|
+
} else {
|
|
254
|
+
inlineFileRefs(v, fileMap)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const k of inlines) {
|
|
258
|
+
const inlineKey = k.replace(/_file$/, "")
|
|
259
|
+
node[inlineKey] = fileMap[node[k]]
|
|
260
|
+
delete node[k]
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Canonical form for diff: doc with file contents inlined back in. Both sides
|
|
266
|
+
* are brought to this shape so file contents are part of the comparison
|
|
267
|
+
* (otherwise identical file paths would mask content changes).
|
|
268
|
+
*/
|
|
269
|
+
function remoteToComparable(row, mapsCtx) {
|
|
270
|
+
const { doc, extractedFiles } = serializeEndpoint(row, mapsCtx)
|
|
271
|
+
const fileMap = Object.fromEntries(extractedFiles.map(f => [f.path, f.content]))
|
|
272
|
+
inlineFileRefs(doc, fileMap)
|
|
273
|
+
return doc
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function localToComparable(entry, mapsCtx) {
|
|
277
|
+
const row = localToCanonical(entry, mapsCtx)
|
|
278
|
+
return remoteToComparable(row, mapsCtx)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function deepEqual(a, b) {
|
|
282
|
+
if (a === b) return true
|
|
283
|
+
if (typeof a !== typeof b) return false
|
|
284
|
+
if (a === null || b === null) return a === b
|
|
285
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false
|
|
286
|
+
if (Array.isArray(a)) {
|
|
287
|
+
if (a.length !== b.length) return false
|
|
288
|
+
return a.every((x, i) => deepEqual(x, b[i]))
|
|
289
|
+
}
|
|
290
|
+
if (typeof a === "object") {
|
|
291
|
+
const ka = Object.keys(a).sort()
|
|
292
|
+
const kb = Object.keys(b).sort()
|
|
293
|
+
if (ka.join(",") !== kb.join(",")) return false
|
|
294
|
+
return ka.every(k => deepEqual(a[k], b[k]))
|
|
295
|
+
}
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Return a list of top-level field paths that differ between two docs.
|
|
301
|
+
* Used for human-readable diff summaries.
|
|
302
|
+
*/
|
|
303
|
+
function summarizeFieldDiffs(a, b, prefix = "") {
|
|
304
|
+
const diffs = []
|
|
305
|
+
const keys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})])
|
|
306
|
+
for (const k of keys) {
|
|
307
|
+
const va = a?.[k]
|
|
308
|
+
const vb = b?.[k]
|
|
309
|
+
if (deepEqual(va, vb)) continue
|
|
310
|
+
const path = prefix ? `${prefix}.${k}` : k
|
|
311
|
+
if (va && vb && typeof va === "object" && typeof vb === "object" && !Array.isArray(va) && !Array.isArray(vb)) {
|
|
312
|
+
diffs.push(...summarizeFieldDiffs(va, vb, path))
|
|
313
|
+
} else {
|
|
314
|
+
diffs.push(path)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return diffs
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Find credential references in a doc and return a list of any that don't
|
|
322
|
+
* resolve against the remote credNameToId map.
|
|
323
|
+
*/
|
|
324
|
+
function findMissingCredentials(doc, credNameToId) {
|
|
325
|
+
const missing = new Set()
|
|
326
|
+
const walk = (node) => {
|
|
327
|
+
if (!node || typeof node !== "object") return
|
|
328
|
+
if (Array.isArray(node)) return node.forEach(walk)
|
|
329
|
+
for (const [k, v] of Object.entries(node)) {
|
|
330
|
+
if (k === "credential" && typeof v === "string" && !credNameToId[v]) {
|
|
331
|
+
missing.add(v)
|
|
332
|
+
} else {
|
|
333
|
+
walk(v)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
walk(doc)
|
|
338
|
+
return Array.from(missing)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Detect conflicts using a prior snapshot (.dypai/state.json). If an endpoint
|
|
343
|
+
* was pulled at time T and its remote updated_at is now > T, someone modified
|
|
344
|
+
* it out-of-band since the pull. Report these so the user can pull first.
|
|
345
|
+
*/
|
|
346
|
+
function detectConflicts(stateSnapshot, remoteByName) {
|
|
347
|
+
if (!stateSnapshot?.endpoints) return []
|
|
348
|
+
const conflicts = []
|
|
349
|
+
for (const [name, snap] of Object.entries(stateSnapshot.endpoints)) {
|
|
350
|
+
const remote = remoteByName[name]
|
|
351
|
+
if (!remote) continue
|
|
352
|
+
const snapT = snap.updated_at ? new Date(snap.updated_at).getTime() : 0
|
|
353
|
+
const remoteT = remote.updated_at ? new Date(remote.updated_at).getTime() : 0
|
|
354
|
+
if (remoteT > snapT + 1000) { // 1s tolerance for clock skew
|
|
355
|
+
conflicts.push({
|
|
356
|
+
endpoint: name,
|
|
357
|
+
snapshot_at: snap.updated_at,
|
|
358
|
+
remote_updated_at: remote.updated_at,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return conflicts
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Compute the plan to reconcile remote state with local.
|
|
367
|
+
*/
|
|
368
|
+
export function computePlan(local, remote, mapsCtx, { deleteOrphans = false, stateSnapshot = null } = {}) {
|
|
369
|
+
const plan = { create: [], update: [], delete: [], unchanged: [] }
|
|
370
|
+
const warnings = []
|
|
371
|
+
|
|
372
|
+
for (const name of Object.keys(local.byName)) {
|
|
373
|
+
const entry = local.byName[name]
|
|
374
|
+
const localCanonical = localToComparable(entry, mapsCtx)
|
|
375
|
+
const remoteRow = remote.byName[name]
|
|
376
|
+
|
|
377
|
+
// Credentials check: warn if a YAML references a credential name missing remotely
|
|
378
|
+
const missing = findMissingCredentials(entry.doc, mapsCtx.credNameToId || {})
|
|
379
|
+
for (const credName of missing) {
|
|
380
|
+
warnings.push({
|
|
381
|
+
type: "missing_credential",
|
|
382
|
+
endpoint: name,
|
|
383
|
+
credential: credName,
|
|
384
|
+
hint: `Create it in the dashboard (same name) before pushing, or push will fail at runtime.`,
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (!remoteRow) {
|
|
389
|
+
plan.create.push({ name, fields: Object.keys(localCanonical) })
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const remoteCanonical = remoteToComparable(remoteRow, mapsCtx)
|
|
394
|
+
if (deepEqual(localCanonical, remoteCanonical)) {
|
|
395
|
+
plan.unchanged.push(name)
|
|
396
|
+
} else {
|
|
397
|
+
const changedFields = summarizeFieldDiffs(localCanonical, remoteCanonical)
|
|
398
|
+
plan.update.push({ name, changedFields })
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (deleteOrphans) {
|
|
403
|
+
for (const name of Object.keys(remote.byName)) {
|
|
404
|
+
if (!local.byName[name]) plan.delete.push({ name })
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
plan.orphansIgnored = Object.keys(remote.byName).filter(n => !local.byName[n])
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Conflict detection (remote changed since last pull)
|
|
411
|
+
const conflicts = detectConflicts(stateSnapshot, remote.byName)
|
|
412
|
+
if (conflicts.length) {
|
|
413
|
+
for (const c of conflicts) {
|
|
414
|
+
warnings.push({
|
|
415
|
+
type: "remote_changed_since_pull",
|
|
416
|
+
endpoint: c.endpoint,
|
|
417
|
+
snapshot_at: c.snapshot_at,
|
|
418
|
+
remote_updated_at: c.remote_updated_at,
|
|
419
|
+
hint: "Run dypai_pull to refresh local state before pushing (otherwise you may overwrite remote changes).",
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (warnings.length) plan.warnings = warnings
|
|
425
|
+
return plan
|
|
426
|
+
}
|