@pipefy/pipefy-process-coder 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/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- package/src/workspace/write.ts +225 -0
package/src/diff/diff.ts
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { canonicalString } from '../util/json.ts';
|
|
2
|
+
import { VOLATILE_KEYS } from '../model/volatile.ts';
|
|
3
|
+
import { ENTITIES, type Coverage, type EntityName, type Row } from '../model/payload.ts';
|
|
4
|
+
import type { AutomationNode, FieldConditionNode, PhaseNode, Tree } from '../model/tree.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The diff engine, modelled on what the Change Migrator recipes actually do.
|
|
8
|
+
*
|
|
9
|
+
* The recipes reduce a migration to one list per entity type, each row tagged
|
|
10
|
+
* create / update / delete, and then walk those lists in a fixed order:
|
|
11
|
+
* phases → start-form fields → phase fields → field conditions → automations
|
|
12
|
+
* (`Apply Changes` steps 4, 30, 45, 65, 116). This produces the same ChangeSet,
|
|
13
|
+
* with two things the recipes do by hand:
|
|
14
|
+
*
|
|
15
|
+
* - **Matching.** The recipes match phases by name and fields through a Pipefy
|
|
16
|
+
* table keyed `dev_pipe_field_internal_id → prod_pipe_field_internal_id`.
|
|
17
|
+
* Here, `same-repo` mode matches on uuid then id — exact, no lookups — and
|
|
18
|
+
* `cross-repo` mode falls back to the natural key (phase name, phase+label,
|
|
19
|
+
* automation name), which is the recipes' rule, and records the id map so
|
|
20
|
+
* embedded field references can be rewritten.
|
|
21
|
+
* - **Nesting.** Conditions, field_maps and condition actions are applied
|
|
22
|
+
* through their owner, so a change to one is reported as a change to the
|
|
23
|
+
* owner rather than as an independent change that could be ordered wrongly.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export type ChangeOp = 'create' | 'update' | 'delete' | 'reorder' | 'archive' | 'unarchive';
|
|
27
|
+
|
|
28
|
+
export type FieldDelta = { key: string; before: unknown; after: unknown };
|
|
29
|
+
|
|
30
|
+
export type Change = {
|
|
31
|
+
entity: EntityName | 'repo';
|
|
32
|
+
op: ChangeOp;
|
|
33
|
+
/** Stable identity for this change: uuid, id, or owner+natural key. */
|
|
34
|
+
key: string;
|
|
35
|
+
/** Human label, for the CLI and the viewer. */
|
|
36
|
+
label: string;
|
|
37
|
+
/** Workspace-relative file the change came from, when there is one. */
|
|
38
|
+
file?: string;
|
|
39
|
+
before?: Row | null;
|
|
40
|
+
after?: Row | null;
|
|
41
|
+
deltas: FieldDelta[];
|
|
42
|
+
/** For nested/owned entities: what owns this change. */
|
|
43
|
+
owner?: { entity: EntityName; key: string; label: string };
|
|
44
|
+
/** Set when a change is a special case the planner treats differently. */
|
|
45
|
+
flags: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ChangeSet = {
|
|
49
|
+
mode: 'same-repo' | 'cross-repo';
|
|
50
|
+
from: { repoId: number; name: string; source: string };
|
|
51
|
+
to: { repoId: number; name: string; source: string };
|
|
52
|
+
changes: Change[];
|
|
53
|
+
/** Entity groups skipped because one side had no read path for them. */
|
|
54
|
+
skipped: Array<{ entity: EntityName; reason: string }>;
|
|
55
|
+
/**
|
|
56
|
+
* uuid/natural-key → id map, per entity, built while matching. This is the
|
|
57
|
+
* recipes' dev→prod lookup table, computed locally instead of by an API call
|
|
58
|
+
* per id.
|
|
59
|
+
*/
|
|
60
|
+
idMap: Partial<Record<EntityName, Record<string, number>>>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── comparison ───────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
const IGNORED_IN_COMPARISON = new Set([...VOLATILE_KEYS, 'id', 'repo_id', 'organization_id', 'organization_uuid']);
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Keys holding an id, compared as strings.
|
|
70
|
+
*
|
|
71
|
+
* Pipefy is inconsistent about whether an id is `12345` or `"12345"` —
|
|
72
|
+
* the payload uses numbers, the APIs mostly strings — and an id is the same id
|
|
73
|
+
* either way. Comparing them typed reported changes on values that matched.
|
|
74
|
+
*/
|
|
75
|
+
const ID_VALUED_KEYS = new Set([
|
|
76
|
+
'phase_id', 'field_id', 'to_phase_id', 'from_phase_id', 'in_phase_id', 'card_id',
|
|
77
|
+
'title_field_id', 'connected_pipe_id', 'source_id', 'owner_id', 'field_address',
|
|
78
|
+
'target_phase_id', 'source_phase_id', 'action_repo_id', 'event_repo_id',
|
|
79
|
+
'submitter_email_field_id', 'email_template_id', 'field_condition_id',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
const looseId = (v: unknown): unknown =>
|
|
83
|
+
typeof v === 'number' ? String(v) : v;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Text columns where the endpoint uses `null` and `""` interchangeably.
|
|
87
|
+
*
|
|
88
|
+
* Kept to a named set rather than applied to every string column, because
|
|
89
|
+
* elsewhere the difference between "unset" and "set to empty" can carry meaning.
|
|
90
|
+
* These four were measured returning both spellings from the same read.
|
|
91
|
+
*/
|
|
92
|
+
const EMPTY_EQUIVALENT = new Set(['description', 'help', 'custom_validation', 'index_name']);
|
|
93
|
+
|
|
94
|
+
const isBlank = (v: unknown) => v === null || v === undefined || v === '';
|
|
95
|
+
|
|
96
|
+
const comparable = (row: Row, extraIgnore: Set<string> = new Set()): Row => {
|
|
97
|
+
const out: Row = {};
|
|
98
|
+
for (const [k, v] of Object.entries(row)) {
|
|
99
|
+
if (IGNORED_IN_COMPARISON.has(k) || extraIgnore.has(k) || k.startsWith('_')) continue;
|
|
100
|
+
out[k] = ID_VALUED_KEYS.has(k) ? looseId(v) : v;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Which side of a comparison read this entity group only partially.
|
|
107
|
+
*
|
|
108
|
+
* Coverage is tracked per group, but a *partial* read is partial per column: the
|
|
109
|
+
* public API has no `color_uuid`, no `only_admin_can_move_to_previous`, no
|
|
110
|
+
* `authorization`. Comparing a snapshot against a reconstruction without this,
|
|
111
|
+
* a real pipe reported ten "changes" per phase, every one of them a column the
|
|
112
|
+
* reconstruction simply cannot see — and every one of them a proposal to set it
|
|
113
|
+
* to null.
|
|
114
|
+
*
|
|
115
|
+
* So absence on a partial side is treated as no information, exactly as an
|
|
116
|
+
* `unknown` group is. Absence on a *complete* side still means null.
|
|
117
|
+
*/
|
|
118
|
+
type PartialSides = { before: boolean; after: boolean };
|
|
119
|
+
|
|
120
|
+
const NO_PARTIAL: PartialSides = { before: false, after: false };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Columns the server mints, which no create path accepts.
|
|
124
|
+
*
|
|
125
|
+
* When verifying intent, a null on the authored side of one of these is the
|
|
126
|
+
* absence of an opinion, not a request to clear it: `createPhaseField` derives
|
|
127
|
+
* the slug from the label, `createPhase` picks a `color_uuid`, and neither takes
|
|
128
|
+
* the value as input. Comparing them turned 60 successful creates into 60
|
|
129
|
+
* reported failures. A value the author *did* write is still asserted.
|
|
130
|
+
*/
|
|
131
|
+
const SERVER_MINTED = new Set(['slug', 'color_uuid', 'suid']);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Column names the payload never uses, per entity.
|
|
135
|
+
*
|
|
136
|
+
* `createPipeRelation` names three of its flags `canCreateNewItems`,
|
|
137
|
+
* `canConnectExistingItems` and `canConnectMultipleItems`; the payload row
|
|
138
|
+
* spells the same three `can_create_connected_cards`, `can_search_connected_cards`
|
|
139
|
+
* and `can_connect_multiple_cards`. An author who writes the mutation's spelling
|
|
140
|
+
* — the one the API documents — gets a row whose keys no read can ever produce,
|
|
141
|
+
* and comparing them reported three differences on a relation that was exactly
|
|
142
|
+
* right. They are aliases, dropped from comparison; the payload spellings beside
|
|
143
|
+
* them are compared normally, because those the payload does carry.
|
|
144
|
+
*/
|
|
145
|
+
const UNREADABLE_IN_PAYLOAD: Partial<Record<EntityName, Set<string>>> = {
|
|
146
|
+
pipe_relations: new Set([
|
|
147
|
+
'can_create_new_items',
|
|
148
|
+
'can_connect_existing_items',
|
|
149
|
+
'can_connect_multiple_items',
|
|
150
|
+
]),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Two jump target lists, compared as the sets they are.
|
|
155
|
+
*
|
|
156
|
+
* `PUT /internal_api/settings/phases/:id` replaces the whole set (PLAN.md §2.1)
|
|
157
|
+
* and answers in whatever order it likes, so ordering carries no meaning on
|
|
158
|
+
* either side. Comparing them as ordered lists reported a phase whose jumps were
|
|
159
|
+
* exactly right as needing another write — every single run.
|
|
160
|
+
*/
|
|
161
|
+
const sameIdSet = (x: unknown, y: unknown): boolean => {
|
|
162
|
+
const norm = (v: unknown) =>
|
|
163
|
+
JSON.stringify(
|
|
164
|
+
(Array.isArray(v) ? v : [])
|
|
165
|
+
.map((n) => String(n))
|
|
166
|
+
.sort(),
|
|
167
|
+
);
|
|
168
|
+
return norm(x) === norm(y);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* `intentOnly` compares only the columns the *after* side actually states.
|
|
173
|
+
*
|
|
174
|
+
* This is what verification means. A newly authored entity is a partial
|
|
175
|
+
* specification: the author writes the columns they care about and the server
|
|
176
|
+
* fills the rest — `slug`, `card_synced`, `unique`, the connector flags. Asking
|
|
177
|
+
* "are these rows identical" then fails a perfectly correct apply, reporting
|
|
178
|
+
* seven differences per created field, all of them defaults the author never
|
|
179
|
+
* spoke about. Asking "does the live pipe satisfy the intent" is both the useful
|
|
180
|
+
* question and the answerable one.
|
|
181
|
+
*
|
|
182
|
+
* Off for the ordinary edit-loop diff, where both sides come from full reads and
|
|
183
|
+
* an absent column really is information.
|
|
184
|
+
*/
|
|
185
|
+
const deltasOf = (
|
|
186
|
+
before: Row,
|
|
187
|
+
after: Row,
|
|
188
|
+
extraIgnore?: Set<string>,
|
|
189
|
+
partial: PartialSides = NO_PARTIAL,
|
|
190
|
+
intentOnly = false,
|
|
191
|
+
): FieldDelta[] => {
|
|
192
|
+
const a = comparable(before, extraIgnore);
|
|
193
|
+
const b = comparable(after, extraIgnore);
|
|
194
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
195
|
+
const out: FieldDelta[] = [];
|
|
196
|
+
for (const k of keys) {
|
|
197
|
+
if (intentOnly && !(k in b)) continue;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A client-minted uuid on a new entity is a local handle, not a request.
|
|
201
|
+
* `CreatePhaseFieldInput` has no `uuid` field — the introspection filter drops
|
|
202
|
+
* it and the server mints its own — so verification must not assert a value
|
|
203
|
+
* that was never sent. The post-apply sync replaces the handle with the real
|
|
204
|
+
* uuid, and from then on it is compared normally.
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* A uuid is never asserted when verifying intent.
|
|
208
|
+
*
|
|
209
|
+
* No create path on this endpoint accepts one — `CreatePhaseFieldInput` and
|
|
210
|
+
* the internal `createAutomation` both lack the field, and the server mints
|
|
211
|
+
* its own — so a uuid is always server-owned and never something the tool
|
|
212
|
+
* asked for. It also cannot survive the recreate strategy: delete + create
|
|
213
|
+
* is the only automation update available, and it necessarily yields a new
|
|
214
|
+
* uuid, which verification would otherwise report as a failed apply.
|
|
215
|
+
*/
|
|
216
|
+
if (intentOnly && k === 'uuid') continue;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Nor is a slug the author never wrote.
|
|
220
|
+
*
|
|
221
|
+
* `CreatePhaseFieldInput` has no slug either: the server derives one from the
|
|
222
|
+
* label. A field authored by hand therefore says `"slug": null`, and after a
|
|
223
|
+
* successful create the pipe says `"slug": "request_title"` — which
|
|
224
|
+
* verification would report as 55 failed field creates, all of which
|
|
225
|
+
* actually succeeded. A slug the author *did* write is still asserted, since
|
|
226
|
+
* automations and conditions can legitimately reference one.
|
|
227
|
+
*/
|
|
228
|
+
if (intentOnly && SERVER_MINTED.has(k) && (b[k] === null || b[k] === undefined)) continue;
|
|
229
|
+
|
|
230
|
+
const aRaw = a[k] ?? null;
|
|
231
|
+
const bRaw = b[k] ?? null;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* On a text column, `null` and `""` are the same absence.
|
|
235
|
+
*
|
|
236
|
+
* Both spellings come back from the same endpoint for the same column, on
|
|
237
|
+
* the same repo — measured across ten fields of three databases:
|
|
238
|
+
* `description` was `""` nine times and `null` once, `help` the same, and
|
|
239
|
+
* `custom_validation` `""` eight times and `null` twice. So a field whose
|
|
240
|
+
* file says `""` verifies against a pipe that says `null` and reports
|
|
241
|
+
* `custom_validation: null -> ""`, which is a difference nobody can act on
|
|
242
|
+
* and which blocks the file sync.
|
|
243
|
+
*
|
|
244
|
+
* Only the empty pair collapses. Clearing a real value to `""` still differs
|
|
245
|
+
* from that value, which is the change an author actually makes.
|
|
246
|
+
*/
|
|
247
|
+
if (EMPTY_EQUIVALENT.has(k) && isBlank(aRaw) && isBlank(bRaw)) continue;
|
|
248
|
+
|
|
249
|
+
const av = canonicalString(ID_VALUED_KEYS.has(k) ? looseId(aRaw) : aRaw);
|
|
250
|
+
const bv = canonicalString(ID_VALUED_KEYS.has(k) ? looseId(bRaw) : bRaw);
|
|
251
|
+
if (av === bv) continue;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* An unresolved placeholder in the intent was a request to substitute, and
|
|
255
|
+
* the substitution is what the pipe now holds. Verify cannot assert the id
|
|
256
|
+
* the server chose unless it has the plan's id map (which `pipe apply`
|
|
257
|
+
* passes in), so a placeholder is treated as satisfied.
|
|
258
|
+
*/
|
|
259
|
+
if (intentOnly && typeof bRaw === 'string' && bRaw.includes('%{_new:')) continue;
|
|
260
|
+
if (intentOnly && JSON.stringify(bRaw ?? '').includes('%{_new:')) continue;
|
|
261
|
+
|
|
262
|
+
if (partial.after && bRaw === null && aRaw !== null) continue;
|
|
263
|
+
if (partial.before && aRaw === null && bRaw !== null) continue;
|
|
264
|
+
|
|
265
|
+
out.push({ key: k, before: aRaw, after: bRaw });
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/** Owned rows have no identity, so they are compared as sorted sets. */
|
|
271
|
+
const setOf = (rows: Row[]): string[] =>
|
|
272
|
+
rows.map((r) => canonicalString(comparable(r))).sort();
|
|
273
|
+
|
|
274
|
+
const sameSet = (a: Row[], b: Row[]) => canonicalString(setOf(a)) === canonicalString(setOf(b));
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Whether a nested collection differs *informatively*.
|
|
278
|
+
*
|
|
279
|
+
* The collection counterpart of the column and row rules. Measured: the internal
|
|
280
|
+
* automations endpoint returns no `field_map` for four `update_card_field`
|
|
281
|
+
* automations a snapshot shows five entries for, and no `condition` for one that
|
|
282
|
+
* has one. Reported literally, that is "empty every field map" — so an empty
|
|
283
|
+
* collection on a partial side is unseen, not emptied.
|
|
284
|
+
*/
|
|
285
|
+
const hasPlaceholder = (v: unknown): boolean => JSON.stringify(v ?? '').includes('%{_new:');
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A nested structure, normalised for comparison at every depth.
|
|
289
|
+
*
|
|
290
|
+
* `comparable` only ever reached the top level of a row, so a nested collection
|
|
291
|
+
* was compared raw. That made a freshly created field condition impossible to
|
|
292
|
+
* verify: the server mints an `id` on the condition and on every expression, and
|
|
293
|
+
* stores `expressions_structure` leaves as strings where the workspace writes
|
|
294
|
+
* numbers, so the read-back never equalled the intent no matter what was sent.
|
|
295
|
+
*
|
|
296
|
+
* Numbers become strings here, at every depth, for the same reason `looseId`
|
|
297
|
+
* exists: this payload is inconsistent about `0` versus `"0"` and they are the
|
|
298
|
+
* same value. Applied only to nested structures, so column deltas keep comparing
|
|
299
|
+
* types as they always did.
|
|
300
|
+
*/
|
|
301
|
+
const nestedComparable = (v: unknown): unknown => {
|
|
302
|
+
if (Array.isArray(v)) return v.map(nestedComparable);
|
|
303
|
+
if (v && typeof v === 'object') {
|
|
304
|
+
const out: Row = {};
|
|
305
|
+
for (const [k, val] of Object.entries(v as Row)) {
|
|
306
|
+
if (IGNORED_IN_COMPARISON.has(k) || k.startsWith('_')) continue;
|
|
307
|
+
out[k] = nestedComparable(val);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
return typeof v === 'number' ? String(v) : v;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The live side, reduced to what the intent side actually states — at every depth.
|
|
316
|
+
*
|
|
317
|
+
* `deltasOf` already skips a column the intent does not mention (`!(k in b)`).
|
|
318
|
+
* That rule stopped at the top level, so a nested key the workspace never wrote
|
|
319
|
+
* still failed verification: `phase_id` on a field condition action, and
|
|
320
|
+
* `structure_id` on an expression, are both added by the server.
|
|
321
|
+
*
|
|
322
|
+
* Array elements are reduced against the union of the keys their intent
|
|
323
|
+
* counterparts state, rather than pairwise by position, so element order cannot
|
|
324
|
+
* mis-pair them. That can only relax the comparison, never tighten it — the
|
|
325
|
+
* outer comparison still sorts, so a real difference in the values still shows.
|
|
326
|
+
*/
|
|
327
|
+
const asStated = (live: unknown, intent: unknown): unknown => {
|
|
328
|
+
if (Array.isArray(live)) {
|
|
329
|
+
const peers = Array.isArray(intent) ? intent : [];
|
|
330
|
+
const merged = peers.reduce<Row>(
|
|
331
|
+
(acc, e) => (e && typeof e === 'object' && !Array.isArray(e) ? { ...acc, ...(e as Row) } : acc),
|
|
332
|
+
{},
|
|
333
|
+
);
|
|
334
|
+
const shape = Object.keys(merged).length ? merged : peers[0];
|
|
335
|
+
return live.map((l) => asStated(l, shape));
|
|
336
|
+
}
|
|
337
|
+
if (live && typeof live === 'object' && intent && typeof intent === 'object' && !Array.isArray(intent)) {
|
|
338
|
+
const out: Row = {};
|
|
339
|
+
for (const [k, val] of Object.entries(live as Row)) {
|
|
340
|
+
if (!(k in (intent as Row))) continue;
|
|
341
|
+
out[k] = asStated(val, (intent as Row)[k]);
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
return live;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const nestedSame = (a: unknown, b: unknown, intentOnly: boolean): boolean => {
|
|
349
|
+
const left = intentOnly ? asStated(a, b) : a;
|
|
350
|
+
return canonicalString(nestedComparable(left)) === canonicalString(nestedComparable(b));
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const nestedDiffers = (a: Row[], b: Row[], partial: PartialSides, intentOnly = false): boolean => {
|
|
354
|
+
if (sameSet(a, b)) return false;
|
|
355
|
+
const norm = (rows: unknown) =>
|
|
356
|
+
canonicalString((nestedComparable(rows) as unknown[]).map(canonicalString).sort());
|
|
357
|
+
if (norm(intentOnly ? asStated(a, b) : a) === norm(b)) return false;
|
|
358
|
+
if (intentOnly && hasPlaceholder(b)) return false;
|
|
359
|
+
if (partial.after && b.length === 0 && a.length > 0) return false;
|
|
360
|
+
if (partial.before && a.length === 0 && b.length > 0) return false;
|
|
361
|
+
return true;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
/** The same rule for a single nested object, such as an automation's condition. */
|
|
365
|
+
const nestedObjectDiffers = (a: unknown, b: unknown, partial: PartialSides, intentOnly = false): boolean => {
|
|
366
|
+
if (canonicalString(a) === canonicalString(b)) return false;
|
|
367
|
+
if (nestedSame(a, b, intentOnly)) return false;
|
|
368
|
+
if (intentOnly && hasPlaceholder(b)) return false;
|
|
369
|
+
if (partial.after && (b === null || b === undefined) && a) return false;
|
|
370
|
+
if (partial.before && (a === null || a === undefined) && b) return false;
|
|
371
|
+
return true;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// ── matching ─────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
type Matcher<T extends Row> = {
|
|
377
|
+
/** Primary key: exact, used within one repo. */
|
|
378
|
+
primary: (row: T) => string | null;
|
|
379
|
+
/** Fallback key: the recipes' rule, used across two different repos. */
|
|
380
|
+
natural: (row: T) => string;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const naturalKeyOf = (entity: EntityName, row: Row, ctx: { phaseName?: string } = {}): string => {
|
|
384
|
+
const spec = ENTITIES[entity];
|
|
385
|
+
if (entity === 'fields') return `${ctx.phaseName ?? row['phase_id'] ?? ''}::${String(row['label'] ?? '')}`;
|
|
386
|
+
const keys = spec.naturalKey ?? ['name'];
|
|
387
|
+
return keys.map((k) => String(row[k] ?? '')).join('::');
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const matchRows = <T extends Row>(
|
|
391
|
+
before: T[],
|
|
392
|
+
after: T[],
|
|
393
|
+
m: Matcher<T>,
|
|
394
|
+
mode: ChangeSet['mode'],
|
|
395
|
+
): { pairs: Array<[T, T]>; added: T[]; removed: T[] } => {
|
|
396
|
+
const pairs: Array<[T, T]> = [];
|
|
397
|
+
const usedAfter = new Set<T>();
|
|
398
|
+
const remainingBefore: T[] = [];
|
|
399
|
+
|
|
400
|
+
const afterByPrimary = new Map<string, T>();
|
|
401
|
+
for (const r of after) {
|
|
402
|
+
const k = m.primary(r);
|
|
403
|
+
if (k) afterByPrimary.set(k, r);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const b of before) {
|
|
407
|
+
const k = mode === 'same-repo' ? m.primary(b) : null;
|
|
408
|
+
const hit = k ? afterByPrimary.get(k) : undefined;
|
|
409
|
+
if (hit && !usedAfter.has(hit)) {
|
|
410
|
+
pairs.push([b, hit]);
|
|
411
|
+
usedAfter.add(hit);
|
|
412
|
+
} else {
|
|
413
|
+
remainingBefore.push(b);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Fallback pass on the natural key. Within one repo this catches an entity
|
|
418
|
+
// whose uuid Claude dropped; across repos it is the primary mechanism.
|
|
419
|
+
const afterByNatural = new Map<string, T[]>();
|
|
420
|
+
for (const r of after) {
|
|
421
|
+
if (usedAfter.has(r)) continue;
|
|
422
|
+
const k = m.natural(r);
|
|
423
|
+
const arr = afterByNatural.get(k) ?? [];
|
|
424
|
+
arr.push(r);
|
|
425
|
+
afterByNatural.set(k, arr);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const removed: T[] = [];
|
|
429
|
+
for (const b of remainingBefore) {
|
|
430
|
+
const candidates = afterByNatural.get(m.natural(b));
|
|
431
|
+
const hit = candidates?.find((c) => !usedAfter.has(c));
|
|
432
|
+
if (hit) {
|
|
433
|
+
pairs.push([b, hit]);
|
|
434
|
+
usedAfter.add(hit);
|
|
435
|
+
} else {
|
|
436
|
+
removed.push(b);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const added = after.filter((r) => !usedAfter.has(r));
|
|
441
|
+
return { pairs, added, removed };
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// ── entity diffs ─────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
const num = (v: unknown): number | null => {
|
|
447
|
+
const n = Number(v);
|
|
448
|
+
return Number.isFinite(n) ? n : null;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const label = (row: Row, fallback: string) => String(row['name'] ?? row['label'] ?? fallback);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The live condition, reduced to what the intent states, before it is summarised.
|
|
455
|
+
*
|
|
456
|
+
* `conditionSummary` canonicalises each expression into a *string* through
|
|
457
|
+
* `setOf`, so by the time the summary exists there are no keys left to reduce —
|
|
458
|
+
* a server-minted `structure_id` is already baked into the string and the two
|
|
459
|
+
* sides can never match. The reduction has to happen first.
|
|
460
|
+
*/
|
|
461
|
+
const statedCondition = (
|
|
462
|
+
b: { condition?: Row | null },
|
|
463
|
+
a: { condition?: Row | null },
|
|
464
|
+
intentOnly: boolean,
|
|
465
|
+
): { condition: Row | null } => ({
|
|
466
|
+
condition: (intentOnly
|
|
467
|
+
? (asStated(b.condition ?? null, a.condition ?? null) as Row | null)
|
|
468
|
+
: (b.condition ?? null)),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const conditionSummary = (node: { condition?: Row | null }): Row => {
|
|
472
|
+
const c = node.condition as Row | null | undefined;
|
|
473
|
+
if (!c) return {};
|
|
474
|
+
return { expressions_structure: c['expressions_structure'] ?? [], expressions: setOf((c['expressions'] as Row[]) ?? []) };
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
export type DiffOpts = {
|
|
478
|
+
mode?: ChangeSet['mode'];
|
|
479
|
+
/**
|
|
480
|
+
* Compare only the columns the target side states. Set by `pipe verify`:
|
|
481
|
+
* see deltasOf.
|
|
482
|
+
*/
|
|
483
|
+
intentOnly?: boolean;
|
|
484
|
+
/**
|
|
485
|
+
* Ids this apply created. Pipefy auto-links a new phase with its neighbour in
|
|
486
|
+
* both directions, so the neighbour ends up with a jump the workspace never
|
|
487
|
+
* asked for. A jump-set difference made up only of ids created just now is the
|
|
488
|
+
* platform's doing, not a failed write.
|
|
489
|
+
*/
|
|
490
|
+
createdIds?: number[];
|
|
491
|
+
/** Ignore an entity group entirely — used for coverage gaps. */
|
|
492
|
+
skipEntities?: EntityName[];
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
export const diffTrees = (before: Tree, after: Tree, opts: DiffOpts = {}): ChangeSet => {
|
|
496
|
+
const mode = opts.mode ?? 'same-repo';
|
|
497
|
+
const intentOnly = opts.intentOnly ?? false;
|
|
498
|
+
const createdIds = new Set((opts.createdIds ?? []).map(Number));
|
|
499
|
+
|
|
500
|
+
/** True when two jump sets differ only by ids this apply created. */
|
|
501
|
+
const jumpsDifferOnlyByNewIds = (x: Array<number | string>, y: Array<number | string>): boolean => {
|
|
502
|
+
if (!createdIds.size) return false;
|
|
503
|
+
const sx = new Set(x.map(Number));
|
|
504
|
+
const sy = new Set(y.map(Number));
|
|
505
|
+
const only = [...sx].filter((v) => !sy.has(v)).concat([...sy].filter((v) => !sx.has(v)));
|
|
506
|
+
return only.length > 0 && only.every((v) => createdIds.has(v));
|
|
507
|
+
};
|
|
508
|
+
const changes: Change[] = [];
|
|
509
|
+
const skipped: ChangeSet['skipped'] = [];
|
|
510
|
+
const idMap: ChangeSet['idMap'] = {};
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* An entity group neither read path could see is not diffed. This is the
|
|
514
|
+
* whole reason coverage is tracked: a reconstructed pull cannot tell "no
|
|
515
|
+
* webhooks" from "cannot read webhooks", and proposing deletions from the
|
|
516
|
+
* second would be catastrophic.
|
|
517
|
+
*/
|
|
518
|
+
const skipSet = new Set<EntityName>(opts.skipEntities ?? []);
|
|
519
|
+
|
|
520
|
+
/** Which sides read this group only partially. See deltasOf. */
|
|
521
|
+
const partialSides = (entity: EntityName): PartialSides => ({
|
|
522
|
+
before: (before.meta.coverage?.[entity] ?? 'unknown') === 'partial',
|
|
523
|
+
after: (after.meta.coverage?.[entity] ?? 'unknown') === 'partial',
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Whether a row's absence from one side is evidence of anything.
|
|
528
|
+
*
|
|
529
|
+
* The row-level counterpart of the column rule, and the more dangerous of the
|
|
530
|
+
* two. Measured on a real pipe: the internal automations list returns 13 of the
|
|
531
|
+
* 15 automations a snapshot contains. Diffed naively, the two it omits are
|
|
532
|
+
* "deletes" — and an apply would have deleted two live automations because one
|
|
533
|
+
* read path could not see them.
|
|
534
|
+
*
|
|
535
|
+
* So on a partial side, absence means unseen: a delete is suppressed when the
|
|
536
|
+
* *after* side is partial, and a create when the *before* side is partial.
|
|
537
|
+
*/
|
|
538
|
+
const suppressed: Array<{ entity: EntityName; op: 'create' | 'delete'; label: string }> = [];
|
|
539
|
+
const rowAbsenceIsUnknown = (entity: EntityName, op: 'create' | 'delete', label: string): boolean => {
|
|
540
|
+
const sides = partialSides(entity);
|
|
541
|
+
const unknown = op === 'delete' ? sides.after : sides.before;
|
|
542
|
+
if (unknown) suppressed.push({ entity, op, label });
|
|
543
|
+
return unknown;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The repo row is not an entity group, so its partial-ness comes from the read
|
|
548
|
+
* path: a reconstruction that had to skip pipe columns records that in
|
|
549
|
+
* meta.repo_columns.
|
|
550
|
+
*/
|
|
551
|
+
const repoPartial: PartialSides = {
|
|
552
|
+
before: before.meta.repo_columns === 'partial',
|
|
553
|
+
after: after.meta.repo_columns === 'partial',
|
|
554
|
+
};
|
|
555
|
+
const coverageBlocked = (entity: EntityName): boolean => {
|
|
556
|
+
if (skipSet.has(entity)) return true;
|
|
557
|
+
const a: Coverage = before.meta.coverage?.[entity] ?? 'unknown';
|
|
558
|
+
const b: Coverage = after.meta.coverage?.[entity] ?? 'unknown';
|
|
559
|
+
if (a === 'unknown' || b === 'unknown') {
|
|
560
|
+
skipped.push({
|
|
561
|
+
entity,
|
|
562
|
+
reason: `coverage is ${a === 'unknown' ? 'unknown on the baseline' : 'unknown on the edited tree'} — not diffed, so absence is never read as deletion`,
|
|
563
|
+
});
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const record = (c: Omit<Change, 'deltas' | 'flags'> & Partial<Pick<Change, 'deltas' | 'flags'>>) => {
|
|
570
|
+
changes.push({ deltas: [], flags: [], ...c } as Change);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// ── repo row ───────────────────────────────────────────────────────────────
|
|
574
|
+
const repoDeltas = deltasOf(before.repo, after.repo, undefined, repoPartial, intentOnly);
|
|
575
|
+
if (repoDeltas.length) {
|
|
576
|
+
record({
|
|
577
|
+
entity: 'repo',
|
|
578
|
+
op: 'update',
|
|
579
|
+
key: `repo:${after.repo['id']}`,
|
|
580
|
+
label: String(after.repo['name'] ?? 'pipe'),
|
|
581
|
+
file: 'pipe.json',
|
|
582
|
+
before: before.repo,
|
|
583
|
+
after: after.repo,
|
|
584
|
+
deltas: repoDeltas,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── phases (+ fields, jumps, memberships) ──────────────────────────────────
|
|
589
|
+
const phaseMatcher: Matcher<PhaseNode> = {
|
|
590
|
+
primary: (p) => (p['uuid'] ? `uuid:${p['uuid']}` : p['id'] ? `id:${p['id']}` : null),
|
|
591
|
+
natural: (p) => naturalKeyOf('phases', p),
|
|
592
|
+
};
|
|
593
|
+
const phaseMatch = matchRows(before.phases, after.phases, phaseMatcher, mode);
|
|
594
|
+
|
|
595
|
+
const phaseIdMap: Record<string, number> = {};
|
|
596
|
+
for (const [b, a] of phaseMatch.pairs) {
|
|
597
|
+
const bid = num(b['id']);
|
|
598
|
+
const aid = num(a['id']);
|
|
599
|
+
if (bid !== null && aid !== null) phaseIdMap[String(bid)] = aid;
|
|
600
|
+
}
|
|
601
|
+
idMap.phases = phaseIdMap;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Field changes are collected before they are recorded.
|
|
605
|
+
*
|
|
606
|
+
* Fields are matched inside their phase, so a field moved from one phase to
|
|
607
|
+
* another looks like a delete here and a create there. Compiling that pair
|
|
608
|
+
* literally would delete the field and its data and recreate it empty —
|
|
609
|
+
* precisely the loss PLAN.md §2 says must be refused. So the pair is
|
|
610
|
+
* reconciled afterwards into one `fields.update` carrying a `phase_id` delta,
|
|
611
|
+
* which the registry then refuses by name.
|
|
612
|
+
*/
|
|
613
|
+
const fieldChanges: Change[] = [];
|
|
614
|
+
const pushField = (c: Omit<Change, 'deltas' | 'flags'> & Partial<Pick<Change, 'deltas' | 'flags'>>) => {
|
|
615
|
+
fieldChanges.push({ deltas: [], flags: [], ...c } as Change);
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
for (const p of phaseMatch.added) {
|
|
619
|
+
if (rowAbsenceIsUnknown('phases', 'create', label(p, 'phase'))) continue;
|
|
620
|
+
record({
|
|
621
|
+
entity: 'phases',
|
|
622
|
+
op: 'create',
|
|
623
|
+
key: phaseMatcher.primary(p) ?? `new:${naturalKeyOf('phases', p)}`,
|
|
624
|
+
label: label(p, 'phase'),
|
|
625
|
+
after: p,
|
|
626
|
+
flags: ['new-phase'],
|
|
627
|
+
});
|
|
628
|
+
// A new phase's fields are created with it, as separate steps.
|
|
629
|
+
for (const f of p.fields) {
|
|
630
|
+
pushField({
|
|
631
|
+
entity: 'fields',
|
|
632
|
+
op: 'create',
|
|
633
|
+
key: f['uuid'] ? `uuid:${f['uuid']}` : `new:${naturalKeyOf('fields', f, { phaseName: label(p, '') })}`,
|
|
634
|
+
label: `${label(p, 'phase')} / ${label(f, 'field')}`,
|
|
635
|
+
after: f,
|
|
636
|
+
owner: { entity: 'phases', key: phaseMatcher.primary(p) ?? '', label: label(p, 'phase') },
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
for (const p of phaseMatch.removed) {
|
|
642
|
+
if (rowAbsenceIsUnknown('phases', 'delete', label(p, 'phase'))) continue;
|
|
643
|
+
record({
|
|
644
|
+
entity: 'phases',
|
|
645
|
+
op: 'delete',
|
|
646
|
+
key: phaseMatcher.primary(p) ?? `gone:${naturalKeyOf('phases', p)}`,
|
|
647
|
+
label: label(p, 'phase'),
|
|
648
|
+
before: p,
|
|
649
|
+
flags: Number(p['index']) === 0 ? ['start-form', 'destructive'] : ['destructive'],
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (const [b, a] of phaseMatch.pairs) {
|
|
654
|
+
const phaseLabel = label(a, 'phase');
|
|
655
|
+
const ownerKey = phaseMatcher.primary(a) ?? '';
|
|
656
|
+
|
|
657
|
+
const pDeltas = deltasOf(b, a, new Set(['fields', 'jump_target_ids', 'team_member_ids', 'index']), partialSides('phases'), intentOnly);
|
|
658
|
+
if (pDeltas.length) {
|
|
659
|
+
record({
|
|
660
|
+
entity: 'phases',
|
|
661
|
+
op: 'update',
|
|
662
|
+
key: ownerKey,
|
|
663
|
+
label: phaseLabel,
|
|
664
|
+
before: b,
|
|
665
|
+
after: a,
|
|
666
|
+
deltas: pDeltas,
|
|
667
|
+
// Only the name is refused (compile.ts) — everything else about the
|
|
668
|
+
// start form (description, lateness, and the like) is an ordinary
|
|
669
|
+
// update. Tagged the same way its delete already is, rather than a
|
|
670
|
+
// second mechanism, so both live in one place.
|
|
671
|
+
flags: Number(a['index']) === 0 ? ['start-form'] : [],
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (num(b['index']) !== num(a['index'])) {
|
|
676
|
+
record({
|
|
677
|
+
entity: 'phases',
|
|
678
|
+
op: 'reorder',
|
|
679
|
+
key: ownerKey,
|
|
680
|
+
label: phaseLabel,
|
|
681
|
+
before: b,
|
|
682
|
+
after: a,
|
|
683
|
+
deltas: [{ key: 'index', before: b['index'], after: a['index'] }],
|
|
684
|
+
flags: ['phase-reorder'],
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* A newly created phase gets jumps whether it asks for them or not: Pipefy
|
|
690
|
+
* auto-links a new phase with its neighbour in both directions. So an empty
|
|
691
|
+
* stated set on a new phase is an absence of instruction, not an instruction
|
|
692
|
+
* — a create path has no way to say "and nothing else" — and verification
|
|
693
|
+
* must not fail on the platform's default. A *stated* non-empty set is real
|
|
694
|
+
* intent and is written; see compile.ts.
|
|
695
|
+
*/
|
|
696
|
+
const newPhaseWithoutStatedJumps =
|
|
697
|
+
intentOnly && (a['_new'] === true || a['id'] === null) && (a.jump_target_ids ?? []).length === 0;
|
|
698
|
+
|
|
699
|
+
if (
|
|
700
|
+
!coverageBlocked('phase_jumps') &&
|
|
701
|
+
!newPhaseWithoutStatedJumps &&
|
|
702
|
+
!sameIdSet(b.jump_target_ids, a.jump_target_ids) &&
|
|
703
|
+
!(intentOnly && jumpsDifferOnlyByNewIds(b.jump_target_ids, a.jump_target_ids))
|
|
704
|
+
) {
|
|
705
|
+
record({
|
|
706
|
+
entity: 'phase_jumps',
|
|
707
|
+
op: 'update',
|
|
708
|
+
key: `jumps:${ownerKey}`,
|
|
709
|
+
label: `${phaseLabel} → jumps`,
|
|
710
|
+
// The phase's numeric id travels with the change. The matcher key is
|
|
711
|
+
// "uuid:<uuid>" for a phase, and stripping that prefix yielded a uuid
|
|
712
|
+
// where the settings endpoint wants an id — a silent 404 on write.
|
|
713
|
+
before: { phase_id: b['id'], jump_target_ids: b.jump_target_ids },
|
|
714
|
+
after: { phase_id: a['id'], jump_target_ids: a.jump_target_ids },
|
|
715
|
+
deltas: [{ key: 'jump_target_ids', before: b.jump_target_ids, after: a.jump_target_ids }],
|
|
716
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
717
|
+
flags: ['internal-api'],
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (canonicalString(b.team_member_ids) !== canonicalString(a.team_member_ids)) {
|
|
722
|
+
record({
|
|
723
|
+
entity: 'phase_team_memberships',
|
|
724
|
+
op: 'update',
|
|
725
|
+
key: `team:${ownerKey}`,
|
|
726
|
+
label: `${phaseLabel} → team`,
|
|
727
|
+
before: { phase_id: b['id'], team_member_ids: b.team_member_ids },
|
|
728
|
+
after: { phase_id: a['id'], team_member_ids: a.team_member_ids },
|
|
729
|
+
deltas: [{ key: 'team_member_ids', before: b.team_member_ids, after: a.team_member_ids }],
|
|
730
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* A field authored inside a phase file inherits that phase's id.
|
|
736
|
+
*
|
|
737
|
+
* Fields live nested under their phase precisely so nobody has to keep a
|
|
738
|
+
* `phase_id` in two places, so a hand-authored new field arrives with
|
|
739
|
+
* `"phase_id": null`. Filling it from the owner here is not inventing data —
|
|
740
|
+
* `pack` does exactly the same thing on the way back to the payload — and
|
|
741
|
+
* skipping it sent `createPhaseField` an empty id, which the API answered
|
|
742
|
+
* with "Phase not found with id: ".
|
|
743
|
+
*
|
|
744
|
+
* A field under a phase that is itself new keeps its null: that id does not
|
|
745
|
+
* exist yet, and the compiler substitutes a placeholder for it instead.
|
|
746
|
+
*/
|
|
747
|
+
const own = (f: Row): Row => (f['phase_id'] == null && a['id'] != null ? { ...f, phase_id: a['id'] } : f);
|
|
748
|
+
|
|
749
|
+
// fields inside a matched phase
|
|
750
|
+
const fieldMatcher: Matcher<Row> = {
|
|
751
|
+
primary: (f) => (f['uuid'] ? `uuid:${f['uuid']}` : f['id'] ? `id:${f['id']}` : null),
|
|
752
|
+
natural: (f) => naturalKeyOf('fields', f, { phaseName: phaseLabel }),
|
|
753
|
+
};
|
|
754
|
+
const fm = matchRows(b.fields, a.fields, fieldMatcher, mode);
|
|
755
|
+
|
|
756
|
+
for (const f of fm.added) {
|
|
757
|
+
if (rowAbsenceIsUnknown('fields', 'create', label(f, 'field'))) continue;
|
|
758
|
+
pushField({
|
|
759
|
+
entity: 'fields',
|
|
760
|
+
op: 'create',
|
|
761
|
+
key: fieldMatcher.primary(f) ?? `new:${fieldMatcher.natural(f)}`,
|
|
762
|
+
label: `${phaseLabel} / ${label(f, 'field')}`,
|
|
763
|
+
after: own(f),
|
|
764
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
for (const f of fm.removed) {
|
|
768
|
+
if (rowAbsenceIsUnknown('fields', 'delete', label(f, 'field'))) continue;
|
|
769
|
+
pushField({
|
|
770
|
+
entity: 'fields',
|
|
771
|
+
op: 'delete',
|
|
772
|
+
key: fieldMatcher.primary(f) ?? `gone:${fieldMatcher.natural(f)}`,
|
|
773
|
+
label: `${phaseLabel} / ${label(f, 'field')}`,
|
|
774
|
+
before: f,
|
|
775
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
776
|
+
flags: ['destructive'],
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
for (const [fb, fa] of fm.pairs) {
|
|
780
|
+
const fid = num(fb['id']);
|
|
781
|
+
const faid = num(fa['id']);
|
|
782
|
+
if (fid !== null && faid !== null) {
|
|
783
|
+
idMap.fields = { ...(idMap.fields ?? {}), [String(fid)]: faid };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* `phase_id` on a field is a consequence of which phase file it sits in, not
|
|
788
|
+
* a value anyone edits — and a genuine cross-phase move is detected by the
|
|
789
|
+
* reconciliation pass below, which states the delta explicitly. Comparing it
|
|
790
|
+
* here only ever reports noise, such as the `null` an author writes for a
|
|
791
|
+
* field inside a phase that does not exist yet.
|
|
792
|
+
*/
|
|
793
|
+
const archivedBefore = Boolean(fb['archived_at']);
|
|
794
|
+
const archivedAfter = Boolean(fa['archived_at']);
|
|
795
|
+
/**
|
|
796
|
+
* On a database, `index` is excluded from verification but not from the diff.
|
|
797
|
+
*
|
|
798
|
+
* A database's column order is written by `setTableFieldOrder`, which
|
|
799
|
+
* **renumbers**: asking for slugs in a given order and reading back gives
|
|
800
|
+
* 1, 2, 3, 4 where the workspace wrote 0, 1, 2, 3. The order is right and
|
|
801
|
+
* every absolute index is different, so comparing the column turns a
|
|
802
|
+
* correct reorder into a verification failure — and a failed verification
|
|
803
|
+
* blocks the file sync, leaving the workspace permanently dirty for a
|
|
804
|
+
* difference nobody can fix. The order write asserts its own effect from
|
|
805
|
+
* the `table_fields` it returns, which is the stronger check anyway.
|
|
806
|
+
*/
|
|
807
|
+
const fieldIgnore =
|
|
808
|
+
intentOnly && before.meta.repoKind === 'table'
|
|
809
|
+
? new Set(['archived_at', 'phase_id', 'index'])
|
|
810
|
+
: new Set(['archived_at', 'phase_id']);
|
|
811
|
+
const deltas = deltasOf(fb, fa, fieldIgnore, partialSides('fields'), intentOnly);
|
|
812
|
+
|
|
813
|
+
if (archivedBefore !== archivedAfter) {
|
|
814
|
+
pushField({
|
|
815
|
+
entity: 'fields',
|
|
816
|
+
op: archivedAfter ? 'archive' : 'unarchive',
|
|
817
|
+
key: fieldMatcher.primary(fa) ?? '',
|
|
818
|
+
label: `${phaseLabel} / ${label(fa, 'field')}`,
|
|
819
|
+
before: fb,
|
|
820
|
+
after: fa,
|
|
821
|
+
deltas: [{ key: 'archived_at', before: fb['archived_at'] ?? null, after: fa['archived_at'] ?? null }],
|
|
822
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (!deltas.length) continue;
|
|
827
|
+
|
|
828
|
+
const flags: string[] = [];
|
|
829
|
+
if (deltas.some((d) => d.key === 'type_id')) flags.push('field-type-change');
|
|
830
|
+
if (deltas.some((d) => d.key === 'phase_id')) flags.push('field-phase-move');
|
|
831
|
+
if (deltas.length === 1 && deltas[0]!.key === 'index') flags.push('field-reorder');
|
|
832
|
+
|
|
833
|
+
pushField({
|
|
834
|
+
entity: 'fields',
|
|
835
|
+
op: 'update',
|
|
836
|
+
key: fieldMatcher.primary(fa) ?? '',
|
|
837
|
+
label: `${phaseLabel} / ${label(fa, 'field')}`,
|
|
838
|
+
before: fb,
|
|
839
|
+
after: fa,
|
|
840
|
+
deltas,
|
|
841
|
+
owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
|
|
842
|
+
flags,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Reconcile field moves. A delete and a create sharing an exact identity —
|
|
849
|
+
* uuid, or numeric id — is one field that changed phase, never two fields.
|
|
850
|
+
* Matching only on exact identity is deliberate: pairing on label would turn
|
|
851
|
+
* "delete Notes from A, add Notes to B" into a move when it may genuinely be
|
|
852
|
+
* two different fields.
|
|
853
|
+
*/
|
|
854
|
+
const identityOf = (c: Change): string | null => {
|
|
855
|
+
const row = c.after ?? c.before;
|
|
856
|
+
if (!row) return null;
|
|
857
|
+
if (row['uuid']) return `uuid:${row['uuid']}`;
|
|
858
|
+
if (row['id'] !== null && row['id'] !== undefined) return `id:${row['id']}`;
|
|
859
|
+
return null;
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const createdById = new Map<string, Change>();
|
|
863
|
+
for (const c of fieldChanges) {
|
|
864
|
+
if (c.op !== 'create') continue;
|
|
865
|
+
const id = identityOf(c);
|
|
866
|
+
if (id) createdById.set(id, c);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const consumed = new Set<Change>();
|
|
870
|
+
for (const del of fieldChanges) {
|
|
871
|
+
if (del.op !== 'delete') continue;
|
|
872
|
+
const id = identityOf(del);
|
|
873
|
+
if (!id) continue;
|
|
874
|
+
const created = createdById.get(id);
|
|
875
|
+
if (!created || consumed.has(created)) continue;
|
|
876
|
+
|
|
877
|
+
consumed.add(created);
|
|
878
|
+
consumed.add(del);
|
|
879
|
+
|
|
880
|
+
const before = del.before ?? {};
|
|
881
|
+
const after = created.after ?? {};
|
|
882
|
+
const deltas = deltasOf(before, after, undefined, partialSides('fields'), intentOnly);
|
|
883
|
+
if (!deltas.some((d) => d.key === 'phase_id')) {
|
|
884
|
+
deltas.push({ key: 'phase_id', before: before['phase_id'] ?? null, after: after['phase_id'] ?? null });
|
|
885
|
+
}
|
|
886
|
+
record({
|
|
887
|
+
entity: 'fields',
|
|
888
|
+
op: 'update',
|
|
889
|
+
key: id,
|
|
890
|
+
label: `${del.owner?.label ?? '?'} -> ${created.owner?.label ?? '?'} / ${label(after, 'field')}`,
|
|
891
|
+
before,
|
|
892
|
+
after,
|
|
893
|
+
deltas,
|
|
894
|
+
owner: created.owner,
|
|
895
|
+
flags: ['field-phase-move'],
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
for (const c of fieldChanges) if (!consumed.has(c)) changes.push(c);
|
|
900
|
+
|
|
901
|
+
// ── automations (condition, field_maps, schemas fold in) ───────────────────
|
|
902
|
+
if (!coverageBlocked('automations')) {
|
|
903
|
+
const autoMatcher: Matcher<AutomationNode> = {
|
|
904
|
+
primary: (a) => (a['uuid'] ? `uuid:${a['uuid']}` : a['id'] ? `id:${a['id']}` : null),
|
|
905
|
+
natural: (a) => naturalKeyOf('automations', a),
|
|
906
|
+
};
|
|
907
|
+
/**
|
|
908
|
+
* A behavior — an automation whose `action_id` is `ai_behavior` — is
|
|
909
|
+
* excluded here, not diffed as an automation at all. `diff/agents.ts` diffs
|
|
910
|
+
* it instead, because sending one is not `createAutomation`/
|
|
911
|
+
* `updateAutomation`: this pipe's catalogue does not offer `ai_behavior` to
|
|
912
|
+
* either (confirmed live, refused), and the real write path —
|
|
913
|
+
* `createAiAgent`/`updateAiAgent` — replaces an agent's entire behaviors
|
|
914
|
+
* list in one call, which this per-automation diff has no way to express.
|
|
915
|
+
* Diffing it here too would propose a doomed `createAutomation`/
|
|
916
|
+
* `updateAutomation`/`deleteAutomation` step alongside the real one.
|
|
917
|
+
*
|
|
918
|
+
* Matched on `action_id`, not the `_agent_dir` tag: the tag is derived by
|
|
919
|
+
* `readTree` from where a file sits on disk, so it is only ever present on
|
|
920
|
+
* the *edited* side. The baseline side is `unpack()` of a stored envelope —
|
|
921
|
+
* no filesystem, no tag — so filtering on `_agent_dir` alone was one-sided:
|
|
922
|
+
* every existing behavior read as deleted on the very first diff after a
|
|
923
|
+
* fresh pull, because `before` still had it and `after` had been filtered.
|
|
924
|
+
*/
|
|
925
|
+
const withoutBehaviors = (rows: AutomationNode[]) => rows.filter((a) => a['action_id'] !== 'ai_behavior');
|
|
926
|
+
const am = matchRows(withoutBehaviors(before.automations), withoutBehaviors(after.automations), autoMatcher, mode);
|
|
927
|
+
|
|
928
|
+
for (const a of am.added) {
|
|
929
|
+
if (rowAbsenceIsUnknown('automations', 'create', label(a, 'automation'))) continue;
|
|
930
|
+
record({
|
|
931
|
+
entity: 'automations',
|
|
932
|
+
op: 'create',
|
|
933
|
+
key: autoMatcher.primary(a) ?? `new:${autoMatcher.natural(a)}`,
|
|
934
|
+
label: label(a, 'automation'),
|
|
935
|
+
after: a,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
for (const a of am.removed) {
|
|
939
|
+
if (rowAbsenceIsUnknown('automations', 'delete', label(a, 'automation'))) continue;
|
|
940
|
+
record({
|
|
941
|
+
entity: 'automations',
|
|
942
|
+
op: 'delete',
|
|
943
|
+
key: autoMatcher.primary(a) ?? `gone:${autoMatcher.natural(a)}`,
|
|
944
|
+
label: label(a, 'automation'),
|
|
945
|
+
before: a,
|
|
946
|
+
flags: ['destructive'],
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
for (const [b, a] of am.pairs) {
|
|
950
|
+
const deltas = deltasOf(b, a, new Set(['condition', 'field_maps', 'distribute_assignments', 'response_schemas']), partialSides('automations'), intentOnly);
|
|
951
|
+
const nested: FieldDelta[] = [];
|
|
952
|
+
const condPartial = partialSides('conditions');
|
|
953
|
+
const autoCond = statedCondition(b, a, intentOnly);
|
|
954
|
+
if (
|
|
955
|
+
nestedObjectDiffers(conditionSummary(autoCond), conditionSummary(a), condPartial, intentOnly) &&
|
|
956
|
+
nestedObjectDiffers(autoCond.condition, a.condition, condPartial, intentOnly)
|
|
957
|
+
) {
|
|
958
|
+
nested.push({ key: 'condition', before: b.condition ?? null, after: a.condition ?? null });
|
|
959
|
+
}
|
|
960
|
+
if (nestedDiffers(b.field_maps, a.field_maps, partialSides('field_maps'), intentOnly)) {
|
|
961
|
+
nested.push({ key: 'field_maps', before: b.field_maps, after: a.field_maps });
|
|
962
|
+
}
|
|
963
|
+
if (nestedDiffers(b.distribute_assignments, a.distribute_assignments, partialSides('distribute_assignments'), intentOnly)) {
|
|
964
|
+
nested.push({ key: 'distribute_assignments', before: b.distribute_assignments, after: a.distribute_assignments });
|
|
965
|
+
}
|
|
966
|
+
if (nestedDiffers(b.response_schemas, a.response_schemas, partialSides('response_schemas'), intentOnly)) {
|
|
967
|
+
nested.push({ key: 'response_schemas', before: b.response_schemas, after: a.response_schemas });
|
|
968
|
+
}
|
|
969
|
+
const all = [...deltas, ...nested];
|
|
970
|
+
if (!all.length) continue;
|
|
971
|
+
record({
|
|
972
|
+
entity: 'automations',
|
|
973
|
+
op: 'update',
|
|
974
|
+
key: autoMatcher.primary(a) ?? '',
|
|
975
|
+
label: label(a, 'automation'),
|
|
976
|
+
before: b,
|
|
977
|
+
after: a,
|
|
978
|
+
deltas: all,
|
|
979
|
+
flags: nested.length ? ['nested-change'] : [],
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// ── field conditions (actions + condition fold in) ─────────────────────────
|
|
985
|
+
if (!coverageBlocked('field_conditions')) {
|
|
986
|
+
/**
|
|
987
|
+
* Verification matches a condition by name alone.
|
|
988
|
+
*
|
|
989
|
+
* A field condition has no uuid, so its natural key is (phase_id, name) — and
|
|
990
|
+
* phase_id is exactly what a mis-targeted create gets wrong, which means the
|
|
991
|
+
* rows never match and the result reads as an unrelated create plus delete
|
|
992
|
+
* rather than "this landed on the wrong phase". Measured: createFieldCondition
|
|
993
|
+
* accepted phaseId 344123715 and put the condition on the start form, and
|
|
994
|
+
* this diff called it two changes instead of one wrong one.
|
|
995
|
+
*/
|
|
996
|
+
const fcMatcher: Matcher<FieldConditionNode> = {
|
|
997
|
+
primary: (f) => (f['id'] ? `id:${f['id']}` : null),
|
|
998
|
+
natural: (f) => (intentOnly ? String(f['name'] ?? '') : naturalKeyOf('field_conditions', f)),
|
|
999
|
+
};
|
|
1000
|
+
const fm = matchRows(before.field_conditions, after.field_conditions, fcMatcher, mode);
|
|
1001
|
+
|
|
1002
|
+
for (const f of fm.added) {
|
|
1003
|
+
if (rowAbsenceIsUnknown('field_conditions', 'create', label(f, 'condition'))) continue;
|
|
1004
|
+
record({ entity: 'field_conditions', op: 'create', key: fcMatcher.primary(f) ?? `new:${fcMatcher.natural(f)}`, label: label(f, 'condition'), after: f });
|
|
1005
|
+
}
|
|
1006
|
+
for (const f of fm.removed) {
|
|
1007
|
+
if (rowAbsenceIsUnknown('field_conditions', 'delete', label(f, 'condition'))) continue;
|
|
1008
|
+
record({ entity: 'field_conditions', op: 'delete', key: fcMatcher.primary(f) ?? `gone:${fcMatcher.natural(f)}`, label: label(f, 'condition'), before: f, flags: ['destructive'] });
|
|
1009
|
+
}
|
|
1010
|
+
for (const [b, a] of fm.pairs) {
|
|
1011
|
+
/**
|
|
1012
|
+
* `index` is excluded from verification, not from the diff.
|
|
1013
|
+
*
|
|
1014
|
+
* A condition's position is written by `setFieldConditionOrder`, a
|
|
1015
|
+
* separate set-replacing step that reports its own success or failure. The
|
|
1016
|
+
* server assigns an index on create, so a newly created condition almost
|
|
1017
|
+
* never lands on the number the workspace wrote — and failing
|
|
1018
|
+
* verification on that one column blocked the file sync entirely, leaving
|
|
1019
|
+
* the workspace permanently dirty for a difference nobody could fix.
|
|
1020
|
+
*/
|
|
1021
|
+
const fcIgnore = intentOnly ? ['actions', 'condition', 'index'] : ['actions', 'condition'];
|
|
1022
|
+
const deltas = deltasOf(b, a, new Set(fcIgnore), partialSides('field_conditions'), intentOnly);
|
|
1023
|
+
const nested: FieldDelta[] = [];
|
|
1024
|
+
if (nestedDiffers(b.actions, a.actions, partialSides('field_condition_actions'), intentOnly)) {
|
|
1025
|
+
nested.push({ key: 'actions', before: b.actions, after: a.actions });
|
|
1026
|
+
}
|
|
1027
|
+
const fcCond = statedCondition(b, a, intentOnly);
|
|
1028
|
+
if (nestedObjectDiffers(conditionSummary(fcCond), conditionSummary(a), partialSides('conditions'), intentOnly)) {
|
|
1029
|
+
nested.push({ key: 'condition', before: b.condition ?? null, after: a.condition ?? null });
|
|
1030
|
+
}
|
|
1031
|
+
const all = [...deltas, ...nested];
|
|
1032
|
+
if (!all.length) continue;
|
|
1033
|
+
record({ entity: 'field_conditions', op: 'update', key: fcMatcher.primary(a) ?? '', label: label(a, 'condition'), before: b, after: a, deltas: all });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ── flat arrays ────────────────────────────────────────────────────────────
|
|
1038
|
+
const flat: Array<{ entity: EntityName; before: Row[]; after: Row[] }> = [
|
|
1039
|
+
{ entity: 'labels', before: before.labels, after: after.labels },
|
|
1040
|
+
{ entity: 'webhooks', before: before.webhooks, after: after.webhooks },
|
|
1041
|
+
{ entity: 'pipe_relations', before: before.pipe_relations, after: after.pipe_relations },
|
|
1042
|
+
];
|
|
1043
|
+
for (const group of flat) {
|
|
1044
|
+
if (coverageBlocked(group.entity)) continue;
|
|
1045
|
+
const spec = ENTITIES[group.entity];
|
|
1046
|
+
const matcher: Matcher<Row> = {
|
|
1047
|
+
primary: (r) => (spec.identity === 'uuid' && r['uuid'] ? `uuid:${r['uuid']}` : r['id'] ? `id:${r['id']}` : null),
|
|
1048
|
+
natural: (r) => naturalKeyOf(group.entity, r),
|
|
1049
|
+
};
|
|
1050
|
+
const m = matchRows(group.before, group.after, matcher, mode);
|
|
1051
|
+
for (const r of m.added) if (!rowAbsenceIsUnknown(group.entity, 'create', label(r, group.entity))) record({ entity: group.entity, op: 'create', key: matcher.primary(r) ?? `new:${matcher.natural(r)}`, label: label(r, group.entity), after: r });
|
|
1052
|
+
for (const r of m.removed) if (!rowAbsenceIsUnknown(group.entity, 'delete', label(r, group.entity))) record({ entity: group.entity, op: 'delete', key: matcher.primary(r) ?? `gone:${matcher.natural(r)}`, label: label(r, group.entity), before: r, flags: ['destructive'] });
|
|
1053
|
+
for (const [b, a] of m.pairs) {
|
|
1054
|
+
const deltas = deltasOf(b, a, UNREADABLE_IN_PAYLOAD[group.entity], partialSides(group.entity), intentOnly);
|
|
1055
|
+
if (deltas.length) record({ entity: group.entity, op: 'update', key: matcher.primary(a) ?? '', label: label(a, group.entity), before: b, after: a, deltas });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// ── singletons ─────────────────────────────────────────────────────────────
|
|
1060
|
+
const singleton = (entity: EntityName, b: Row | null, a: Row | null, file: string) => {
|
|
1061
|
+
if (coverageBlocked(entity)) return;
|
|
1062
|
+
if (!b && !a) return;
|
|
1063
|
+
if (b && !a) {
|
|
1064
|
+
record({ entity, op: 'delete', key: entity, label: entity, before: b, file, flags: ['destructive'] });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (!b && a) {
|
|
1068
|
+
record({ entity, op: 'create', key: entity, label: entity, after: a, file });
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const deltas = deltasOf(b as Row, a as Row, undefined, partialSides(entity), intentOnly);
|
|
1072
|
+
if (deltas.length) record({ entity, op: 'update', key: entity, label: entity, before: b, after: a, deltas, file });
|
|
1073
|
+
};
|
|
1074
|
+
singleton('public_forms', before.public_form, after.public_form, 'public-form.json');
|
|
1075
|
+
singleton('repo_preferences', before.preferences, after.preferences, 'preferences.json');
|
|
1076
|
+
|
|
1077
|
+
// ── read-only groups: any change here is a mistake, reported as such ───────
|
|
1078
|
+
const ro: Array<[EntityName, Row[], Row[], string]> = [
|
|
1079
|
+
['email_templates', before.readonly.email_templates, after.readonly.email_templates, '_readonly/email-templates.json'],
|
|
1080
|
+
['email_inboxes', before.readonly.email_inboxes, after.readonly.email_inboxes, '_readonly/email-inboxes.json'],
|
|
1081
|
+
['visibilities', before.readonly.visibilities, after.readonly.visibilities, '_readonly/visibilities.json'],
|
|
1082
|
+
['field_maps', before.readonly.field_maps, after.readonly.field_maps, '_readonly/field-maps.json'],
|
|
1083
|
+
];
|
|
1084
|
+
for (const [entity, b, a, file] of ro) {
|
|
1085
|
+
if (sameSet(b, a) && b.length === a.length) continue;
|
|
1086
|
+
/**
|
|
1087
|
+
* A read path that cannot see a read-only group is not an edit to it. The
|
|
1088
|
+
* reconstruction has no query for visibilities, so without this every
|
|
1089
|
+
* reconstructed tree "edited" them to empty.
|
|
1090
|
+
*/
|
|
1091
|
+
if (coverageBlocked(entity) || nestedDiffers(b, a, partialSides(entity)) === false) continue;
|
|
1092
|
+
record({
|
|
1093
|
+
entity,
|
|
1094
|
+
op: 'update',
|
|
1095
|
+
key: entity,
|
|
1096
|
+
label: `${entity} (read-only)`,
|
|
1097
|
+
before: { rows: b } as Row,
|
|
1098
|
+
after: { rows: a } as Row,
|
|
1099
|
+
deltas: [{ key: 'rows', before: b, after: a }],
|
|
1100
|
+
file,
|
|
1101
|
+
flags: ['readonly-edit'],
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
for (const s of suppressed) {
|
|
1106
|
+
skipped.push({
|
|
1107
|
+
entity: s.entity,
|
|
1108
|
+
reason:
|
|
1109
|
+
`"${s.label}" would read as a ${s.op}, but that side's coverage is partial — ` +
|
|
1110
|
+
`absence there is unseen, not gone. Re-read from a snapshot to decide.`,
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
return {
|
|
1115
|
+
mode,
|
|
1116
|
+
from: { repoId: Number(before.repo['id']), name: String(before.repo['name'] ?? ''), source: before.meta.source },
|
|
1117
|
+
to: { repoId: Number(after.repo['id']), name: String(after.repo['name'] ?? ''), source: after.meta.source },
|
|
1118
|
+
changes,
|
|
1119
|
+
skipped,
|
|
1120
|
+
idMap,
|
|
1121
|
+
};
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
export const isEmpty = (cs: ChangeSet) => cs.changes.length === 0;
|
|
1125
|
+
|
|
1126
|
+
export const countByOp = (cs: ChangeSet): Record<ChangeOp, number> => {
|
|
1127
|
+
const out = { create: 0, update: 0, delete: 0, reorder: 0, archive: 0, unarchive: 0 } as Record<ChangeOp, number>;
|
|
1128
|
+
for (const c of cs.changes) out[c.op]++;
|
|
1129
|
+
return out;
|
|
1130
|
+
};
|