primitive-admin 1.0.55 → 1.0.57
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/assets/skill/skills/primitive-platform/SKILL.md +230 -2
- package/dist/bin/primitive.js +1 -1
- package/dist/src/commands/database-types.js +4 -4
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +5 -5
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +2 -2
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/guides.d.ts +15 -12
- package/dist/src/commands/guides.js +22 -15
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/metadata.js +48 -3
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +18 -0
- package/dist/src/commands/scripts.js +59 -5
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync-app-settings.d.ts +14 -4
- package/dist/src/commands/sync-app-settings.js +48 -8
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +100 -0
- package/dist/src/commands/sync.js +1578 -402
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +23 -6
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +27 -1
- package/dist/src/lib/api-client.js +25 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.js +4 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -1
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +21 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +40 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
- package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
- package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.js +8 -5
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +5 -0
- package/dist/src/lib/db-codegen/dbNaming.js +8 -2
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +17 -2
- package/dist/src/lib/db-codegen/dbTemplates.js +85 -40
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +13 -22
- package/dist/src/lib/db-codegen/dbTsTypes.js +33 -28
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +11 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +11 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -1
- package/dist/src/lib/generated-allowlist.js +7 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/platform-owned.d.ts +37 -0
- package/dist/src/lib/platform-owned.js +46 -0
- package/dist/src/lib/platform-owned.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbGenerator.d.ts +32 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +338 -36
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +1 -1
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js +1 -1
- package/dist/src/lib/swift-codegen/generator.d.ts +1 -0
- package/dist/src/lib/swift-codegen/generator.js +91 -3
- package/dist/src/lib/swift-codegen/generator.js.map +1 -1
- package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +90 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js +152 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +222 -10
- package/dist/src/lib/sync-resource-types.js +407 -20
- package/dist/src/lib/sync-resource-types.js.map +1 -1
- package/dist/src/lib/template.d.ts +1 -1
- package/dist/src/lib/template.js +6 -3
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.js +18 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -1
- package/dist/src/lib/workflow-apply.js +12 -2
- package/dist/src/lib/workflow-apply.js.map +1 -1
- package/dist/src/lib/workflow-codegen/generator.d.ts +9 -0
- package/dist/src/lib/workflow-codegen/generator.js +126 -55
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -8
- package/dist/src/lib/workflow-codegen/invokerIR.js +8 -8
- package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +17 -1
- package/dist/src/lib/workflow-codegen/schemaToTs.js +59 -38
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -1
- package/dist/src/lib/workflow-payload.d.ts +19 -0
- package/dist/src/lib/workflow-payload.js +21 -0
- package/dist/src/lib/workflow-payload.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +30 -0
- package/dist/src/lib/workflow-toml-validator.js +206 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +1 -0
- package/package.json +2 -1
|
@@ -7,20 +7,21 @@ import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOpera
|
|
|
7
7
|
import { canEmitNative } from "../lib/toml-native-form.js";
|
|
8
8
|
import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
|
|
9
9
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
10
|
+
import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
|
|
10
11
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
11
12
|
import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, } from "../lib/sync-paths.js";
|
|
12
13
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
13
|
-
import { validateWorkflowToml, formatWorkflowTomlErrors, } from "../lib/workflow-toml-validator.js";
|
|
14
|
+
import { validateWorkflowToml, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
14
15
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
15
16
|
import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
|
|
16
17
|
import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
|
|
17
18
|
import { serializeAppSettings, collectAppSettingsPushErrors, } from "./sync-app-settings.js";
|
|
18
19
|
import { applyWorkflowBody } from "../lib/workflow-apply.js";
|
|
19
|
-
import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
|
|
20
|
+
import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../lib/workflow-payload.js";
|
|
20
21
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
21
|
-
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
22
|
+
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, applyPrune, attemptFetch, classifyLocalOnly, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, hasUncommittedChanges, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
22
23
|
import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
|
|
23
|
-
import { confirmPrompt } from "../lib/confirm-prompt.js";
|
|
24
|
+
import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
|
|
24
25
|
import chalk from "chalk";
|
|
25
26
|
function ensureDir(dirPath) {
|
|
26
27
|
if (!existsSync(dirPath)) {
|
|
@@ -711,12 +712,13 @@ function serializeBlobBucket(bucket) {
|
|
|
711
712
|
* Build the PATCH payload for a blob-bucket update from local TOML.
|
|
712
713
|
*
|
|
713
714
|
* EXACT extraction of the inline builder shared by the existing-update and
|
|
714
|
-
* 409-adopt branches — do NOT "clean up" the truthiness checks
|
|
715
|
-
* treats `preset` and `accessPolicy` as mutually exclusive,
|
|
716
|
-
* `ruleSetId` when a preset/accessPolicy is also present, and
|
|
717
|
-
*
|
|
718
|
-
* `bucketKey` and `ttlTier` are immutable and never
|
|
719
|
-
* must go through this helper so their field sets never
|
|
715
|
+
* 409-adopt branches — do NOT "clean up" the truthiness checks on the access
|
|
716
|
+
* model. The server treats `preset` and `accessPolicy` as mutually exclusive,
|
|
717
|
+
* only clears `ruleSetId` when a preset/accessPolicy is also present, and
|
|
718
|
+
* refuses to leave a bucket with no access model or a blank name — so those
|
|
719
|
+
* fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
|
|
720
|
+
* sent. Both call sites must go through this helper so their field sets never
|
|
721
|
+
* drift.
|
|
720
722
|
*/
|
|
721
723
|
function buildBlobBucketUpdatePayload(bucket) {
|
|
722
724
|
const updatePayload = {};
|
|
@@ -728,8 +730,11 @@ function buildBlobBucketUpdatePayload(bucket) {
|
|
|
728
730
|
updatePayload.ruleSetId = bucket.ruleSetId;
|
|
729
731
|
if (bucket.name)
|
|
730
732
|
updatePayload.name = bucket.name;
|
|
731
|
-
|
|
732
|
-
|
|
733
|
+
// Issue #1567: `description` is TOML-owned — always send it as value-or-`null`
|
|
734
|
+
// so removing the line from the TOML clears it server-side (the server nulls
|
|
735
|
+
// `description` on an explicit `null`). Previously it was only sent when the
|
|
736
|
+
// key was present, so an omitted description preserved the stale value.
|
|
737
|
+
updatePayload.description = bucket.description ?? null;
|
|
733
738
|
return updatePayload;
|
|
734
739
|
}
|
|
735
740
|
function serializePrompt(prompt) {
|
|
@@ -856,7 +861,17 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
|
856
861
|
// Determine which steps to use (prefer config steps if non-empty, else draft steps)
|
|
857
862
|
const configSteps = activeConfig?.steps;
|
|
858
863
|
const draftSteps = draft?.steps;
|
|
859
|
-
const
|
|
864
|
+
const useConfigSteps = configSteps && configSteps.length > 0;
|
|
865
|
+
const steps = useConfigSteps ? configSteps : (draftSteps || []);
|
|
866
|
+
// #1614 — the named `expr.*` definitions travel with the steps payload, so
|
|
867
|
+
// pull emits them from the same source (config vs draft) the steps came from,
|
|
868
|
+
// as a top-level `[expr.cel]` table for the semantic round-trip.
|
|
869
|
+
const selectedExpressions = useConfigSteps
|
|
870
|
+
? activeConfig?.expressions
|
|
871
|
+
: draft?.expressions;
|
|
872
|
+
const expressionsCel = selectedExpressions?.cel && typeof selectedExpressions.cel === "object"
|
|
873
|
+
? selectedExpressions.cel
|
|
874
|
+
: undefined;
|
|
860
875
|
const data = {
|
|
861
876
|
workflow: {
|
|
862
877
|
key: workflow.workflowKey,
|
|
@@ -912,6 +927,11 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
|
|
|
912
927
|
// `secrets`, merged the same way the collection/database type configs do so
|
|
913
928
|
// a workflow's `md`/`secrets` declarations round-trip through pull → push.
|
|
914
929
|
...serializeDeclaredAccessManifest(workflow.metadataManifest),
|
|
930
|
+
// #1614 — emit `[expr.cel]` named-guard definitions (only when present) so a
|
|
931
|
+
// pull → push cycle round-trips them semantically.
|
|
932
|
+
...(expressionsCel && Object.keys(expressionsCel).length > 0
|
|
933
|
+
? { expr: { cel: expressionsCel } }
|
|
934
|
+
: {}),
|
|
915
935
|
};
|
|
916
936
|
return stringifyConfigToml(data);
|
|
917
937
|
}
|
|
@@ -952,7 +972,7 @@ function serializeEmailTemplate(template) {
|
|
|
952
972
|
}
|
|
953
973
|
return stringifyConfigToml(data);
|
|
954
974
|
}
|
|
955
|
-
function serializeRuleSet(ruleSet) {
|
|
975
|
+
export function serializeRuleSet(ruleSet) {
|
|
956
976
|
const data = {
|
|
957
977
|
ruleSet: {
|
|
958
978
|
name: ruleSet.name,
|
|
@@ -997,6 +1017,83 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
|
|
|
997
1017
|
};
|
|
998
1018
|
return stringifyConfigToml(data);
|
|
999
1019
|
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Issue #1567 — the TOML-owned scalar fields of a database-type config.
|
|
1022
|
+
*
|
|
1023
|
+
* These are fields the `database-types/*.toml` file OWNS: the local file is the
|
|
1024
|
+
* source of truth, so removing one from the TOML must clear it server-side
|
|
1025
|
+
* (config-as-code), not silently preserve the stale value. `schema` is NOT in
|
|
1026
|
+
* this list — it keeps its own `hasSchema` prior-state discriminator, since a
|
|
1027
|
+
* schema is a large sub-tree, not a scalar.
|
|
1028
|
+
*/
|
|
1029
|
+
export const DB_TYPE_OWNED_SCALARS = [
|
|
1030
|
+
"ruleSetId",
|
|
1031
|
+
"triggers",
|
|
1032
|
+
"metadataAccess",
|
|
1033
|
+
"defaultAccess",
|
|
1034
|
+
"autoPopulatedFields",
|
|
1035
|
+
"timestamps",
|
|
1036
|
+
"metadataManifest",
|
|
1037
|
+
];
|
|
1038
|
+
// User-facing TOML key names for the "Cleared <field>" push output. The wire
|
|
1039
|
+
// field differs from the key the user writes for two of them.
|
|
1040
|
+
const DB_TYPE_FIELD_LABELS = {
|
|
1041
|
+
metadataAccess: "celContextAccess",
|
|
1042
|
+
ruleSetId: "ruleSetName",
|
|
1043
|
+
};
|
|
1044
|
+
/** The user-facing TOML key for a wire field (for push output). */
|
|
1045
|
+
export function dbTypeFieldLabel(key) {
|
|
1046
|
+
return DB_TYPE_FIELD_LABELS[key] || key;
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* True when a value carries real content. null/undefined, blank/whitespace
|
|
1050
|
+
* strings, and empty objects/arrays all count as "no value" — the same
|
|
1051
|
+
* normalization the server applies (`value || null`) when it decides whether a
|
|
1052
|
+
* field is set.
|
|
1053
|
+
*/
|
|
1054
|
+
export function hasMeaningfulValue(v) {
|
|
1055
|
+
if (v === null || v === undefined)
|
|
1056
|
+
return false;
|
|
1057
|
+
if (typeof v === "string")
|
|
1058
|
+
return v.trim().length > 0;
|
|
1059
|
+
if (Array.isArray(v))
|
|
1060
|
+
return v.length > 0;
|
|
1061
|
+
if (typeof v === "object")
|
|
1062
|
+
return Object.keys(v).length > 0;
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Issue #1567 — always-send owned db-type scalars as value-or-`null`.
|
|
1067
|
+
*
|
|
1068
|
+
* The server PATCH endpoint distinguishes an omitted key (preserve) from an
|
|
1069
|
+
* explicit `null` (clear), so sending every owned scalar as `value ?? null`
|
|
1070
|
+
* converges the server to the local TOML: a field removed from the file lands
|
|
1071
|
+
* as `null` and is cleared. Matches the always-send pattern the sibling
|
|
1072
|
+
* `collection-type-config` push already uses.
|
|
1073
|
+
*/
|
|
1074
|
+
export function buildOwnedScalarUpdate(typeConfig) {
|
|
1075
|
+
const u = {};
|
|
1076
|
+
for (const key of DB_TYPE_OWNED_SCALARS) {
|
|
1077
|
+
u[key] = typeConfig[key] ?? null;
|
|
1078
|
+
}
|
|
1079
|
+
return u;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Issue #1567 — the owned scalars this push genuinely clears: the outgoing
|
|
1083
|
+
* value is empty but the server currently holds a real value (a non-null →
|
|
1084
|
+
* null transition). Used for the "Cleared <field>" report so removing an
|
|
1085
|
+
* access gate is never silent, and to decide whether an otherwise
|
|
1086
|
+
* type-field-less push still needs a type-config PATCH. Returns wire field
|
|
1087
|
+
* names (map with `dbTypeFieldLabel` for output). `serverConfig` is the live
|
|
1088
|
+
* GET; without it (fresh type or a failed fetch) nothing is reported cleared.
|
|
1089
|
+
*/
|
|
1090
|
+
export function ownedScalarsBeingCleared(updateData, serverConfig) {
|
|
1091
|
+
if (!serverConfig)
|
|
1092
|
+
return [];
|
|
1093
|
+
return DB_TYPE_OWNED_SCALARS.filter((key) => key in updateData &&
|
|
1094
|
+
!hasMeaningfulValue(updateData[key]) &&
|
|
1095
|
+
hasMeaningfulValue(serverConfig[key]));
|
|
1096
|
+
}
|
|
1000
1097
|
export function parseDatabaseTypeToml(tomlData) {
|
|
1001
1098
|
const typeSection = tomlData.type || {};
|
|
1002
1099
|
const typeConfig = {
|
|
@@ -1010,11 +1107,14 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
1010
1107
|
typeConfig.ruleSetId = typeSection.ruleSetId;
|
|
1011
1108
|
}
|
|
1012
1109
|
// Accept both `celContextAccess` (new user-facing key) and `metadataAccess`
|
|
1013
|
-
// (legacy). The newer key wins when both appear.
|
|
1014
|
-
|
|
1110
|
+
// (legacy). The newer key wins when both appear. Issue #1567: use a
|
|
1111
|
+
// defined-check, not a truthy guard, so `celContextAccess = ""` is honored as
|
|
1112
|
+
// an explicit clear (empty string → server nulls it) rather than falling
|
|
1113
|
+
// through to the legacy key or being dropped.
|
|
1114
|
+
if (typeSection.celContextAccess !== undefined) {
|
|
1015
1115
|
typeConfig.metadataAccess = typeSection.celContextAccess;
|
|
1016
1116
|
}
|
|
1017
|
-
else if (typeSection.metadataAccess) {
|
|
1117
|
+
else if (typeSection.metadataAccess !== undefined) {
|
|
1018
1118
|
typeConfig.metadataAccess = typeSection.metadataAccess;
|
|
1019
1119
|
}
|
|
1020
1120
|
if (typeSection.defaultAccess !== undefined) {
|
|
@@ -1081,7 +1181,7 @@ export function parseDatabaseTypeToml(tomlData) {
|
|
|
1081
1181
|
const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
|
|
1082
1182
|
return { typeConfig, operations, subscriptions };
|
|
1083
1183
|
}
|
|
1084
|
-
function parseRuleSetToml(tomlData) {
|
|
1184
|
+
export function parseRuleSetToml(tomlData) {
|
|
1085
1185
|
const ruleSetSection = tomlData.ruleSet || {};
|
|
1086
1186
|
return {
|
|
1087
1187
|
name: ruleSetSection.name,
|
|
@@ -1141,6 +1241,102 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
1141
1241
|
}
|
|
1142
1242
|
return result;
|
|
1143
1243
|
}
|
|
1244
|
+
// ── Content-aware `sync diff` for the five database-config resource types ──
|
|
1245
|
+
// (issue #1485)
|
|
1246
|
+
//
|
|
1247
|
+
// database-types, rule-sets, group-type-configs, collection-type-configs and
|
|
1248
|
+
// metadata-category-configs were absent from `sync diff` entirely, so an edit
|
|
1249
|
+
// to any of them read as "nothing differs" until `sync push` applied it. These
|
|
1250
|
+
// helpers give diff the same content-aware comparison the workflow and
|
|
1251
|
+
// transform blocks already have: the server entity is run through the SAME pull
|
|
1252
|
+
// serializer that writes the local file, and BOTH sides are then parsed with
|
|
1253
|
+
// the SAME push parser before hashing.
|
|
1254
|
+
//
|
|
1255
|
+
// Parsing through the push parser — rather than hashing the raw parsed TOML —
|
|
1256
|
+
// is what makes the comparison encoding-independent, and is why the four known
|
|
1257
|
+
// false-diff traps do not fire: a legacy JSON-string operation and the native
|
|
1258
|
+
// `[operations.definition]` table the server emits normalize to one shape
|
|
1259
|
+
// (`normalizeOperationFromToml`); a `ruleSetName` reference and its legacy
|
|
1260
|
+
// `ruleSetId` resolve to the same id (`normalizeRuleSetRefForDiff`); an omitted
|
|
1261
|
+
// `autoAddCreator` picks up the same default the server serializes
|
|
1262
|
+
// (`parseGroupTypeConfigToml`); and a metadata-category's identity is read from
|
|
1263
|
+
// the parsed `[metadataCategoryConfig]` content, not the filename. Hashing raw
|
|
1264
|
+
// parsed TOML would show a false `Modified` for each of those equivalent forms.
|
|
1265
|
+
/**
|
|
1266
|
+
* Resolve a parsed entity's rule-set reference to a stable id for hashing.
|
|
1267
|
+
* `parseGroupTypeConfigToml` / `parseCollectionTypeConfigToml` /
|
|
1268
|
+
* `parseDatabaseTypeToml` leave either `_ruleSetName` (key-based) or a legacy
|
|
1269
|
+
* `ruleSetId` on the entity; collapsing both to `ruleSetId` here means the two
|
|
1270
|
+
* encodings of the same reference hash equal. `throwOnMissing: false` so an
|
|
1271
|
+
* unresolvable name degrades gracefully rather than aborting the diff.
|
|
1272
|
+
*
|
|
1273
|
+
* When the name does NOT resolve — a typo, or a rule set added locally in the
|
|
1274
|
+
* same tree but not yet on the server — `resolveRuleSetReference` deletes
|
|
1275
|
+
* `_ruleSetName` WITHOUT setting `ruleSetId`, leaving the entity with no
|
|
1276
|
+
* reference at all. If the remote config also currently has no rule set the two
|
|
1277
|
+
* hashes would then be equal and diff would report `Synced`, silently hiding a
|
|
1278
|
+
* real pending change (push will either reject the typo or create the rule set
|
|
1279
|
+
* and update the config). So we preserve the unresolved name in the hashed
|
|
1280
|
+
* value under a distinct key, ensuring the pending change surfaces as
|
|
1281
|
+
* `Modified` instead of `Synced`.
|
|
1282
|
+
*/
|
|
1283
|
+
function normalizeRuleSetRefForDiff(entity, ruleSetNameToId, label) {
|
|
1284
|
+
const referencedName = entity._ruleSetName;
|
|
1285
|
+
const wasResolvable = referencedName !== undefined && ruleSetNameToId.has(referencedName);
|
|
1286
|
+
resolveRuleSetReference(entity, ruleSetNameToId, label, {
|
|
1287
|
+
throwOnMissing: false,
|
|
1288
|
+
});
|
|
1289
|
+
if (referencedName !== undefined && !wasResolvable) {
|
|
1290
|
+
entity._unresolvedRuleSetName = referencedName;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
export function hashLocalRuleSetForDiff(parsedToml) {
|
|
1294
|
+
return computeExpandedContentHash(parseRuleSetToml(parsedToml));
|
|
1295
|
+
}
|
|
1296
|
+
export function hashRemoteRuleSetForDiff(ruleSet) {
|
|
1297
|
+
return computeExpandedContentHash(parseRuleSetToml(parseConfigToml(serializeRuleSet(ruleSet))));
|
|
1298
|
+
}
|
|
1299
|
+
export function hashLocalGroupTypeConfigForDiff(parsedToml, ruleSetNameToId) {
|
|
1300
|
+
const entity = parseGroupTypeConfigToml(parsedToml);
|
|
1301
|
+
normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `group type config "${entity.groupType}"`);
|
|
1302
|
+
return computeExpandedContentHash(entity);
|
|
1303
|
+
}
|
|
1304
|
+
export function hashRemoteGroupTypeConfigForDiff(config, ruleSetIdToName, ruleSetNameToId) {
|
|
1305
|
+
const entity = parseGroupTypeConfigToml(parseConfigToml(serializeGroupTypeConfig(config, ruleSetIdToName)));
|
|
1306
|
+
normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `group type config "${entity.groupType}"`);
|
|
1307
|
+
return computeExpandedContentHash(entity);
|
|
1308
|
+
}
|
|
1309
|
+
export function hashLocalCollectionTypeConfigForDiff(parsedToml, ruleSetNameToId) {
|
|
1310
|
+
const entity = parseCollectionTypeConfigToml(parsedToml);
|
|
1311
|
+
normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `collection type config "${entity.collectionType}"`);
|
|
1312
|
+
return computeExpandedContentHash(entity);
|
|
1313
|
+
}
|
|
1314
|
+
export function hashRemoteCollectionTypeConfigForDiff(config, ruleSetIdToName, ruleSetNameToId) {
|
|
1315
|
+
const entity = parseCollectionTypeConfigToml(parseConfigToml(serializeCollectionTypeConfig(config, ruleSetIdToName)));
|
|
1316
|
+
normalizeRuleSetRefForDiff(entity, ruleSetNameToId, `collection type config "${entity.collectionType}"`);
|
|
1317
|
+
return computeExpandedContentHash(entity);
|
|
1318
|
+
}
|
|
1319
|
+
export function hashLocalDatabaseTypeForDiff(parsedToml, ruleSetNameToId) {
|
|
1320
|
+
const { typeConfig, operations, subscriptions } = parseDatabaseTypeToml(parsedToml);
|
|
1321
|
+
normalizeRuleSetRefForDiff(typeConfig, ruleSetNameToId, `database type "${typeConfig.databaseType}"`);
|
|
1322
|
+
return computeExpandedContentHash({ typeConfig, operations, subscriptions });
|
|
1323
|
+
}
|
|
1324
|
+
export function hashRemoteDatabaseTypeForDiff(typeConfig, operations, subscriptions, ruleSetIdToName, ruleSetNameToId) {
|
|
1325
|
+
const serialized = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, { defaultForm: "native", subscriptions });
|
|
1326
|
+
const parsed = parseDatabaseTypeToml(parseConfigToml(serialized));
|
|
1327
|
+
normalizeRuleSetRefForDiff(parsed.typeConfig, ruleSetNameToId, `database type "${parsed.typeConfig.databaseType}"`);
|
|
1328
|
+
return computeExpandedContentHash({
|
|
1329
|
+
typeConfig: parsed.typeConfig,
|
|
1330
|
+
operations: parsed.operations,
|
|
1331
|
+
subscriptions: parsed.subscriptions,
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
export function hashLocalMetadataCategoryForDiff(parsedToml) {
|
|
1335
|
+
return computeExpandedContentHash(parseMetadataCategoryToml(parsedToml));
|
|
1336
|
+
}
|
|
1337
|
+
export function hashRemoteMetadataCategoryForDiff(config) {
|
|
1338
|
+
return computeExpandedContentHash(parseMetadataCategoryToml(parseConfigToml(serializeMetadataCategoryConfig(config))));
|
|
1339
|
+
}
|
|
1144
1340
|
// Parsing helpers
|
|
1145
1341
|
//
|
|
1146
1342
|
// All 20+ TOML parse sites in this file route through `parseTomlFile()`.
|
|
@@ -1148,7 +1344,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
|
|
|
1148
1344
|
// prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
|
|
1149
1345
|
// expander is a no-op: if the parsed result has no `include` key, it returns
|
|
1150
1346
|
// the original object untouched.
|
|
1151
|
-
function parseTomlFile(filePath) {
|
|
1347
|
+
export function parseTomlFile(filePath) {
|
|
1152
1348
|
const content = readFileSync(filePath, "utf-8");
|
|
1153
1349
|
const parsed = parseConfigToml(content);
|
|
1154
1350
|
return expandWorkflowTomlData(parsed, filePath);
|
|
@@ -1170,6 +1366,83 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
|
|
|
1170
1366
|
} while (cursor);
|
|
1171
1367
|
return all;
|
|
1172
1368
|
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Issue #976 / #1006: shared 409 → adopt-by-key recovery for `sync push`
|
|
1371
|
+
* create paths. When a CREATE hits a per-app unique-key constraint, the resource
|
|
1372
|
+
* is already on the server but missing from local sync state: orphaned by a
|
|
1373
|
+
* prior push that aborted before recording it, a mid-apply crash, or an
|
|
1374
|
+
* out-of-band create with the same key. Rather than hard-fail forever, this
|
|
1375
|
+
* helper looks the resource up by its unique key and hands the matched item
|
|
1376
|
+
* back so the caller can re-issue the create as an UPDATE and converge.
|
|
1377
|
+
*
|
|
1378
|
+
* The conflict surfaces inconsistently across create routes, so detection is
|
|
1379
|
+
* deliberately broad (see `isConflict` below):
|
|
1380
|
+
* - a 409 status (webhooks, cron triggers, the standard case);
|
|
1381
|
+
* - an "already exists" message — some routes surface the 409 as HTTP 400,
|
|
1382
|
+
* e.g. workflows;
|
|
1383
|
+
* - a "must be unique" message — routes with no explicit conflict catch let
|
|
1384
|
+
* dynamo-bao's per-app unique-constraint error propagate verbatim (e.g. rule
|
|
1385
|
+
* sets, whose create route has no 409 translation). This matches the
|
|
1386
|
+
* server's own admin-api convention (`src/admin-api.ts`), which treats
|
|
1387
|
+
* "must be unique" as a duplicate-key conflict.
|
|
1388
|
+
* Any other error is NOT a conflict and re-throws unchanged (see below).
|
|
1389
|
+
*
|
|
1390
|
+
* Safety (load-bearing): exact-key match only. The `lookup` is app-scoped, so a
|
|
1391
|
+
* key match is also an ownership match — an unrelated resource is never
|
|
1392
|
+
* overwritten. The three non-adopt outcomes each surface a clear error and do
|
|
1393
|
+
* NOT touch the server:
|
|
1394
|
+
* - a non-409 create error is re-wrapped via `wrapEntityError` and re-thrown
|
|
1395
|
+
* (no lookup is attempted);
|
|
1396
|
+
* - a lookup failure throws a clear "already exists but could not be adopted
|
|
1397
|
+
* (lookup failed: …)";
|
|
1398
|
+
* - a 409 with no matching key re-throws the ORIGINAL create error rather
|
|
1399
|
+
* than silently proceeding.
|
|
1400
|
+
*
|
|
1401
|
+
* Pagination is the caller's concern: `lookup` must return the COMPLETE
|
|
1402
|
+
* candidate set. For cursor-based lists (webhooks, integrations, prompts,
|
|
1403
|
+
* workflows) wrap the list call in `fetchAll`; the cron-triggers route already
|
|
1404
|
+
* drains every page server-side (#1668) so its flat `{ items }` is complete.
|
|
1405
|
+
* The UPDATE re-issue and sync-state stamping stay with the caller because they
|
|
1406
|
+
* differ per entity.
|
|
1407
|
+
*
|
|
1408
|
+
* @returns the matched existing item (guaranteed non-null on return).
|
|
1409
|
+
*/
|
|
1410
|
+
export async function adoptByKeyOnCreate409(opts) {
|
|
1411
|
+
const { err, kind, key, lookup, matchKey } = opts;
|
|
1412
|
+
const msg = String(err?.message ?? err);
|
|
1413
|
+
// A per-app unique-key conflict, surfaced any of the ways create routes report
|
|
1414
|
+
// it (409 status / "already exists" / dynamo-bao's "must be unique"). Anything
|
|
1415
|
+
// else is a genuine create failure and must not trigger an adopt.
|
|
1416
|
+
const isConflict = err?.statusCode === 409 ||
|
|
1417
|
+
msg.includes("already exists") ||
|
|
1418
|
+
msg.includes("must be unique");
|
|
1419
|
+
if (!isConflict) {
|
|
1420
|
+
// Not an adoptable conflict — surface the original create failure.
|
|
1421
|
+
throw wrapEntityError(err, "create", kind, key);
|
|
1422
|
+
}
|
|
1423
|
+
const label = kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
1424
|
+
info(` ${label} already exists on server, adopting by key: ${key}`);
|
|
1425
|
+
let items;
|
|
1426
|
+
try {
|
|
1427
|
+
items = await lookup();
|
|
1428
|
+
}
|
|
1429
|
+
catch (lookupErr) {
|
|
1430
|
+
// A lookup failure after a 409 means we cannot adopt — surface it clearly
|
|
1431
|
+
// rather than swallowing it (a bare throw or silent catch loops
|
|
1432
|
+
// create→409 on every subsequent push).
|
|
1433
|
+
throw wrapEntityError(new Error(`${kind} "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", kind, key);
|
|
1434
|
+
}
|
|
1435
|
+
// Exact-key match only. The list endpoint is app-scoped, so a key match is
|
|
1436
|
+
// also an ownership match — require exact equality and never overwrite an
|
|
1437
|
+
// unrelated resource.
|
|
1438
|
+
const existing = (items || []).find((item) => matchKey(item));
|
|
1439
|
+
if (!existing) {
|
|
1440
|
+
// 409 but no matching key found on the server — surface the original
|
|
1441
|
+
// create error rather than silently overwriting an unrelated resource.
|
|
1442
|
+
throw wrapEntityError(err, "create", kind, key);
|
|
1443
|
+
}
|
|
1444
|
+
return existing;
|
|
1445
|
+
}
|
|
1173
1446
|
/**
|
|
1174
1447
|
* Resolve a key-based rule set name reference to an ID.
|
|
1175
1448
|
* Throws if the name cannot be resolved and throwOnMissing is true.
|
|
@@ -1799,10 +2072,19 @@ Directory Structure:
|
|
|
1799
2072
|
const emailTemplatesResult = emailTemplatesOutcome.ok
|
|
1800
2073
|
? emailTemplatesOutcome.value
|
|
1801
2074
|
: { templates: [] };
|
|
2075
|
+
// #1660 — drop platform-owned workflows (the reserved `__internal.*`
|
|
2076
|
+
// namespace) BEFORE the per-item detail fetch below. They are not app
|
|
2077
|
+
// config: the platform creates and owns them, their exported TOML is
|
|
2078
|
+
// hollow and doesn't round-trip, and `sync push` would abort trying to
|
|
2079
|
+
// activate a configuration-less workflow. Filtering here means pull
|
|
2080
|
+
// writes no file for them and wastes no `getWorkflow`/`getWorkflowConfig`
|
|
2081
|
+
// call. Prefers the server's `platformOwned` flag, falls back to the key
|
|
2082
|
+
// prefix for older servers (edge case 1).
|
|
2083
|
+
const appWorkflowItems = workflowItems.filter((w) => !isPlatformOwnedWorkflow(w));
|
|
1802
2084
|
// Fetch details for each entity
|
|
1803
2085
|
const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
|
|
1804
2086
|
const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
|
|
1805
|
-
const workflows = await Promise.all(
|
|
2087
|
+
const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
|
|
1806
2088
|
const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
|
|
1807
2089
|
// Fetch active config with steps if available
|
|
1808
2090
|
const activeConfigId = workflowData.workflow?.activeConfigId;
|
|
@@ -2084,6 +2366,33 @@ Directory Structure:
|
|
|
2084
2366
|
};
|
|
2085
2367
|
info(` Wrote workflows/${filename}`);
|
|
2086
2368
|
}
|
|
2369
|
+
// #1660 — remove any pre-fix stray `workflows/__internal.*.toml` a prior
|
|
2370
|
+
// (buggy) pull exported. Match on the RESOLVED `[workflow].key`, never on
|
|
2371
|
+
// filename alone, so an app-authored file is never touched even if it is
|
|
2372
|
+
// coincidentally named oddly (edge case 4). We don't write these files
|
|
2373
|
+
// anymore, so a leftover would otherwise show as permanent "Remote only"
|
|
2374
|
+
// diff noise (#1659) and re-abort `sync push`.
|
|
2375
|
+
const workflowsPullDir = join(configDir, "workflows");
|
|
2376
|
+
if (existsSync(workflowsPullDir)) {
|
|
2377
|
+
for (const file of readdirSync(workflowsPullDir).filter((f) => f.endsWith(".toml"))) {
|
|
2378
|
+
const strayPath = join(workflowsPullDir, file);
|
|
2379
|
+
let resolvedKey;
|
|
2380
|
+
try {
|
|
2381
|
+
const parsed = parseTomlFile(strayPath);
|
|
2382
|
+
resolvedKey = parsed.workflow?.key || basename(file, ".toml");
|
|
2383
|
+
}
|
|
2384
|
+
catch {
|
|
2385
|
+
// Unparseable file — fall back to the filename stem so a hollow
|
|
2386
|
+
// `__internal.*.toml` is still recognized and cleaned up.
|
|
2387
|
+
resolvedKey = basename(file, ".toml");
|
|
2388
|
+
}
|
|
2389
|
+
if (isPlatformOwnedWorkflowKey(resolvedKey)) {
|
|
2390
|
+
unlinkSync(strayPath);
|
|
2391
|
+
delete workflowEntities[resolvedKey];
|
|
2392
|
+
info(` Removed workflows/${file} — platform-owned workflow "${resolvedKey}" (managed by the platform)`);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2087
2396
|
// Pull transforms (Rhai scripts) — issue #892, slice 7 + codex
|
|
2088
2397
|
// follow-up on PR #893. Mirrors the prompts pull pattern: list
|
|
2089
2398
|
// server-side `Script` rows, write each body to
|
|
@@ -2305,7 +2614,10 @@ Directory Structure:
|
|
|
2305
2614
|
integrations: { ok: true, serverKeys: integrationItems.map((i) => i.integrationKey) },
|
|
2306
2615
|
webhooks: { ok: true, serverKeys: webhookItems.map((w) => w.webhookKey) },
|
|
2307
2616
|
prompts: { ok: true, serverKeys: promptItems.map((p) => p.promptKey) },
|
|
2308
|
-
|
|
2617
|
+
// #1660 — use the platform-owned-filtered set so prune reconciliation
|
|
2618
|
+
// never treats a platform-owned workflow as a server key that a local
|
|
2619
|
+
// file should exist for (we intentionally write none).
|
|
2620
|
+
workflows: { ok: true, serverKeys: appWorkflowItems.map((w) => w.workflowKey) },
|
|
2309
2621
|
cronTriggers: cronTriggersOutcome.ok
|
|
2310
2622
|
? { ok: true, serverKeys: cronTriggerItems.map((t) => t.triggerKey) }
|
|
2311
2623
|
: { ok: false },
|
|
@@ -2522,6 +2834,8 @@ Directory Structure:
|
|
|
2522
2834
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
2523
2835
|
.option("--dry-run", "Show what would be changed without applying")
|
|
2524
2836
|
.option("--force", "Overwrite remote even if modified since last pull")
|
|
2837
|
+
.option("--prune", "Delete remote entities whose local TOML was removed since the last sync (issue #1655)")
|
|
2838
|
+
.option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
|
|
2525
2839
|
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
2526
2840
|
.action(async (appId, options) => {
|
|
2527
2841
|
const resolvedAppId = resolveAppId(appId, options);
|
|
@@ -2628,6 +2942,13 @@ Directory Structure:
|
|
|
2628
2942
|
}
|
|
2629
2943
|
}
|
|
2630
2944
|
}
|
|
2945
|
+
// #1767: the "convergence trap" — this gate checks the operations
|
|
2946
|
+
// as currently registered, and a push that changes an operation and
|
|
2947
|
+
// its schema together skips both, so fixing the operation locally
|
|
2948
|
+
// isn't enough. Spell out the two-step recovery.
|
|
2949
|
+
info(`If you also changed these operations in this push, their updates were skipped along with the schema. ` +
|
|
2950
|
+
`Push the operation fix first: temporarily remove the ${chalk.cyan("[models.*]")} block for this type, ` +
|
|
2951
|
+
`run ${chalk.cyan("sync")} (the operation updates apply), then restore the schema block and sync again.`);
|
|
2631
2952
|
console.log();
|
|
2632
2953
|
}
|
|
2633
2954
|
if (schemaErrors.uncheckableOps.length > 0) {
|
|
@@ -2710,6 +3031,19 @@ Directory Structure:
|
|
|
2710
3031
|
const filePath = join(preflightWorkflowsDir, file);
|
|
2711
3032
|
try {
|
|
2712
3033
|
const tomlData = parseTomlFile(filePath);
|
|
3034
|
+
// #1660 — skip platform-owned workflows (the reserved
|
|
3035
|
+
// `__internal.*` namespace) BEFORE validating, mirroring the
|
|
3036
|
+
// apply loop below. `sync push` validates every workflow TOML
|
|
3037
|
+
// here, up-front, before reaching that skip — so without this
|
|
3038
|
+
// second skip a stale/hollow `__internal.*.toml` with an invalid
|
|
3039
|
+
// `steps` shape would abort the whole push in preflight. Resolve
|
|
3040
|
+
// the key the same way the apply loop does (`[workflow].key`,
|
|
3041
|
+
// falling back to the filename) and match on the resolved key,
|
|
3042
|
+
// never the filename alone, so a renamed file is still caught.
|
|
3043
|
+
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
3044
|
+
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
3045
|
+
continue;
|
|
3046
|
+
}
|
|
2713
3047
|
const tomlErrors = validateWorkflowToml(tomlData);
|
|
2714
3048
|
if (tomlErrors.length > 0) {
|
|
2715
3049
|
preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
@@ -2756,6 +3090,21 @@ Directory Structure:
|
|
|
2756
3090
|
for (const e of validation.errors) {
|
|
2757
3091
|
preflightValidationErrors.push(` ${formatIssue(e)}`);
|
|
2758
3092
|
}
|
|
3093
|
+
// Issue #1422: non-fatal deprecation notice. A `celContextAccess`
|
|
3094
|
+
// / `metadataAccess` gate on a database type config is a single CEL
|
|
3095
|
+
// expression that gates BOTH read and update of the database CEL
|
|
3096
|
+
// context — granting read implicitly grants update. Steer operators
|
|
3097
|
+
// to resource metadata categories (separate readRule/writeRule).
|
|
3098
|
+
// This is advisory only: the push continues and the runtime gate is
|
|
3099
|
+
// unchanged. It fires only when the deprecated key is actually
|
|
3100
|
+
// present, so metadata-category-only configs never trip it.
|
|
3101
|
+
const typeSection = tomlData.type || {};
|
|
3102
|
+
if (typeSection.celContextAccess || typeSection.metadataAccess) {
|
|
3103
|
+
const usedKey = typeSection.celContextAccess
|
|
3104
|
+
? "celContextAccess"
|
|
3105
|
+
: "metadataAccess";
|
|
3106
|
+
warn(` ${file}: \`${usedKey}\` is deprecated — it gates database CEL context READ and WRITE with one expression (read implies update). Prefer a resource metadata category with separate readRule/writeRule (issue #1420). Sync will continue; the runtime gate is unchanged.`);
|
|
3107
|
+
}
|
|
2759
3108
|
}
|
|
2760
3109
|
}
|
|
2761
3110
|
// Validate app.toml settings up-front (issue #1033). A hand-added
|
|
@@ -3080,31 +3429,74 @@ Directory Structure:
|
|
|
3080
3429
|
// Create new rule set
|
|
3081
3430
|
changes.push({ type: "rule-set", action: "create", key: fileKey });
|
|
3082
3431
|
if (!options.dryRun) {
|
|
3083
|
-
let
|
|
3432
|
+
let ruleSetId;
|
|
3433
|
+
let ruleSetModifiedAt;
|
|
3084
3434
|
try {
|
|
3085
|
-
created = await client.createRuleSet(resolvedAppId, {
|
|
3435
|
+
const created = await client.createRuleSet(resolvedAppId, {
|
|
3086
3436
|
name: ruleSetData.name,
|
|
3087
3437
|
resourceType: ruleSetData.resourceType,
|
|
3088
3438
|
rules: ruleSetData.rules,
|
|
3089
3439
|
description: ruleSetData.description,
|
|
3090
3440
|
});
|
|
3441
|
+
info(` Created rule set: ${fileKey}`);
|
|
3442
|
+
ruleSetId = created.ruleSetId;
|
|
3443
|
+
ruleSetModifiedAt = created.modifiedAt || new Date().toISOString();
|
|
3091
3444
|
}
|
|
3092
3445
|
catch (err) {
|
|
3093
|
-
|
|
3446
|
+
// #1006: idempotent create — adopt-by-key on 409. A rule set
|
|
3447
|
+
// can be orphaned on the server (a prior push aborted before
|
|
3448
|
+
// recording it in sync state, or an out-of-band create). On
|
|
3449
|
+
// retry this CREATE hits the `ruleSetNamePerApp` unique
|
|
3450
|
+
// constraint, so adoption matches on the server key `name`
|
|
3451
|
+
// (NOT the sync-state fileKey). `listRuleSets` returns a raw
|
|
3452
|
+
// array on the app-scoped app-API route (no `{ items }`, no
|
|
3453
|
+
// cursor), so a name match is also an ownership match. Non-409
|
|
3454
|
+
// errors, lookup failures, and no-match 409s all throw (see
|
|
3455
|
+
// adoptByKeyOnCreate409). `key` stays `fileKey` so non-409
|
|
3456
|
+
// errors wrap identically to before; `matchKey` does the real
|
|
3457
|
+
// name match.
|
|
3458
|
+
const existing = await adoptByKeyOnCreate409({
|
|
3459
|
+
err,
|
|
3460
|
+
kind: "rule set",
|
|
3461
|
+
key: fileKey,
|
|
3462
|
+
lookup: async () => await client.listRuleSets(resolvedAppId),
|
|
3463
|
+
matchKey: (rs) => rs?.name === ruleSetData.name,
|
|
3464
|
+
});
|
|
3465
|
+
// resourceType is immutable and cannot be reconciled by an
|
|
3466
|
+
// UPDATE (updateRuleSet's payload has no resourceType field).
|
|
3467
|
+
// Adoption matches only on `name`, so a same-name rule set on
|
|
3468
|
+
// a different resource type would be silently stamped as
|
|
3469
|
+
// synced while the server stays on the old type. Fail loudly
|
|
3470
|
+
// instead — mirrors the blob-bucket ttlTier-immutability guard.
|
|
3471
|
+
if (ruleSetData.resourceType &&
|
|
3472
|
+
existing.resourceType &&
|
|
3473
|
+
ruleSetData.resourceType !== existing.resourceType) {
|
|
3474
|
+
throw wrapEntityError(new Error(`rule set "${fileKey}" resourceType mismatch: local TOML has "${ruleSetData.resourceType}" but the server rule set "${ruleSetData.name}" has "${existing.resourceType}". resourceType is immutable; align the TOML with the server (or rename/recreate the rule set) before pushing.`), "update", "rule set", fileKey);
|
|
3475
|
+
}
|
|
3476
|
+
const updated = await client.updateRuleSet(resolvedAppId, existing.ruleSetId, {
|
|
3477
|
+
name: ruleSetData.name,
|
|
3478
|
+
description: ruleSetData.description,
|
|
3479
|
+
rules: ruleSetData.rules,
|
|
3480
|
+
});
|
|
3481
|
+
info(` Adopted + updated rule set: ${fileKey}`);
|
|
3482
|
+
ruleSetId = existing.ruleSetId;
|
|
3483
|
+
ruleSetModifiedAt =
|
|
3484
|
+
updated?.modifiedAt ||
|
|
3485
|
+
existing.modifiedAt ||
|
|
3486
|
+
new Date().toISOString();
|
|
3094
3487
|
}
|
|
3095
|
-
|
|
3096
|
-
if (syncState && created?.ruleSetId) {
|
|
3488
|
+
if (syncState && ruleSetId) {
|
|
3097
3489
|
if (!syncState.entities.ruleSets) {
|
|
3098
3490
|
syncState.entities.ruleSets = {};
|
|
3099
3491
|
}
|
|
3100
3492
|
syncState.entities.ruleSets[fileKey] = {
|
|
3101
|
-
id:
|
|
3102
|
-
modifiedAt:
|
|
3493
|
+
id: ruleSetId,
|
|
3494
|
+
modifiedAt: ruleSetModifiedAt,
|
|
3103
3495
|
contentHash: computeFileHash(filePath),
|
|
3104
3496
|
};
|
|
3105
3497
|
}
|
|
3106
3498
|
// Track name→ID for cross-reference resolution
|
|
3107
|
-
ruleSetNameToId.set(ruleSetData.name,
|
|
3499
|
+
ruleSetNameToId.set(ruleSetData.name, ruleSetId);
|
|
3108
3500
|
}
|
|
3109
3501
|
else {
|
|
3110
3502
|
// In dry-run mode, use a placeholder so dependent entities can resolve
|
|
@@ -3131,7 +3523,14 @@ Directory Structure:
|
|
|
3131
3523
|
const payload = {
|
|
3132
3524
|
integrationKey: key,
|
|
3133
3525
|
displayName: integration.displayName || key,
|
|
3134
|
-
|
|
3526
|
+
// Issue #1567: `description` is TOML-owned — send `?? null` so
|
|
3527
|
+
// removing it from the TOML clears it server-side (the server
|
|
3528
|
+
// nulls `description` on an explicit `null`). A bare
|
|
3529
|
+
// `integration.description` is `undefined` on absence and dropped
|
|
3530
|
+
// by `JSON.stringify`, so the server kept the stale value.
|
|
3531
|
+
// (`timeoutMs`/`status` are defaulted/enum server-side — `null` is
|
|
3532
|
+
// not a valid clear for them, so they stay as-is.)
|
|
3533
|
+
description: integration.description ?? null,
|
|
3135
3534
|
requestConfig: tomlData.requestConfig || {},
|
|
3136
3535
|
timeoutMs: integration.timeoutMs,
|
|
3137
3536
|
status: integration.status,
|
|
@@ -3185,7 +3584,35 @@ Directory Structure:
|
|
|
3185
3584
|
}
|
|
3186
3585
|
}
|
|
3187
3586
|
catch (err) {
|
|
3188
|
-
|
|
3587
|
+
// #1006: idempotent create — adopt-by-key on 409. An
|
|
3588
|
+
// integration orphaned on the server (a prior push aborted
|
|
3589
|
+
// before recording it, or an out-of-band create) hits the
|
|
3590
|
+
// `integrationKeyPerApp` unique constraint on retry. Adopt by
|
|
3591
|
+
// `integrationKey` and re-issue as an UPDATE so the push
|
|
3592
|
+
// converges. `listIntegrations` is cursor-paginated → drain
|
|
3593
|
+
// fully with fetchAll. Non-409 errors, lookup failures, and
|
|
3594
|
+
// no-match 409s all throw (see adoptByKeyOnCreate409).
|
|
3595
|
+
const existing = await adoptByKeyOnCreate409({
|
|
3596
|
+
err,
|
|
3597
|
+
kind: "integration",
|
|
3598
|
+
key,
|
|
3599
|
+
lookup: () => fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
|
|
3600
|
+
matchKey: (i) => i?.integrationKey === key,
|
|
3601
|
+
});
|
|
3602
|
+
const updated = await client.updateIntegration(resolvedAppId, existing.integrationId, payload);
|
|
3603
|
+
info(` Adopted + updated integration: ${key}`);
|
|
3604
|
+
if (syncState) {
|
|
3605
|
+
if (!syncState.entities.integrations) {
|
|
3606
|
+
syncState.entities.integrations = {};
|
|
3607
|
+
}
|
|
3608
|
+
syncState.entities.integrations[key] = {
|
|
3609
|
+
id: existing.integrationId,
|
|
3610
|
+
modifiedAt: updated?.modifiedAt ||
|
|
3611
|
+
existing.modifiedAt ||
|
|
3612
|
+
new Date().toISOString(),
|
|
3613
|
+
contentHash: computeFileHash(filePath),
|
|
3614
|
+
};
|
|
3615
|
+
}
|
|
3189
3616
|
}
|
|
3190
3617
|
}
|
|
3191
3618
|
}
|
|
@@ -3209,7 +3636,12 @@ Directory Structure:
|
|
|
3209
3636
|
const payload = {
|
|
3210
3637
|
webhookKey: key,
|
|
3211
3638
|
displayName: webhook.displayName || key,
|
|
3212
|
-
|
|
3639
|
+
// Issue #1567: `description` is TOML-owned — send `?? null` so
|
|
3640
|
+
// removing it from the TOML clears it on the server (the PATCH
|
|
3641
|
+
// nulls `description` on an explicit `null`). A bare
|
|
3642
|
+
// `webhook.description` is `undefined` on absence and dropped by
|
|
3643
|
+
// JSON.stringify, so the stale value would otherwise persist.
|
|
3644
|
+
description: webhook.description ?? null,
|
|
3213
3645
|
workflowKey: webhook.workflowKey,
|
|
3214
3646
|
verificationScheme: webhook.verificationScheme,
|
|
3215
3647
|
status: webhook.status,
|
|
@@ -3283,7 +3715,35 @@ Directory Structure:
|
|
|
3283
3715
|
}
|
|
3284
3716
|
}
|
|
3285
3717
|
catch (err) {
|
|
3286
|
-
|
|
3718
|
+
// #1006: idempotent create — adopt-by-key on 409. A webhook
|
|
3719
|
+
// orphaned on the server (a prior push aborted before
|
|
3720
|
+
// recording it, or an out-of-band create) hits the
|
|
3721
|
+
// `webhookKeyPerApp` unique constraint on retry. Adopt by
|
|
3722
|
+
// `webhookKey` and re-issue as an UPDATE so the push
|
|
3723
|
+
// converges. `listWebhooks` is cursor-paginated → drain fully
|
|
3724
|
+
// with fetchAll. Non-409 errors, lookup failures, and no-match
|
|
3725
|
+
// 409s all throw (see adoptByKeyOnCreate409).
|
|
3726
|
+
const existing = await adoptByKeyOnCreate409({
|
|
3727
|
+
err,
|
|
3728
|
+
kind: "webhook",
|
|
3729
|
+
key,
|
|
3730
|
+
lookup: () => fetchAll((p) => client.listWebhooks(resolvedAppId, p)),
|
|
3731
|
+
matchKey: (w) => w?.webhookKey === key,
|
|
3732
|
+
});
|
|
3733
|
+
const updated = await client.updateWebhook(resolvedAppId, existing.webhookId, payload);
|
|
3734
|
+
info(` Adopted + updated webhook: ${key}`);
|
|
3735
|
+
if (syncState) {
|
|
3736
|
+
if (!syncState.entities.webhooks) {
|
|
3737
|
+
syncState.entities.webhooks = {};
|
|
3738
|
+
}
|
|
3739
|
+
syncState.entities.webhooks[key] = {
|
|
3740
|
+
id: existing.webhookId,
|
|
3741
|
+
modifiedAt: updated?.modifiedAt ||
|
|
3742
|
+
existing.modifiedAt ||
|
|
3743
|
+
new Date().toISOString(),
|
|
3744
|
+
contentHash: computeFileHash(filePath),
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3287
3747
|
}
|
|
3288
3748
|
}
|
|
3289
3749
|
}
|
|
@@ -3307,7 +3767,14 @@ Directory Structure:
|
|
|
3307
3767
|
const payload = {
|
|
3308
3768
|
triggerKey: key,
|
|
3309
3769
|
displayName: cronTrigger.displayName || key,
|
|
3310
|
-
|
|
3770
|
+
// Issue #1567: `description` is TOML-owned — send `?? null` so
|
|
3771
|
+
// removing it from the TOML clears it server-side (the server
|
|
3772
|
+
// nulls `description` on an explicit `null`). A bare
|
|
3773
|
+
// `cronTrigger.description` is `undefined` on absence and dropped
|
|
3774
|
+
// by `JSON.stringify`, so the server kept the stale value.
|
|
3775
|
+
// (`timezone`/`overlapPolicy`/`state` are defaulted/enum
|
|
3776
|
+
// server-side — `null` is not a valid clear for them.)
|
|
3777
|
+
description: cronTrigger.description ?? null,
|
|
3311
3778
|
cron: cronTrigger.cron,
|
|
3312
3779
|
timezone: cronTrigger.timezone,
|
|
3313
3780
|
workflowKey: cronTrigger.workflowKey,
|
|
@@ -3355,57 +3822,38 @@ Directory Structure:
|
|
|
3355
3822
|
}
|
|
3356
3823
|
}
|
|
3357
3824
|
catch (err) {
|
|
3358
|
-
// Issue #976 (fix B): idempotent create — adopt-by-key
|
|
3359
|
-
// A cron trigger can be orphaned on the server (
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3362
|
-
//
|
|
3363
|
-
//
|
|
3364
|
-
//
|
|
3365
|
-
//
|
|
3366
|
-
//
|
|
3367
|
-
//
|
|
3368
|
-
//
|
|
3369
|
-
|
|
3370
|
-
const
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
}
|
|
3384
|
-
}
|
|
3385
|
-
catch (lookupErr) {
|
|
3386
|
-
throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
|
|
3387
|
-
}
|
|
3388
|
-
if (!adoptedId) {
|
|
3389
|
-
// 409 but no matching key found on the server — surface
|
|
3390
|
-
// the original error rather than silently overwriting.
|
|
3391
|
-
throw wrapEntityError(err, "create", "cron trigger", key);
|
|
3392
|
-
}
|
|
3393
|
-
// Switch to UPDATE on the adopted trigger.
|
|
3394
|
-
const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
|
|
3395
|
-
info(` Adopted + updated cron trigger: ${key}`);
|
|
3396
|
-
if (syncState) {
|
|
3397
|
-
if (!syncState.entities.cronTriggers) {
|
|
3398
|
-
syncState.entities.cronTriggers = {};
|
|
3399
|
-
}
|
|
3400
|
-
syncState.entities.cronTriggers[key] = {
|
|
3401
|
-
id: adoptedId,
|
|
3402
|
-
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
3403
|
-
contentHash: computeFileHash(filePath),
|
|
3404
|
-
};
|
|
3825
|
+
// Issue #976 (fix B) / #1006: idempotent create — adopt-by-key
|
|
3826
|
+
// on 409. A cron trigger can be orphaned on the server (a prior
|
|
3827
|
+
// push that aborted before recording it in sync state, a
|
|
3828
|
+
// mid-apply crash, or an out-of-band create with the same key).
|
|
3829
|
+
// On retry this CREATE hits the `triggerKeyPerApp` unique
|
|
3830
|
+
// constraint and the server returns 409 "already exists". The
|
|
3831
|
+
// shared helper looks the trigger up by key, verifies exact-key
|
|
3832
|
+
// (== owned, since the list is app-scoped) ownership, and hands
|
|
3833
|
+
// it back; we then re-issue as an UPDATE so the push converges.
|
|
3834
|
+
// Non-409 errors, lookup failures, and no-match 409s all throw
|
|
3835
|
+
// (see adoptByKeyOnCreate409). listCronTriggers drains every
|
|
3836
|
+
// page server-side (#1668), so its flat items list is complete.
|
|
3837
|
+
const existing = await adoptByKeyOnCreate409({
|
|
3838
|
+
err,
|
|
3839
|
+
kind: "cron trigger",
|
|
3840
|
+
key,
|
|
3841
|
+
lookup: async () => (await client.listCronTriggers(resolvedAppId)).items || [],
|
|
3842
|
+
matchKey: (t) => t?.triggerKey === key,
|
|
3843
|
+
});
|
|
3844
|
+
const adoptedId = existing.triggerId;
|
|
3845
|
+
// Switch to UPDATE on the adopted trigger.
|
|
3846
|
+
const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
|
|
3847
|
+
info(` Adopted + updated cron trigger: ${key}`);
|
|
3848
|
+
if (syncState) {
|
|
3849
|
+
if (!syncState.entities.cronTriggers) {
|
|
3850
|
+
syncState.entities.cronTriggers = {};
|
|
3405
3851
|
}
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3852
|
+
syncState.entities.cronTriggers[key] = {
|
|
3853
|
+
id: adoptedId,
|
|
3854
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
3855
|
+
contentHash: computeFileHash(filePath),
|
|
3856
|
+
};
|
|
3409
3857
|
}
|
|
3410
3858
|
}
|
|
3411
3859
|
}
|
|
@@ -3483,92 +3931,73 @@ Directory Structure:
|
|
|
3483
3931
|
}
|
|
3484
3932
|
}
|
|
3485
3933
|
catch (err) {
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3934
|
+
// #1006: adopt-by-key on 409 via the shared
|
|
3935
|
+
// `adoptByKeyOnCreate409` helper (Phase 3 — blob buckets onto
|
|
3936
|
+
// the shared core). A blob bucket can already exist on the
|
|
3937
|
+
// server (an out-of-band create, or a prior push that aborted
|
|
3938
|
+
// before recording it in sync state). The helper detects the
|
|
3939
|
+
// conflict (409 or an "already exists" message), lists the
|
|
3940
|
+
// app's buckets, and returns the one whose `bucketKey` matches
|
|
3941
|
+
// exactly — so an unrelated bucket is never adopted. Its three
|
|
3942
|
+
// non-adopt outcomes each surface a clear error and never
|
|
3943
|
+
// touch the server: a non-409 create error re-wraps via
|
|
3944
|
+
// `wrapEntityError` (no lookup); a lookup failure throws
|
|
3945
|
+
// "already exists but could not be adopted (lookup failed: …)"
|
|
3946
|
+
// (no longer swallowed as the pre-#1459 code did); a 409 with
|
|
3947
|
+
// no matching key re-throws the ORIGINAL create error.
|
|
3948
|
+
//
|
|
3949
|
+
// Matching by `bucketKey` through `listBlobBuckets` (not
|
|
3950
|
+
// `getBlobBucket`, which resolves the identifier by `bucketId`
|
|
3951
|
+
// BEFORE `bucketKey`) also removes the id-collision hazard the
|
|
3952
|
+
// old inline handler had to guard against: a ULID-shaped local
|
|
3953
|
+
// key can no longer resolve to an unrelated bucket's id,
|
|
3954
|
+
// because adoption only ever matches on `bucketKey`.
|
|
3955
|
+
const existing = await adoptByKeyOnCreate409({
|
|
3956
|
+
err,
|
|
3957
|
+
kind: "blob bucket",
|
|
3958
|
+
key,
|
|
3959
|
+
lookup: async () => (await client.listBlobBuckets(resolvedAppId)).items || [],
|
|
3960
|
+
matchKey: (b) => b?.bucketKey === key,
|
|
3961
|
+
});
|
|
3962
|
+
// ttlTier is immutable and cannot be reconciled by an UPDATE.
|
|
3963
|
+
// A local TOML whose retention tier diverges from the server
|
|
3964
|
+
// must fail loudly (sponsor decision) rather than be silently
|
|
3965
|
+
// blessed while the cosmetic fields converge.
|
|
3966
|
+
if (bucket.ttlTier &&
|
|
3967
|
+
existing.ttlTier &&
|
|
3968
|
+
bucket.ttlTier !== existing.ttlTier) {
|
|
3969
|
+
throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
|
|
3970
|
+
}
|
|
3971
|
+
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
3972
|
+
let updated;
|
|
3973
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
3974
|
+
// Surface an update failure via wrapEntityError (no stamp) —
|
|
3975
|
+
// matches the cron/workflow adopt paths.
|
|
3499
3976
|
try {
|
|
3500
|
-
|
|
3501
|
-
}
|
|
3502
|
-
catch (lookupErr) {
|
|
3503
|
-
// A lookup failure after a 409 means we cannot adopt —
|
|
3504
|
-
// surface it instead of swallowing it (the old silent
|
|
3505
|
-
// `catch {}` left no sync-state entry, looping
|
|
3506
|
-
// create→409 on every push).
|
|
3507
|
-
throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
|
|
3508
|
-
}
|
|
3509
|
-
if (!existing?.bucketId) {
|
|
3510
|
-
// 409 but no matching bucket found — surface the original
|
|
3511
|
-
// create error rather than silently proceeding.
|
|
3512
|
-
throw wrapEntityError(err, "create", "blob bucket", key);
|
|
3513
|
-
}
|
|
3514
|
-
// Verify the resolved bucket is actually the one we asked
|
|
3515
|
-
// for. `getBlobBucket` resolves the identifier by primary id
|
|
3516
|
-
// (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
|
|
3517
|
-
// a ULID-shaped string (the key regex permits it). If the
|
|
3518
|
-
// local key collides with an unrelated bucket's bucketId,
|
|
3519
|
-
// the lookup returns that unrelated bucket — patching and
|
|
3520
|
-
// stamping it would overwrite the wrong resource. Require an
|
|
3521
|
-
// exact key match, matching the cron/workflow adopt paths'
|
|
3522
|
-
// exact-key equality (they list + filter by key for the same
|
|
3523
|
-
// reason). Surface the mismatch via wrapEntityError; do NOT
|
|
3524
|
-
// patch or stamp.
|
|
3525
|
-
if (existing.bucketKey !== key) {
|
|
3526
|
-
throw wrapEntityError(new Error(`blob bucket "${key}" already exists, but the server resolved a different bucket (bucketKey "${existing.bucketKey}", bucketId "${existing.bucketId}"); refusing to adopt. This happens when the local key collides with an unrelated bucket's id — rename the local bucket key so it does not match an existing bucket id.`), "create", "blob bucket", key);
|
|
3977
|
+
updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
|
|
3527
3978
|
}
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
// the server must fail loudly (sponsor decision) rather
|
|
3531
|
-
// than be silently blessed while the cosmetic fields
|
|
3532
|
-
// converge.
|
|
3533
|
-
if (bucket.ttlTier &&
|
|
3534
|
-
existing.ttlTier &&
|
|
3535
|
-
bucket.ttlTier !== existing.ttlTier) {
|
|
3536
|
-
throw wrapEntityError(new Error(`blob bucket "${key}" ttlTier mismatch: local TOML has "${bucket.ttlTier}" but the server bucket has "${existing.ttlTier}". ttlTier is immutable; align the TOML with the server (or recreate the bucket) before pushing.`), "update", "blob bucket", key);
|
|
3537
|
-
}
|
|
3538
|
-
const updatePayload = buildBlobBucketUpdatePayload(bucket);
|
|
3539
|
-
let updated;
|
|
3540
|
-
if (Object.keys(updatePayload).length > 0) {
|
|
3541
|
-
// Surface an update failure via wrapEntityError (no
|
|
3542
|
-
// stamp) — matches the cron adopt path.
|
|
3543
|
-
try {
|
|
3544
|
-
updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
|
|
3545
|
-
}
|
|
3546
|
-
catch (updateErr) {
|
|
3547
|
-
throw wrapEntityError(updateErr, "update", "blob bucket", key);
|
|
3548
|
-
}
|
|
3549
|
-
info(` Adopted + updated blob bucket: ${key}`);
|
|
3550
|
-
}
|
|
3551
|
-
else {
|
|
3552
|
-
// No mutable fields in the local TOML — the server
|
|
3553
|
-
// rejects an empty update (400). Adopt the existing
|
|
3554
|
-
// bucket without a PATCH.
|
|
3555
|
-
info(` Adopted blob bucket (no updatable fields): ${key}`);
|
|
3556
|
-
}
|
|
3557
|
-
if (syncState) {
|
|
3558
|
-
if (!syncState.entities.blobBuckets) {
|
|
3559
|
-
syncState.entities.blobBuckets = {};
|
|
3560
|
-
}
|
|
3561
|
-
syncState.entities.blobBuckets[key] = {
|
|
3562
|
-
id: existing.bucketId,
|
|
3563
|
-
modifiedAt: updated?.modifiedAt ||
|
|
3564
|
-
existing.modifiedAt ||
|
|
3565
|
-
new Date().toISOString(),
|
|
3566
|
-
contentHash: computeFileHash(filePath),
|
|
3567
|
-
};
|
|
3979
|
+
catch (updateErr) {
|
|
3980
|
+
throw wrapEntityError(updateErr, "update", "blob bucket", key);
|
|
3568
3981
|
}
|
|
3982
|
+
info(` Adopted + updated blob bucket: ${key}`);
|
|
3569
3983
|
}
|
|
3570
3984
|
else {
|
|
3571
|
-
|
|
3985
|
+
// No mutable fields in the local TOML — the server rejects
|
|
3986
|
+
// an empty update (400). Adopt the existing bucket without a
|
|
3987
|
+
// PATCH.
|
|
3988
|
+
info(` Adopted blob bucket (no updatable fields): ${key}`);
|
|
3989
|
+
}
|
|
3990
|
+
if (syncState) {
|
|
3991
|
+
if (!syncState.entities.blobBuckets) {
|
|
3992
|
+
syncState.entities.blobBuckets = {};
|
|
3993
|
+
}
|
|
3994
|
+
syncState.entities.blobBuckets[key] = {
|
|
3995
|
+
id: existing.bucketId,
|
|
3996
|
+
modifiedAt: updated?.modifiedAt ||
|
|
3997
|
+
existing.modifiedAt ||
|
|
3998
|
+
new Date().toISOString(),
|
|
3999
|
+
contentHash: computeFileHash(filePath),
|
|
4000
|
+
};
|
|
3572
4001
|
}
|
|
3573
4002
|
}
|
|
3574
4003
|
}
|
|
@@ -3602,6 +4031,88 @@ Directory Structure:
|
|
|
3602
4031
|
}
|
|
3603
4032
|
continue;
|
|
3604
4033
|
}
|
|
4034
|
+
// Shared update body for both the in-manifest update branch and the
|
|
4035
|
+
// adopt-by-key recovery branch (#1006). Re-issues the prompt as an
|
|
4036
|
+
// UPDATE and syncs its configs (update existing by name, create
|
|
4037
|
+
// missing) so the post-create prompt-config creation still runs
|
|
4038
|
+
// after adoption. Both call sites MUST run it identically, so it
|
|
4039
|
+
// lives here as a single closure rather than being duplicated
|
|
4040
|
+
// (mirrors `applyWorkflowUpdate`). The adopt branch seeds the
|
|
4041
|
+
// sync-state entity record before calling this so the stamp lands.
|
|
4042
|
+
const applyPromptUpdate = async (promptId, expectedModifiedAt) => {
|
|
4043
|
+
const updated = await client.updatePrompt(resolvedAppId, promptId, {
|
|
4044
|
+
displayName: prompt.displayName,
|
|
4045
|
+
// Issue #1567: `description` and `inputSchema` are TOML-owned
|
|
4046
|
+
// — send `?? null` so removing either from the TOML clears it
|
|
4047
|
+
// server-side (the server nulls both on an explicit `null`).
|
|
4048
|
+
// Bare values are `undefined` on absence and dropped by
|
|
4049
|
+
// `JSON.stringify`, so the server kept the stale values.
|
|
4050
|
+
// (`status` is a defaulted enum — not clearable via `null`.)
|
|
4051
|
+
description: prompt.description ?? null,
|
|
4052
|
+
inputSchema: prompt.inputSchema ?? null,
|
|
4053
|
+
status: prompt.status,
|
|
4054
|
+
}, expectedModifiedAt);
|
|
4055
|
+
info(` Updated prompt: ${key}`);
|
|
4056
|
+
// Update sync state with new modifiedAt
|
|
4057
|
+
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
4058
|
+
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
4059
|
+
syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
|
|
4060
|
+
}
|
|
4061
|
+
// Fetch full prompt to get config name→ID mappings
|
|
4062
|
+
// (updatePrompt response doesn't include configs)
|
|
4063
|
+
const fullPrompt = await client.getPrompt(resolvedAppId, promptId);
|
|
4064
|
+
if (fullPrompt?.configs) {
|
|
4065
|
+
for (const config of fullPrompt.configs) {
|
|
4066
|
+
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
// Update existing prompt configs with TOML values
|
|
4070
|
+
if (fullPrompt?.configs && configs.length > 0) {
|
|
4071
|
+
for (const tomlConfig of configs) {
|
|
4072
|
+
const configName = tomlConfig.name;
|
|
4073
|
+
if (!configName)
|
|
4074
|
+
continue;
|
|
4075
|
+
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
4076
|
+
if (serverConfig) {
|
|
4077
|
+
// Update existing config
|
|
4078
|
+
await client.updatePromptConfig(resolvedAppId, promptId, serverConfig.configId, {
|
|
4079
|
+
// Issue #1567: `description` is TOML-owned — send
|
|
4080
|
+
// `?? null` so removing it from a `[[config]]` block
|
|
4081
|
+
// clears it on the server (the PATCH nulls `description`
|
|
4082
|
+
// on an explicit `null`, preserves on omit). A bare
|
|
4083
|
+
// `tomlConfig.description` is `undefined` on absence and
|
|
4084
|
+
// dropped by JSON.stringify, so the stale value persists.
|
|
4085
|
+
description: tomlConfig.description ?? null,
|
|
4086
|
+
provider: tomlConfig.provider,
|
|
4087
|
+
model: tomlConfig.model,
|
|
4088
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
4089
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
4090
|
+
temperature: tomlConfig.temperature,
|
|
4091
|
+
maxTokens: tomlConfig.maxTokens,
|
|
4092
|
+
outputFormat: tomlConfig.outputFormat,
|
|
4093
|
+
});
|
|
4094
|
+
}
|
|
4095
|
+
else {
|
|
4096
|
+
// Create new config that doesn't exist on server yet
|
|
4097
|
+
const newConfig = await client.createPromptConfig(resolvedAppId, promptId, {
|
|
4098
|
+
configName,
|
|
4099
|
+
description: tomlConfig.description,
|
|
4100
|
+
provider: tomlConfig.provider || "openrouter",
|
|
4101
|
+
model: tomlConfig.model || "google/gemini-2.0-flash-001",
|
|
4102
|
+
systemPrompt: tomlConfig.systemPrompt,
|
|
4103
|
+
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
4104
|
+
temperature: tomlConfig.temperature,
|
|
4105
|
+
maxTokens: tomlConfig.maxTokens,
|
|
4106
|
+
outputFormat: tomlConfig.outputFormat,
|
|
4107
|
+
});
|
|
4108
|
+
if (newConfig?.configId) {
|
|
4109
|
+
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
}
|
|
4113
|
+
info(` Synced ${configs.length} config(s) for prompt: ${key}`);
|
|
4114
|
+
}
|
|
4115
|
+
};
|
|
3605
4116
|
if (existingId) {
|
|
3606
4117
|
// Update existing prompt
|
|
3607
4118
|
changes.push({ type: "prompt", action: "update", key });
|
|
@@ -3611,66 +4122,7 @@ Directory Structure:
|
|
|
3611
4122
|
? undefined
|
|
3612
4123
|
: syncState?.entities?.prompts?.[key]?.modifiedAt;
|
|
3613
4124
|
try {
|
|
3614
|
-
|
|
3615
|
-
displayName: prompt.displayName,
|
|
3616
|
-
description: prompt.description,
|
|
3617
|
-
inputSchema: prompt.inputSchema,
|
|
3618
|
-
status: prompt.status,
|
|
3619
|
-
}, expectedModifiedAt);
|
|
3620
|
-
info(` Updated prompt: ${key}`);
|
|
3621
|
-
// Update sync state with new modifiedAt
|
|
3622
|
-
if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
|
|
3623
|
-
syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
|
|
3624
|
-
syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
|
|
3625
|
-
}
|
|
3626
|
-
// Fetch full prompt to get config name→ID mappings
|
|
3627
|
-
// (updatePrompt response doesn't include configs)
|
|
3628
|
-
const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
|
|
3629
|
-
if (fullPrompt?.configs) {
|
|
3630
|
-
for (const config of fullPrompt.configs) {
|
|
3631
|
-
promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
|
|
3632
|
-
}
|
|
3633
|
-
}
|
|
3634
|
-
// Update existing prompt configs with TOML values
|
|
3635
|
-
if (fullPrompt?.configs && configs.length > 0) {
|
|
3636
|
-
for (const tomlConfig of configs) {
|
|
3637
|
-
const configName = tomlConfig.name;
|
|
3638
|
-
if (!configName)
|
|
3639
|
-
continue;
|
|
3640
|
-
const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
|
|
3641
|
-
if (serverConfig) {
|
|
3642
|
-
// Update existing config
|
|
3643
|
-
await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
|
|
3644
|
-
description: tomlConfig.description,
|
|
3645
|
-
provider: tomlConfig.provider,
|
|
3646
|
-
model: tomlConfig.model,
|
|
3647
|
-
systemPrompt: tomlConfig.systemPrompt,
|
|
3648
|
-
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
3649
|
-
temperature: tomlConfig.temperature,
|
|
3650
|
-
maxTokens: tomlConfig.maxTokens,
|
|
3651
|
-
outputFormat: tomlConfig.outputFormat,
|
|
3652
|
-
});
|
|
3653
|
-
}
|
|
3654
|
-
else {
|
|
3655
|
-
// Create new config that doesn't exist on server yet
|
|
3656
|
-
const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
|
|
3657
|
-
configName,
|
|
3658
|
-
description: tomlConfig.description,
|
|
3659
|
-
provider: tomlConfig.provider || "openrouter",
|
|
3660
|
-
model: tomlConfig.model || "google/gemini-2.0-flash-001",
|
|
3661
|
-
systemPrompt: tomlConfig.systemPrompt,
|
|
3662
|
-
userPromptTemplate: tomlConfig.userPromptTemplate,
|
|
3663
|
-
temperature: tomlConfig.temperature,
|
|
3664
|
-
maxTokens: tomlConfig.maxTokens,
|
|
3665
|
-
outputFormat: tomlConfig.outputFormat,
|
|
3666
|
-
});
|
|
3667
|
-
if (newConfig?.configId) {
|
|
3668
|
-
promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
|
|
3669
|
-
}
|
|
3670
|
-
}
|
|
3671
|
-
}
|
|
3672
|
-
info(` Synced ${configs.length} config(s) for prompt: ${key}`);
|
|
3673
|
-
}
|
|
4125
|
+
await applyPromptUpdate(existingId, expectedModifiedAt);
|
|
3674
4126
|
}
|
|
3675
4127
|
catch (err) {
|
|
3676
4128
|
if (err instanceof ConflictError) {
|
|
@@ -3692,8 +4144,19 @@ Directory Structure:
|
|
|
3692
4144
|
const firstConfig = configs[0] || {};
|
|
3693
4145
|
changes.push({ type: "prompt", action: "create", key });
|
|
3694
4146
|
if (!options.dryRun) {
|
|
4147
|
+
// The adopt-by-key recovery must cover ONLY the createPrompt
|
|
4148
|
+
// call. If createPrompt succeeds and a SUBSEQUENT step (an
|
|
4149
|
+
// extra prompt-config create, or an activation) returns a
|
|
4150
|
+
// 409/"must be unique" — e.g. duplicate `[[configs]]` names —
|
|
4151
|
+
// that is a genuine invalid-config error, NOT a prompt-create
|
|
4152
|
+
// conflict. Adopting on it would find the just-created prompt,
|
|
4153
|
+
// run the update path, and stamp the file hash, swallowing the
|
|
4154
|
+
// real error. So the post-create config work runs OUTSIDE the
|
|
4155
|
+
// catch and its errors surface unchanged.
|
|
4156
|
+
let created = null;
|
|
4157
|
+
let adopted = false;
|
|
3695
4158
|
try {
|
|
3696
|
-
|
|
4159
|
+
created = await client.createPrompt(resolvedAppId, {
|
|
3697
4160
|
promptKey: key,
|
|
3698
4161
|
displayName: prompt.displayName || key,
|
|
3699
4162
|
description: prompt.description,
|
|
@@ -3707,17 +4170,73 @@ Directory Structure:
|
|
|
3707
4170
|
inputSchema: prompt.inputSchema,
|
|
3708
4171
|
});
|
|
3709
4172
|
info(` Created prompt: ${key}`);
|
|
3710
|
-
|
|
3711
|
-
|
|
4173
|
+
}
|
|
4174
|
+
catch (err) {
|
|
4175
|
+
// #1006: idempotent create — adopt-by-key on 409. A prompt
|
|
4176
|
+
// orphaned on the server (a prior push aborted before
|
|
4177
|
+
// recording it, or an out-of-band create) hits the
|
|
4178
|
+
// `promptKeyPerApp` unique constraint on retry. Adopt by
|
|
4179
|
+
// `promptKey` and re-issue as an UPDATE via the shared
|
|
4180
|
+
// `applyPromptUpdate` so the base fields AND the configs
|
|
4181
|
+
// (the post-create prompt-config creation) still land after
|
|
4182
|
+
// adoption. `listPrompts` is cursor-paginated → drain fully
|
|
4183
|
+
// with fetchAll. Non-409 errors, lookup failures, and no-match
|
|
4184
|
+
// 409s all throw (see adoptByKeyOnCreate409).
|
|
4185
|
+
const existing = await adoptByKeyOnCreate409({
|
|
4186
|
+
err,
|
|
4187
|
+
kind: "prompt",
|
|
4188
|
+
key,
|
|
4189
|
+
lookup: () => fetchAll((p) => client.listPrompts(resolvedAppId, p)),
|
|
4190
|
+
matchKey: (p) => p?.promptKey === key,
|
|
4191
|
+
});
|
|
4192
|
+
adopted = true;
|
|
4193
|
+
const adoptedId = existing.promptId;
|
|
4194
|
+
// Seed the sync-state entity record so the shared update body
|
|
4195
|
+
// can stamp modifiedAt/contentHash into it (sync state has no
|
|
4196
|
+
// entry for an orphaned prompt), and track the key→ID map.
|
|
4197
|
+
if (syncState) {
|
|
3712
4198
|
if (!syncState.entities.prompts) {
|
|
3713
4199
|
syncState.entities.prompts = {};
|
|
3714
4200
|
}
|
|
3715
|
-
syncState.entities.prompts[key]
|
|
3716
|
-
id:
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
4201
|
+
if (!syncState.entities.prompts[key]) {
|
|
4202
|
+
syncState.entities.prompts[key] = { id: adoptedId };
|
|
4203
|
+
}
|
|
4204
|
+
else {
|
|
4205
|
+
syncState.entities.prompts[key].id = adoptedId;
|
|
4206
|
+
}
|
|
3720
4207
|
}
|
|
4208
|
+
promptKeyToId.set(key, adoptedId);
|
|
4209
|
+
// First adopt has no stored modifiedAt → omit
|
|
4210
|
+
// expectedModifiedAt to force the update (matches the cron +
|
|
4211
|
+
// workflow adopt paths). A ConflictError here is still
|
|
4212
|
+
// adoptable-resilience scope, so collect it; other errors
|
|
4213
|
+
// surface. `applyPromptUpdate` reconciles ALL configs on the
|
|
4214
|
+
// adopt path, so the create-path extra-config loop below is
|
|
4215
|
+
// intentionally skipped when `adopted`.
|
|
4216
|
+
try {
|
|
4217
|
+
await applyPromptUpdate(adoptedId, undefined);
|
|
4218
|
+
info(` Adopted + updated prompt: ${key}`);
|
|
4219
|
+
}
|
|
4220
|
+
catch (updErr) {
|
|
4221
|
+
if (updErr instanceof ConflictError) {
|
|
4222
|
+
conflicts.push({
|
|
4223
|
+
type: "prompt",
|
|
4224
|
+
key,
|
|
4225
|
+
serverModifiedAt: updErr.serverModifiedAt,
|
|
4226
|
+
localModifiedAt: "unknown",
|
|
4227
|
+
});
|
|
4228
|
+
}
|
|
4229
|
+
else {
|
|
4230
|
+
throw wrapEntityError(updErr, "update", "prompt", key);
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
// Post-create config work — runs ONLY for a real create, never
|
|
4235
|
+
// for an adopt. A 409/"must be unique" from an extra config
|
|
4236
|
+
// create or activation here is NOT wrapped by the adopt
|
|
4237
|
+
// recovery above, so a genuine invalid-config error surfaces
|
|
4238
|
+
// unchanged instead of being mis-treated as a prompt conflict.
|
|
4239
|
+
if (!adopted && created) {
|
|
3721
4240
|
// Track prompt key→ID and config name→ID
|
|
3722
4241
|
if (created?.promptId) {
|
|
3723
4242
|
promptKeyToId.set(key, created.promptId);
|
|
@@ -3753,9 +4272,22 @@ Directory Structure:
|
|
|
3753
4272
|
}
|
|
3754
4273
|
info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
|
|
3755
4274
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
4275
|
+
// Stamp sync state ONLY after all create work (base prompt +
|
|
4276
|
+
// every extra config) succeeded. If an extra-config create
|
|
4277
|
+
// above threw, we never reach here, so the file hash is not
|
|
4278
|
+
// recorded and the save-on-failure path leaves the entity
|
|
4279
|
+
// unstamped — a future push retries instead of skipping the
|
|
4280
|
+
// unresolved divergence.
|
|
4281
|
+
if (syncState && created?.promptId && created?.modifiedAt) {
|
|
4282
|
+
if (!syncState.entities.prompts) {
|
|
4283
|
+
syncState.entities.prompts = {};
|
|
4284
|
+
}
|
|
4285
|
+
syncState.entities.prompts[key] = {
|
|
4286
|
+
id: created.promptId,
|
|
4287
|
+
modifiedAt: created.modifiedAt,
|
|
4288
|
+
contentHash: computeFileHash(filePath),
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
3759
4291
|
}
|
|
3760
4292
|
}
|
|
3761
4293
|
}
|
|
@@ -3900,6 +4432,19 @@ Directory Structure:
|
|
|
3900
4432
|
for (const file of files) {
|
|
3901
4433
|
const filePath = join(workflowsDir, file);
|
|
3902
4434
|
const tomlData = parseTomlFile(filePath);
|
|
4435
|
+
// #1660 — skip platform-owned workflows (the reserved `__internal.*`
|
|
4436
|
+
// namespace). Resolve the key FIRST and skip BEFORE the workflow
|
|
4437
|
+
// TOML validation below, so a hollow exported `__internal.*.toml`
|
|
4438
|
+
// (steps = [], no active config) is skipped, not failed by
|
|
4439
|
+
// `validateWorkflowToml`, and does not abort the whole push trying
|
|
4440
|
+
// to activate a configuration-less workflow. Match on the resolved
|
|
4441
|
+
// `[workflow].key` (not filename) so a renamed file is still caught
|
|
4442
|
+
// (edge case 2).
|
|
4443
|
+
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
4444
|
+
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
4445
|
+
info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
|
|
4446
|
+
continue;
|
|
4447
|
+
}
|
|
3903
4448
|
// Issue #685: reject misnested headers (e.g.
|
|
3904
4449
|
// [steps.<id>.request]) before pushing. The runtime silently
|
|
3905
4450
|
// ignores fields outside the allowlist, so this is the only
|
|
@@ -3931,13 +4476,39 @@ Directory Structure:
|
|
|
3931
4476
|
throw new Error(`${filePath}: ${workflowManifestError}`);
|
|
3932
4477
|
}
|
|
3933
4478
|
workflow.metadataManifest = workflowManifest;
|
|
4479
|
+
// #1614 — parse the top-level `[expr.cel]` named-guard definitions
|
|
4480
|
+
// (structural, like steps/metadataManifest) and validate them
|
|
4481
|
+
// client-side (parse / undeclared / cycle) before pushing, so a bad
|
|
4482
|
+
// definition fails fast with the same diagnostics the server returns.
|
|
4483
|
+
const workflowExpressions = parseWorkflowExpressionsToml(tomlData);
|
|
4484
|
+
// #1614 — validate the RAW `[expr.cel]` table shape first, so a
|
|
4485
|
+
// non-string body (e.g. `ready = true`) fails fast instead of being
|
|
4486
|
+
// silently dropped by parseWorkflowExpressionsToml.
|
|
4487
|
+
const exprErrors = [
|
|
4488
|
+
...validateWorkflowExpressionsRawToml(tomlData),
|
|
4489
|
+
...validateWorkflowExpressionsToml(steps, workflowExpressions),
|
|
4490
|
+
];
|
|
4491
|
+
if (exprErrors.length > 0) {
|
|
4492
|
+
throw new Error(`${filePath}: workflow expressions are invalid:\n - ${exprErrors.join("\n - ")}`);
|
|
4493
|
+
}
|
|
4494
|
+
workflow.expressions = workflowExpressions;
|
|
3934
4495
|
const existingId = syncState?.entities?.workflows?.[key]?.id;
|
|
3935
4496
|
const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
|
|
3936
4497
|
// Skip if file hasn't changed since last sync. Use the expanded
|
|
3937
4498
|
// content hash (post fragment splice) so that edits to included
|
|
3938
4499
|
// `workflow-fragments/*.toml` files invalidate the push-skip cache
|
|
3939
4500
|
// for any workflow that references them. See `computeExpandedContentHash`.
|
|
3940
|
-
|
|
4501
|
+
//
|
|
4502
|
+
// #1684: hash a FRESH `parseTomlFile(filePath)` here, not the
|
|
4503
|
+
// in-scope `tomlData`. The `metadataManifest` injection above
|
|
4504
|
+
// (#1304) mutated `tomlData.workflow`, so hashing `tomlData` would
|
|
4505
|
+
// include the injected `metadataManifest` key and never match the
|
|
4506
|
+
// stored hash — the workflow would re-push on every `sync push`.
|
|
4507
|
+
// Pull stores the hash of the pristine parse
|
|
4508
|
+
// (`computeExpandedContentHash(parseTomlFile(filePath))`, see the
|
|
4509
|
+
// pull path), so re-parsing the same file keeps push symmetric with
|
|
4510
|
+
// pull and lets the content-hash round-trip.
|
|
4511
|
+
if (!options.force && existingId && !shouldPushExpandedFile(parseTomlFile(filePath), syncState?.entities?.workflows?.[key]?.contentHash)) {
|
|
3941
4512
|
skippedCount++;
|
|
3942
4513
|
// Only fetch config name→ID mappings if test cases exist for this workflow
|
|
3943
4514
|
const workflowTestsDir = getTestsDir(configDir, "workflow", key);
|
|
@@ -3980,7 +4551,15 @@ Directory Structure:
|
|
|
3980
4551
|
// the entry exists for both call sites.
|
|
3981
4552
|
if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
|
|
3982
4553
|
syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
|
|
3983
|
-
|
|
4554
|
+
// #1684: store the hash of the pristine `parseTomlFile(filePath)`,
|
|
4555
|
+
// not the in-scope `tomlData`. The `metadataManifest` injection
|
|
4556
|
+
// (#1304) mutated `tomlData.workflow`, so hashing `tomlData` here
|
|
4557
|
+
// would persist the mutated hash while the push-skip check (above)
|
|
4558
|
+
// and pull-store (:2502) both hash the pristine parse — the
|
|
4559
|
+
// workflow would then re-push on every subsequent `sync push`
|
|
4560
|
+
// until the next `sync pull`. Re-parsing keeps store symmetric
|
|
4561
|
+
// with skip and pull so the content-hash round-trips.
|
|
4562
|
+
syncState.entities.workflows[key].contentHash = computeExpandedContentHash(parseTomlFile(filePath));
|
|
3984
4563
|
}
|
|
3985
4564
|
// `applyWorkflowBody` already fetched the full workflow; reuse it
|
|
3986
4565
|
// for the config name→ID mappings (the PATCH response omits configs).
|
|
@@ -4039,6 +4618,8 @@ Directory Structure:
|
|
|
4039
4618
|
// #1304 (P-C): declared-access manifest (absent → undefined,
|
|
4040
4619
|
// server default null).
|
|
4041
4620
|
metadataManifest: workflow.metadataManifest ?? undefined,
|
|
4621
|
+
// #1614 — named `expr.*` definitions (absent → undefined).
|
|
4622
|
+
expressions: workflow.expressions ?? undefined,
|
|
4042
4623
|
});
|
|
4043
4624
|
info(` Created workflow: ${key}`);
|
|
4044
4625
|
// Add new entity to sync state (including activeConfigId)
|
|
@@ -4050,7 +4631,11 @@ Directory Structure:
|
|
|
4050
4631
|
id: created.workflow.workflowId,
|
|
4051
4632
|
modifiedAt: created.workflow.modifiedAt,
|
|
4052
4633
|
activeConfigId: created.workflow.activeConfigId,
|
|
4053
|
-
|
|
4634
|
+
// #1684: hash the pristine `parseTomlFile(filePath)`, not the
|
|
4635
|
+
// `metadataManifest`-mutated `tomlData` (see the update-store
|
|
4636
|
+
// note above), so a just-created workflow is skipped on the
|
|
4637
|
+
// next `sync push` instead of re-pushing every time.
|
|
4638
|
+
contentHash: computeExpandedContentHash(parseTomlFile(filePath)),
|
|
4054
4639
|
};
|
|
4055
4640
|
}
|
|
4056
4641
|
// Track config name→ID mappings
|
|
@@ -4069,6 +4654,11 @@ Directory Structure:
|
|
|
4069
4654
|
configName: extraConfig.name || `config-${i + 1}`,
|
|
4070
4655
|
description: extraConfig.description,
|
|
4071
4656
|
steps,
|
|
4657
|
+
// #1614 — the extra configs reuse the same
|
|
4658
|
+
// guard-referencing `steps`, so forward the workflow's
|
|
4659
|
+
// named `expr.*` definitions too; otherwise the server
|
|
4660
|
+
// 400s on the undeclared `expr.<name>` mid-push.
|
|
4661
|
+
expressions: workflow.expressions ?? undefined,
|
|
4072
4662
|
});
|
|
4073
4663
|
if (extraCreated?.configId) {
|
|
4074
4664
|
const configName = extraConfig.name || `config-${i + 1}`;
|
|
@@ -4087,49 +4677,31 @@ Directory Structure:
|
|
|
4087
4677
|
}
|
|
4088
4678
|
}
|
|
4089
4679
|
catch (err) {
|
|
4090
|
-
// Issue #1174: idempotent create — adopt-by-key on
|
|
4091
|
-
//
|
|
4680
|
+
// Issue #1174 / #1006: idempotent create — adopt-by-key on
|
|
4681
|
+
// conflict, via the shared `adoptByKeyOnCreate409` helper. A
|
|
4682
|
+
// workflow can exist on the server but be absent from local
|
|
4092
4683
|
// sync state — e.g. created out-of-band via the admin API
|
|
4093
4684
|
// (#971), or orphaned by a prior push that aborted before
|
|
4094
4685
|
// recording it. On retry this CREATE path hits the
|
|
4095
|
-
// `workflowKeyPerApp` unique constraint and the server
|
|
4096
|
-
//
|
|
4097
|
-
//
|
|
4098
|
-
//
|
|
4099
|
-
//
|
|
4100
|
-
// adopt its id into sync
|
|
4101
|
-
//
|
|
4102
|
-
//
|
|
4103
|
-
//
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
4115
|
-
// Verify same key + app ownership: the list endpoint only
|
|
4116
|
-
// returns workflows for `resolvedAppId`, so a workflowKey
|
|
4117
|
-
// match is also an ownership match. Never overwrite an
|
|
4118
|
-
// unrelated resource — require exact key equality.
|
|
4119
|
-
const existing = (items || []).find((w) => w?.workflowKey === key);
|
|
4120
|
-
if (existing?.workflowId) {
|
|
4121
|
-
adoptedId = existing.workflowId;
|
|
4122
|
-
}
|
|
4123
|
-
}
|
|
4124
|
-
catch (lookupErr) {
|
|
4125
|
-
throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
|
|
4126
|
-
}
|
|
4127
|
-
if (!adoptedId) {
|
|
4128
|
-
// "already exists" but no matching key found on the server —
|
|
4129
|
-
// surface the original error rather than silently
|
|
4130
|
-
// overwriting an unrelated workflow.
|
|
4131
|
-
throw wrapEntityError(err, "create", "workflow", key);
|
|
4132
|
-
}
|
|
4686
|
+
// `workflowKeyPerApp` unique constraint and the server returns
|
|
4687
|
+
// "workflowKey already exists" (HTTP 400; cron uses 409).
|
|
4688
|
+
// Rather than abort the whole push, look up the existing
|
|
4689
|
+
// workflow by key (the list endpoint is app-scoped, so a
|
|
4690
|
+
// workflowKey match is also an ownership match — never
|
|
4691
|
+
// overwrite an unrelated resource), adopt its id into sync
|
|
4692
|
+
// state, and re-issue as an UPDATE so the push converges.
|
|
4693
|
+
// `listWorkflows` is paginated → drain fully with fetchAll.
|
|
4694
|
+
// Non-409 errors, lookup failures, and no-match 409s all throw
|
|
4695
|
+
// (see adoptByKeyOnCreate409). Mirrors the cron-trigger
|
|
4696
|
+
// recovery at ~sync.ts:2627.
|
|
4697
|
+
const existing = await adoptByKeyOnCreate409({
|
|
4698
|
+
err,
|
|
4699
|
+
kind: "workflow",
|
|
4700
|
+
key,
|
|
4701
|
+
lookup: () => fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
|
|
4702
|
+
matchKey: (w) => w?.workflowKey === key,
|
|
4703
|
+
});
|
|
4704
|
+
const adoptedId = existing.workflowId;
|
|
4133
4705
|
// Out-of-manifest adopt: warn (info-level), matching the cron
|
|
4134
4706
|
// adopt line, and seed the sync-state entity record so the
|
|
4135
4707
|
// shared update body can write modifiedAt/contentHash into it.
|
|
@@ -4288,30 +4860,19 @@ Directory Structure:
|
|
|
4288
4860
|
// Build the type-level `updateData` (same logic as the apply path
|
|
4289
4861
|
// below) so the validate pass and the apply pass agree on whether
|
|
4290
4862
|
// a schema PATCH would run and what it carries.
|
|
4863
|
+
//
|
|
4864
|
+
// Issue #1567: the owned scalar fields are now ALWAYS sent as
|
|
4865
|
+
// value-or-`null` (via `buildOwnedScalarUpdate`) rather than only
|
|
4866
|
+
// when the TOML declares them. The server preserves a field only
|
|
4867
|
+
// when the key is absent from the PATCH body, so the previous
|
|
4868
|
+
// `if ("x" in typeConfig)` guards meant a field removed from the
|
|
4869
|
+
// TOML silently kept its stale server value. Always-send converges
|
|
4870
|
+
// the server to the local file (config-as-code): a removed field
|
|
4871
|
+
// lands as `null` and is cleared. `schema` keeps its own
|
|
4872
|
+
// `hasSchema` prior-state discriminator (it is a sub-tree, not a
|
|
4873
|
+
// scalar).
|
|
4291
4874
|
const computeTypeUpdateData = () => {
|
|
4292
|
-
const u =
|
|
4293
|
-
if ("ruleSetId" in typeConfig)
|
|
4294
|
-
u.ruleSetId = typeConfig.ruleSetId || null;
|
|
4295
|
-
if ("triggers" in typeConfig)
|
|
4296
|
-
u.triggers = typeConfig.triggers || null;
|
|
4297
|
-
if ("metadataAccess" in typeConfig)
|
|
4298
|
-
u.metadataAccess = typeConfig.metadataAccess || null;
|
|
4299
|
-
if ("defaultAccess" in typeConfig)
|
|
4300
|
-
u.defaultAccess = typeConfig.defaultAccess || null;
|
|
4301
|
-
if ("autoPopulatedFields" in typeConfig) {
|
|
4302
|
-
u.autoPopulatedFields = typeConfig.autoPopulatedFields || null;
|
|
4303
|
-
}
|
|
4304
|
-
if ("timestamps" in typeConfig) {
|
|
4305
|
-
u.timestamps = typeConfig.timestamps || null;
|
|
4306
|
-
}
|
|
4307
|
-
// Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
|
|
4308
|
-
// only sets `metadataManifest` when the TOML declares a `[metadata]`
|
|
4309
|
-
// / `secrets` block, so this forwards a set/update; clearing a
|
|
4310
|
-
// manifest by removing the block is a follow-on (mirrors the
|
|
4311
|
-
// schema-deletion tracking).
|
|
4312
|
-
if ("metadataManifest" in typeConfig) {
|
|
4313
|
-
u.metadataManifest = typeConfig.metadataManifest || null;
|
|
4314
|
-
}
|
|
4875
|
+
const u = buildOwnedScalarUpdate(typeConfig);
|
|
4315
4876
|
const localHasSchema = typeof typeConfig.schema === "string" &&
|
|
4316
4877
|
typeConfig.schema.trim().length > 0;
|
|
4317
4878
|
if (localHasSchema) {
|
|
@@ -4322,6 +4883,43 @@ Directory Structure:
|
|
|
4322
4883
|
}
|
|
4323
4884
|
return u;
|
|
4324
4885
|
};
|
|
4886
|
+
// Issue #1567: fetch the live server config once per changed
|
|
4887
|
+
// existing type. Sync state stores no scalar VALUES (only hashes /
|
|
4888
|
+
// modifiedAt), so a genuine non-null → null clear can only be
|
|
4889
|
+
// detected against live state. We use it for two things: (a) report
|
|
4890
|
+
// "Cleared <field>" only on real transitions (never for every
|
|
4891
|
+
// `null` sent — that noise would bury the access-gate line), and
|
|
4892
|
+
// (b) decide whether a push whose only type-level effect is a clear
|
|
4893
|
+
// still needs the type-config PATCH. A fresh type has nothing to
|
|
4894
|
+
// clear; a failed fetch degrades to "report nothing cleared".
|
|
4895
|
+
let serverTypeConfig = null;
|
|
4896
|
+
if (existingEntry) {
|
|
4897
|
+
try {
|
|
4898
|
+
serverTypeConfig = await client.getDatabaseTypeConfig(resolvedAppId, dbType);
|
|
4899
|
+
}
|
|
4900
|
+
catch {
|
|
4901
|
+
serverTypeConfig = null;
|
|
4902
|
+
}
|
|
4903
|
+
}
|
|
4904
|
+
// The owned scalars this push clears (non-null server → null local).
|
|
4905
|
+
const clearedOwnedFields = ownedScalarsBeingCleared(buildOwnedScalarUpdate(typeConfig), serverTypeConfig);
|
|
4906
|
+
// Does this push change any TYPE-LEVEL field? True when the TOML
|
|
4907
|
+
// declares an owned scalar (matches the pre-#1567 PATCH trigger), a
|
|
4908
|
+
// schema set/delete is in the payload, or an owned scalar is being
|
|
4909
|
+
// cleared. When false, the type-config PATCH is skipped entirely so
|
|
4910
|
+
// an operations-only edit does not spuriously report the type as
|
|
4911
|
+
// "Updated" (issue #369 regression) and no idle clear-report fires.
|
|
4912
|
+
const declaresOwnedField = DB_TYPE_OWNED_SCALARS.some((k) => k in typeConfig);
|
|
4913
|
+
const schemaInUpdateData = "schema" in computeTypeUpdateData();
|
|
4914
|
+
const typeLevelChange = declaresOwnedField ||
|
|
4915
|
+
schemaInUpdateData ||
|
|
4916
|
+
clearedOwnedFields.length > 0;
|
|
4917
|
+
// Emit one "Cleared <field> on <type>" line per genuine clear.
|
|
4918
|
+
const reportClearedFields = (prefix) => {
|
|
4919
|
+
for (const key of clearedOwnedFields) {
|
|
4920
|
+
info(` ${prefix}${dbTypeFieldLabel(key)} on ${dbType}`);
|
|
4921
|
+
}
|
|
4922
|
+
};
|
|
4325
4923
|
// Validate-first pass (decision 4a): run every gate for this type
|
|
4326
4924
|
// BEFORE issuing any mutating call. If a gate fails, the whole
|
|
4327
4925
|
// type's mutating calls (op creates/updates/deletes AND the schema
|
|
@@ -4341,11 +4939,16 @@ Directory Structure:
|
|
|
4341
4939
|
try {
|
|
4342
4940
|
// 1. Schema-edit gate (type-config PATCH dry-run) — only when the
|
|
4343
4941
|
// type already exists on the server and we'd actually PATCH a
|
|
4344
|
-
//
|
|
4942
|
+
// type-level field. A fresh type (no existingEntry) has no
|
|
4345
4943
|
// ops to break, so there's nothing to gate yet.
|
|
4346
|
-
|
|
4944
|
+
// Issue #1567: `computeTypeUpdateData` now always returns the
|
|
4945
|
+
// owned scalars, so gate on `typeLevelChange` (a genuine
|
|
4946
|
+
// declared/schema/clear change) rather than "payload
|
|
4947
|
+
// non-empty" — an operations-only edit must not run the
|
|
4948
|
+
// type-config dry-run at all.
|
|
4949
|
+
if (existingEntry && typeLevelChange) {
|
|
4347
4950
|
const validateUpdateData = computeTypeUpdateData();
|
|
4348
|
-
|
|
4951
|
+
{
|
|
4349
4952
|
await client.updateDatabaseTypeConfig(resolvedAppId, dbType, pendingOpDeletes.length > 0
|
|
4350
4953
|
? { ...validateUpdateData, pendingOpDeletes, finalOpNames }
|
|
4351
4954
|
: validateUpdateData, validateExpectedModifiedAt, {
|
|
@@ -4548,15 +5151,11 @@ Directory Structure:
|
|
|
4548
5151
|
// labels (so dry-run still shows what WOULD change) but skip ALL
|
|
4549
5152
|
// mutating calls for this type.
|
|
4550
5153
|
if (typeGateBlocked) {
|
|
4551
|
-
const wouldUpdate = "ruleSetId" in typeConfig ||
|
|
4552
|
-
"triggers" in typeConfig ||
|
|
4553
|
-
"metadataAccess" in typeConfig ||
|
|
4554
|
-
"defaultAccess" in typeConfig ||
|
|
4555
|
-
"autoPopulatedFields" in typeConfig ||
|
|
4556
|
-
"timestamps" in typeConfig ||
|
|
4557
|
-
"schema" in typeConfig;
|
|
4558
5154
|
if (existingEntry) {
|
|
4559
|
-
|
|
5155
|
+
// Issue #1567: a genuine type-level change (declared field,
|
|
5156
|
+
// schema set/delete, or an owned-scalar clear) — same predicate
|
|
5157
|
+
// the apply path gates the PATCH on.
|
|
5158
|
+
if (typeLevelChange) {
|
|
4560
5159
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
4561
5160
|
}
|
|
4562
5161
|
}
|
|
@@ -4577,10 +5176,15 @@ Directory Structure:
|
|
|
4577
5176
|
continue;
|
|
4578
5177
|
}
|
|
4579
5178
|
if (existingEntry) {
|
|
4580
|
-
// Update existing type config — only
|
|
4581
|
-
// Operations are handled separately below
|
|
4582
|
-
//
|
|
4583
|
-
// with HTTP 400)
|
|
5179
|
+
// Update existing type config — only when there is a genuine
|
|
5180
|
+
// type-level change. Operations are handled separately below.
|
|
5181
|
+
// Issue #369: skipping the PATCH on an operations-only edit avoids
|
|
5182
|
+
// sending an empty body (which the server rejects with HTTP 400)
|
|
5183
|
+
// and a spurious "Updated database type". Issue #1567: the owned
|
|
5184
|
+
// scalars are always in `updateData` now (value-or-`null`), so the
|
|
5185
|
+
// skip decision moved from "payload non-empty" to the
|
|
5186
|
+
// `typeLevelChange` predicate (a declared field, a schema
|
|
5187
|
+
// set/delete, or an owned-scalar clear).
|
|
4584
5188
|
if (!options.dryRun) {
|
|
4585
5189
|
const expectedModifiedAt = options.force
|
|
4586
5190
|
? undefined
|
|
@@ -4590,11 +5194,8 @@ Directory Structure:
|
|
|
4590
5194
|
// one (a set/update), OR when the server had one at last
|
|
4591
5195
|
// sync and the local file no longer does (a deletion —
|
|
4592
5196
|
// `schema: null` clears it server-side; codex review gap on
|
|
4593
|
-
// PR #766).
|
|
4594
|
-
//
|
|
4595
|
-
// register as an empty type-level update (issue #369). Built
|
|
4596
|
-
// by the shared helper so the validate pass and apply pass
|
|
4597
|
-
// agree (issue #813).
|
|
5197
|
+
// PR #766). Built by the shared helper so the validate pass
|
|
5198
|
+
// and apply pass agree (issue #813).
|
|
4598
5199
|
const updateData = computeTypeUpdateData();
|
|
4599
5200
|
// Issue #813 (2B): forward the ops being deleted in this push
|
|
4600
5201
|
// so the schema-edit / OPS_EXIST gates evaluate against the
|
|
@@ -4608,13 +5209,17 @@ Directory Structure:
|
|
|
4608
5209
|
// exclusion (see derivation above).
|
|
4609
5210
|
updateData.finalOpNames = finalOpNames;
|
|
4610
5211
|
}
|
|
4611
|
-
if (
|
|
5212
|
+
if (typeLevelChange) {
|
|
4612
5213
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
4613
5214
|
const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
|
|
4614
5215
|
dryRun: false,
|
|
4615
5216
|
acceptWarnings: !!options.acceptWarnings,
|
|
4616
5217
|
});
|
|
4617
5218
|
info(` Updated database type: ${dbType}`);
|
|
5219
|
+
// Issue #1567: name each owned scalar this push cleared, so
|
|
5220
|
+
// removing an access gate (celContextAccess / defaultAccess)
|
|
5221
|
+
// is never silent.
|
|
5222
|
+
reportClearedFields("Cleared ");
|
|
4618
5223
|
printStaleInstanceWarning(updated);
|
|
4619
5224
|
if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
|
|
4620
5225
|
syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
|
|
@@ -4674,17 +5279,12 @@ Directory Structure:
|
|
|
4674
5279
|
}
|
|
4675
5280
|
}
|
|
4676
5281
|
else {
|
|
4677
|
-
// In dry-run mode, still report the change iff we would
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
"defaultAccess" in typeConfig ||
|
|
4682
|
-
"autoPopulatedFields" in typeConfig ||
|
|
4683
|
-
"timestamps" in typeConfig ||
|
|
4684
|
-
"schema" in typeConfig ||
|
|
4685
|
-
"metadataManifest" in typeConfig;
|
|
4686
|
-
if (wouldUpdate) {
|
|
5282
|
+
// In dry-run mode, still report the change iff we would
|
|
5283
|
+
// actually PATCH (issue #1567: the same `typeLevelChange`
|
|
5284
|
+
// predicate the apply path uses), and preview the clears.
|
|
5285
|
+
if (typeLevelChange) {
|
|
4687
5286
|
changes.push({ type: "database-type", action: "update", key: dbType });
|
|
5287
|
+
reportClearedFields("Would clear ");
|
|
4688
5288
|
}
|
|
4689
5289
|
}
|
|
4690
5290
|
}
|
|
@@ -5172,7 +5772,15 @@ Directory Structure:
|
|
|
5172
5772
|
: existingEntry.modifiedAt;
|
|
5173
5773
|
try {
|
|
5174
5774
|
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
5175
|
-
|
|
5775
|
+
// Issue #1567: send `?? null`, not a bare
|
|
5776
|
+
// `configData.ruleSetId`. When the TOML drops the rule-set
|
|
5777
|
+
// reference the parse leaves `ruleSetId` undefined, which
|
|
5778
|
+
// `JSON.stringify` omits from the body — so the server saw
|
|
5779
|
+
// no key and preserved the stale rule set. `null` sends an
|
|
5780
|
+
// explicit clear (server: `"ruleSetId" in body` → null).
|
|
5781
|
+
ruleSetId: configData.ruleSetId ?? null,
|
|
5782
|
+
// `autoAddCreator` already converges: the parse defaults it
|
|
5783
|
+
// to `true` when absent, so it is always sent explicitly.
|
|
5176
5784
|
autoAddCreator: configData.autoAddCreator,
|
|
5177
5785
|
// Issue #1350: null clears the manifest server-side when
|
|
5178
5786
|
// the TOML drops the `[metadata]` block (matches the
|
|
@@ -5225,7 +5833,53 @@ Directory Structure:
|
|
|
5225
5833
|
}
|
|
5226
5834
|
}
|
|
5227
5835
|
catch (err) {
|
|
5228
|
-
|
|
5836
|
+
// #1851: idempotent create — adopt-by-key on 409, matching the
|
|
5837
|
+
// seven other synced types (rule sets, integrations, webhooks,
|
|
5838
|
+
// cron triggers, blob buckets, prompts, workflows). A group
|
|
5839
|
+
// type config can already exist on the server (created
|
|
5840
|
+
// out-of-band via the console or an ad-hoc CLI call, or a prior
|
|
5841
|
+
// push that aborted before recording it in sync state). Without
|
|
5842
|
+
// this, that CREATE re-hits the server's 409 "Config for this
|
|
5843
|
+
// group type already exists"
|
|
5844
|
+
// (group-type-config-controller.ts) on every subsequent push
|
|
5845
|
+
// and the operator has to hand-edit `.primitive-sync.json` to
|
|
5846
|
+
// recover. The shared helper detects the conflict (409 status /
|
|
5847
|
+
// "already exists" message), lists the app's configs — the list
|
|
5848
|
+
// route drains every page server-side (#1668), so the candidate
|
|
5849
|
+
// set is complete — and returns the one whose `groupType`
|
|
5850
|
+
// matches EXACTLY (the app-scoped list means a key match is an
|
|
5851
|
+
// ownership match; an unrelated config is never adopted). Its
|
|
5852
|
+
// non-adopt outcomes each throw a clear error and never touch
|
|
5853
|
+
// the server. Unlike blob buckets (ttlTier), a group type
|
|
5854
|
+
// config has no immutable field, so a plain adopt-and-update
|
|
5855
|
+
// converges. We re-issue as an UPDATE with the same `?? null`
|
|
5856
|
+
// clear semantics as the existing-update branch above, so the
|
|
5857
|
+
// adopted config converges to the local TOML.
|
|
5858
|
+
await adoptByKeyOnCreate409({
|
|
5859
|
+
err,
|
|
5860
|
+
kind: "group type config",
|
|
5861
|
+
key: groupType,
|
|
5862
|
+
lookup: async () => await client.listGroupTypeConfigs(resolvedAppId),
|
|
5863
|
+
matchKey: (c) => c?.groupType === groupType,
|
|
5864
|
+
});
|
|
5865
|
+
// No expectedModifiedAt: the config was not in sync state, so
|
|
5866
|
+
// there is no baseline to guard against — mirror the `--force`
|
|
5867
|
+
// update path, which also omits it.
|
|
5868
|
+
const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
|
|
5869
|
+
ruleSetId: configData.ruleSetId ?? null,
|
|
5870
|
+
autoAddCreator: configData.autoAddCreator,
|
|
5871
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
5872
|
+
});
|
|
5873
|
+
info(` Adopted + updated group type config: ${groupType}`);
|
|
5874
|
+
if (syncState) {
|
|
5875
|
+
if (!syncState.entities.groupTypeConfigs) {
|
|
5876
|
+
syncState.entities.groupTypeConfigs = {};
|
|
5877
|
+
}
|
|
5878
|
+
syncState.entities.groupTypeConfigs[groupType] = {
|
|
5879
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
5880
|
+
contentHash: computeFileHash(filePath),
|
|
5881
|
+
};
|
|
5882
|
+
}
|
|
5229
5883
|
}
|
|
5230
5884
|
}
|
|
5231
5885
|
}
|
|
@@ -5305,7 +5959,41 @@ Directory Structure:
|
|
|
5305
5959
|
}
|
|
5306
5960
|
}
|
|
5307
5961
|
catch (err) {
|
|
5308
|
-
|
|
5962
|
+
// #1851: idempotent create — adopt-by-key on 409, the same
|
|
5963
|
+
// pattern as group type configs above. The server returns 409
|
|
5964
|
+
// "Config for this collection type already exists"
|
|
5965
|
+
// (collection-type-config-controller.ts) when a config for this
|
|
5966
|
+
// type already exists (out-of-band create, or a prior push that
|
|
5967
|
+
// aborted before recording it in sync state). The shared helper
|
|
5968
|
+
// lists the app's configs (complete — the list route drains all
|
|
5969
|
+
// pages, #1668) and returns the exact `collectionType` match; an
|
|
5970
|
+
// unrelated config is never adopted. Collection type configs
|
|
5971
|
+
// have no immutable field, so a plain adopt-and-update
|
|
5972
|
+
// converges. Re-issue as an UPDATE with the `?? null` clear
|
|
5973
|
+
// semantics of the existing-update branch.
|
|
5974
|
+
await adoptByKeyOnCreate409({
|
|
5975
|
+
err,
|
|
5976
|
+
kind: "collection type config",
|
|
5977
|
+
key: collectionType,
|
|
5978
|
+
lookup: async () => await client.listCollectionTypeConfigs(resolvedAppId),
|
|
5979
|
+
matchKey: (c) => c?.collectionType === collectionType,
|
|
5980
|
+
});
|
|
5981
|
+
// No expectedModifiedAt — the config was not in sync state, so
|
|
5982
|
+
// there is no baseline to guard against (mirrors `--force`).
|
|
5983
|
+
const updated = await client.updateCollectionTypeConfig(resolvedAppId, collectionType, {
|
|
5984
|
+
ruleSetId: configData.ruleSetId ?? null,
|
|
5985
|
+
metadataManifest: configData.metadataManifest ?? null,
|
|
5986
|
+
});
|
|
5987
|
+
info(` Adopted + updated collection type config: ${collectionType}`);
|
|
5988
|
+
if (syncState) {
|
|
5989
|
+
if (!syncState.entities.collectionTypeConfigs) {
|
|
5990
|
+
syncState.entities.collectionTypeConfigs = {};
|
|
5991
|
+
}
|
|
5992
|
+
syncState.entities.collectionTypeConfigs[collectionType] = {
|
|
5993
|
+
modifiedAt: updated?.modifiedAt || new Date().toISOString(),
|
|
5994
|
+
contentHash: computeFileHash(filePath),
|
|
5995
|
+
};
|
|
5996
|
+
}
|
|
5309
5997
|
}
|
|
5310
5998
|
}
|
|
5311
5999
|
}
|
|
@@ -5467,6 +6155,189 @@ Directory Structure:
|
|
|
5467
6155
|
skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
|
|
5468
6156
|
}
|
|
5469
6157
|
}
|
|
6158
|
+
// ------------------------------------------------------------------
|
|
6159
|
+
// Prune pass — server-side deletion of managed entities whose local
|
|
6160
|
+
// TOML the operator removed since the last sync (issue #1655). Opt-in:
|
|
6161
|
+
// a push WITHOUT `--prune` deletes nothing and makes no extra server
|
|
6162
|
+
// calls. Each candidate is confirmed by a point read (detail-fetch by
|
|
6163
|
+
// id/key, immune to listing truncation) that also carries a drift check
|
|
6164
|
+
// — delete only when the live `modifiedAt` matches the one recorded at
|
|
6165
|
+
// last sync, unless `--force`. One batch confirmation gates the deletes.
|
|
6166
|
+
// ------------------------------------------------------------------
|
|
6167
|
+
if (options.prune) {
|
|
6168
|
+
const dropPruneState = (spec, key) => {
|
|
6169
|
+
if (!syncState?.entities)
|
|
6170
|
+
return;
|
|
6171
|
+
const slot = syncState.entities[spec.resourceType.stateSlot];
|
|
6172
|
+
if (slot)
|
|
6173
|
+
delete slot[key];
|
|
6174
|
+
if (spec.resourceType.testBlockType) {
|
|
6175
|
+
removePrunedSidecar({
|
|
6176
|
+
configDir,
|
|
6177
|
+
resourceType: spec.resourceType,
|
|
6178
|
+
key,
|
|
6179
|
+
testCaseEntities: syncState.entities.testCases,
|
|
6180
|
+
logger: (m) => info(m),
|
|
6181
|
+
});
|
|
6182
|
+
}
|
|
6183
|
+
};
|
|
6184
|
+
const toDelete = [];
|
|
6185
|
+
const alreadyAbsent = [];
|
|
6186
|
+
const skipped = [];
|
|
6187
|
+
for (const spec of PUSH_PRUNE_SPECS) {
|
|
6188
|
+
const priorEntities = syncState?.entities?.[spec.resourceType.stateSlot];
|
|
6189
|
+
const candidates = collectPushPruneCandidates({
|
|
6190
|
+
spec,
|
|
6191
|
+
configDir,
|
|
6192
|
+
priorEntities,
|
|
6193
|
+
});
|
|
6194
|
+
for (const { key, entry } of candidates) {
|
|
6195
|
+
const address = spec.addressOf(key, entry);
|
|
6196
|
+
// Behavior 4: skip the point read for a candidate we could not
|
|
6197
|
+
// delete anyway (no id in state, or no delete surface yet #1426).
|
|
6198
|
+
if (!address || !spec.deleteEntity) {
|
|
6199
|
+
const decision = decidePushPruneCandidate({
|
|
6200
|
+
spec,
|
|
6201
|
+
address,
|
|
6202
|
+
priorModifiedAt: entry?.modifiedAt,
|
|
6203
|
+
fetch: { ok: false },
|
|
6204
|
+
force: !!options.force,
|
|
6205
|
+
});
|
|
6206
|
+
skipped.push({ spec, key, entry, address, decision });
|
|
6207
|
+
continue;
|
|
6208
|
+
}
|
|
6209
|
+
// Point read: confirm live presence + drift. Fail closed — any
|
|
6210
|
+
// non-404 error leaves the entity and its state slot untouched.
|
|
6211
|
+
let fetchOutcome;
|
|
6212
|
+
try {
|
|
6213
|
+
const detail = await spec.fetchDetail(client, resolvedAppId, address);
|
|
6214
|
+
fetchOutcome = { ok: true, detail };
|
|
6215
|
+
}
|
|
6216
|
+
catch (err) {
|
|
6217
|
+
if (err instanceof ApiError && err.statusCode === 404) {
|
|
6218
|
+
fetchOutcome = { ok: false, status: 404 };
|
|
6219
|
+
}
|
|
6220
|
+
else {
|
|
6221
|
+
fetchOutcome = { ok: false };
|
|
6222
|
+
}
|
|
6223
|
+
}
|
|
6224
|
+
const decision = decidePushPruneCandidate({
|
|
6225
|
+
spec,
|
|
6226
|
+
address,
|
|
6227
|
+
priorModifiedAt: entry?.modifiedAt,
|
|
6228
|
+
fetch: fetchOutcome,
|
|
6229
|
+
force: !!options.force,
|
|
6230
|
+
});
|
|
6231
|
+
const item = { spec, key, entry, address, decision };
|
|
6232
|
+
if (decision.action === "delete")
|
|
6233
|
+
toDelete.push(item);
|
|
6234
|
+
else if (decision.action === "drop-state")
|
|
6235
|
+
alreadyAbsent.push(item);
|
|
6236
|
+
else
|
|
6237
|
+
skipped.push(item);
|
|
6238
|
+
}
|
|
6239
|
+
}
|
|
6240
|
+
const totalPlanned = toDelete.length + alreadyAbsent.length + skipped.length;
|
|
6241
|
+
if (totalPlanned > 0) {
|
|
6242
|
+
console.log();
|
|
6243
|
+
info("Prune plan (local TOML removed since last sync):");
|
|
6244
|
+
for (const item of toDelete) {
|
|
6245
|
+
console.log(` ${chalk.red("will delete")} ${item.spec.resourceType.label}: ${item.key}`);
|
|
6246
|
+
}
|
|
6247
|
+
for (const item of alreadyAbsent) {
|
|
6248
|
+
console.log(` ${chalk.dim("already absent")} ${item.spec.resourceType.label}: ${item.key}`);
|
|
6249
|
+
}
|
|
6250
|
+
for (const item of skipped) {
|
|
6251
|
+
const reason = item.decision.action === "skip" ? item.decision.reason : "skip";
|
|
6252
|
+
const label = reason === "drift"
|
|
6253
|
+
? "skipped — modified on server since last sync (use --force)"
|
|
6254
|
+
: reason === "unconfirmed"
|
|
6255
|
+
? "skipped — could not confirm (fetch failed)"
|
|
6256
|
+
: reason === "no-id"
|
|
6257
|
+
? "skipped — no id in sync state"
|
|
6258
|
+
: reason === "unsupported"
|
|
6259
|
+
? "skipped — no delete surface yet (#1426)"
|
|
6260
|
+
: "skipped";
|
|
6261
|
+
console.log(` ${chalk.yellow(label)} ${item.spec.resourceType.label}: ${item.key}`);
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
if (options.dryRun) {
|
|
6265
|
+
// Behavior 6: report the plan, mutate nothing (no delete calls, no
|
|
6266
|
+
// state or filesystem change — not even the already-absent cleanup).
|
|
6267
|
+
for (const item of toDelete) {
|
|
6268
|
+
changes.push({
|
|
6269
|
+
type: item.spec.resourceType.label,
|
|
6270
|
+
action: "delete",
|
|
6271
|
+
key: item.key,
|
|
6272
|
+
});
|
|
6273
|
+
}
|
|
6274
|
+
}
|
|
6275
|
+
else {
|
|
6276
|
+
// Behavior 5: one batch confirmation for the destructive deletes.
|
|
6277
|
+
// No deletes to confirm → apply the already-absent bookkeeping only.
|
|
6278
|
+
let proceed = true;
|
|
6279
|
+
if (toDelete.length > 0 && !options.yes) {
|
|
6280
|
+
try {
|
|
6281
|
+
proceed = await confirmPrompt(`Delete ${toDelete.length} remote entity(ies)? This cannot be undone.`);
|
|
6282
|
+
}
|
|
6283
|
+
catch (err) {
|
|
6284
|
+
if (err instanceof ConfirmPromptError) {
|
|
6285
|
+
error(err.message);
|
|
6286
|
+
process.exit(1);
|
|
6287
|
+
}
|
|
6288
|
+
throw err;
|
|
6289
|
+
}
|
|
6290
|
+
}
|
|
6291
|
+
if (!proceed) {
|
|
6292
|
+
// Declining leaves every entity AND every prior-state slot intact.
|
|
6293
|
+
info("Prune cancelled — no entities were deleted.");
|
|
6294
|
+
}
|
|
6295
|
+
else {
|
|
6296
|
+
let prunedCount = 0;
|
|
6297
|
+
let blockedCount = 0;
|
|
6298
|
+
// Entities already gone server-side: drop their stale state (2).
|
|
6299
|
+
for (const item of alreadyAbsent) {
|
|
6300
|
+
dropPruneState(item.spec, item.key);
|
|
6301
|
+
info(` ${item.spec.resourceType.label} ${item.key} already absent — cleared from sync state`);
|
|
6302
|
+
}
|
|
6303
|
+
for (const item of toDelete) {
|
|
6304
|
+
try {
|
|
6305
|
+
await item.spec.deleteEntity(client, resolvedAppId, item.address);
|
|
6306
|
+
dropPruneState(item.spec, item.key);
|
|
6307
|
+
changes.push({
|
|
6308
|
+
type: item.spec.resourceType.label,
|
|
6309
|
+
action: "delete",
|
|
6310
|
+
key: item.key,
|
|
6311
|
+
});
|
|
6312
|
+
prunedCount++;
|
|
6313
|
+
info(` Deleted ${item.spec.resourceType.label}: ${item.key}`);
|
|
6314
|
+
}
|
|
6315
|
+
catch (err) {
|
|
6316
|
+
if (err instanceof ConflictError ||
|
|
6317
|
+
(err instanceof ApiError && err.statusCode === 409)) {
|
|
6318
|
+
// Behavior 8: still referenced — report, keep state, and
|
|
6319
|
+
// continue deleting the rest (partial success + re-run).
|
|
6320
|
+
blockedCount++;
|
|
6321
|
+
warn(` Blocked — ${item.spec.resourceType.label} ${item.key} is still referenced: ${err instanceof Error ? err.message : String(err)}`);
|
|
6322
|
+
}
|
|
6323
|
+
else if (err instanceof ApiError && err.statusCode === 404) {
|
|
6324
|
+
// Raced to 404 between the point read and the delete: the
|
|
6325
|
+
// desired (deleted) state is met — treat as success.
|
|
6326
|
+
dropPruneState(item.spec, item.key);
|
|
6327
|
+
info(` ${item.spec.resourceType.label} ${item.key} already absent (nothing to delete)`);
|
|
6328
|
+
}
|
|
6329
|
+
else {
|
|
6330
|
+
blockedCount++;
|
|
6331
|
+
warn(` Skipped — could not delete ${item.spec.resourceType.label} ${item.key}: ${err instanceof Error ? err.message : String(err)}`);
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
if (totalPlanned > 0) {
|
|
6336
|
+
info(`Prune: ${prunedCount} deleted, ${alreadyAbsent.length} already absent, ${skipped.length + blockedCount} skipped.`);
|
|
6337
|
+
}
|
|
6338
|
+
}
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
5470
6341
|
divider();
|
|
5471
6342
|
// Issue #813: a gate failure surfaced by the validate-first pass (which
|
|
5472
6343
|
// also runs in dry-run mode via the server's dry-run gate endpoints).
|
|
@@ -5585,33 +6456,106 @@ Directory Structure:
|
|
|
5585
6456
|
const syncState = loadSyncState(configDir);
|
|
5586
6457
|
info(`Comparing local configuration with app ${resolvedAppId}...`);
|
|
5587
6458
|
try {
|
|
5588
|
-
// Fetch remote state
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
6459
|
+
// Fetch remote state. Every list flows through the same ok/error gate
|
|
6460
|
+
// (issue #1485): a failed fetch must NOT be read as "the app has none of
|
|
6461
|
+
// these" — that would print false `local only` rows and let the summary
|
|
6462
|
+
// read as exhaustive while a whole type went uncompared. A failed type
|
|
6463
|
+
// is recorded in `notCompared`, skipped entirely (no local reads, no
|
|
6464
|
+
// rows), and surfaced as an explicit `not compared:` summary line so the
|
|
6465
|
+
// roll-up can never be misread. This replaces the older mix of
|
|
6466
|
+
// `.catch(() => [])` (silent empty) and un-caught `fetchAll` (whole-diff
|
|
6467
|
+
// abort) with one uniform, honest degradation.
|
|
6468
|
+
const notCompared = [];
|
|
6469
|
+
const gateReason = (err) => err && err.statusCode === 404
|
|
6470
|
+
? "server does not support"
|
|
6471
|
+
: "remote fetch failed";
|
|
6472
|
+
const gate = (outcome, label, empty) => {
|
|
6473
|
+
if (outcome.ok)
|
|
6474
|
+
return outcome.value;
|
|
6475
|
+
notCompared.push({ type: label, reason: gateReason(outcome.error) });
|
|
6476
|
+
return empty;
|
|
6477
|
+
};
|
|
6478
|
+
const [integrationsOutcome, promptsOutcome, workflowsOutcome, emailTemplatesOutcomeDiff, webhooksOutcome, cronTriggersOutcomeDiff, blobBucketsOutcomeDiff, scriptsOutcomeDiff, varsOutcomeDiff, databaseTypeConfigsOutcomeDiff, ruleSetsOutcomeDiff, groupTypeConfigsOutcomeDiff, collectionTypeConfigsOutcomeDiff, metadataCategoryConfigsOutcomeDiff,] = await Promise.all([
|
|
6479
|
+
attemptFetch(() => fetchAll((p) => client.listIntegrations(resolvedAppId, p))),
|
|
6480
|
+
attemptFetch(() => fetchAll((p) => client.listPrompts(resolvedAppId, p))),
|
|
6481
|
+
attemptFetch(() => fetchAll((p) => client.listWorkflows(resolvedAppId, p))),
|
|
6482
|
+
attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
|
|
6483
|
+
attemptFetch(() => fetchAll((p) => client.listWebhooks(resolvedAppId, p))),
|
|
6484
|
+
attemptFetch(() => client.listCronTriggers(resolvedAppId)),
|
|
6485
|
+
attemptFetch(() => client.listBlobBuckets(resolvedAppId)),
|
|
6486
|
+
attemptFetch(() => client.listScripts(resolvedAppId)),
|
|
6487
|
+
attemptFetch(() => client.listAppConfigVars(resolvedAppId)),
|
|
6488
|
+
attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
|
|
6489
|
+
attemptFetch(() => client.listRuleSets(resolvedAppId)),
|
|
6490
|
+
attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
|
|
6491
|
+
attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
|
|
6492
|
+
attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
|
|
5594
6493
|
]);
|
|
5595
|
-
const
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
}
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
const
|
|
6494
|
+
const integrationItems = gate(integrationsOutcome, "integrations", []);
|
|
6495
|
+
const promptItems = gate(promptsOutcome, "prompts", []);
|
|
6496
|
+
const workflowItems = gate(workflowsOutcome, "workflows", []);
|
|
6497
|
+
const emailTemplatesResult = gate(emailTemplatesOutcomeDiff, "email-templates", { templates: [] });
|
|
6498
|
+
const webhookItems = gate(webhooksOutcome, "webhooks", []);
|
|
6499
|
+
const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
|
|
6500
|
+
const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
|
|
6501
|
+
const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
|
|
6502
|
+
const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
|
|
6503
|
+
const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-types", []);
|
|
6504
|
+
const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
|
|
6505
|
+
const groupTypeConfigsDiff = gate(groupTypeConfigsOutcomeDiff, "group-type-configs", []);
|
|
6506
|
+
const collectionTypeConfigsDiff = gate(collectionTypeConfigsOutcomeDiff, "collection-type-configs", []);
|
|
6507
|
+
const metadataCategoryConfigsDiff = gate(metadataCategoryConfigsOutcomeDiff, "metadata-category-configs", []);
|
|
6508
|
+
// ruleSetId ⇄ name maps, built once from the rule-set listing, drive the
|
|
6509
|
+
// content-aware comparison of the three rule-set-referencing types
|
|
6510
|
+
// (database-types, group/collection-type-configs). If the rule-set list
|
|
6511
|
+
// itself failed, the maps are empty and those types degrade to
|
|
6512
|
+
// "content not compared" rather than risk a false `Modified` from an
|
|
6513
|
+
// unresolvable reference (see `ruleSetsOk` below).
|
|
6514
|
+
const ruleSetsArr = Array.isArray(ruleSetsDiff) ? ruleSetsDiff : [];
|
|
6515
|
+
const ruleSetIdToNameDiff = new Map();
|
|
6516
|
+
const ruleSetNameToIdDiff = new Map();
|
|
6517
|
+
for (const rs of ruleSetsArr) {
|
|
6518
|
+
ruleSetIdToNameDiff.set(rs.ruleSetId, rs.name);
|
|
6519
|
+
ruleSetNameToIdDiff.set(rs.name, rs.ruleSetId);
|
|
6520
|
+
}
|
|
6521
|
+
const ruleSetsOk = ruleSetsOutcomeDiff.ok;
|
|
5605
6522
|
const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
|
|
5606
6523
|
const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
|
|
5607
6524
|
const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
|
|
5608
6525
|
const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
|
|
5609
6526
|
const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
|
|
5610
|
-
|
|
6527
|
+
// #1660 — exclude platform-owned workflows (reserved `__internal.*`)
|
|
6528
|
+
// from the remote set so `diff` never reports them (they are not app
|
|
6529
|
+
// config). The local-file set below applies the same exclusion, so a
|
|
6530
|
+
// platform-owned workflow appears in none of synced/modified/remote-only/
|
|
6531
|
+
// local-only. Prefers the server flag, falls back to the key prefix.
|
|
6532
|
+
const remoteWorkflows = new Set(workflowItems
|
|
6533
|
+
.filter((w) => !isPlatformOwnedWorkflow(w))
|
|
6534
|
+
.map((w) => w.workflowKey));
|
|
5611
6535
|
const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
|
|
5612
6536
|
.filter((t) => t.hasOverride)
|
|
5613
6537
|
.map((t) => t.emailType));
|
|
5614
|
-
//
|
|
6538
|
+
// Remote maps keyed the SAME way the sync-state slot is (so local-only /
|
|
6539
|
+
// remote-only classification lines up), value = the server entity for
|
|
6540
|
+
// content comparison.
|
|
6541
|
+
const remoteDatabaseTypes = new Map((Array.isArray(databaseTypeConfigsDiff) ? databaseTypeConfigsDiff : [])
|
|
6542
|
+
.filter((c) => c?.databaseType)
|
|
6543
|
+
.map((c) => [c.databaseType, c]));
|
|
6544
|
+
const remoteRuleSets = new Map(ruleSetsArr
|
|
6545
|
+
.filter((rs) => rs?.name || rs?.ruleSetId)
|
|
6546
|
+
.map((rs) => [ruleSetFileKey(rs), rs]));
|
|
6547
|
+
const remoteGroupTypeConfigs = new Map((Array.isArray(groupTypeConfigsDiff) ? groupTypeConfigsDiff : [])
|
|
6548
|
+
.filter((c) => c?.groupType)
|
|
6549
|
+
.map((c) => [c.groupType, c]));
|
|
6550
|
+
const remoteCollectionTypeConfigs = new Map((Array.isArray(collectionTypeConfigsDiff) ? collectionTypeConfigsDiff : [])
|
|
6551
|
+
.filter((c) => c?.collectionType)
|
|
6552
|
+
.map((c) => [c.collectionType, c]));
|
|
6553
|
+
const remoteMetadataCategoryConfigs = new Map((Array.isArray(metadataCategoryConfigsDiff) ? metadataCategoryConfigsDiff : [])
|
|
6554
|
+
.filter((c) => c?.resourceType && c?.category)
|
|
6555
|
+
.map((c) => [`${c.resourceType}#${c.category}`, c]));
|
|
6556
|
+
// Get local files. Each read is guarded by its type's fetch outcome: a
|
|
6557
|
+
// type whose remote fetch failed is not read locally at all, so it never
|
|
6558
|
+
// produces `local only` rows — it is reported once under `not compared:`.
|
|
5615
6559
|
const localIntegrations = new Set();
|
|
5616
6560
|
const localWebhooks = new Set();
|
|
5617
6561
|
const localCronTriggers = new Set();
|
|
@@ -5619,8 +6563,14 @@ Directory Structure:
|
|
|
5619
6563
|
const localPrompts = new Set();
|
|
5620
6564
|
const localWorkflows = new Set();
|
|
5621
6565
|
const localEmailTemplates = new Set();
|
|
6566
|
+
// Parsed local TOML per key for the five content-aware config types.
|
|
6567
|
+
const localDatabaseTypesParsed = new Map();
|
|
6568
|
+
const localRuleSetsParsed = new Map();
|
|
6569
|
+
const localGroupTypeConfigsParsed = new Map();
|
|
6570
|
+
const localCollectionTypeConfigsParsed = new Map();
|
|
6571
|
+
const localMetadataCategoryParsed = new Map();
|
|
5622
6572
|
const integrationsDir = join(configDir, "integrations");
|
|
5623
|
-
if (existsSync(integrationsDir)) {
|
|
6573
|
+
if (integrationsOutcome.ok && existsSync(integrationsDir)) {
|
|
5624
6574
|
for (const file of readdirSync(integrationsDir).filter((f) => f.endsWith(".toml"))) {
|
|
5625
6575
|
const tomlData = parseTomlFile(join(integrationsDir, file));
|
|
5626
6576
|
const key = tomlData.integration?.key || basename(file, ".toml");
|
|
@@ -5628,7 +6578,7 @@ Directory Structure:
|
|
|
5628
6578
|
}
|
|
5629
6579
|
}
|
|
5630
6580
|
const webhooksDirPath = join(configDir, "webhooks");
|
|
5631
|
-
if (existsSync(webhooksDirPath)) {
|
|
6581
|
+
if (webhooksOutcome.ok && existsSync(webhooksDirPath)) {
|
|
5632
6582
|
for (const file of readdirSync(webhooksDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
5633
6583
|
const tomlData = parseTomlFile(join(webhooksDirPath, file));
|
|
5634
6584
|
const key = tomlData.webhook?.key || basename(file, ".toml");
|
|
@@ -5636,7 +6586,7 @@ Directory Structure:
|
|
|
5636
6586
|
}
|
|
5637
6587
|
}
|
|
5638
6588
|
const cronTriggersDirPath = join(configDir, "cron-triggers");
|
|
5639
|
-
if (existsSync(cronTriggersDirPath)) {
|
|
6589
|
+
if (cronTriggersOutcomeDiff.ok && existsSync(cronTriggersDirPath)) {
|
|
5640
6590
|
for (const file of readdirSync(cronTriggersDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
5641
6591
|
const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
|
|
5642
6592
|
const key = tomlData.cronTrigger?.key || basename(file, ".toml");
|
|
@@ -5644,7 +6594,7 @@ Directory Structure:
|
|
|
5644
6594
|
}
|
|
5645
6595
|
}
|
|
5646
6596
|
const blobBucketsDiffDir = join(configDir, "blob-buckets");
|
|
5647
|
-
if (existsSync(blobBucketsDiffDir)) {
|
|
6597
|
+
if (blobBucketsOutcomeDiff.ok && existsSync(blobBucketsDiffDir)) {
|
|
5648
6598
|
for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
|
|
5649
6599
|
const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
|
|
5650
6600
|
const key = tomlData.bucket?.key || basename(file, ".toml");
|
|
@@ -5652,77 +6602,163 @@ Directory Structure:
|
|
|
5652
6602
|
}
|
|
5653
6603
|
}
|
|
5654
6604
|
const promptsDir = join(configDir, "prompts");
|
|
5655
|
-
if (existsSync(promptsDir)) {
|
|
6605
|
+
if (promptsOutcome.ok && existsSync(promptsDir)) {
|
|
5656
6606
|
for (const file of readdirSync(promptsDir).filter((f) => f.endsWith(".toml"))) {
|
|
5657
6607
|
const tomlData = parseTomlFile(join(promptsDir, file));
|
|
5658
6608
|
const key = tomlData.prompt?.key || basename(file, ".toml");
|
|
5659
6609
|
localPrompts.add(key);
|
|
5660
6610
|
}
|
|
5661
6611
|
}
|
|
6612
|
+
// Database-config types (issue #1485). Read each type's local dir only
|
|
6613
|
+
// when its remote fetch succeeded, and capture the parsed TOML per key
|
|
6614
|
+
// so the content comparison below can hash it without a second read.
|
|
6615
|
+
//
|
|
6616
|
+
// When the identity field is omitted and derived from the filename
|
|
6617
|
+
// (the fallback `sync push` supports — see ~sync.ts:5196 for
|
|
6618
|
+
// database-types, :6176 group, :6267 collection, :6358 metadata), the
|
|
6619
|
+
// derived identity is injected back into the parsed TOML BEFORE it is
|
|
6620
|
+
// stored. Otherwise the content hash would carry `databaseType: undefined`
|
|
6621
|
+
// (etc.) while the remote entity carries the real identity, and diff would
|
|
6622
|
+
// report `Modified` forever — even right after a successful push.
|
|
6623
|
+
const databaseTypesDir = join(configDir, "database-types");
|
|
6624
|
+
if (databaseTypeConfigsOutcomeDiff.ok && existsSync(databaseTypesDir)) {
|
|
6625
|
+
for (const file of readdirSync(databaseTypesDir).filter((f) => f.endsWith(".toml"))) {
|
|
6626
|
+
const tomlData = parseTomlFile(join(databaseTypesDir, file));
|
|
6627
|
+
const key = tomlData.type?.databaseType || basename(file, ".toml");
|
|
6628
|
+
if (!tomlData.type)
|
|
6629
|
+
tomlData.type = {};
|
|
6630
|
+
tomlData.type.databaseType = key;
|
|
6631
|
+
localDatabaseTypesParsed.set(key, tomlData);
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
const ruleSetsDir = join(configDir, "rule-sets");
|
|
6635
|
+
if (ruleSetsOutcomeDiff.ok && existsSync(ruleSetsDir)) {
|
|
6636
|
+
for (const file of readdirSync(ruleSetsDir).filter((f) => f.endsWith(".toml"))) {
|
|
6637
|
+
const tomlData = parseTomlFile(join(ruleSetsDir, file));
|
|
6638
|
+
// Key by the FILENAME, exactly as `sync push` does (it looks the
|
|
6639
|
+
// sync-state entry up by `basename(file)` and PATCHes that id). If
|
|
6640
|
+
// an existing pulled rule set is renamed by editing `ruleSet.name`
|
|
6641
|
+
// without renaming the file, push still updates the same entity, so
|
|
6642
|
+
// diff must match it to the same remote entity — one Modified row —
|
|
6643
|
+
// rather than rekey by the new `name` (which would read as a new
|
|
6644
|
+
// local rule set PLUS a prunable remote one). The renamed `name`
|
|
6645
|
+
// still differs in the hashed content, so the row is `Modified`.
|
|
6646
|
+
const key = basename(file, ".toml");
|
|
6647
|
+
localRuleSetsParsed.set(key, tomlData);
|
|
6648
|
+
}
|
|
6649
|
+
}
|
|
6650
|
+
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
6651
|
+
if (groupTypeConfigsOutcomeDiff.ok && existsSync(groupTypeConfigsDir)) {
|
|
6652
|
+
for (const file of readdirSync(groupTypeConfigsDir).filter((f) => f.endsWith(".toml"))) {
|
|
6653
|
+
const tomlData = parseTomlFile(join(groupTypeConfigsDir, file));
|
|
6654
|
+
const key = tomlData.groupTypeConfig?.groupType || basename(file, ".toml");
|
|
6655
|
+
if (!tomlData.groupTypeConfig)
|
|
6656
|
+
tomlData.groupTypeConfig = {};
|
|
6657
|
+
tomlData.groupTypeConfig.groupType = key;
|
|
6658
|
+
localGroupTypeConfigsParsed.set(key, tomlData);
|
|
6659
|
+
}
|
|
6660
|
+
}
|
|
6661
|
+
const collectionTypeConfigsDir = join(configDir, "collection-type-configs");
|
|
6662
|
+
if (collectionTypeConfigsOutcomeDiff.ok && existsSync(collectionTypeConfigsDir)) {
|
|
6663
|
+
for (const file of readdirSync(collectionTypeConfigsDir).filter((f) => f.endsWith(".toml"))) {
|
|
6664
|
+
const tomlData = parseTomlFile(join(collectionTypeConfigsDir, file));
|
|
6665
|
+
const key = tomlData.collectionTypeConfig?.collectionType || basename(file, ".toml");
|
|
6666
|
+
if (!tomlData.collectionTypeConfig)
|
|
6667
|
+
tomlData.collectionTypeConfig = {};
|
|
6668
|
+
tomlData.collectionTypeConfig.collectionType = key;
|
|
6669
|
+
localCollectionTypeConfigsParsed.set(key, tomlData);
|
|
6670
|
+
}
|
|
6671
|
+
}
|
|
6672
|
+
// metadata-category key is the `resourceType#category` pair read from the
|
|
6673
|
+
// parsed `[metadataCategoryConfig]` content (matching push), NOT the
|
|
6674
|
+
// filename — a renamed file still matches its server entity.
|
|
6675
|
+
const metadataCategoryConfigsDir = join(configDir, "metadata-category-configs");
|
|
6676
|
+
if (metadataCategoryConfigsOutcomeDiff.ok && existsSync(metadataCategoryConfigsDir)) {
|
|
6677
|
+
for (const file of readdirSync(metadataCategoryConfigsDir).filter((f) => f.endsWith(".toml"))) {
|
|
6678
|
+
const tomlData = parseTomlFile(join(metadataCategoryConfigsDir, file));
|
|
6679
|
+
const section = tomlData.metadataCategoryConfig || {};
|
|
6680
|
+
let key;
|
|
6681
|
+
if (section.resourceType && section.category) {
|
|
6682
|
+
key = `${section.resourceType}#${section.category}`;
|
|
6683
|
+
}
|
|
6684
|
+
else {
|
|
6685
|
+
// Derive the `resourceType#category` identity from the filename
|
|
6686
|
+
// (`resourceType.category.toml`) and inject it back into the
|
|
6687
|
+
// parsed section — matching push's fallback (~sync.ts:6358) — so
|
|
6688
|
+
// the content hash carries the identity instead of `undefined`
|
|
6689
|
+
// and does not report `Modified` after a successful push.
|
|
6690
|
+
const base = basename(file, ".toml");
|
|
6691
|
+
const dot = base.indexOf(".");
|
|
6692
|
+
if (dot > 0) {
|
|
6693
|
+
const resourceType = base.slice(0, dot);
|
|
6694
|
+
const category = base.slice(dot + 1);
|
|
6695
|
+
key = `${resourceType}#${category}`;
|
|
6696
|
+
if (!tomlData.metadataCategoryConfig) {
|
|
6697
|
+
tomlData.metadataCategoryConfig = {};
|
|
6698
|
+
}
|
|
6699
|
+
tomlData.metadataCategoryConfig.resourceType =
|
|
6700
|
+
section.resourceType || resourceType;
|
|
6701
|
+
tomlData.metadataCategoryConfig.category =
|
|
6702
|
+
section.category || category;
|
|
6703
|
+
}
|
|
6704
|
+
else {
|
|
6705
|
+
key = base;
|
|
6706
|
+
}
|
|
6707
|
+
}
|
|
6708
|
+
localMetadataCategoryParsed.set(key, tomlData);
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
5662
6711
|
// #1175: capture the parsed (fragment-expanded) local workflow TOML
|
|
5663
6712
|
// per key so the content-aware comparison below can hash it without a
|
|
5664
6713
|
// second read.
|
|
5665
6714
|
const localWorkflowParsed = new Map();
|
|
5666
6715
|
const workflowsDir = join(configDir, "workflows");
|
|
5667
|
-
if (existsSync(workflowsDir)) {
|
|
6716
|
+
if (workflowsOutcome.ok && existsSync(workflowsDir)) {
|
|
5668
6717
|
for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
|
|
5669
6718
|
const tomlData = parseTomlFile(join(workflowsDir, file));
|
|
5670
6719
|
const key = tomlData.workflow?.key || basename(file, ".toml");
|
|
6720
|
+
// #1660 — a stray local `__internal.*.toml` is platform-owned, not
|
|
6721
|
+
// app config; keep it out of the local set (matched on resolved key,
|
|
6722
|
+
// edge case 2) so `diff` reports it in no set. `sync pull` cleans up
|
|
6723
|
+
// the file; `sync push` skips it.
|
|
6724
|
+
if (isPlatformOwnedWorkflowKey(key))
|
|
6725
|
+
continue;
|
|
5671
6726
|
localWorkflows.add(key);
|
|
5672
6727
|
localWorkflowParsed.set(key, tomlData);
|
|
5673
6728
|
}
|
|
5674
6729
|
}
|
|
5675
6730
|
const emailTemplatesDirPath = join(configDir, "email-templates");
|
|
5676
|
-
if (existsSync(emailTemplatesDirPath)) {
|
|
6731
|
+
if (emailTemplatesOutcomeDiff.ok && existsSync(emailTemplatesDirPath)) {
|
|
5677
6732
|
for (const file of readdirSync(emailTemplatesDirPath).filter((f) => f.endsWith(".toml"))) {
|
|
5678
6733
|
const tomlData = parseTomlFile(join(emailTemplatesDirPath, file));
|
|
5679
6734
|
const emailType = tomlData.template?.emailType || basename(file, ".toml");
|
|
5680
6735
|
localEmailTemplates.add(emailType);
|
|
5681
6736
|
}
|
|
5682
6737
|
}
|
|
5683
|
-
// Transforms (Rhai scripts) — issue #1196.
|
|
5684
|
-
//
|
|
5685
|
-
//
|
|
5686
|
-
//
|
|
5687
|
-
// contents so the comparison below can hash both sides.
|
|
5688
|
-
let scriptItemsDiff = [];
|
|
5689
|
-
try {
|
|
5690
|
-
const scriptsResult = await client.listScripts(resolvedAppId);
|
|
5691
|
-
scriptItemsDiff = scriptsResult.items || [];
|
|
5692
|
-
}
|
|
5693
|
-
catch {
|
|
5694
|
-
// Older server without the scripts route — treat as no scripts.
|
|
5695
|
-
}
|
|
6738
|
+
// Transforms (Rhai scripts) — issue #1196. Content-aware comparison
|
|
6739
|
+
// against the server's active-config body. The server list was fetched
|
|
6740
|
+
// through the ok/error gate above (`scriptItemsDiff`); the local `.rhai`
|
|
6741
|
+
// dir is read only when that fetch succeeded.
|
|
5696
6742
|
const remoteScripts = new Map(scriptItemsDiff
|
|
5697
6743
|
.filter((s) => s?.name)
|
|
5698
6744
|
.map((s) => [s.name, s]));
|
|
5699
6745
|
const localScripts = new Map();
|
|
5700
6746
|
const transformsDirPath = join(configDir, "transforms");
|
|
5701
|
-
if (existsSync(transformsDirPath)) {
|
|
6747
|
+
if (scriptsOutcomeDiff.ok && existsSync(transformsDirPath)) {
|
|
5702
6748
|
for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
|
|
5703
6749
|
const name = basename(file, ".rhai");
|
|
5704
6750
|
localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
|
|
5705
6751
|
}
|
|
5706
6752
|
}
|
|
5707
|
-
// Config vars — issue #1423.
|
|
5708
|
-
//
|
|
5709
|
-
//
|
|
5710
|
-
// would change or unset them. Fetch the remote vars (value included —
|
|
5711
|
-
// vars are non-secret) and read the local `vars.toml` so the comparison
|
|
5712
|
-
// below can report added/removed/modified vars.
|
|
5713
|
-
let remoteVarItemsDiff = [];
|
|
5714
|
-
try {
|
|
5715
|
-
remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
|
|
5716
|
-
}
|
|
5717
|
-
catch {
|
|
5718
|
-
// Older server without the vars route — treat as no remote vars.
|
|
5719
|
-
}
|
|
6753
|
+
// Config vars — issue #1423. The remote vars were fetched through the
|
|
6754
|
+
// ok/error gate above (`remoteVarItemsDiff`); the local `vars.toml` is
|
|
6755
|
+
// read only when that fetch succeeded.
|
|
5720
6756
|
const remoteVars = new Map(remoteVarItemsDiff
|
|
5721
6757
|
.filter((v) => typeof v?.key === "string")
|
|
5722
6758
|
.map((v) => [v.key, String(v.value ?? "")]));
|
|
5723
6759
|
const localVars = new Map();
|
|
5724
6760
|
const varsTomlPathDiff = join(configDir, "vars.toml");
|
|
5725
|
-
if (existsSync(varsTomlPathDiff)) {
|
|
6761
|
+
if (varsOutcomeDiff.ok && existsSync(varsTomlPathDiff)) {
|
|
5726
6762
|
const parsed = parseTomlFile(varsTomlPathDiff);
|
|
5727
6763
|
for (const [key, rawValue] of Object.entries(parsed)) {
|
|
5728
6764
|
if (typeof rawValue === "string")
|
|
@@ -5739,6 +6775,13 @@ Directory Structure:
|
|
|
5739
6775
|
// is the point. Prior sync state is the discriminator, exactly as it is
|
|
5740
6776
|
// for the pull-side prune.
|
|
5741
6777
|
const localOnlyStatus = (slot, key) => classifyLocalOnly(syncState?.entities, slot, key);
|
|
6778
|
+
// Issue #1655: a server entity with no local file also means two
|
|
6779
|
+
// different things. If a prior pull managed the key, the operator
|
|
6780
|
+
// removed its local TOML and `push --prune` will DELETE the remote
|
|
6781
|
+
// entity; if it was never synced, it was authored server-side and is
|
|
6782
|
+
// left alone. Prior sync state is the discriminator, mirroring the
|
|
6783
|
+
// local-only split above.
|
|
6784
|
+
const remoteOnlyStatus = (slot, key) => classifyRemoteOnly(syncState?.entities, slot, key);
|
|
5742
6785
|
// Integrations
|
|
5743
6786
|
for (const key of localIntegrations) {
|
|
5744
6787
|
if (!remoteIntegrations.has(key)) {
|
|
@@ -5750,7 +6793,7 @@ Directory Structure:
|
|
|
5750
6793
|
}
|
|
5751
6794
|
for (const key of remoteIntegrations) {
|
|
5752
6795
|
if (!localIntegrations.has(key)) {
|
|
5753
|
-
differences.push({ type: "integration", key, status: "
|
|
6796
|
+
differences.push({ type: "integration", key, status: remoteOnlyStatus("integrations", key) });
|
|
5754
6797
|
}
|
|
5755
6798
|
}
|
|
5756
6799
|
// Webhooks
|
|
@@ -5764,7 +6807,7 @@ Directory Structure:
|
|
|
5764
6807
|
}
|
|
5765
6808
|
for (const key of remoteWebhooks) {
|
|
5766
6809
|
if (!localWebhooks.has(key)) {
|
|
5767
|
-
differences.push({ type: "webhook", key, status: "
|
|
6810
|
+
differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
|
|
5768
6811
|
}
|
|
5769
6812
|
}
|
|
5770
6813
|
// Cron Triggers
|
|
@@ -5778,7 +6821,7 @@ Directory Structure:
|
|
|
5778
6821
|
}
|
|
5779
6822
|
for (const key of remoteCronTriggers) {
|
|
5780
6823
|
if (!localCronTriggers.has(key)) {
|
|
5781
|
-
differences.push({ type: "cron-trigger", key, status: "
|
|
6824
|
+
differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
|
|
5782
6825
|
}
|
|
5783
6826
|
}
|
|
5784
6827
|
// Blob Buckets
|
|
@@ -5792,7 +6835,7 @@ Directory Structure:
|
|
|
5792
6835
|
}
|
|
5793
6836
|
for (const key of remoteBlobBuckets) {
|
|
5794
6837
|
if (!localBlobBuckets.has(key)) {
|
|
5795
|
-
differences.push({ type: "blob-bucket", key, status: "
|
|
6838
|
+
differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
|
|
5796
6839
|
}
|
|
5797
6840
|
}
|
|
5798
6841
|
// Prompts
|
|
@@ -5806,7 +6849,7 @@ Directory Structure:
|
|
|
5806
6849
|
}
|
|
5807
6850
|
for (const key of remotePrompts) {
|
|
5808
6851
|
if (!localPrompts.has(key)) {
|
|
5809
|
-
differences.push({ type: "prompt", key, status: "
|
|
6852
|
+
differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
|
|
5810
6853
|
}
|
|
5811
6854
|
}
|
|
5812
6855
|
// Workflows — #1175: content-aware comparison.
|
|
@@ -5874,8 +6917,17 @@ Directory Structure:
|
|
|
5874
6917
|
differences.push({ type: "workflow", key, status, hint });
|
|
5875
6918
|
}
|
|
5876
6919
|
for (const key of remoteWorkflows) {
|
|
6920
|
+
// Platform-owned internal workflows (`__internal.*`) are never part of
|
|
6921
|
+
// an app's config tree, so they must not show as `remote only` (the
|
|
6922
|
+
// reporter's `Remote only: 1 · __internal.reindex-instances`). Removing
|
|
6923
|
+
// them from every synced surface is #1660's job; until it lands, skip
|
|
6924
|
+
// the prefix here so this diff's coverage extension doesn't surface a
|
|
6925
|
+
// spurious remote-only row. When #1660 lands it replaces this with its
|
|
6926
|
+
// `isPlatformOwnedEntity` predicate.
|
|
6927
|
+
if (key && key.startsWith("__internal."))
|
|
6928
|
+
continue;
|
|
5877
6929
|
if (!localWorkflows.has(key)) {
|
|
5878
|
-
differences.push({ type: "workflow", key, status: "
|
|
6930
|
+
differences.push({ type: "workflow", key, status: remoteOnlyStatus("workflows", key) });
|
|
5879
6931
|
}
|
|
5880
6932
|
}
|
|
5881
6933
|
// Email Templates
|
|
@@ -5889,7 +6941,7 @@ Directory Structure:
|
|
|
5889
6941
|
}
|
|
5890
6942
|
for (const key of remoteEmailTemplates) {
|
|
5891
6943
|
if (!localEmailTemplates.has(key)) {
|
|
5892
|
-
differences.push({ type: "email-template", key, status: "
|
|
6944
|
+
differences.push({ type: "email-template", key, status: remoteOnlyStatus("emailTemplates", key) });
|
|
5893
6945
|
}
|
|
5894
6946
|
}
|
|
5895
6947
|
// Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
|
|
@@ -5938,13 +6990,106 @@ Directory Structure:
|
|
|
5938
6990
|
}
|
|
5939
6991
|
for (const name of remoteScripts.keys()) {
|
|
5940
6992
|
if (!localScripts.has(name)) {
|
|
5941
|
-
differences.push({ type: "transform", key: name, status: "
|
|
6993
|
+
differences.push({ type: "transform", key: name, status: remoteOnlyStatus("scripts", name) });
|
|
5942
6994
|
}
|
|
5943
6995
|
}
|
|
5944
6996
|
// Config vars — issue #1423 (see `diffVars`).
|
|
5945
6997
|
for (const row of diffVars(localVars, remoteVars)) {
|
|
5946
6998
|
differences.push(row);
|
|
5947
6999
|
}
|
|
7000
|
+
// Database-config types (issue #1485). rule-sets, group/collection-type
|
|
7001
|
+
// and metadata-category configs carry their full entity in the list
|
|
7002
|
+
// response (pull serializes straight from it), so content is compared
|
|
7003
|
+
// directly; database-types need a per-type ops/subscriptions fetch (see
|
|
7004
|
+
// below). Content comparison is a preview of `sync pull` (pull would
|
|
7005
|
+
// rewrite the local TOML to match running state), exactly like the
|
|
7006
|
+
// workflow/transform blocks.
|
|
7007
|
+
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, hashLocal, hashRemote, contentComparable) => {
|
|
7008
|
+
for (const [key, parsed] of localParsed) {
|
|
7009
|
+
if (!remoteEntities.has(key)) {
|
|
7010
|
+
differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
|
|
7011
|
+
continue;
|
|
7012
|
+
}
|
|
7013
|
+
let status = "exists";
|
|
7014
|
+
let hint;
|
|
7015
|
+
if (!contentComparable) {
|
|
7016
|
+
// Rule-set list failed, so a `ruleSetName` reference can't be
|
|
7017
|
+
// resolved on either side — decline the content comparison rather
|
|
7018
|
+
// than risk a false `Modified`.
|
|
7019
|
+
hint = "content not compared (rule sets unavailable)";
|
|
7020
|
+
}
|
|
7021
|
+
else {
|
|
7022
|
+
try {
|
|
7023
|
+
if (hashLocal(parsed) !== hashRemote(remoteEntities.get(key))) {
|
|
7024
|
+
status = "modified";
|
|
7025
|
+
hint = "run `sync pull` to rewrite the local TOML to match running state";
|
|
7026
|
+
}
|
|
7027
|
+
}
|
|
7028
|
+
catch {
|
|
7029
|
+
// A malformed local file must not abort the whole diff.
|
|
7030
|
+
hint = "content not compared (comparison failed)";
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
differences.push({ type: typeLabel, key, status, hint });
|
|
7034
|
+
}
|
|
7035
|
+
for (const key of remoteEntities.keys()) {
|
|
7036
|
+
if (!localParsed.has(key)) {
|
|
7037
|
+
differences.push({ type: typeLabel, key, status: remoteOnlyStatus(slot, key) });
|
|
7038
|
+
}
|
|
7039
|
+
}
|
|
7040
|
+
};
|
|
7041
|
+
compareConfigType("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, (p) => hashLocalRuleSetForDiff(p), (e) => hashRemoteRuleSetForDiff(e), true);
|
|
7042
|
+
compareConfigType("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, (p) => hashLocalGroupTypeConfigForDiff(p, ruleSetNameToIdDiff), (e) => hashRemoteGroupTypeConfigForDiff(e, ruleSetIdToNameDiff, ruleSetNameToIdDiff), ruleSetsOk);
|
|
7043
|
+
compareConfigType("collection-type-config", "collectionTypeConfigs", localCollectionTypeConfigsParsed, remoteCollectionTypeConfigs, (p) => hashLocalCollectionTypeConfigForDiff(p, ruleSetNameToIdDiff), (e) => hashRemoteCollectionTypeConfigForDiff(e, ruleSetIdToNameDiff, ruleSetNameToIdDiff), ruleSetsOk);
|
|
7044
|
+
compareConfigType("metadata-category-config", "metadataCategoryConfigs", localMetadataCategoryParsed, remoteMetadataCategoryConfigs, (p) => hashLocalMetadataCategoryForDiff(p), (e) => hashRemoteMetadataCategoryForDiff(e), true);
|
|
7045
|
+
// database-types: the list carries only the type header, so operations
|
|
7046
|
+
// and subscriptions are fetched per type (mirroring pull) before the
|
|
7047
|
+
// content comparison. A per-type fetch failure degrades that one row to
|
|
7048
|
+
// "content not compared" rather than aborting the whole diff (#1485
|
|
7049
|
+
// edge case). Both operations AND subscriptions are included so an
|
|
7050
|
+
// op-only or subscription-only change is not falsely reported as Synced.
|
|
7051
|
+
for (const [key, parsed] of localDatabaseTypesParsed) {
|
|
7052
|
+
const remote = remoteDatabaseTypes.get(key);
|
|
7053
|
+
if (!remote) {
|
|
7054
|
+
differences.push({ type: "database-type", key, status: localOnlyStatus("databaseTypes", key) });
|
|
7055
|
+
continue;
|
|
7056
|
+
}
|
|
7057
|
+
let status = "exists";
|
|
7058
|
+
let hint;
|
|
7059
|
+
if (!ruleSetsOk) {
|
|
7060
|
+
hint = "content not compared (rule sets unavailable)";
|
|
7061
|
+
}
|
|
7062
|
+
else {
|
|
7063
|
+
const [opsOutcome, subsOutcome] = await Promise.all([
|
|
7064
|
+
attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, key)),
|
|
7065
|
+
attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, key)),
|
|
7066
|
+
]);
|
|
7067
|
+
if (!opsOutcome.ok || !subsOutcome.ok) {
|
|
7068
|
+
hint = "content not compared (fetch failed)";
|
|
7069
|
+
}
|
|
7070
|
+
else {
|
|
7071
|
+
try {
|
|
7072
|
+
const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
|
|
7073
|
+
const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
|
|
7074
|
+
const remoteHash = hashRemoteDatabaseTypeForDiff(remote, operations, subscriptions, ruleSetIdToNameDiff, ruleSetNameToIdDiff);
|
|
7075
|
+
const localHash = hashLocalDatabaseTypeForDiff(parsed, ruleSetNameToIdDiff);
|
|
7076
|
+
if (remoteHash !== localHash) {
|
|
7077
|
+
status = "modified";
|
|
7078
|
+
hint = "run `sync pull` to rewrite the local TOML to match running state";
|
|
7079
|
+
}
|
|
7080
|
+
}
|
|
7081
|
+
catch {
|
|
7082
|
+
hint = "content not compared (comparison failed)";
|
|
7083
|
+
}
|
|
7084
|
+
}
|
|
7085
|
+
}
|
|
7086
|
+
differences.push({ type: "database-type", key, status, hint });
|
|
7087
|
+
}
|
|
7088
|
+
for (const key of remoteDatabaseTypes.keys()) {
|
|
7089
|
+
if (!localDatabaseTypesParsed.has(key)) {
|
|
7090
|
+
differences.push({ type: "database-type", key, status: remoteOnlyStatus("databaseTypes", key) });
|
|
7091
|
+
}
|
|
7092
|
+
}
|
|
5948
7093
|
// Compare test cases for synced prompts and workflows
|
|
5949
7094
|
const testCaseDiffs = [];
|
|
5950
7095
|
// Helper to compare test cases for a block
|
|
@@ -5995,7 +7140,11 @@ Directory Structure:
|
|
|
5995
7140
|
divider();
|
|
5996
7141
|
const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
5997
7142
|
const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
|
|
5998
|
-
|
|
7143
|
+
// #1655: a server entity with no local file, split by whether a prior
|
|
7144
|
+
// pull managed it. Managed → `push --prune` will delete it; unmanaged
|
|
7145
|
+
// (never synced, authored server-side) → left alone.
|
|
7146
|
+
const remoteOnlyWillDelete = differences.filter((d) => d.status === REMOTE_ONLY_WILL_DELETE);
|
|
7147
|
+
const remoteOnlyUnmanaged = differences.filter((d) => d.status === REMOTE_ONLY_UNMANAGED);
|
|
5999
7148
|
const modified = differences.filter((d) => d.status === "modified");
|
|
6000
7149
|
const existing = differences.filter((d) => d.status === "exists");
|
|
6001
7150
|
if (localOnly.length > 0) {
|
|
@@ -6016,13 +7165,23 @@ Directory Structure:
|
|
|
6016
7165
|
}
|
|
6017
7166
|
console.log();
|
|
6018
7167
|
}
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
|
|
7168
|
+
// #1655: managed entities whose local file is gone — `push --prune`
|
|
7169
|
+
// deletes these (the git-committed file deletion is the intent record).
|
|
7170
|
+
if (remoteOnlyWillDelete.length > 0) {
|
|
7171
|
+
warn("Remote only — managed (will delete with `push --prune`):");
|
|
7172
|
+
for (const d of remoteOnlyWillDelete) {
|
|
6022
7173
|
console.log(` ${chalk.red("-")} ${d.type}: ${d.key}`);
|
|
6023
7174
|
}
|
|
6024
7175
|
console.log();
|
|
6025
7176
|
}
|
|
7177
|
+
// #1655: server entities that were never synced — push leaves them be.
|
|
7178
|
+
if (remoteOnlyUnmanaged.length > 0) {
|
|
7179
|
+
info("Remote only — unmanaged (left alone, never synced):");
|
|
7180
|
+
for (const d of remoteOnlyUnmanaged) {
|
|
7181
|
+
console.log(` ${chalk.dim("-")} ${d.type}: ${d.key}`);
|
|
7182
|
+
}
|
|
7183
|
+
console.log();
|
|
7184
|
+
}
|
|
6026
7185
|
// #1175: workflows whose deployed content differs from the local TOML.
|
|
6027
7186
|
// Framed as a preview of `sync pull` — pull would rewrite these files
|
|
6028
7187
|
// to match what's actually running (NOT "push would send").
|
|
@@ -6041,6 +7200,19 @@ Directory Structure:
|
|
|
6041
7200
|
console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
|
|
6042
7201
|
}
|
|
6043
7202
|
}
|
|
7203
|
+
// #1485: types whose remote list could not be fetched are excluded from
|
|
7204
|
+
// the comparison entirely. Printing them explicitly is what keeps the
|
|
7205
|
+
// roll-up below from being misread as exhaustive — with every list
|
|
7206
|
+
// fetch succeeding this section is empty and every push-handled type
|
|
7207
|
+
// appears in the itemized sections or the counts; otherwise the excluded
|
|
7208
|
+
// types are named here rather than silently dropped.
|
|
7209
|
+
if (notCompared.length > 0) {
|
|
7210
|
+
console.log();
|
|
7211
|
+
warn("Not compared (excluded from the counts below):");
|
|
7212
|
+
for (const nc of notCompared) {
|
|
7213
|
+
console.log(` ${chalk.yellow("?")} ${nc.type} (${nc.reason})`);
|
|
7214
|
+
}
|
|
7215
|
+
}
|
|
6044
7216
|
// Show test case differences
|
|
6045
7217
|
const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
|
|
6046
7218
|
const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
|
|
@@ -6061,9 +7233,13 @@ Directory Structure:
|
|
|
6061
7233
|
divider();
|
|
6062
7234
|
keyValue("Local only (new)", localOnly.length);
|
|
6063
7235
|
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
6064
|
-
keyValue("Remote only",
|
|
7236
|
+
keyValue("Remote only (will delete with --prune)", remoteOnlyWillDelete.length);
|
|
7237
|
+
keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
|
|
6065
7238
|
keyValue("Modified", modified.length);
|
|
6066
7239
|
keyValue("Synced", existing.length);
|
|
7240
|
+
if (notCompared.length > 0) {
|
|
7241
|
+
keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
|
|
7242
|
+
}
|
|
6067
7243
|
if (testCaseDiffs.length > 0) {
|
|
6068
7244
|
keyValue("Test Cases (local only)", tcLocalOnly.length);
|
|
6069
7245
|
keyValue("Test Cases (remote only)", tcRemoteOnly.length);
|