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.
- package/assets/skill/skills/primitive-platform/SKILL.md +230 -2
- package/dist/bin/primitive.js +3 -1
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/apps.js +54 -2
- package/dist/src/commands/apps.js.map +1 -1
- package/dist/src/commands/database-types.js +4 -4
- package/dist/src/commands/database-types.js.map +1 -1
- package/dist/src/commands/databases.js +35 -14
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +77 -0
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/guides.d.ts +15 -12
- package/dist/src/commands/guides.js +22 -15
- package/dist/src/commands/guides.js.map +1 -1
- package/dist/src/commands/metadata.js +48 -3
- package/dist/src/commands/metadata.js.map +1 -1
- package/dist/src/commands/scripts.d.ts +18 -0
- package/dist/src/commands/scripts.js +59 -5
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/settings.d.ts +15 -0
- package/dist/src/commands/settings.js +102 -0
- package/dist/src/commands/settings.js.map +1 -0
- package/dist/src/commands/sync-app-settings.d.ts +115 -0
- package/dist/src/commands/sync-app-settings.js +379 -0
- package/dist/src/commands/sync-app-settings.js.map +1 -0
- package/dist/src/commands/sync.d.ts +134 -19
- package/dist/src/commands/sync.js +1949 -600
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +54 -15
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +29 -1
- package/dist/src/lib/api-client.js +31 -2
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-settings-descriptor.d.ts +108 -0
- package/dist/src/lib/app-settings-descriptor.js +254 -0
- package/dist/src/lib/app-settings-descriptor.js.map +1 -0
- package/dist/src/lib/codegen-shared/generatedFiles.d.ts +36 -0
- package/dist/src/lib/codegen-shared/generatedFiles.js +67 -10
- package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
- package/dist/src/lib/codegen-shared/prettierStable.d.ts +262 -0
- package/dist/src/lib/codegen-shared/prettierStable.js +610 -0
- package/dist/src/lib/codegen-shared/prettierStable.js.map +1 -0
- package/dist/src/lib/db-codegen/dbGenerator.d.ts +11 -83
- package/dist/src/lib/db-codegen/dbGenerator.js +96 -556
- package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
- package/dist/src/lib/db-codegen/dbNaming.d.ts +20 -7
- package/dist/src/lib/db-codegen/dbNaming.js +31 -13
- package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTemplates.d.ts +70 -4
- package/dist/src/lib/db-codegen/dbTemplates.js +244 -70
- package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTsTypes.d.ts +13 -22
- package/dist/src/lib/db-codegen/dbTsTypes.js +33 -28
- package/dist/src/lib/db-codegen/dbTsTypes.js.map +1 -1
- package/dist/src/lib/db-codegen/dbTypeIR.d.ts +146 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js +517 -0
- package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.d.ts +112 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js +211 -0
- package/dist/src/lib/db-codegen/generated-operation-def-descriptor.js.map +1 -0
- package/dist/src/lib/generated-allowlist.js +7 -0
- package/dist/src/lib/generated-allowlist.js.map +1 -1
- package/dist/src/lib/platform-owned.d.ts +37 -0
- package/dist/src/lib/platform-owned.js +46 -0
- package/dist/src/lib/platform-owned.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbGenerator.d.ts +100 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js +682 -0
- package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.d.ts +42 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js +100 -0
- package/dist/src/lib/swift-codegen/dbSwiftTypes.js.map +1 -0
- package/dist/src/lib/swift-codegen/generator.d.ts +85 -0
- package/dist/src/lib/swift-codegen/generator.js +266 -0
- package/dist/src/lib/swift-codegen/generator.js.map +1 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.d.ts +72 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js +644 -0
- package/dist/src/lib/swift-codegen/schemaToSwift.js.map +1 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.d.ts +90 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js +152 -0
- package/dist/src/lib/swift-codegen/siblingSymbols.js.map +1 -0
- package/dist/src/lib/swift-codegen/swiftNaming.d.ts +85 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js +198 -0
- package/dist/src/lib/swift-codegen/swiftNaming.js.map +1 -0
- package/dist/src/lib/sync-resource-types.d.ts +437 -0
- package/dist/src/lib/sync-resource-types.js +781 -0
- package/dist/src/lib/sync-resource-types.js.map +1 -0
- package/dist/src/lib/template.d.ts +1 -1
- package/dist/src/lib/template.js +6 -3
- package/dist/src/lib/template.js.map +1 -1
- package/dist/src/lib/toml-metadata-config.js +18 -0
- package/dist/src/lib/toml-metadata-config.js.map +1 -1
- package/dist/src/lib/workflow-apply.js +12 -2
- package/dist/src/lib/workflow-apply.js.map +1 -1
- package/dist/src/lib/workflow-codegen/generator.d.ts +27 -5
- package/dist/src/lib/workflow-codegen/generator.js +214 -68
- package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
- package/dist/src/lib/workflow-codegen/invokerIR.d.ts +83 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js +71 -0
- package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -0
- package/dist/src/lib/workflow-codegen/schemaToTs.d.ts +17 -1
- package/dist/src/lib/workflow-codegen/schemaToTs.js +59 -38
- package/dist/src/lib/workflow-codegen/schemaToTs.js.map +1 -1
- package/dist/src/lib/workflow-payload.d.ts +19 -0
- package/dist/src/lib/workflow-payload.js +21 -0
- package/dist/src/lib/workflow-payload.js.map +1 -1
- package/dist/src/lib/workflow-toml-validator.d.ts +30 -0
- package/dist/src/lib/workflow-toml-validator.js +206 -0
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- package/dist/src/types/index.d.ts +1 -0
- package/package.json +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
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
-
|
|
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
|
|
786
|
-
* treats `preset` and `accessPolicy` as mutually exclusive,
|
|
787
|
-
* `ruleSetId` when a preset/accessPolicy is also present, and
|
|
788
|
-
*
|
|
789
|
-
* `bucketKey` and `ttlTier` are immutable and never
|
|
790
|
-
* must go through this helper so their field sets never
|
|
715
|
+
* 409-adopt branches — do NOT "clean up" the truthiness checks on the access
|
|
716
|
+
* model. The server treats `preset` and `accessPolicy` as mutually exclusive,
|
|
717
|
+
* only clears `ruleSetId` when a preset/accessPolicy is also present, and
|
|
718
|
+
* refuses to leave a bucket with no access model or a blank name — so those
|
|
719
|
+
* fields are not clearable. `bucketKey` and `ttlTier` are immutable and never
|
|
720
|
+
* sent. Both call sites must go through this helper so their field sets never
|
|
721
|
+
* drift.
|
|
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
|
-
|
|
803
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
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 [
|
|
1895
|
-
client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)
|
|
1896
|
-
client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)
|
|
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 :
|
|
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
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
2041
|
-
const blobBucketItems =
|
|
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
|
-
//
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
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
|
|
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(
|
|
2307
|
-
blobBuckets: Object.keys(
|
|
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(
|
|
2311
|
-
emailTemplates: Object.keys(
|
|
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(
|
|
2314
|
-
ruleSets: Object.keys(
|
|
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(
|
|
2317
|
-
collectionTypeConfigs: Object.keys(
|
|
2318
|
-
?
|
|
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(
|
|
2321
|
-
?
|
|
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
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
2930
|
-
modifiedAt:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3187
|
-
// A cron trigger can be orphaned on the server (
|
|
3188
|
-
//
|
|
3189
|
-
//
|
|
3190
|
-
//
|
|
3191
|
-
//
|
|
3192
|
-
//
|
|
3193
|
-
//
|
|
3194
|
-
//
|
|
3195
|
-
//
|
|
3196
|
-
//
|
|
3197
|
-
|
|
3198
|
-
const
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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
|
-
|
|
3236
|
-
|
|
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
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3343
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3539
|
-
|
|
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:
|
|
3545
|
-
|
|
3546
|
-
|
|
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
|
-
|
|
3586
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3919
|
-
//
|
|
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
|
-
//
|
|
3925
|
-
//
|
|
3926
|
-
//
|
|
3927
|
-
//
|
|
3928
|
-
// adopt its id into sync
|
|
3929
|
-
//
|
|
3930
|
-
//
|
|
3931
|
-
//
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
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
|
-
//
|
|
4942
|
+
// type-level field. A fresh type (no existingEntry) has no
|
|
4173
4943
|
// ops to break, so there's nothing to gate yet.
|
|
4174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4409
|
-
// Operations are handled separately below
|
|
4410
|
-
//
|
|
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).
|
|
4422
|
-
//
|
|
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 (
|
|
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
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
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
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
}
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
const
|
|
6494
|
+
const integrationItems = gate(integrationsOutcome, "integrations", []);
|
|
6495
|
+
const promptItems = gate(promptsOutcome, "prompts", []);
|
|
6496
|
+
const workflowItems = gate(workflowsOutcome, "workflows", []);
|
|
6497
|
+
const emailTemplatesResult = gate(emailTemplatesOutcomeDiff, "email-templates", { templates: [] });
|
|
6498
|
+
const webhookItems = gate(webhooksOutcome, "webhooks", []);
|
|
6499
|
+
const cronTriggerItemsDiff = gate(cronTriggersOutcomeDiff, "cron-triggers", { items: [] }).items || [];
|
|
6500
|
+
const blobBucketsDiffResult = gate(blobBucketsOutcomeDiff, "blob-buckets", { items: [] });
|
|
6501
|
+
const scriptItemsDiff = gate(scriptsOutcomeDiff, "transforms", { items: [] }).items || [];
|
|
6502
|
+
const remoteVarItemsDiff = gate(varsOutcomeDiff, "vars", []);
|
|
6503
|
+
const databaseTypeConfigsDiff = gate(databaseTypeConfigsOutcomeDiff, "database-types", []);
|
|
6504
|
+
const ruleSetsDiff = gate(ruleSetsOutcomeDiff, "rule-sets", []);
|
|
6505
|
+
const groupTypeConfigsDiff = gate(groupTypeConfigsOutcomeDiff, "group-type-configs", []);
|
|
6506
|
+
const collectionTypeConfigsDiff = gate(collectionTypeConfigsOutcomeDiff, "collection-type-configs", []);
|
|
6507
|
+
const metadataCategoryConfigsDiff = gate(metadataCategoryConfigsOutcomeDiff, "metadata-category-configs", []);
|
|
6508
|
+
// ruleSetId ⇄ name maps, built once from the rule-set listing, drive the
|
|
6509
|
+
// content-aware comparison of the three rule-set-referencing types
|
|
6510
|
+
// (database-types, group/collection-type-configs). If the rule-set list
|
|
6511
|
+
// itself failed, the maps are empty and those types degrade to
|
|
6512
|
+
// "content not compared" rather than risk a false `Modified` from an
|
|
6513
|
+
// unresolvable reference (see `ruleSetsOk` below).
|
|
6514
|
+
const ruleSetsArr = Array.isArray(ruleSetsDiff) ? ruleSetsDiff : [];
|
|
6515
|
+
const ruleSetIdToNameDiff = new Map();
|
|
6516
|
+
const ruleSetNameToIdDiff = new Map();
|
|
6517
|
+
for (const rs of ruleSetsArr) {
|
|
6518
|
+
ruleSetIdToNameDiff.set(rs.ruleSetId, rs.name);
|
|
6519
|
+
ruleSetNameToIdDiff.set(rs.name, rs.ruleSetId);
|
|
6520
|
+
}
|
|
6521
|
+
const ruleSetsOk = ruleSetsOutcomeDiff.ok;
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
5512
|
-
//
|
|
5513
|
-
//
|
|
5514
|
-
//
|
|
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.
|
|
5536
|
-
//
|
|
5537
|
-
//
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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:
|
|
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 ===
|
|
5817
|
-
const
|
|
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
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
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 ===
|
|
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("
|
|
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
|