primitive-admin 1.0.54 → 1.0.56

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.
Files changed (110) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +230 -2
  2. package/dist/bin/primitive.js +3 -1
  3. package/dist/bin/primitive.js.map +1 -1
  4. package/dist/src/commands/apps.js +54 -2
  5. package/dist/src/commands/apps.js.map +1 -1
  6. package/dist/src/commands/database-types.js +4 -4
  7. package/dist/src/commands/database-types.js.map +1 -1
  8. package/dist/src/commands/databases.js +35 -14
  9. package/dist/src/commands/databases.js.map +1 -1
  10. package/dist/src/commands/documents.js +77 -0
  11. package/dist/src/commands/documents.js.map +1 -1
  12. package/dist/src/commands/guides.d.ts +15 -12
  13. package/dist/src/commands/guides.js +22 -15
  14. package/dist/src/commands/guides.js.map +1 -1
  15. package/dist/src/commands/metadata.js +48 -3
  16. package/dist/src/commands/metadata.js.map +1 -1
  17. package/dist/src/commands/scripts.d.ts +18 -0
  18. package/dist/src/commands/scripts.js +59 -5
  19. package/dist/src/commands/scripts.js.map +1 -1
  20. package/dist/src/commands/settings.d.ts +15 -0
  21. package/dist/src/commands/settings.js +102 -0
  22. package/dist/src/commands/settings.js.map +1 -0
  23. package/dist/src/commands/sync-app-settings.d.ts +115 -0
  24. package/dist/src/commands/sync-app-settings.js +379 -0
  25. package/dist/src/commands/sync-app-settings.js.map +1 -0
  26. package/dist/src/commands/sync.d.ts +134 -19
  27. package/dist/src/commands/sync.js +1949 -600
  28. package/dist/src/commands/sync.js.map +1 -1
  29. package/dist/src/commands/workflows.js +54 -15
  30. package/dist/src/commands/workflows.js.map +1 -1
  31. package/dist/src/lib/api-client.d.ts +29 -1
  32. package/dist/src/lib/api-client.js +31 -2
  33. package/dist/src/lib/api-client.js.map +1 -1
  34. package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
  35. package/dist/src/lib/app-settings-descriptor.js +254 -0
  36. package/dist/src/lib/app-settings-descriptor.js.map +1 -0
  37. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +36 -0
  38. package/dist/src/lib/codegen-shared/generatedFiles.js +67 -10
  39. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  40. package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
  41. package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
  42. package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
  43. package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
  44. package/dist/src/lib/db-codegen/dbGenerator.js +96 -556
  45. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  46. package/dist/src/lib/db-codegen/dbNaming.d.ts +20 -7
  47. package/dist/src/lib/db-codegen/dbNaming.js +31 -13
  48. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  49. package/dist/src/lib/db-codegen/dbTemplates.d.ts +70 -4
  50. package/dist/src/lib/db-codegen/dbTemplates.js +244 -70
  51. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  52. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +13 -22
  53. package/dist/src/lib/db-codegen/dbTsTypes.js +33 -28
  54. package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
  56. package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
  57. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
  58. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
  59. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
  60. package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
  61. package/dist/src/lib/generated-allowlist.js +7 -0
  62. package/dist/src/lib/generated-allowlist.js.map +1 -1
  63. package/dist/src/lib/platform-owned.d.ts +37 -0
  64. package/dist/src/lib/platform-owned.js +46 -0
  65. package/dist/src/lib/platform-owned.js.map +1 -0
  66. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +100 -0
  67. package/dist/src/lib/swift-codegen/dbGenerator.js +682 -0
  68. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
  69. package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
  70. package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
  71. package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
  72. package/dist/src/lib/swift-codegen/generator.d.ts +85 -0
  73. package/dist/src/lib/swift-codegen/generator.js +266 -0
  74. package/dist/src/lib/swift-codegen/generator.js.map +1 -0
  75. package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
  76. package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
  77. package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
  78. package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +90 -0
  79. package/dist/src/lib/swift-codegen/siblingSymbols.js +152 -0
  80. package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
  81. package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
  82. package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
  83. package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
  84. package/dist/src/lib/sync-resource-types.d.ts +437 -0
  85. package/dist/src/lib/sync-resource-types.js +781 -0
  86. package/dist/src/lib/sync-resource-types.js.map +1 -0
  87. package/dist/src/lib/template.d.ts +1 -1
  88. package/dist/src/lib/template.js +6 -3
  89. package/dist/src/lib/template.js.map +1 -1
  90. package/dist/src/lib/toml-metadata-config.js +18 -0
  91. package/dist/src/lib/toml-metadata-config.js.map +1 -1
  92. package/dist/src/lib/workflow-apply.js +12 -2
  93. package/dist/src/lib/workflow-apply.js.map +1 -1
  94. package/dist/src/lib/workflow-codegen/generator.d.ts +27 -5
  95. package/dist/src/lib/workflow-codegen/generator.js +214 -68
  96. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  97. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +83 -0
  98. package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
  99. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
  100. package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +17 -1
  101. package/dist/src/lib/workflow-codegen/schemaToTs.js +59 -38
  102. package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -1
  103. package/dist/src/lib/workflow-payload.d.ts +19 -0
  104. package/dist/src/lib/workflow-payload.js +21 -0
  105. package/dist/src/lib/workflow-payload.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +30 -0
  107. package/dist/src/lib/workflow-toml-validator.js +206 -0
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/dist/src/types/index.d.ts +1 -0
  110. package/package.json +5 -2
@@ -7,17 +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";
17
+ import { serializeSection, parseTomlToAppSettings, } from "../lib/app-settings-descriptor.js";
18
+ import { serializeAppSettings, collectAppSettingsPushErrors, } from "./sync-app-settings.js";
16
19
  import { applyWorkflowBody } from "../lib/workflow-apply.js";
17
- import { buildWorkflowPayloadFromToml } from "../lib/workflow-payload.js";
20
+ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../lib/workflow-payload.js";
18
21
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.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";
19
23
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
20
- import { confirmPrompt } from "../lib/confirm-prompt.js";
24
+ import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
21
25
  import chalk from "chalk";
22
26
  function ensureDir(dirPath) {
23
27
  if (!existsSync(dirPath)) {
@@ -238,109 +242,35 @@ export function hashRemoteWorkflowForDiff(workflow, draft, configs) {
238
242
  const parsed = parseConfigToml(serialized);
239
243
  return hashWorkflowTomlForDiff(parsed);
240
244
  }
241
- // Boolean [auth] keys in app.toml that map 1:1 to app-settings fields.
242
- const AUTH_BOOLEAN_KEYS = [
243
- "googleOAuthEnabled",
244
- "passkeyEnabled",
245
- "magicLinkEnabled",
246
- "appleSignInEnabled",
247
- "otpEnabled",
248
- ];
249
- // Every recognized [auth] key. Single source of truth shared by pull
250
- // serialization, push parsing, and the unrecognized-key warning so the three
251
- // never drift apart. `appleAudiences` is a string array and `passkeys` is the
252
- // passkey RP config object; the rest are booleans.
253
- const RECOGNIZED_AUTH_KEYS = new Set([
254
- ...AUTH_BOOLEAN_KEYS,
255
- "appleAudiences",
256
- "passkeys",
257
- ]);
258
245
  /**
259
246
  * Build the [auth] block of app.toml from server settings (pull direction).
260
247
  *
261
- * Apple/OTP fields round-trip alongside the legacy google/passkey/magic-link
262
- * keys. Two asymmetries are handled deliberately:
263
- * - `appleSignInEnabled` comes back as `boolean | null`; smol-toml throws on
264
- * null, so an unset value (null) is omitted rather than written.
265
- * - `appleAudiences` is omitted when empty. An empty array and an omitted key
266
- * both mean "no audiences" (the server normalizes [] -> null), so omitting
267
- * an empty list on pull is not lossy.
248
+ * Thin wrapper over the shared field descriptor
249
+ * (`cli/src/lib/app-settings-descriptor.ts`), which now owns the [auth] field
250
+ * set that `AUTH_BOOLEAN_KEYS`/`RECOGNIZED_AUTH_KEYS` used to hard-code. The
251
+ * descriptor drives all four sections in one place, so pull, push, and the
252
+ * unrecognized-key warning can never drift apart. `googleClientSecret` is a
253
+ * secret descriptor entry and is never written.
268
254
  */
269
255
  export function serializeAuthBlock(settings) {
270
- const auth = {
271
- googleOAuthEnabled: settings.googleOAuthEnabled,
272
- passkeyEnabled: settings.passkeyEnabled,
273
- magicLinkEnabled: settings.magicLinkEnabled,
274
- };
275
- if (typeof settings.appleSignInEnabled === "boolean") {
276
- auth.appleSignInEnabled = settings.appleSignInEnabled;
277
- }
278
- if (Array.isArray(settings.appleAudiences) &&
279
- settings.appleAudiences.length > 0) {
280
- auth.appleAudiences = settings.appleAudiences;
281
- }
282
- if (typeof settings.otpEnabled === "boolean") {
283
- auth.otpEnabled = settings.otpEnabled;
284
- }
285
- if (settings.passkeyRpConfig) {
286
- auth.passkeys = settings.passkeyRpConfig;
287
- }
288
- return auth;
256
+ return serializeSection(settings, "auth");
289
257
  }
290
258
  /**
291
259
  * Translate the [auth] block of app.toml into app-settings fields (push
292
- * direction).
293
- *
294
- * Only keys actually present in the TOML are forwarded an omitted key is left
295
- * untouched so it never overwrites server state (e.g. flipping a previously-true
296
- * flag to false). An explicit value, including `false`, is forwarded as-is.
297
- * `appleAudiences = []` is forwarded too; the server normalizes [] -> null
298
- * ("no audiences"). Keys outside RECOGNIZED_AUTH_KEYS produce a warning rather
299
- * than a hard error, so a typo or a future key doesn't silently drop.
260
+ * direction). Kept as a focused helper over the shared descriptor; the full
261
+ * push path uses `parseTomlToAppSettings` for every section. Only keys present
262
+ * in the TOML are forwarded, so an omitted key never overwrites server state.
263
+ * Descriptor errors (a secret key, a type mismatch) are surfaced alongside the
264
+ * unrecognized-key warnings.
300
265
  */
301
266
  export function parseAppAuthSettings(auth) {
302
- const settings = {};
303
- const warnings = [];
304
- for (const key of AUTH_BOOLEAN_KEYS) {
305
- if (key in auth)
306
- settings[key] = auth[key];
307
- }
308
- if ("appleAudiences" in auth) {
309
- settings.appleAudiences = auth.appleAudiences;
310
- }
311
- if ("passkeys" in auth) {
312
- settings.passkeyRpConfig = auth.passkeys;
313
- }
314
- for (const key of Object.keys(auth)) {
315
- if (!RECOGNIZED_AUTH_KEYS.has(key)) {
316
- warnings.push(`Unrecognized [auth] key "${key}" in app.toml — ignored. ` +
317
- `Recognized keys: ${[...RECOGNIZED_AUTH_KEYS].join(", ")}.`);
318
- }
319
- }
320
- return { settings, warnings };
321
- }
322
- // TOML serialization helpers
323
- function serializeAppSettings(settings) {
324
- const data = {
325
- app: {
326
- name: settings.name,
327
- mode: settings.mode,
328
- waitlistEnabled: settings.waitlistEnabled,
329
- baseUrl: settings.baseUrl,
330
- },
331
- auth: serializeAuthBlock(settings),
332
- };
333
- if (settings.corsMode === "custom") {
334
- data.cors = {
335
- mode: settings.corsMode,
336
- allowedOrigins: settings.corsAllowedOrigins,
337
- allowCredentials: settings.corsAllowCredentials,
338
- allowedMethods: settings.corsAllowedMethods,
339
- maxAge: settings.corsMaxAge,
340
- };
341
- }
342
- return stringifyConfigToml(data);
267
+ const { settings, warnings, errors } = parseTomlToAppSettings({ auth });
268
+ return { settings, warnings: [...warnings, ...errors] };
343
269
  }
270
+ // `serializeAppSettings` (pull) and the app.toml push validation now live in
271
+ // `sync-app-settings.ts`, shared with the `primitive settings` command — see
272
+ // the imports above. Both directions stay descriptor-driven
273
+ // (`cli/src/lib/app-settings-descriptor.ts`).
344
274
  // --- Config vars (issue #1423) ---------------------------------------------
345
275
  // Per-environment non-secret scalars, round-tripped as a single flat
346
276
  // `vars.toml` at the sync-dir root (like app.toml). They bind as
@@ -782,12 +712,13 @@ function serializeBlobBucket(bucket) {
782
712
  * Build the PATCH payload for a blob-bucket update from local TOML.
783
713
  *
784
714
  * EXACT extraction of the inline builder shared by the existing-update and
785
- * 409-adopt branches — do NOT "clean up" the truthiness checks. The server
786
- * treats `preset` and `accessPolicy` as mutually exclusive, only clears
787
- * `ruleSetId` when a preset/accessPolicy is also present, and nulls
788
- * `description` only when the key is explicitly present with a falsy value.
789
- * `bucketKey` and `ttlTier` are immutable and never sent. Both call sites
790
- * must go through this helper so their field sets never drift.
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.
791
722
  */
792
723
  function buildBlobBucketUpdatePayload(bucket) {
793
724
  const updatePayload = {};
@@ -799,8 +730,11 @@ function buildBlobBucketUpdatePayload(bucket) {
799
730
  updatePayload.ruleSetId = bucket.ruleSetId;
800
731
  if (bucket.name)
801
732
  updatePayload.name = bucket.name;
802
- if (bucket.description !== undefined)
803
- updatePayload.description = bucket.description || null;
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;
804
738
  return updatePayload;
805
739
  }
806
740
  function serializePrompt(prompt) {
@@ -927,7 +861,17 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
927
861
  // Determine which steps to use (prefer config steps if non-empty, else draft steps)
928
862
  const configSteps = activeConfig?.steps;
929
863
  const draftSteps = draft?.steps;
930
- const steps = (configSteps && configSteps.length > 0) ? configSteps : (draftSteps || []);
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;
931
875
  const data = {
932
876
  workflow: {
933
877
  key: workflow.workflowKey,
@@ -983,6 +927,11 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
983
927
  // `secrets`, merged the same way the collection/database type configs do so
984
928
  // a workflow's `md`/`secrets` declarations round-trip through pull → push.
985
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
+ : {}),
986
935
  };
987
936
  return stringifyConfigToml(data);
988
937
  }
@@ -1023,7 +972,7 @@ function serializeEmailTemplate(template) {
1023
972
  }
1024
973
  return stringifyConfigToml(data);
1025
974
  }
1026
- function serializeRuleSet(ruleSet) {
975
+ export function serializeRuleSet(ruleSet) {
1027
976
  const data = {
1028
977
  ruleSet: {
1029
978
  name: ruleSet.name,
@@ -1068,6 +1017,83 @@ function serializeCollectionTypeConfig(config, ruleSetIdToName) {
1068
1017
  };
1069
1018
  return stringifyConfigToml(data);
1070
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
+ }
1071
1097
  export function parseDatabaseTypeToml(tomlData) {
1072
1098
  const typeSection = tomlData.type || {};
1073
1099
  const typeConfig = {
@@ -1081,11 +1107,14 @@ export function parseDatabaseTypeToml(tomlData) {
1081
1107
  typeConfig.ruleSetId = typeSection.ruleSetId;
1082
1108
  }
1083
1109
  // Accept both `celContextAccess` (new user-facing key) and `metadataAccess`
1084
- // (legacy). The newer key wins when both appear.
1085
- if (typeSection.celContextAccess) {
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) {
1086
1115
  typeConfig.metadataAccess = typeSection.celContextAccess;
1087
1116
  }
1088
- else if (typeSection.metadataAccess) {
1117
+ else if (typeSection.metadataAccess !== undefined) {
1089
1118
  typeConfig.metadataAccess = typeSection.metadataAccess;
1090
1119
  }
1091
1120
  if (typeSection.defaultAccess !== undefined) {
@@ -1152,7 +1181,7 @@ export function parseDatabaseTypeToml(tomlData) {
1152
1181
  const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
1153
1182
  return { typeConfig, operations, subscriptions };
1154
1183
  }
1155
- function parseRuleSetToml(tomlData) {
1184
+ export function parseRuleSetToml(tomlData) {
1156
1185
  const ruleSetSection = tomlData.ruleSet || {};
1157
1186
  return {
1158
1187
  name: ruleSetSection.name,
@@ -1212,6 +1241,102 @@ export function parseCollectionTypeConfigToml(tomlData) {
1212
1241
  }
1213
1242
  return result;
1214
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
+ }
1215
1340
  // Parsing helpers
1216
1341
  //
1217
1342
  // All 20+ TOML parse sites in this file route through `parseTomlFile()`.
@@ -1219,7 +1344,7 @@ export function parseCollectionTypeConfigToml(tomlData) {
1219
1344
  // prompts, tests, etc.) gets it for free. For non-workflow TOMLs the
1220
1345
  // expander is a no-op: if the parsed result has no `include` key, it returns
1221
1346
  // the original object untouched.
1222
- function parseTomlFile(filePath) {
1347
+ export function parseTomlFile(filePath) {
1223
1348
  const content = readFileSync(filePath, "utf-8");
1224
1349
  const parsed = parseConfigToml(content);
1225
1350
  return expandWorkflowTomlData(parsed, filePath);
@@ -1241,6 +1366,83 @@ export async function fetchAll(listFn, pageSize = 100, maxPages = 100) {
1241
1366
  } while (cursor);
1242
1367
  return all;
1243
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
+ }
1244
1446
  /**
1245
1447
  * Resolve a key-based rule set name reference to an ID.
1246
1448
  * Throws if the name cannot be resolved and throwOnMissing is true.
@@ -1417,8 +1619,15 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1417
1619
  items = result.items || [];
1418
1620
  }
1419
1621
  catch {
1420
- return { scriptEntities, count: 0 };
1622
+ return {
1623
+ scriptEntities,
1624
+ count: 0,
1625
+ presence: { ok: false },
1626
+ listOk: false,
1627
+ serverKeys: [],
1628
+ };
1421
1629
  }
1630
+ let detailsOk = true;
1422
1631
  let written = 0;
1423
1632
  for (const script of items) {
1424
1633
  const name = script?.name;
@@ -1437,6 +1646,7 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1437
1646
  full = await client.getScript(appId, script.scriptId);
1438
1647
  }
1439
1648
  catch {
1649
+ detailsOk = false;
1440
1650
  logger(` Skipped transforms/${name}.rhai (could not fetch config)`);
1441
1651
  continue;
1442
1652
  }
@@ -1461,7 +1671,11 @@ export async function pullScripts(client, appId, configDir, logger = () => { })
1461
1671
  written += 1;
1462
1672
  logger(` Wrote transforms/${filename}`);
1463
1673
  }
1464
- return { scriptEntities, count: written };
1674
+ const serverKeys = items.map((s) => s?.name).filter(Boolean);
1675
+ const presence = detailsOk
1676
+ ? { ok: true, serverKeys }
1677
+ : { ok: false };
1678
+ return { scriptEntities, count: written, presence, listOk: true, serverKeys };
1465
1679
  }
1466
1680
  async function pullTestCasesForBlock(client, appId, blockType, blockId, blockKey, configDir, testCaseEntities, lookupMaps) {
1467
1681
  let testCases;
@@ -1827,6 +2041,7 @@ Directory Structure:
1827
2041
  .option("--app <app-id>", "App ID")
1828
2042
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
1829
2043
  .option("--json", "Output as JSON instead of writing files")
2044
+ .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
1830
2045
  .action(async (appId, options) => {
1831
2046
  const resolvedAppId = resolveAppId(appId, options);
1832
2047
  const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
@@ -1839,18 +2054,37 @@ Directory Structure:
1839
2054
  const client = new ApiClient();
1840
2055
  info(`Pulling configuration for app ${resolvedAppId}...`);
1841
2056
  try {
1842
- // Fetch all data
1843
- const [settings, integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
2057
+ // Fetch all data. Issue #1659: prune decides whether a local file's
2058
+ // entity still exists server-side, so every fetch that establishes
2059
+ // presence must report success or failure explicitly — an error that
2060
+ // reads as an empty list would prune every local file of that type.
2061
+ // `integrations`/`prompts`/`workflows` throw straight out of the pull
2062
+ // on failure (no `.catch`), so reaching the prune pass at all proves
2063
+ // their listings succeeded. The types that swallow errors below carry
2064
+ // an explicit outcome instead.
2065
+ const [settings, integrationItems, promptItems, workflowItems, emailTemplatesOutcome] = await Promise.all([
1844
2066
  client.getAppSettings(resolvedAppId).catch(() => null),
1845
2067
  fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
1846
2068
  fetchAll((p) => client.listPrompts(resolvedAppId, p)),
1847
2069
  fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
1848
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
2070
+ attemptFetch(() => client.listEmailTemplates(resolvedAppId)),
1849
2071
  ]);
2072
+ const emailTemplatesResult = emailTemplatesOutcome.ok
2073
+ ? emailTemplatesOutcome.value
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));
1850
2084
  // Fetch details for each entity
1851
2085
  const integrations = await Promise.all(integrationItems.map((i) => client.getIntegration(resolvedAppId, i.integrationId)));
1852
2086
  const prompts = await Promise.all(promptItems.map((p) => client.getPrompt(resolvedAppId, p.promptId)));
1853
- const workflows = await Promise.all(workflowItems.map(async (w) => {
2087
+ const workflows = await Promise.all(appWorkflowItems.map(async (w) => {
1854
2088
  const workflowData = await client.getWorkflow(resolvedAppId, w.workflowId);
1855
2089
  // Fetch active config with steps if available
1856
2090
  const activeConfigId = workflowData.workflow?.activeConfigId;
@@ -1871,30 +2105,71 @@ Directory Structure:
1871
2105
  }
1872
2106
  return workflowData;
1873
2107
  }));
1874
- // Fetch full details for email template overrides
2108
+ // Fetch full details for email template overrides. Only overrides get a
2109
+ // local file, so they are the type's server key set. A detail fetch that
2110
+ // fails leaves the override unwritten — presence for that key is no
2111
+ // longer established, so the type is skipped for pruning (#1659).
1875
2112
  const emailTemplateOverrides = emailTemplatesResult.templates.filter((t) => t.hasOverride);
1876
- const emailTemplates = await Promise.all(emailTemplateOverrides.map((t) => client.getEmailTemplate(resolvedAppId, t.emailType).catch(() => null))).then((results) => results.filter(Boolean));
2113
+ const emailTemplateOutcomes = await Promise.all(emailTemplateOverrides.map((t) => attemptFetch(() => client.getEmailTemplate(resolvedAppId, t.emailType))));
2114
+ const emailTemplates = emailTemplateOutcomes
2115
+ .map((o) => (o.ok ? o.value : null))
2116
+ .filter(Boolean);
2117
+ const emailTemplateDetailsOk = emailTemplateOutcomes.every((o) => o.ok && o.value);
2118
+ const emailTemplatePresence = emailTemplatesOutcome.ok && emailTemplateDetailsOk
2119
+ ? {
2120
+ ok: true,
2121
+ serverKeys: emailTemplateOverrides.map((t) => t.emailType),
2122
+ }
2123
+ : { ok: false };
1877
2124
  if (options.json) {
1878
2125
  json({ settings, integrations, prompts, workflows, emailTemplates });
1879
2126
  return;
1880
2127
  }
1881
2128
  // Fetch database config resources
1882
- const [databaseTypeConfigsResult, ruleSetsResult, groupTypeConfigsResult, collectionTypeConfigsResult, metadataCategoryConfigsResult,] = await Promise.all([
1883
- client.listDatabaseTypeConfigs(resolvedAppId).catch(() => []),
1884
- client.listRuleSets(resolvedAppId).catch(() => []),
1885
- client.listGroupTypeConfigs(resolvedAppId).catch(() => []),
1886
- client.listCollectionTypeConfigs(resolvedAppId).catch(() => []),
1887
- client.listMetadataCategoryConfigs(resolvedAppId).catch(() => []),
2129
+ // These five listings previously used `.catch(() => [])`, which made a
2130
+ // transient 401/500/network error indistinguishable from "the app has
2131
+ // none of these". Pull still tolerates the failure the same way (it
2132
+ // writes nothing for that type and carries on), but it now records the
2133
+ // outcome so the prune pass can skip a type whose presence is unknown
2134
+ // rather than delete every local file of it (#1659).
2135
+ const [databaseTypeConfigsOutcome, ruleSetsOutcome, groupTypeConfigsOutcome, collectionTypeConfigsOutcome, metadataCategoryConfigsOutcome,] = await Promise.all([
2136
+ attemptFetch(() => client.listDatabaseTypeConfigs(resolvedAppId)),
2137
+ attemptFetch(() => client.listRuleSets(resolvedAppId)),
2138
+ attemptFetch(() => client.listGroupTypeConfigs(resolvedAppId)),
2139
+ attemptFetch(() => client.listCollectionTypeConfigs(resolvedAppId)),
2140
+ attemptFetch(() => client.listMetadataCategoryConfigs(resolvedAppId)),
1888
2141
  ]);
2142
+ const databaseTypeConfigsResult = databaseTypeConfigsOutcome.ok
2143
+ ? databaseTypeConfigsOutcome.value
2144
+ : [];
2145
+ const ruleSetsResult = ruleSetsOutcome.ok ? ruleSetsOutcome.value : [];
2146
+ const groupTypeConfigsResult = groupTypeConfigsOutcome.ok
2147
+ ? groupTypeConfigsOutcome.value
2148
+ : [];
2149
+ const collectionTypeConfigsResult = collectionTypeConfigsOutcome.ok
2150
+ ? collectionTypeConfigsOutcome.value
2151
+ : [];
2152
+ const metadataCategoryConfigsResult = metadataCategoryConfigsOutcome.ok
2153
+ ? metadataCategoryConfigsOutcome.value
2154
+ : [];
1889
2155
  // Fetch operations + subscriptions for each database type. Issue #803:
1890
2156
  // subscriptions are pulled symmetrically with operations so a
1891
2157
  // pull → push cycle round-trips `[[subscriptions]]` blocks without a
1892
2158
  // spurious diff. The list endpoint already excludes archived rows.
2159
+ //
2160
+ // A failed operation/subscription fetch also disqualifies the type from
2161
+ // pruning (#1659): the file we write for it is then incomplete, so the
2162
+ // pull's picture of that type is not one to reconcile against.
2163
+ let databaseTypeDetailsOk = true;
1893
2164
  const databaseTypesWithOps = await Promise.all((Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : []).map(async (typeConfig) => {
1894
- const [ops, subs] = await Promise.all([
1895
- client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType).catch(() => []),
1896
- client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType).catch(() => []),
2165
+ const [opsOutcome, subsOutcome] = await Promise.all([
2166
+ attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
2167
+ attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
1897
2168
  ]);
2169
+ if (!opsOutcome.ok || !subsOutcome.ok)
2170
+ databaseTypeDetailsOk = false;
2171
+ const ops = opsOutcome.ok ? opsOutcome.value : [];
2172
+ const subs = subsOutcome.ok ? subsOutcome.value : [];
1898
2173
  return {
1899
2174
  typeConfig,
1900
2175
  operations: Array.isArray(ops) ? ops : [],
@@ -1945,6 +2220,12 @@ Directory Structure:
1945
2220
  ensureDir(join(configDir, "collection-type-configs"));
1946
2221
  ensureDir(join(configDir, "metadata-category-configs"));
1947
2222
  ensureDir(join(configDir, "email-templates"));
2223
+ // The prior sync state is the record of which local files a previous
2224
+ // pull wrote — the discriminator the prune pass keys off (#1659). Read
2225
+ // it now: `saveSyncState` at the end of the pull overwrites it, and the
2226
+ // files themselves are about to be rewritten.
2227
+ const priorState = loadSyncState(configDir);
2228
+ const priorEntities = priorState?.entities ?? {};
1948
2229
  // Write app settings
1949
2230
  if (settings) {
1950
2231
  const appTomlPath = join(configDir, "app.toml");
@@ -1971,7 +2252,7 @@ Directory Structure:
1971
2252
  catch (varErr) {
1972
2253
  varsOutcome = { ok: false, error: varErr };
1973
2254
  }
1974
- const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : loadSyncState(configDir)?.entities?.vars);
2255
+ const varsPlan = planVarsPull(varsOutcome, varsOutcome.ok ? undefined : priorEntities.vars);
1975
2256
  const varEntities = varsPlan.varEntities;
1976
2257
  if (varsPlan.write && varsPlan.content !== null) {
1977
2258
  writeFileSync(varsTomlPathPull, varsPlan.content);
@@ -2013,15 +2294,13 @@ Directory Structure:
2013
2294
  };
2014
2295
  }
2015
2296
  info(` Pulled ${webhooks.length} webhook(s)`);
2016
- // Pull cron triggers
2017
- let cronTriggerItems = [];
2018
- try {
2019
- const cronResult = await client.listCronTriggers(resolvedAppId);
2020
- cronTriggerItems = cronResult.items || [];
2021
- }
2022
- catch {
2023
- // Cron triggers may not be available on older servers
2024
- }
2297
+ // Pull cron triggers. An older server without the route (or any other
2298
+ // failure) leaves the type unlisted — tolerated as before, but recorded
2299
+ // so prune skips it rather than reading it as "no cron triggers".
2300
+ const cronTriggersOutcome = await attemptFetch(() => client.listCronTriggers(resolvedAppId));
2301
+ const cronTriggerItems = cronTriggersOutcome.ok
2302
+ ? cronTriggersOutcome.value.items || []
2303
+ : [];
2025
2304
  const cronTriggersDir = join(configDir, "cron-triggers");
2026
2305
  mkdirSync(cronTriggersDir, { recursive: true });
2027
2306
  const cronTriggerEntities = {};
@@ -2037,8 +2316,10 @@ Directory Structure:
2037
2316
  }
2038
2317
  info(` Pulled ${cronTriggerItems.length} cron trigger(s)`);
2039
2318
  // Pull blob buckets
2040
- const blobBucketsResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
2041
- const blobBucketItems = blobBucketsResult.items || [];
2319
+ const blobBucketsOutcome = await attemptFetch(() => client.listBlobBuckets(resolvedAppId));
2320
+ const blobBucketItems = blobBucketsOutcome.ok
2321
+ ? blobBucketsOutcome.value.items || []
2322
+ : [];
2042
2323
  const blobBucketsDir = join(configDir, "blob-buckets");
2043
2324
  mkdirSync(blobBucketsDir, { recursive: true });
2044
2325
  const blobBucketEntities = {};
@@ -2085,13 +2366,40 @@ Directory Structure:
2085
2366
  };
2086
2367
  info(` Wrote workflows/${filename}`);
2087
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
+ }
2088
2396
  // Pull transforms (Rhai scripts) — issue #892, slice 7 + codex
2089
2397
  // follow-up on PR #893. Mirrors the prompts pull pattern: list
2090
2398
  // server-side `Script` rows, write each body to
2091
2399
  // `transforms/<name>.rhai`, and record `{id, modifiedAt,
2092
2400
  // contentHash}` under `entities.scripts[name]` so a subsequent
2093
2401
  // `sync push` round-trips without false diffs.
2094
- const { scriptEntities, count: pulledScriptsCount } = await pullScripts(client, resolvedAppId, configDir, info);
2402
+ const { scriptEntities, count: pulledScriptsCount, presence: scriptPresence, listOk: scriptListOk, serverKeys: scriptServerKeys, } = await pullScripts(client, resolvedAppId, configDir, info);
2095
2403
  if (pulledScriptsCount > 0) {
2096
2404
  info(` Pulled ${pulledScriptsCount} transform(s)`);
2097
2405
  }
@@ -2143,19 +2451,10 @@ Directory Structure:
2143
2451
  };
2144
2452
  info(` Wrote email-templates/${filename}`);
2145
2453
  }
2146
- // Clean up local email template files for remotely-deleted overrides
2147
- const emailTemplatesDir = join(configDir, "email-templates");
2148
- if (existsSync(emailTemplatesDir)) {
2149
- const pulledTypes = new Set(emailTemplates.map((t) => t.emailType));
2150
- const localFiles = readdirSync(emailTemplatesDir).filter((f) => f.endsWith(".toml"));
2151
- for (const file of localFiles) {
2152
- const localType = basename(file, ".toml");
2153
- if (!pulledTypes.has(localType)) {
2154
- unlinkSync(join(emailTemplatesDir, file));
2155
- info(` Removed email-templates/${file} (override deleted on server)`);
2156
- }
2157
- }
2158
- }
2454
+ // Email-template files for removed overrides used to be cleaned up here
2455
+ // by a hand-coded loop — the only type that pruned at all (#1659). It is
2456
+ // now folded into the general prune pass below, under the same rules and
2457
+ // the same `--no-prune` flag as every other type.
2159
2458
  // Pull test cases for prompts and workflows
2160
2459
  const testCaseEntities = {};
2161
2460
  let totalTestCases = 0;
@@ -2238,7 +2537,7 @@ Directory Structure:
2238
2537
  // Write rule sets
2239
2538
  const ruleSetEntities = {};
2240
2539
  for (const ruleSet of ruleSets) {
2241
- const fileKey = (ruleSet.name || ruleSet.ruleSetId).replace(/[/\\:*?"<>|]/g, "_");
2540
+ const fileKey = ruleSetFileKey(ruleSet);
2242
2541
  const filename = `${fileKey}.toml`;
2243
2542
  const filePath = join(configDir, "rule-sets", filename);
2244
2543
  writeFileSync(filePath, serializeRuleSet(ruleSet));
@@ -2294,6 +2593,185 @@ Directory Structure:
2294
2593
  };
2295
2594
  info(` Wrote metadata-category-configs/${filename}`);
2296
2595
  }
2596
+ // ── Prune reconciliation (issue #1659) ───────────────────────────────
2597
+ //
2598
+ // Every write above is done, so we now know which entities the server
2599
+ // still exports. A local file is removed iff its key was MANAGED (a
2600
+ // prior pull wrote it, so it is in `priorEntities[<slot>]`) and is
2601
+ // ABSENT from this pull's server keys. A file that was never in prior
2602
+ // state was hand-authored — it stays, and `sync diff` reports it as new.
2603
+ //
2604
+ // Presence comes from the server LISTING, never from "what we just
2605
+ // wrote": several types legitimately skip writing a present entity (a
2606
+ // script with no active config), and pruning those would delete a live
2607
+ // entity's file. A type whose presence could not be established is
2608
+ // skipped whole, and its prior state slot is preserved so the managed
2609
+ // discriminator survives to the next pull.
2610
+ //
2611
+ // This runs after the pre-pull snapshot, so `sync revert` restores
2612
+ // anything pruned in error.
2613
+ const presenceBySlot = {
2614
+ integrations: { ok: true, serverKeys: integrationItems.map((i) => i.integrationKey) },
2615
+ webhooks: { ok: true, serverKeys: webhookItems.map((w) => w.webhookKey) },
2616
+ prompts: { ok: true, serverKeys: promptItems.map((p) => p.promptKey) },
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) },
2621
+ cronTriggers: cronTriggersOutcome.ok
2622
+ ? { ok: true, serverKeys: cronTriggerItems.map((t) => t.triggerKey) }
2623
+ : { ok: false },
2624
+ blobBuckets: blobBucketsOutcome.ok
2625
+ ? { ok: true, serverKeys: blobBucketItems.map((b) => b.bucketKey) }
2626
+ : { ok: false },
2627
+ scripts: scriptPresence,
2628
+ emailTemplates: emailTemplatePresence,
2629
+ databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
2630
+ ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
2631
+ : { ok: false },
2632
+ ruleSets: ruleSetsOutcome.ok
2633
+ ? { ok: true, serverKeys: ruleSets.map((rs) => ruleSetFileKey(rs)) }
2634
+ : { ok: false },
2635
+ groupTypeConfigs: groupTypeConfigsOutcome.ok
2636
+ ? { ok: true, serverKeys: groupTypeConfigs.map((c) => c.groupType) }
2637
+ : { ok: false },
2638
+ collectionTypeConfigs: collectionTypeConfigsOutcome.ok
2639
+ ? { ok: true, serverKeys: collectionTypeConfigs.map((c) => c.collectionType) }
2640
+ : { ok: false },
2641
+ metadataCategoryConfigs: metadataCategoryConfigsOutcome.ok
2642
+ ? {
2643
+ ok: true,
2644
+ serverKeys: metadataCategoryConfigs.map((c) => `${c.resourceType}#${c.category}`),
2645
+ }
2646
+ : { ok: false },
2647
+ };
2648
+ let prunedCount = 0;
2649
+ // Keys whose file this prune deliberately left in place. They are absent
2650
+ // server-side, so nothing wrote them into this pull's entity map — see
2651
+ // the restore pass below `keepPrior`.
2652
+ const keptKeysBySlot = {};
2653
+ for (const resourceType of SYNC_RESOURCE_TYPES) {
2654
+ const presence = presenceBySlot[resourceType.stateSlot] ?? { ok: false };
2655
+ const plan = planPrune({
2656
+ resourceType,
2657
+ priorKeys: Object.keys(priorEntities[resourceType.stateSlot] ?? {}),
2658
+ presence,
2659
+ enabled: options.prune !== false,
2660
+ });
2661
+ if (plan.skipped) {
2662
+ // `--no-prune` is the operator's explicit choice — no warning for it.
2663
+ // A failed fetch is worth saying out loud: stale files survive this
2664
+ // pull, and the next push would re-create their entities.
2665
+ if (plan.skipReason === "fetch-failed" && Object.keys(priorEntities[resourceType.stateSlot] ?? {}).length > 0) {
2666
+ warn(` Skipped pruning ${resourceType.dir} — could not establish which entities the server has; ` +
2667
+ "local files left untouched");
2668
+ }
2669
+ // Only worth saying when a file is actually being left behind:
2670
+ // this listing is incomplete on every pull, so warning
2671
+ // unconditionally would just train operators to ignore it.
2672
+ if (plan.skipReason === "listing-incomplete" && plan.unprunedAbsentKeys.length > 0) {
2673
+ warn(` Skipped pruning ${plan.unprunedAbsentKeys.length} ${resourceType.dir} file(s) — ` +
2674
+ `the server's ${resourceType.dir} listing is not paginated, so an entity missing from it ` +
2675
+ "may still be live (#1668). Delete the file yourself if the entity is really gone: " +
2676
+ plan.unprunedAbsentKeys.join(", "));
2677
+ }
2678
+ // The files stayed, so their keys stay managed — same rule as the
2679
+ // kept-file restore below.
2680
+ if (plan.unprunedAbsentKeys.length > 0) {
2681
+ keptKeysBySlot[resourceType.stateSlot] = [...plan.unprunedAbsentKeys];
2682
+ }
2683
+ continue;
2684
+ }
2685
+ for (const key of plan.unsafeKeys) {
2686
+ warn(` Skipped pruning ${resourceType.dir} entry "${key}" — its sync-state key does not map to a safe filename`);
2687
+ }
2688
+ const result = await applyPrune({
2689
+ configDir,
2690
+ resourceType,
2691
+ keys: plan.keys,
2692
+ testCaseEntities,
2693
+ logger: info,
2694
+ });
2695
+ prunedCount += result.prunedKeys.length;
2696
+ const kept = [...result.keptWithLocalEdits, ...plan.unsafeKeys];
2697
+ if (kept.length > 0) {
2698
+ keptKeysBySlot[resourceType.stateSlot] = kept;
2699
+ }
2700
+ }
2701
+ // Preserve prior state for types whose listing failed: nothing was
2702
+ // written for them, so the untouched local files must keep their
2703
+ // managed marker (the same rule `planVarsPull` applies to vars.toml).
2704
+ // For a type that listed fine but had a per-entity fetch failure, keep
2705
+ // the prior entry for that one key — see `preserveUnwrittenEntries`.
2706
+ const keepPrior = (ok, built, slot) => ok ? built : (priorEntities[slot] ?? built);
2707
+ preserveUnwrittenEntries(scriptEntities, priorEntities.scripts, scriptServerKeys);
2708
+ preserveUnwrittenEntries(emailTemplateEntities, priorEntities.emailTemplates, emailTemplatesOutcome.ok
2709
+ ? emailTemplateOverrides.map((t) => t.emailType)
2710
+ : []);
2711
+ const finalCronTriggerEntities = keepPrior(cronTriggersOutcome.ok, cronTriggerEntities, "cronTriggers");
2712
+ const finalBlobBucketEntities = keepPrior(blobBucketsOutcome.ok, blobBucketEntities, "blobBuckets");
2713
+ const finalScriptEntities = keepPrior(scriptListOk, scriptEntities, "scripts");
2714
+ const finalEmailTemplateEntities = keepPrior(emailTemplatesOutcome.ok, emailTemplateEntities, "emailTemplates");
2715
+ const finalDatabaseTypeEntities = keepPrior(databaseTypeConfigsOutcome.ok, databaseTypeEntities, "databaseTypes");
2716
+ const finalRuleSetEntities = keepPrior(ruleSetsOutcome.ok, ruleSetEntities, "ruleSets");
2717
+ const finalGroupTypeConfigEntities = keepPrior(groupTypeConfigsOutcome.ok, groupTypeConfigEntities, "groupTypeConfigs");
2718
+ const finalCollectionTypeConfigEntities = keepPrior(collectionTypeConfigsOutcome.ok, collectionTypeConfigEntities, "collectionTypeConfigs");
2719
+ const finalMetadataCategoryConfigEntities = keepPrior(metadataCategoryConfigsOutcome.ok, metadataCategoryConfigEntities, "metadataCategoryConfigs");
2720
+ // Keep the managed marker on every file prune deliberately left in
2721
+ // place: uncommitted local edits, an unsafe filename, `--no-prune`, or
2722
+ // a listing too incomplete to trust.
2723
+ //
2724
+ // Those keys are absent from the server, so this pull wrote no entry for
2725
+ // them: `preserveUnwrittenEntries` only rescues keys the server LISTED,
2726
+ // and `keepPrior` only fires when a whole listing failed. Without this
2727
+ // pass the key drops out of `entities[<slot>]` here, and on the next pull
2728
+ // the file is indistinguishable from a hand-authored one — never pruned,
2729
+ // never warned about, and reported by `sync diff` as "local only" rather
2730
+ // than absent-from-export. The skip-with-warning safety hatch would work
2731
+ // once and then quietly stop, re-creating the entity on the next push.
2732
+ // Carrying the prior entry forward keeps the file managed until it is
2733
+ // actually gone.
2734
+ const entitiesBySlot = {
2735
+ integrations: integrationEntities,
2736
+ webhooks: webhookEntities,
2737
+ cronTriggers: finalCronTriggerEntities,
2738
+ blobBuckets: finalBlobBucketEntities,
2739
+ prompts: promptEntities,
2740
+ workflows: workflowEntities,
2741
+ scripts: finalScriptEntities,
2742
+ emailTemplates: finalEmailTemplateEntities,
2743
+ databaseTypes: finalDatabaseTypeEntities,
2744
+ ruleSets: finalRuleSetEntities,
2745
+ groupTypeConfigs: finalGroupTypeConfigEntities,
2746
+ collectionTypeConfigs: finalCollectionTypeConfigEntities,
2747
+ metadataCategoryConfigs: finalMetadataCategoryConfigEntities,
2748
+ };
2749
+ for (const [slot, keys] of Object.entries(keptKeysBySlot)) {
2750
+ const built = entitiesBySlot[slot];
2751
+ const prior = priorEntities[slot];
2752
+ if (!built || !prior)
2753
+ continue;
2754
+ for (const key of keys) {
2755
+ if (!(key in built) && key in prior) {
2756
+ built[key] = prior[key];
2757
+ }
2758
+ }
2759
+ // The block's sidecar test cases were not written either (the block is
2760
+ // gone from the export), and its `<key>.tests/` files are still on disk
2761
+ // because the block's own file was kept — so their records carry
2762
+ // forward on the same rule.
2763
+ const resourceType = SYNC_RESOURCE_TYPES.find((rt) => rt.stateSlot === slot);
2764
+ const blockType = resourceType?.testBlockType;
2765
+ if (!blockType || !priorEntities.testCases)
2766
+ continue;
2767
+ for (const [tcKey, tcValue] of Object.entries(priorEntities.testCases)) {
2768
+ if (tcValue.blockType === blockType &&
2769
+ keys.includes(tcValue.blockKey) &&
2770
+ !(tcKey in testCaseEntities)) {
2771
+ testCaseEntities[tcKey] = tcValue;
2772
+ }
2773
+ }
2774
+ }
2297
2775
  // Save sync state
2298
2776
  const state = {
2299
2777
  appId: resolvedAppId,
@@ -2303,22 +2781,22 @@ Directory Structure:
2303
2781
  app: settings ? { modifiedAt: new Date().toISOString(), contentHash: computeFileHash(join(configDir, "app.toml")) } : undefined,
2304
2782
  integrations: integrationEntities,
2305
2783
  webhooks: webhookEntities,
2306
- cronTriggers: Object.keys(cronTriggerEntities).length > 0 ? cronTriggerEntities : undefined,
2307
- blobBuckets: Object.keys(blobBucketEntities).length > 0 ? blobBucketEntities : undefined,
2784
+ cronTriggers: Object.keys(finalCronTriggerEntities).length > 0 ? finalCronTriggerEntities : undefined,
2785
+ blobBuckets: Object.keys(finalBlobBucketEntities).length > 0 ? finalBlobBucketEntities : undefined,
2308
2786
  prompts: promptEntities,
2309
2787
  workflows: workflowEntities,
2310
- scripts: Object.keys(scriptEntities).length > 0 ? scriptEntities : undefined,
2311
- emailTemplates: Object.keys(emailTemplateEntities).length > 0 ? emailTemplateEntities : undefined,
2788
+ scripts: Object.keys(finalScriptEntities).length > 0 ? finalScriptEntities : undefined,
2789
+ emailTemplates: Object.keys(finalEmailTemplateEntities).length > 0 ? finalEmailTemplateEntities : undefined,
2312
2790
  testCases: Object.keys(testCaseEntities).length > 0 ? testCaseEntities : undefined,
2313
- databaseTypes: Object.keys(databaseTypeEntities).length > 0 ? databaseTypeEntities : undefined,
2314
- ruleSets: Object.keys(ruleSetEntities).length > 0 ? ruleSetEntities : undefined,
2791
+ databaseTypes: Object.keys(finalDatabaseTypeEntities).length > 0 ? finalDatabaseTypeEntities : undefined,
2792
+ ruleSets: Object.keys(finalRuleSetEntities).length > 0 ? finalRuleSetEntities : undefined,
2315
2793
  vars: Object.keys(varEntities).length > 0 ? varEntities : undefined,
2316
- groupTypeConfigs: Object.keys(groupTypeConfigEntities).length > 0 ? groupTypeConfigEntities : undefined,
2317
- collectionTypeConfigs: Object.keys(collectionTypeConfigEntities).length > 0
2318
- ? collectionTypeConfigEntities
2794
+ groupTypeConfigs: Object.keys(finalGroupTypeConfigEntities).length > 0 ? finalGroupTypeConfigEntities : undefined,
2795
+ collectionTypeConfigs: Object.keys(finalCollectionTypeConfigEntities).length > 0
2796
+ ? finalCollectionTypeConfigEntities
2319
2797
  : undefined,
2320
- metadataCategoryConfigs: Object.keys(metadataCategoryConfigEntities).length > 0
2321
- ? metadataCategoryConfigEntities
2798
+ metadataCategoryConfigs: Object.keys(finalMetadataCategoryConfigEntities).length > 0
2799
+ ? finalMetadataCategoryConfigEntities
2322
2800
  : undefined,
2323
2801
  },
2324
2802
  };
@@ -2340,6 +2818,7 @@ Directory Structure:
2340
2818
  keyValue("Group Type Configs", groupTypeConfigs.length);
2341
2819
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
2342
2820
  keyValue("Metadata Category Configs", metadataCategoryConfigs.length);
2821
+ keyValue("Pruned", prunedCount);
2343
2822
  }
2344
2823
  catch (err) {
2345
2824
  error(err.message);
@@ -2355,6 +2834,8 @@ Directory Structure:
2355
2834
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
2356
2835
  .option("--dry-run", "Show what would be changed without applying")
2357
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)")
2358
2839
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
2359
2840
  .action(async (appId, options) => {
2360
2841
  const resolvedAppId = resolveAppId(appId, options);
@@ -2461,6 +2942,13 @@ Directory Structure:
2461
2942
  }
2462
2943
  }
2463
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.`);
2464
2952
  console.log();
2465
2953
  }
2466
2954
  if (schemaErrors.uncheckableOps.length > 0) {
@@ -2543,6 +3031,19 @@ Directory Structure:
2543
3031
  const filePath = join(preflightWorkflowsDir, file);
2544
3032
  try {
2545
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
+ }
2546
3047
  const tomlErrors = validateWorkflowToml(tomlData);
2547
3048
  if (tomlErrors.length > 0) {
2548
3049
  preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
@@ -2589,6 +3090,40 @@ Directory Structure:
2589
3090
  for (const e of validation.errors) {
2590
3091
  preflightValidationErrors.push(` ${formatIssue(e)}`);
2591
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
+ }
3108
+ }
3109
+ }
3110
+ // Validate app.toml settings up-front (issue #1033). A hand-added
3111
+ // `googleClientSecret` key or a mistyped value is a hard error that must
3112
+ // abort BEFORE any mutation — same fail-before-mutate contract as the
3113
+ // vars preflight below. The apply block re-parses for the payload.
3114
+ const preflightAppTomlPath = join(configDir, "app.toml");
3115
+ if (existsSync(preflightAppTomlPath)) {
3116
+ try {
3117
+ const parsedApp = parseTomlFile(preflightAppTomlPath);
3118
+ const appErrors = collectAppSettingsPushErrors(parsedApp, {
3119
+ isCrossAppPush,
3120
+ });
3121
+ for (const e of appErrors) {
3122
+ preflightValidationErrors.push(` app.toml: ${e}`);
3123
+ }
3124
+ }
3125
+ catch (err) {
3126
+ preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
2592
3127
  }
2593
3128
  }
2594
3129
  // Validate config vars up-front (issue #1423 review). Parse vars.toml
@@ -2687,27 +3222,13 @@ Directory Structure:
2687
3222
  }
2688
3223
  else {
2689
3224
  const tomlData = parseTomlFile(appTomlPath);
2690
- const settings = {};
2691
- if (tomlData.app) {
2692
- Object.assign(settings, tomlData.app);
2693
- // Don't overwrite the target app's name during cross-app push
2694
- if (isCrossAppPush) {
2695
- delete settings.name;
2696
- }
2697
- }
2698
- if (tomlData.auth) {
2699
- const { settings: authSettings, warnings: authWarnings } = parseAppAuthSettings(tomlData.auth);
2700
- Object.assign(settings, authSettings);
2701
- for (const w of authWarnings) {
2702
- warn(` ${w}`);
2703
- }
2704
- }
2705
- if (tomlData.cors) {
2706
- settings.corsMode = tomlData.cors.mode;
2707
- settings.corsAllowedOrigins = tomlData.cors.allowedOrigins;
2708
- settings.corsAllowCredentials = tomlData.cors.allowCredentials;
2709
- settings.corsAllowedMethods = tomlData.cors.allowedMethods;
2710
- settings.corsMaxAge = tomlData.cors.maxAge;
3225
+ // Descriptor-driven parse of every section ([app]/[auth]/[cors]/
3226
+ // [invitations]) — issue #1033. `name` is stripped on cross-app
3227
+ // push. Errors (secret key / type mismatch) already aborted the
3228
+ // push in the preflight pass above, so only warnings remain here.
3229
+ const { settings, warnings: appWarnings } = parseTomlToAppSettings(tomlData, { isCrossAppPush });
3230
+ for (const w of appWarnings) {
3231
+ warn(` ${w}`);
2711
3232
  }
2712
3233
  changes.push({ type: "app", action: "update", key: "settings" });
2713
3234
  if (!options.dryRun) {
@@ -2908,31 +3429,74 @@ Directory Structure:
2908
3429
  // Create new rule set
2909
3430
  changes.push({ type: "rule-set", action: "create", key: fileKey });
2910
3431
  if (!options.dryRun) {
2911
- let created;
3432
+ let ruleSetId;
3433
+ let ruleSetModifiedAt;
2912
3434
  try {
2913
- created = await client.createRuleSet(resolvedAppId, {
3435
+ const created = await client.createRuleSet(resolvedAppId, {
2914
3436
  name: ruleSetData.name,
2915
3437
  resourceType: ruleSetData.resourceType,
2916
3438
  rules: ruleSetData.rules,
2917
3439
  description: ruleSetData.description,
2918
3440
  });
3441
+ info(` Created rule set: ${fileKey}`);
3442
+ ruleSetId = created.ruleSetId;
3443
+ ruleSetModifiedAt = created.modifiedAt || new Date().toISOString();
2919
3444
  }
2920
3445
  catch (err) {
2921
- throw wrapEntityError(err, "create", "rule set", fileKey);
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();
2922
3487
  }
2923
- info(` Created rule set: ${fileKey}`);
2924
- if (syncState && created?.ruleSetId) {
3488
+ if (syncState && ruleSetId) {
2925
3489
  if (!syncState.entities.ruleSets) {
2926
3490
  syncState.entities.ruleSets = {};
2927
3491
  }
2928
3492
  syncState.entities.ruleSets[fileKey] = {
2929
- id: created.ruleSetId,
2930
- modifiedAt: created.modifiedAt || new Date().toISOString(),
3493
+ id: ruleSetId,
3494
+ modifiedAt: ruleSetModifiedAt,
2931
3495
  contentHash: computeFileHash(filePath),
2932
3496
  };
2933
3497
  }
2934
3498
  // Track name→ID for cross-reference resolution
2935
- ruleSetNameToId.set(ruleSetData.name, created.ruleSetId);
3499
+ ruleSetNameToId.set(ruleSetData.name, ruleSetId);
2936
3500
  }
2937
3501
  else {
2938
3502
  // In dry-run mode, use a placeholder so dependent entities can resolve
@@ -2959,7 +3523,14 @@ Directory Structure:
2959
3523
  const payload = {
2960
3524
  integrationKey: key,
2961
3525
  displayName: integration.displayName || key,
2962
- description: integration.description,
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,
2963
3534
  requestConfig: tomlData.requestConfig || {},
2964
3535
  timeoutMs: integration.timeoutMs,
2965
3536
  status: integration.status,
@@ -3013,7 +3584,35 @@ Directory Structure:
3013
3584
  }
3014
3585
  }
3015
3586
  catch (err) {
3016
- throw wrapEntityError(err, "create", "integration", key);
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
+ }
3017
3616
  }
3018
3617
  }
3019
3618
  }
@@ -3037,7 +3636,12 @@ Directory Structure:
3037
3636
  const payload = {
3038
3637
  webhookKey: key,
3039
3638
  displayName: webhook.displayName || key,
3040
- description: webhook.description,
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,
3041
3645
  workflowKey: webhook.workflowKey,
3042
3646
  verificationScheme: webhook.verificationScheme,
3043
3647
  status: webhook.status,
@@ -3111,7 +3715,35 @@ Directory Structure:
3111
3715
  }
3112
3716
  }
3113
3717
  catch (err) {
3114
- throw wrapEntityError(err, "create", "webhook", key);
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
+ }
3115
3747
  }
3116
3748
  }
3117
3749
  }
@@ -3135,7 +3767,14 @@ Directory Structure:
3135
3767
  const payload = {
3136
3768
  triggerKey: key,
3137
3769
  displayName: cronTrigger.displayName || key,
3138
- description: cronTrigger.description,
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,
3139
3778
  cron: cronTrigger.cron,
3140
3779
  timezone: cronTrigger.timezone,
3141
3780
  workflowKey: cronTrigger.workflowKey,
@@ -3183,57 +3822,38 @@ Directory Structure:
3183
3822
  }
3184
3823
  }
3185
3824
  catch (err) {
3186
- // Issue #976 (fix B): idempotent create — adopt-by-key on 409.
3187
- // A cron trigger can be orphaned on the server (created by a
3188
- // prior push that aborted before recording it in sync state,
3189
- // a mid-apply crash, or an out-of-band create with the same
3190
- // key). On retry this CREATE path then hits the
3191
- // `triggerKeyPerApp` unique constraint and the server returns
3192
- // 409 "A cron trigger with this key already exists". Rather
3193
- // than hard-fail forever, look up the existing trigger by key
3194
- // (the list endpoint is app-scoped, so every item is owned by
3195
- // this app), verify the SAME triggerKey, adopt its id into
3196
- // sync state, and re-issue as an UPDATE so the push converges.
3197
- const msg = String(err?.message || err);
3198
- const is409 = err?.statusCode === 409 || msg.includes("already exists");
3199
- if (is409) {
3200
- info(` Cron trigger already exists on server, adopting by key: ${key}`);
3201
- let adoptedId;
3202
- try {
3203
- const { items } = await client.listCronTriggers(resolvedAppId);
3204
- // Verify same key + app ownership: the list endpoint only
3205
- // returns triggers for `resolvedAppId`, so a triggerKey
3206
- // match is also an ownership match. Never overwrite an
3207
- // unrelated resource require an exact key equality.
3208
- const existing = (items || []).find((t) => t?.triggerKey === key);
3209
- if (existing?.triggerId) {
3210
- adoptedId = existing.triggerId;
3211
- }
3212
- }
3213
- catch (lookupErr) {
3214
- throw wrapEntityError(new Error(`cron trigger "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "cron trigger", key);
3215
- }
3216
- if (!adoptedId) {
3217
- // 409 but no matching key found on the server — surface
3218
- // the original error rather than silently overwriting.
3219
- throw wrapEntityError(err, "create", "cron trigger", key);
3220
- }
3221
- // Switch to UPDATE on the adopted trigger.
3222
- const updated = await client.updateCronTrigger(resolvedAppId, adoptedId, payload);
3223
- info(` Adopted + updated cron trigger: ${key}`);
3224
- if (syncState) {
3225
- if (!syncState.entities.cronTriggers) {
3226
- syncState.entities.cronTriggers = {};
3227
- }
3228
- syncState.entities.cronTriggers[key] = {
3229
- id: adoptedId,
3230
- modifiedAt: updated?.modifiedAt || new Date().toISOString(),
3231
- contentHash: computeFileHash(filePath),
3232
- };
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 = {};
3233
3851
  }
3234
- }
3235
- else {
3236
- throw wrapEntityError(err, "create", "cron trigger", key);
3852
+ syncState.entities.cronTriggers[key] = {
3853
+ id: adoptedId,
3854
+ modifiedAt: updated?.modifiedAt || new Date().toISOString(),
3855
+ contentHash: computeFileHash(filePath),
3856
+ };
3237
3857
  }
3238
3858
  }
3239
3859
  }
@@ -3311,92 +3931,73 @@ Directory Structure:
3311
3931
  }
3312
3932
  }
3313
3933
  catch (err) {
3314
- const msg = String(err?.message || err);
3315
- if (msg.includes("already exists") || err.statusCode === 409) {
3316
- // #1459: adopt-by-key on 409. A blob bucket can already
3317
- // exist on the server (an out-of-band create, or a prior
3318
- // push that aborted before recording it in sync state). The
3319
- // old handler stamped the LOCAL file's contentHash WITHOUT
3320
- // applying the local config, so a divergent bucket was
3321
- // silently marked in-sync and never reconciled. Mirror the
3322
- // cron/workflow adopt paths: look up the bucket by key,
3323
- // re-issue the local payload as an UPDATE, and stamp the
3324
- // hash ONLY after the update succeeds.
3325
- info(` Blob bucket already exists on server, adopting by key: ${key}`);
3326
- let existing;
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.
3327
3976
  try {
3328
- existing = await client.getBlobBucket(resolvedAppId, key);
3329
- }
3330
- catch (lookupErr) {
3331
- // A lookup failure after a 409 means we cannot adopt —
3332
- // surface it instead of swallowing it (the old silent
3333
- // `catch {}` left no sync-state entry, looping
3334
- // create→409 on every push).
3335
- throw wrapEntityError(new Error(`blob bucket "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "blob bucket", key);
3336
- }
3337
- if (!existing?.bucketId) {
3338
- // 409 but no matching bucket found — surface the original
3339
- // create error rather than silently proceeding.
3340
- throw wrapEntityError(err, "create", "blob bucket", key);
3977
+ updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3341
3978
  }
3342
- // Verify the resolved bucket is actually the one we asked
3343
- // for. `getBlobBucket` resolves the identifier by primary id
3344
- // (appId, bucketId) BEFORE bucketKey, and a bucketKey may be
3345
- // a ULID-shaped string (the key regex permits it). If the
3346
- // local key collides with an unrelated bucket's bucketId,
3347
- // the lookup returns that unrelated bucket — patching and
3348
- // stamping it would overwrite the wrong resource. Require an
3349
- // exact key match, matching the cron/workflow adopt paths'
3350
- // exact-key equality (they list + filter by key for the same
3351
- // reason). Surface the mismatch via wrapEntityError; do NOT
3352
- // patch or stamp.
3353
- if (existing.bucketKey !== key) {
3354
- 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);
3355
- }
3356
- // ttlTier is immutable and cannot be reconciled by an
3357
- // UPDATE. A local TOML whose retention tier diverges from
3358
- // the server must fail loudly (sponsor decision) rather
3359
- // than be silently blessed while the cosmetic fields
3360
- // converge.
3361
- if (bucket.ttlTier &&
3362
- existing.ttlTier &&
3363
- bucket.ttlTier !== existing.ttlTier) {
3364
- 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);
3365
- }
3366
- const updatePayload = buildBlobBucketUpdatePayload(bucket);
3367
- let updated;
3368
- if (Object.keys(updatePayload).length > 0) {
3369
- // Surface an update failure via wrapEntityError (no
3370
- // stamp) — matches the cron adopt path.
3371
- try {
3372
- updated = await client.updateBlobBucket(resolvedAppId, existing.bucketId, updatePayload);
3373
- }
3374
- catch (updateErr) {
3375
- throw wrapEntityError(updateErr, "update", "blob bucket", key);
3376
- }
3377
- info(` Adopted + updated blob bucket: ${key}`);
3378
- }
3379
- else {
3380
- // No mutable fields in the local TOML — the server
3381
- // rejects an empty update (400). Adopt the existing
3382
- // bucket without a PATCH.
3383
- info(` Adopted blob bucket (no updatable fields): ${key}`);
3384
- }
3385
- if (syncState) {
3386
- if (!syncState.entities.blobBuckets) {
3387
- syncState.entities.blobBuckets = {};
3388
- }
3389
- syncState.entities.blobBuckets[key] = {
3390
- id: existing.bucketId,
3391
- modifiedAt: updated?.modifiedAt ||
3392
- existing.modifiedAt ||
3393
- new Date().toISOString(),
3394
- contentHash: computeFileHash(filePath),
3395
- };
3979
+ catch (updateErr) {
3980
+ throw wrapEntityError(updateErr, "update", "blob bucket", key);
3396
3981
  }
3982
+ info(` Adopted + updated blob bucket: ${key}`);
3397
3983
  }
3398
3984
  else {
3399
- throw wrapEntityError(err, "create", "blob bucket", key);
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
+ };
3400
4001
  }
3401
4002
  }
3402
4003
  }
@@ -3430,6 +4031,88 @@ Directory Structure:
3430
4031
  }
3431
4032
  continue;
3432
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
+ };
3433
4116
  if (existingId) {
3434
4117
  // Update existing prompt
3435
4118
  changes.push({ type: "prompt", action: "update", key });
@@ -3439,66 +4122,7 @@ Directory Structure:
3439
4122
  ? undefined
3440
4123
  : syncState?.entities?.prompts?.[key]?.modifiedAt;
3441
4124
  try {
3442
- const updated = await client.updatePrompt(resolvedAppId, existingId, {
3443
- displayName: prompt.displayName,
3444
- description: prompt.description,
3445
- inputSchema: prompt.inputSchema,
3446
- status: prompt.status,
3447
- }, expectedModifiedAt);
3448
- info(` Updated prompt: ${key}`);
3449
- // Update sync state with new modifiedAt
3450
- if (syncState?.entities?.prompts?.[key] && updated?.modifiedAt) {
3451
- syncState.entities.prompts[key].modifiedAt = updated.modifiedAt;
3452
- syncState.entities.prompts[key].contentHash = computeFileHash(filePath);
3453
- }
3454
- // Fetch full prompt to get config name→ID mappings
3455
- // (updatePrompt response doesn't include configs)
3456
- const fullPrompt = await client.getPrompt(resolvedAppId, existingId);
3457
- if (fullPrompt?.configs) {
3458
- for (const config of fullPrompt.configs) {
3459
- promptConfigNameToId.set(`${key}#${config.configName}`, config.configId);
3460
- }
3461
- }
3462
- // Update existing prompt configs with TOML values
3463
- if (fullPrompt?.configs && configs.length > 0) {
3464
- for (const tomlConfig of configs) {
3465
- const configName = tomlConfig.name;
3466
- if (!configName)
3467
- continue;
3468
- const serverConfig = fullPrompt.configs.find((c) => c.configName === configName);
3469
- if (serverConfig) {
3470
- // Update existing config
3471
- await client.updatePromptConfig(resolvedAppId, existingId, serverConfig.configId, {
3472
- description: tomlConfig.description,
3473
- provider: tomlConfig.provider,
3474
- model: tomlConfig.model,
3475
- systemPrompt: tomlConfig.systemPrompt,
3476
- userPromptTemplate: tomlConfig.userPromptTemplate,
3477
- temperature: tomlConfig.temperature,
3478
- maxTokens: tomlConfig.maxTokens,
3479
- outputFormat: tomlConfig.outputFormat,
3480
- });
3481
- }
3482
- else {
3483
- // Create new config that doesn't exist on server yet
3484
- const newConfig = await client.createPromptConfig(resolvedAppId, existingId, {
3485
- configName,
3486
- description: tomlConfig.description,
3487
- provider: tomlConfig.provider || "openrouter",
3488
- model: tomlConfig.model || "google/gemini-2.0-flash-001",
3489
- systemPrompt: tomlConfig.systemPrompt,
3490
- userPromptTemplate: tomlConfig.userPromptTemplate,
3491
- temperature: tomlConfig.temperature,
3492
- maxTokens: tomlConfig.maxTokens,
3493
- outputFormat: tomlConfig.outputFormat,
3494
- });
3495
- if (newConfig?.configId) {
3496
- promptConfigNameToId.set(`${key}#${configName}`, newConfig.configId);
3497
- }
3498
- }
3499
- }
3500
- info(` Synced ${configs.length} config(s) for prompt: ${key}`);
3501
- }
4125
+ await applyPromptUpdate(existingId, expectedModifiedAt);
3502
4126
  }
3503
4127
  catch (err) {
3504
4128
  if (err instanceof ConflictError) {
@@ -3520,8 +4144,19 @@ Directory Structure:
3520
4144
  const firstConfig = configs[0] || {};
3521
4145
  changes.push({ type: "prompt", action: "create", key });
3522
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;
3523
4158
  try {
3524
- const created = await client.createPrompt(resolvedAppId, {
4159
+ created = await client.createPrompt(resolvedAppId, {
3525
4160
  promptKey: key,
3526
4161
  displayName: prompt.displayName || key,
3527
4162
  description: prompt.description,
@@ -3535,17 +4170,73 @@ Directory Structure:
3535
4170
  inputSchema: prompt.inputSchema,
3536
4171
  });
3537
4172
  info(` Created prompt: ${key}`);
3538
- // Add new entity to sync state
3539
- if (syncState && created?.promptId && created?.modifiedAt) {
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) {
3540
4198
  if (!syncState.entities.prompts) {
3541
4199
  syncState.entities.prompts = {};
3542
4200
  }
3543
- syncState.entities.prompts[key] = {
3544
- id: created.promptId,
3545
- modifiedAt: created.modifiedAt,
3546
- contentHash: computeFileHash(filePath),
3547
- };
4201
+ if (!syncState.entities.prompts[key]) {
4202
+ syncState.entities.prompts[key] = { id: adoptedId };
4203
+ }
4204
+ else {
4205
+ syncState.entities.prompts[key].id = adoptedId;
4206
+ }
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
+ }
3548
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) {
3549
4240
  // Track prompt key→ID and config name→ID
3550
4241
  if (created?.promptId) {
3551
4242
  promptKeyToId.set(key, created.promptId);
@@ -3581,9 +4272,22 @@ Directory Structure:
3581
4272
  }
3582
4273
  info(` Created ${configs.length - 1} additional config(s) for prompt: ${key}`);
3583
4274
  }
3584
- }
3585
- catch (err) {
3586
- throw wrapEntityError(err, "create", "prompt", key);
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
+ }
3587
4291
  }
3588
4292
  }
3589
4293
  }
@@ -3728,6 +4432,19 @@ Directory Structure:
3728
4432
  for (const file of files) {
3729
4433
  const filePath = join(workflowsDir, file);
3730
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
+ }
3731
4448
  // Issue #685: reject misnested headers (e.g.
3732
4449
  // [steps.<id>.request]) before pushing. The runtime silently
3733
4450
  // ignores fields outside the allowlist, so this is the only
@@ -3759,13 +4476,39 @@ Directory Structure:
3759
4476
  throw new Error(`${filePath}: ${workflowManifestError}`);
3760
4477
  }
3761
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;
3762
4495
  const existingId = syncState?.entities?.workflows?.[key]?.id;
3763
4496
  const existingActiveConfigId = syncState?.entities?.workflows?.[key]?.activeConfigId;
3764
4497
  // Skip if file hasn't changed since last sync. Use the expanded
3765
4498
  // content hash (post fragment splice) so that edits to included
3766
4499
  // `workflow-fragments/*.toml` files invalidate the push-skip cache
3767
4500
  // for any workflow that references them. See `computeExpandedContentHash`.
3768
- if (!options.force && existingId && !shouldPushExpandedFile(tomlData, syncState?.entities?.workflows?.[key]?.contentHash)) {
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)) {
3769
4512
  skippedCount++;
3770
4513
  // Only fetch config name→ID mappings if test cases exist for this workflow
3771
4514
  const workflowTestsDir = getTestsDir(configDir, "workflow", key);
@@ -3808,7 +4551,15 @@ Directory Structure:
3808
4551
  // the entry exists for both call sites.
3809
4552
  if (syncState?.entities?.workflows?.[key] && latestModifiedAt) {
3810
4553
  syncState.entities.workflows[key].modifiedAt = latestModifiedAt;
3811
- syncState.entities.workflows[key].contentHash = computeExpandedContentHash(tomlData);
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));
3812
4563
  }
3813
4564
  // `applyWorkflowBody` already fetched the full workflow; reuse it
3814
4565
  // for the config name→ID mappings (the PATCH response omits configs).
@@ -3867,6 +4618,8 @@ Directory Structure:
3867
4618
  // #1304 (P-C): declared-access manifest (absent → undefined,
3868
4619
  // server default null).
3869
4620
  metadataManifest: workflow.metadataManifest ?? undefined,
4621
+ // #1614 — named `expr.*` definitions (absent → undefined).
4622
+ expressions: workflow.expressions ?? undefined,
3870
4623
  });
3871
4624
  info(` Created workflow: ${key}`);
3872
4625
  // Add new entity to sync state (including activeConfigId)
@@ -3878,7 +4631,11 @@ Directory Structure:
3878
4631
  id: created.workflow.workflowId,
3879
4632
  modifiedAt: created.workflow.modifiedAt,
3880
4633
  activeConfigId: created.workflow.activeConfigId,
3881
- contentHash: computeExpandedContentHash(tomlData),
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)),
3882
4639
  };
3883
4640
  }
3884
4641
  // Track config name→ID mappings
@@ -3897,6 +4654,11 @@ Directory Structure:
3897
4654
  configName: extraConfig.name || `config-${i + 1}`,
3898
4655
  description: extraConfig.description,
3899
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,
3900
4662
  });
3901
4663
  if (extraCreated?.configId) {
3902
4664
  const configName = extraConfig.name || `config-${i + 1}`;
@@ -3915,49 +4677,31 @@ Directory Structure:
3915
4677
  }
3916
4678
  }
3917
4679
  catch (err) {
3918
- // Issue #1174: idempotent create — adopt-by-key on conflict.
3919
- // A workflow can exist on the server but be absent from local
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
3920
4683
  // sync state — e.g. created out-of-band via the admin API
3921
4684
  // (#971), or orphaned by a prior push that aborted before
3922
4685
  // recording it. On retry this CREATE path hits the
3923
- // `workflowKeyPerApp` unique constraint and the server
3924
- // returns "workflowKey already exists" (HTTP 400; cron uses
3925
- // 409). Rather than abort the whole push, look up the
3926
- // existing workflow by key (the list endpoint is app-scoped,
3927
- // so every item is owned by this app), verify the SAME key,
3928
- // adopt its id into sync state, and re-issue as an UPDATE so
3929
- // the push converges. Mirrors the cron-trigger recovery at
3930
- // ~sync.ts:2627. Unexpected errors still surface via
3931
- // `wrapEntityError`, matching the db-type precedent.
3932
- const msg = String(err?.message || err);
3933
- const isConflict = err?.statusCode === 409 || msg.includes("already exists");
3934
- if (!isConflict) {
3935
- throw wrapEntityError(err, "create", "workflow", key);
3936
- }
3937
- info(` Workflow already exists on server, adopting by key: ${key}`);
3938
- let adoptedId;
3939
- try {
3940
- // Use fetchAll — listWorkflows is paginated, so a single
3941
- // page could miss the by-key match on a large app.
3942
- const items = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
3943
- // Verify same key + app ownership: the list endpoint only
3944
- // returns workflows for `resolvedAppId`, so a workflowKey
3945
- // match is also an ownership match. Never overwrite an
3946
- // unrelated resource — require exact key equality.
3947
- const existing = (items || []).find((w) => w?.workflowKey === key);
3948
- if (existing?.workflowId) {
3949
- adoptedId = existing.workflowId;
3950
- }
3951
- }
3952
- catch (lookupErr) {
3953
- throw wrapEntityError(new Error(`workflow "${key}" already exists but could not be adopted (lookup failed: ${String(lookupErr?.message || lookupErr)})`), "create", "workflow", key);
3954
- }
3955
- if (!adoptedId) {
3956
- // "already exists" but no matching key found on the server —
3957
- // surface the original error rather than silently
3958
- // overwriting an unrelated workflow.
3959
- throw wrapEntityError(err, "create", "workflow", key);
3960
- }
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;
3961
4705
  // Out-of-manifest adopt: warn (info-level), matching the cron
3962
4706
  // adopt line, and seed the sync-state entity record so the
3963
4707
  // shared update body can write modifiedAt/contentHash into it.
@@ -4116,30 +4860,19 @@ Directory Structure:
4116
4860
  // Build the type-level `updateData` (same logic as the apply path
4117
4861
  // below) so the validate pass and the apply pass agree on whether
4118
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).
4119
4874
  const computeTypeUpdateData = () => {
4120
- const u = {};
4121
- if ("ruleSetId" in typeConfig)
4122
- u.ruleSetId = typeConfig.ruleSetId || null;
4123
- if ("triggers" in typeConfig)
4124
- u.triggers = typeConfig.triggers || null;
4125
- if ("metadataAccess" in typeConfig)
4126
- u.metadataAccess = typeConfig.metadataAccess || null;
4127
- if ("defaultAccess" in typeConfig)
4128
- u.defaultAccess = typeConfig.defaultAccess || null;
4129
- if ("autoPopulatedFields" in typeConfig) {
4130
- u.autoPopulatedFields = typeConfig.autoPopulatedFields || null;
4131
- }
4132
- if ("timestamps" in typeConfig) {
4133
- u.timestamps = typeConfig.timestamps || null;
4134
- }
4135
- // Issue #1304 (P-C): declared-access manifest. `parseDatabaseTypeToml`
4136
- // only sets `metadataManifest` when the TOML declares a `[metadata]`
4137
- // / `secrets` block, so this forwards a set/update; clearing a
4138
- // manifest by removing the block is a follow-on (mirrors the
4139
- // schema-deletion tracking).
4140
- if ("metadataManifest" in typeConfig) {
4141
- u.metadataManifest = typeConfig.metadataManifest || null;
4142
- }
4875
+ const u = buildOwnedScalarUpdate(typeConfig);
4143
4876
  const localHasSchema = typeof typeConfig.schema === "string" &&
4144
4877
  typeConfig.schema.trim().length > 0;
4145
4878
  if (localHasSchema) {
@@ -4150,6 +4883,43 @@ Directory Structure:
4150
4883
  }
4151
4884
  return u;
4152
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
+ };
4153
4923
  // Validate-first pass (decision 4a): run every gate for this type
4154
4924
  // BEFORE issuing any mutating call. If a gate fails, the whole
4155
4925
  // type's mutating calls (op creates/updates/deletes AND the schema
@@ -4169,11 +4939,16 @@ Directory Structure:
4169
4939
  try {
4170
4940
  // 1. Schema-edit gate (type-config PATCH dry-run) — only when the
4171
4941
  // type already exists on the server and we'd actually PATCH a
4172
- // schema-relevant field. A fresh type (no existingEntry) has no
4942
+ // type-level field. A fresh type (no existingEntry) has no
4173
4943
  // ops to break, so there's nothing to gate yet.
4174
- if (existingEntry) {
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) {
4175
4950
  const validateUpdateData = computeTypeUpdateData();
4176
- if (Object.keys(validateUpdateData).length > 0) {
4951
+ {
4177
4952
  await client.updateDatabaseTypeConfig(resolvedAppId, dbType, pendingOpDeletes.length > 0
4178
4953
  ? { ...validateUpdateData, pendingOpDeletes, finalOpNames }
4179
4954
  : validateUpdateData, validateExpectedModifiedAt, {
@@ -4376,15 +5151,11 @@ Directory Structure:
4376
5151
  // labels (so dry-run still shows what WOULD change) but skip ALL
4377
5152
  // mutating calls for this type.
4378
5153
  if (typeGateBlocked) {
4379
- const wouldUpdate = "ruleSetId" in typeConfig ||
4380
- "triggers" in typeConfig ||
4381
- "metadataAccess" in typeConfig ||
4382
- "defaultAccess" in typeConfig ||
4383
- "autoPopulatedFields" in typeConfig ||
4384
- "timestamps" in typeConfig ||
4385
- "schema" in typeConfig;
4386
5154
  if (existingEntry) {
4387
- if (wouldUpdate) {
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) {
4388
5159
  changes.push({ type: "database-type", action: "update", key: dbType });
4389
5160
  }
4390
5161
  }
@@ -4405,10 +5176,15 @@ Directory Structure:
4405
5176
  continue;
4406
5177
  }
4407
5178
  if (existingEntry) {
4408
- // Update existing type config — only if there are type-level fields to update.
4409
- // Operations are handled separately below, so skipping the PATCH here when
4410
- // updateData is empty avoids sending an empty body (which the server rejects
4411
- // 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).
4412
5188
  if (!options.dryRun) {
4413
5189
  const expectedModifiedAt = options.force
4414
5190
  ? undefined
@@ -4418,11 +5194,8 @@ Directory Structure:
4418
5194
  // one (a set/update), OR when the server had one at last
4419
5195
  // sync and the local file no longer does (a deletion —
4420
5196
  // `schema: null` clears it server-side; codex review gap on
4421
- // PR #766). When the type never had a schema and still
4422
- // doesn't, omit it so an operations-only edit doesn't
4423
- // register as an empty type-level update (issue #369). Built
4424
- // by the shared helper so the validate pass and apply pass
4425
- // agree (issue #813).
5197
+ // PR #766). Built by the shared helper so the validate pass
5198
+ // and apply pass agree (issue #813).
4426
5199
  const updateData = computeTypeUpdateData();
4427
5200
  // Issue #813 (2B): forward the ops being deleted in this push
4428
5201
  // so the schema-edit / OPS_EXIST gates evaluate against the
@@ -4436,13 +5209,17 @@ Directory Structure:
4436
5209
  // exclusion (see derivation above).
4437
5210
  updateData.finalOpNames = finalOpNames;
4438
5211
  }
4439
- if (Object.keys(updateData).filter((k) => k !== "pendingOpDeletes" && k !== "finalOpNames").length > 0) {
5212
+ if (typeLevelChange) {
4440
5213
  changes.push({ type: "database-type", action: "update", key: dbType });
4441
5214
  const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
4442
5215
  dryRun: false,
4443
5216
  acceptWarnings: !!options.acceptWarnings,
4444
5217
  });
4445
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 ");
4446
5223
  printStaleInstanceWarning(updated);
4447
5224
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
4448
5225
  syncState.entities.databaseTypes[dbType].modifiedAt = updated.modifiedAt;
@@ -4502,17 +5279,12 @@ Directory Structure:
4502
5279
  }
4503
5280
  }
4504
5281
  else {
4505
- // In dry-run mode, still report the change iff we would actually PATCH.
4506
- const wouldUpdate = "ruleSetId" in typeConfig ||
4507
- "triggers" in typeConfig ||
4508
- "metadataAccess" in typeConfig ||
4509
- "defaultAccess" in typeConfig ||
4510
- "autoPopulatedFields" in typeConfig ||
4511
- "timestamps" in typeConfig ||
4512
- "schema" in typeConfig ||
4513
- "metadataManifest" in typeConfig;
4514
- 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) {
4515
5286
  changes.push({ type: "database-type", action: "update", key: dbType });
5287
+ reportClearedFields("Would clear ");
4516
5288
  }
4517
5289
  }
4518
5290
  }
@@ -5000,7 +5772,15 @@ Directory Structure:
5000
5772
  : existingEntry.modifiedAt;
5001
5773
  try {
5002
5774
  const updated = await client.updateGroupTypeConfig(resolvedAppId, groupType, {
5003
- ruleSetId: configData.ruleSetId,
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.
5004
5784
  autoAddCreator: configData.autoAddCreator,
5005
5785
  // Issue #1350: null clears the manifest server-side when
5006
5786
  // the TOML drops the `[metadata]` block (matches the
@@ -5053,7 +5833,53 @@ Directory Structure:
5053
5833
  }
5054
5834
  }
5055
5835
  catch (err) {
5056
- throw wrapEntityError(err, "create", "group type config", groupType);
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
+ }
5057
5883
  }
5058
5884
  }
5059
5885
  }
@@ -5133,7 +5959,41 @@ Directory Structure:
5133
5959
  }
5134
5960
  }
5135
5961
  catch (err) {
5136
- throw wrapEntityError(err, "create", "collection type config", collectionType);
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
+ }
5137
5997
  }
5138
5998
  }
5139
5999
  }
@@ -5295,6 +6155,189 @@ Directory Structure:
5295
6155
  skippedCount += await pushTestCasesForBlock(client, resolvedAppId, "script", blockId, scriptName, configDir, syncState, options.dryRun, changes, pushMaps, { force: options.force });
5296
6156
  }
5297
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
+ }
5298
6341
  divider();
5299
6342
  // Issue #813: a gate failure surfaced by the validate-first pass (which
5300
6343
  // also runs in dry-run mode via the server's dry-run gate endpoints).
@@ -5413,33 +6456,106 @@ Directory Structure:
5413
6456
  const syncState = loadSyncState(configDir);
5414
6457
  info(`Comparing local configuration with app ${resolvedAppId}...`);
5415
6458
  try {
5416
- // Fetch remote state
5417
- const [integrationItems, promptItems, workflowItems, emailTemplatesResult] = await Promise.all([
5418
- fetchAll((p) => client.listIntegrations(resolvedAppId, p)),
5419
- fetchAll((p) => client.listPrompts(resolvedAppId, p)),
5420
- fetchAll((p) => client.listWorkflows(resolvedAppId, p)),
5421
- client.listEmailTemplates(resolvedAppId).catch(() => ({ templates: [] })),
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)),
5422
6493
  ]);
5423
- const webhookItems = await fetchAll((p) => client.listWebhooks(resolvedAppId, p));
5424
- let cronTriggerItemsDiff = [];
5425
- try {
5426
- const cronResult = await client.listCronTriggers(resolvedAppId);
5427
- cronTriggerItemsDiff = cronResult.items || [];
5428
- }
5429
- catch {
5430
- // Cron triggers may not be available on older servers
5431
- }
5432
- const blobBucketsDiffResult = await client.listBlobBuckets(resolvedAppId).catch(() => ({ items: [] }));
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;
5433
6522
  const remoteIntegrations = new Set(integrationItems.map((i) => i.integrationKey));
5434
6523
  const remoteWebhooks = new Set(webhookItems.map((w) => w.webhookKey));
5435
6524
  const remoteCronTriggers = new Set(cronTriggerItemsDiff.map((t) => t.triggerKey));
5436
6525
  const remoteBlobBuckets = new Set((blobBucketsDiffResult.items || []).map((b) => b.bucketKey));
5437
6526
  const remotePrompts = new Set(promptItems.map((p) => p.promptKey));
5438
- const remoteWorkflows = new Set(workflowItems.map((w) => w.workflowKey));
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));
5439
6535
  const remoteEmailTemplates = new Set((emailTemplatesResult.templates || [])
5440
6536
  .filter((t) => t.hasOverride)
5441
6537
  .map((t) => t.emailType));
5442
- // Get local files
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:`.
5443
6559
  const localIntegrations = new Set();
5444
6560
  const localWebhooks = new Set();
5445
6561
  const localCronTriggers = new Set();
@@ -5447,8 +6563,14 @@ Directory Structure:
5447
6563
  const localPrompts = new Set();
5448
6564
  const localWorkflows = new Set();
5449
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();
5450
6572
  const integrationsDir = join(configDir, "integrations");
5451
- if (existsSync(integrationsDir)) {
6573
+ if (integrationsOutcome.ok && existsSync(integrationsDir)) {
5452
6574
  for (const file of readdirSync(integrationsDir).filter((f) => f.endsWith(".toml"))) {
5453
6575
  const tomlData = parseTomlFile(join(integrationsDir, file));
5454
6576
  const key = tomlData.integration?.key || basename(file, ".toml");
@@ -5456,7 +6578,7 @@ Directory Structure:
5456
6578
  }
5457
6579
  }
5458
6580
  const webhooksDirPath = join(configDir, "webhooks");
5459
- if (existsSync(webhooksDirPath)) {
6581
+ if (webhooksOutcome.ok && existsSync(webhooksDirPath)) {
5460
6582
  for (const file of readdirSync(webhooksDirPath).filter((f) => f.endsWith(".toml"))) {
5461
6583
  const tomlData = parseTomlFile(join(webhooksDirPath, file));
5462
6584
  const key = tomlData.webhook?.key || basename(file, ".toml");
@@ -5464,7 +6586,7 @@ Directory Structure:
5464
6586
  }
5465
6587
  }
5466
6588
  const cronTriggersDirPath = join(configDir, "cron-triggers");
5467
- if (existsSync(cronTriggersDirPath)) {
6589
+ if (cronTriggersOutcomeDiff.ok && existsSync(cronTriggersDirPath)) {
5468
6590
  for (const file of readdirSync(cronTriggersDirPath).filter((f) => f.endsWith(".toml"))) {
5469
6591
  const tomlData = parseTomlFile(join(cronTriggersDirPath, file));
5470
6592
  const key = tomlData.cronTrigger?.key || basename(file, ".toml");
@@ -5472,7 +6594,7 @@ Directory Structure:
5472
6594
  }
5473
6595
  }
5474
6596
  const blobBucketsDiffDir = join(configDir, "blob-buckets");
5475
- if (existsSync(blobBucketsDiffDir)) {
6597
+ if (blobBucketsOutcomeDiff.ok && existsSync(blobBucketsDiffDir)) {
5476
6598
  for (const file of readdirSync(blobBucketsDiffDir).filter((f) => f.endsWith(".toml"))) {
5477
6599
  const tomlData = parseTomlFile(join(blobBucketsDiffDir, file));
5478
6600
  const key = tomlData.bucket?.key || basename(file, ".toml");
@@ -5480,77 +6602,163 @@ Directory Structure:
5480
6602
  }
5481
6603
  }
5482
6604
  const promptsDir = join(configDir, "prompts");
5483
- if (existsSync(promptsDir)) {
6605
+ if (promptsOutcome.ok && existsSync(promptsDir)) {
5484
6606
  for (const file of readdirSync(promptsDir).filter((f) => f.endsWith(".toml"))) {
5485
6607
  const tomlData = parseTomlFile(join(promptsDir, file));
5486
6608
  const key = tomlData.prompt?.key || basename(file, ".toml");
5487
6609
  localPrompts.add(key);
5488
6610
  }
5489
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
+ }
5490
6711
  // #1175: capture the parsed (fragment-expanded) local workflow TOML
5491
6712
  // per key so the content-aware comparison below can hash it without a
5492
6713
  // second read.
5493
6714
  const localWorkflowParsed = new Map();
5494
6715
  const workflowsDir = join(configDir, "workflows");
5495
- if (existsSync(workflowsDir)) {
6716
+ if (workflowsOutcome.ok && existsSync(workflowsDir)) {
5496
6717
  for (const file of readdirSync(workflowsDir).filter((f) => f.endsWith(".toml"))) {
5497
6718
  const tomlData = parseTomlFile(join(workflowsDir, file));
5498
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;
5499
6726
  localWorkflows.add(key);
5500
6727
  localWorkflowParsed.set(key, tomlData);
5501
6728
  }
5502
6729
  }
5503
6730
  const emailTemplatesDirPath = join(configDir, "email-templates");
5504
- if (existsSync(emailTemplatesDirPath)) {
6731
+ if (emailTemplatesOutcomeDiff.ok && existsSync(emailTemplatesDirPath)) {
5505
6732
  for (const file of readdirSync(emailTemplatesDirPath).filter((f) => f.endsWith(".toml"))) {
5506
6733
  const tomlData = parseTomlFile(join(emailTemplatesDirPath, file));
5507
6734
  const emailType = tomlData.template?.emailType || basename(file, ".toml");
5508
6735
  localEmailTemplates.add(emailType);
5509
6736
  }
5510
6737
  }
5511
- // Transforms (Rhai scripts) — issue #1196. Before this, `sync diff`
5512
- // ignored scripts entirely, so a body that drifted between the local
5513
- // `.rhai` and the server's active config silently read as Synced. We
5514
- // list the server scripts (header only) and read the local file
5515
- // contents so the comparison below can hash both sides.
5516
- let scriptItemsDiff = [];
5517
- try {
5518
- const scriptsResult = await client.listScripts(resolvedAppId);
5519
- scriptItemsDiff = scriptsResult.items || [];
5520
- }
5521
- catch {
5522
- // Older server without the scripts route — treat as no scripts.
5523
- }
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.
5524
6742
  const remoteScripts = new Map(scriptItemsDiff
5525
6743
  .filter((s) => s?.name)
5526
6744
  .map((s) => [s.name, s]));
5527
6745
  const localScripts = new Map();
5528
6746
  const transformsDirPath = join(configDir, "transforms");
5529
- if (existsSync(transformsDirPath)) {
6747
+ if (scriptsOutcomeDiff.ok && existsSync(transformsDirPath)) {
5530
6748
  for (const file of readdirSync(transformsDirPath).filter((f) => f.endsWith(".rhai"))) {
5531
6749
  const name = basename(file, ".rhai");
5532
6750
  localScripts.set(name, readFileSync(join(transformsDirPath, file), "utf-8"));
5533
6751
  }
5534
6752
  }
5535
- // Config vars — issue #1423. Before this, `sync diff` ignored vars
5536
- // entirely, so an add/remove/value-drift between the local `vars.toml`
5537
- // and the server silently read as no difference right before a push
5538
- // would change or unset them. Fetch the remote vars (value included —
5539
- // vars are non-secret) and read the local `vars.toml` so the comparison
5540
- // below can report added/removed/modified vars.
5541
- let remoteVarItemsDiff = [];
5542
- try {
5543
- remoteVarItemsDiff = await client.listAppConfigVars(resolvedAppId);
5544
- }
5545
- catch {
5546
- // Older server without the vars route — treat as no remote vars.
5547
- }
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.
5548
6756
  const remoteVars = new Map(remoteVarItemsDiff
5549
6757
  .filter((v) => typeof v?.key === "string")
5550
6758
  .map((v) => [v.key, String(v.value ?? "")]));
5551
6759
  const localVars = new Map();
5552
6760
  const varsTomlPathDiff = join(configDir, "vars.toml");
5553
- if (existsSync(varsTomlPathDiff)) {
6761
+ if (varsOutcomeDiff.ok && existsSync(varsTomlPathDiff)) {
5554
6762
  const parsed = parseTomlFile(varsTomlPathDiff);
5555
6763
  for (const [key, rawValue] of Object.entries(parsed)) {
5556
6764
  if (typeof rawValue === "string")
@@ -5559,10 +6767,25 @@ Directory Structure:
5559
6767
  }
5560
6768
  // Compare
5561
6769
  const differences = [];
6770
+ // Issue #1659: a local file with no server entity means one of two very
6771
+ // different things, and the operator needs to tell them apart. If a
6772
+ // prior pull managed the key, the entity was deleted server-side and
6773
+ // this stale file will RE-create it on the next push.
6774
+ // If it was never pulled, the operator authored it and push creating it
6775
+ // is the point. Prior sync state is the discriminator, exactly as it is
6776
+ // for the pull-side prune.
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);
5562
6785
  // Integrations
5563
6786
  for (const key of localIntegrations) {
5564
6787
  if (!remoteIntegrations.has(key)) {
5565
- differences.push({ type: "integration", key, status: "local only" });
6788
+ differences.push({ type: "integration", key, status: localOnlyStatus("integrations", key) });
5566
6789
  }
5567
6790
  else {
5568
6791
  differences.push({ type: "integration", key, status: "exists" });
@@ -5570,13 +6793,13 @@ Directory Structure:
5570
6793
  }
5571
6794
  for (const key of remoteIntegrations) {
5572
6795
  if (!localIntegrations.has(key)) {
5573
- differences.push({ type: "integration", key, status: "remote only" });
6796
+ differences.push({ type: "integration", key, status: remoteOnlyStatus("integrations", key) });
5574
6797
  }
5575
6798
  }
5576
6799
  // Webhooks
5577
6800
  for (const key of localWebhooks) {
5578
6801
  if (!remoteWebhooks.has(key)) {
5579
- differences.push({ type: "webhook", key, status: "local only" });
6802
+ differences.push({ type: "webhook", key, status: localOnlyStatus("webhooks", key) });
5580
6803
  }
5581
6804
  else {
5582
6805
  differences.push({ type: "webhook", key, status: "exists" });
@@ -5584,13 +6807,13 @@ Directory Structure:
5584
6807
  }
5585
6808
  for (const key of remoteWebhooks) {
5586
6809
  if (!localWebhooks.has(key)) {
5587
- differences.push({ type: "webhook", key, status: "remote only" });
6810
+ differences.push({ type: "webhook", key, status: remoteOnlyStatus("webhooks", key) });
5588
6811
  }
5589
6812
  }
5590
6813
  // Cron Triggers
5591
6814
  for (const key of localCronTriggers) {
5592
6815
  if (!remoteCronTriggers.has(key)) {
5593
- differences.push({ type: "cron-trigger", key, status: "local only" });
6816
+ differences.push({ type: "cron-trigger", key, status: localOnlyStatus("cronTriggers", key) });
5594
6817
  }
5595
6818
  else {
5596
6819
  differences.push({ type: "cron-trigger", key, status: "exists" });
@@ -5598,13 +6821,13 @@ Directory Structure:
5598
6821
  }
5599
6822
  for (const key of remoteCronTriggers) {
5600
6823
  if (!localCronTriggers.has(key)) {
5601
- differences.push({ type: "cron-trigger", key, status: "remote only" });
6824
+ differences.push({ type: "cron-trigger", key, status: remoteOnlyStatus("cronTriggers", key) });
5602
6825
  }
5603
6826
  }
5604
6827
  // Blob Buckets
5605
6828
  for (const key of localBlobBuckets) {
5606
6829
  if (!remoteBlobBuckets.has(key)) {
5607
- differences.push({ type: "blob-bucket", key, status: "local only" });
6830
+ differences.push({ type: "blob-bucket", key, status: localOnlyStatus("blobBuckets", key) });
5608
6831
  }
5609
6832
  else {
5610
6833
  differences.push({ type: "blob-bucket", key, status: "exists" });
@@ -5612,13 +6835,13 @@ Directory Structure:
5612
6835
  }
5613
6836
  for (const key of remoteBlobBuckets) {
5614
6837
  if (!localBlobBuckets.has(key)) {
5615
- differences.push({ type: "blob-bucket", key, status: "remote only" });
6838
+ differences.push({ type: "blob-bucket", key, status: remoteOnlyStatus("blobBuckets", key) });
5616
6839
  }
5617
6840
  }
5618
6841
  // Prompts
5619
6842
  for (const key of localPrompts) {
5620
6843
  if (!remotePrompts.has(key)) {
5621
- differences.push({ type: "prompt", key, status: "local only" });
6844
+ differences.push({ type: "prompt", key, status: localOnlyStatus("prompts", key) });
5622
6845
  }
5623
6846
  else {
5624
6847
  differences.push({ type: "prompt", key, status: "exists" });
@@ -5626,7 +6849,7 @@ Directory Structure:
5626
6849
  }
5627
6850
  for (const key of remotePrompts) {
5628
6851
  if (!localPrompts.has(key)) {
5629
- differences.push({ type: "prompt", key, status: "remote only" });
6852
+ differences.push({ type: "prompt", key, status: remoteOnlyStatus("prompts", key) });
5630
6853
  }
5631
6854
  }
5632
6855
  // Workflows — #1175: content-aware comparison.
@@ -5650,7 +6873,7 @@ Directory Structure:
5650
6873
  const remoteWorkflowIds = new Map(workflowItems.map((w) => [w.workflowKey, w.workflowId]));
5651
6874
  for (const key of localWorkflows) {
5652
6875
  if (!remoteWorkflows.has(key)) {
5653
- differences.push({ type: "workflow", key, status: "local only" });
6876
+ differences.push({ type: "workflow", key, status: localOnlyStatus("workflows", key) });
5654
6877
  continue;
5655
6878
  }
5656
6879
  // Present on both sides → compare content.
@@ -5694,14 +6917,23 @@ Directory Structure:
5694
6917
  differences.push({ type: "workflow", key, status, hint });
5695
6918
  }
5696
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;
5697
6929
  if (!localWorkflows.has(key)) {
5698
- differences.push({ type: "workflow", key, status: "remote only" });
6930
+ differences.push({ type: "workflow", key, status: remoteOnlyStatus("workflows", key) });
5699
6931
  }
5700
6932
  }
5701
6933
  // Email Templates
5702
6934
  for (const key of localEmailTemplates) {
5703
6935
  if (!remoteEmailTemplates.has(key)) {
5704
- differences.push({ type: "email-template", key, status: "local only" });
6936
+ differences.push({ type: "email-template", key, status: localOnlyStatus("emailTemplates", key) });
5705
6937
  }
5706
6938
  else {
5707
6939
  differences.push({ type: "email-template", key, status: "exists" });
@@ -5709,7 +6941,7 @@ Directory Structure:
5709
6941
  }
5710
6942
  for (const key of remoteEmailTemplates) {
5711
6943
  if (!localEmailTemplates.has(key)) {
5712
- differences.push({ type: "email-template", key, status: "remote only" });
6944
+ differences.push({ type: "email-template", key, status: remoteOnlyStatus("emailTemplates", key) });
5713
6945
  }
5714
6946
  }
5715
6947
  // Transforms (Rhai scripts) — issue #1196. Content-aware, mirroring
@@ -5722,7 +6954,7 @@ Directory Structure:
5722
6954
  for (const [name, localBody] of localScripts) {
5723
6955
  const remote = remoteScripts.get(name);
5724
6956
  if (!remote) {
5725
- differences.push({ type: "transform", key: name, status: "local only" });
6957
+ differences.push({ type: "transform", key: name, status: localOnlyStatus("scripts", name) });
5726
6958
  continue;
5727
6959
  }
5728
6960
  let status = "exists";
@@ -5758,13 +6990,106 @@ Directory Structure:
5758
6990
  }
5759
6991
  for (const name of remoteScripts.keys()) {
5760
6992
  if (!localScripts.has(name)) {
5761
- differences.push({ type: "transform", key: name, status: "remote only" });
6993
+ differences.push({ type: "transform", key: name, status: remoteOnlyStatus("scripts", name) });
5762
6994
  }
5763
6995
  }
5764
6996
  // Config vars — issue #1423 (see `diffVars`).
5765
6997
  for (const row of diffVars(localVars, remoteVars)) {
5766
6998
  differences.push(row);
5767
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
+ }
5768
7093
  // Compare test cases for synced prompts and workflows
5769
7094
  const testCaseDiffs = [];
5770
7095
  // Helper to compare test cases for a block
@@ -5789,7 +7114,7 @@ Directory Structure:
5789
7114
  }
5790
7115
  for (const slug of localTestSlugs) {
5791
7116
  if (!remoteTestNames.has(slug)) {
5792
- testCaseDiffs.push({ blockType, blockKey, slug, status: "local only" });
7117
+ testCaseDiffs.push({ blockType, blockKey, slug, status: LOCAL_ONLY_NEW });
5793
7118
  }
5794
7119
  else {
5795
7120
  testCaseDiffs.push({ blockType, blockKey, slug, status: "exists" });
@@ -5813,24 +7138,50 @@ Directory Structure:
5813
7138
  }
5814
7139
  }
5815
7140
  divider();
5816
- const localOnly = differences.filter((d) => d.status === "local only");
5817
- const remoteOnly = differences.filter((d) => d.status === "remote only");
7141
+ const localOnly = differences.filter((d) => d.status === LOCAL_ONLY_NEW);
7142
+ const localOnlyAbsent = differences.filter((d) => d.status === LOCAL_ONLY_ABSENT);
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);
5818
7148
  const modified = differences.filter((d) => d.status === "modified");
5819
7149
  const existing = differences.filter((d) => d.status === "exists");
5820
7150
  if (localOnly.length > 0) {
5821
- info("Local only (will be created on push):");
7151
+ info("Local only — new (will be created on push):");
5822
7152
  for (const d of localOnly) {
5823
7153
  console.log(` ${chalk.green("+")} ${d.type}: ${d.key}`);
5824
7154
  }
5825
7155
  console.log();
5826
7156
  }
5827
- if (remoteOnly.length > 0) {
5828
- warn("Remote only (not in local config):");
5829
- for (const d of remoteOnly) {
7157
+ // #1659: these files were written by a prior pull, so their entity was
7158
+ // deleted server-side afterwards. `sync pull` prunes them by
7159
+ // default; they only survive under `--no-prune` or a skipped prune, and
7160
+ // while they sit here the next push re-creates what was deleted.
7161
+ if (localOnlyAbsent.length > 0) {
7162
+ warn(`Local only — ${ABSENT_FROM_EXPORT}; push will RE-create these unless you remove them:`);
7163
+ for (const d of localOnlyAbsent) {
7164
+ console.log(` ${chalk.yellow("!")} ${d.type}: ${d.key}`);
7165
+ }
7166
+ console.log();
7167
+ }
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) {
5830
7173
  console.log(` ${chalk.red("-")} ${d.type}: ${d.key}`);
5831
7174
  }
5832
7175
  console.log();
5833
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
+ }
5834
7185
  // #1175: workflows whose deployed content differs from the local TOML.
5835
7186
  // Framed as a preview of `sync pull` — pull would rewrite these files
5836
7187
  // to match what's actually running (NOT "push would send").
@@ -5849,8 +7200,21 @@ Directory Structure:
5849
7200
  console.log(` ${chalk.dim("=")} ${d.type}: ${d.key}${hint}`);
5850
7201
  }
5851
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
+ }
5852
7216
  // Show test case differences
5853
- const tcLocalOnly = testCaseDiffs.filter((d) => d.status === "local only");
7217
+ const tcLocalOnly = testCaseDiffs.filter((d) => d.status === LOCAL_ONLY_NEW);
5854
7218
  const tcRemoteOnly = testCaseDiffs.filter((d) => d.status === "remote only");
5855
7219
  const tcSynced = testCaseDiffs.filter((d) => d.status === "exists");
5856
7220
  if (tcLocalOnly.length > 0 || tcRemoteOnly.length > 0) {
@@ -5867,10 +7231,15 @@ Directory Structure:
5867
7231
  }
5868
7232
  }
5869
7233
  divider();
5870
- keyValue("Local only", localOnly.length);
5871
- keyValue("Remote only", remoteOnly.length);
7234
+ keyValue("Local only (new)", localOnly.length);
7235
+ keyValue("Local only (absent from export)", localOnlyAbsent.length);
7236
+ keyValue("Remote only (will delete with --prune)", remoteOnlyWillDelete.length);
7237
+ keyValue("Remote only (unmanaged, left alone)", remoteOnlyUnmanaged.length);
5872
7238
  keyValue("Modified", modified.length);
5873
7239
  keyValue("Synced", existing.length);
7240
+ if (notCompared.length > 0) {
7241
+ keyValue("Not compared", notCompared.map((nc) => `${nc.type} (${nc.reason})`).join(", "));
7242
+ }
5874
7243
  if (testCaseDiffs.length > 0) {
5875
7244
  keyValue("Test Cases (local only)", tcLocalOnly.length);
5876
7245
  keyValue("Test Cases (remote only)", tcRemoteOnly.length);
@@ -6194,24 +7563,4 @@ Directory Structure:
6194
7563
  info("Run 'primitive sync diff' to inspect the restored state versus the server.");
6195
7564
  });
6196
7565
  }
6197
- /**
6198
- * Best-effort check for uncommitted git changes under `dir`. Used only to warn
6199
- * before a revert overwrites local edits — never fatal. Returns false if git
6200
- * isn't available, the dir isn't in a repo, or anything goes wrong.
6201
- */
6202
- async function hasUncommittedChanges(dir) {
6203
- if (!existsSync(dir))
6204
- return false;
6205
- try {
6206
- const { execSync } = await import("child_process");
6207
- const out = execSync(`git status --porcelain -- "${dir}"`, {
6208
- stdio: ["ignore", "pipe", "ignore"],
6209
- encoding: "utf-8",
6210
- });
6211
- return out.trim().length > 0;
6212
- }
6213
- catch {
6214
- return false;
6215
- }
6216
- }
6217
7566
  //# sourceMappingURL=sync.js.map