primitive-admin 1.1.0-alpha.74 → 1.1.0-alpha.76
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/dist/bin/primitive.js +110 -21
- package/dist/bin/primitive.js.map +1 -1
- package/dist/src/commands/collection-type-configs.js +1 -1
- package/dist/src/commands/collection-type-configs.js.map +1 -1
- package/dist/src/commands/config.js +8 -4
- package/dist/src/commands/config.js.map +1 -1
- package/dist/src/commands/database-type-configs.js +1 -1
- package/dist/src/commands/database-type-configs.js.map +1 -1
- package/dist/src/commands/databases.js +25 -31
- package/dist/src/commands/databases.js.map +1 -1
- package/dist/src/commands/documents.js +9 -24
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/env.d.ts +1 -1
- package/dist/src/commands/env.js +14 -11
- package/dist/src/commands/env.js.map +1 -1
- package/dist/src/commands/functions.js +173 -4
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/groups.js +1 -1
- package/dist/src/commands/groups.js.map +1 -1
- package/dist/src/commands/init.d.ts +12 -15
- package/dist/src/commands/init.js +36 -77
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/prompts.js +54 -39
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/rule-sets.js +1 -1
- package/dist/src/commands/rule-sets.js.map +1 -1
- package/dist/src/commands/scripts.js +17 -4
- package/dist/src/commands/scripts.js.map +1 -1
- package/dist/src/commands/sync-app-settings.js +2 -4
- package/dist/src/commands/sync-app-settings.js.map +1 -1
- package/dist/src/commands/sync.d.ts +41 -2
- package/dist/src/commands/sync.js +635 -364
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.js +62 -31
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +23 -2
- package/dist/src/lib/api-client.js +24 -5
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/app-match-guard.d.ts +44 -0
- package/dist/src/lib/app-match-guard.js +72 -0
- package/dist/src/lib/app-match-guard.js.map +1 -0
- package/dist/src/lib/block-selector.d.ts +58 -0
- package/dist/src/lib/block-selector.js +92 -0
- package/dist/src/lib/block-selector.js.map +1 -0
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
- package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +1 -3
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/config.d.ts +2 -2
- package/dist/src/lib/config.js +2 -2
- package/dist/src/lib/credentials-store.d.ts +3 -3
- package/dist/src/lib/credentials-store.js +3 -3
- package/dist/src/lib/credentials-store.js.map +1 -1
- package/dist/src/lib/document-export-permissions.d.ts +30 -0
- package/dist/src/lib/document-export-permissions.js +54 -0
- package/dist/src/lib/document-export-permissions.js.map +1 -0
- package/dist/src/lib/env-resolver-core.d.ts +41 -9
- package/dist/src/lib/env-resolver-core.js +84 -13
- package/dist/src/lib/env-resolver-core.js.map +1 -1
- package/dist/src/lib/env-resolver.d.ts +3 -3
- package/dist/src/lib/env-resolver.js +2 -2
- package/dist/src/lib/function-bundle.d.ts +11 -1
- package/dist/src/lib/function-bundle.js +13 -1
- package/dist/src/lib/function-bundle.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +123 -0
- package/dist/src/lib/function-collect.js +463 -0
- package/dist/src/lib/function-collect.js.map +1 -0
- package/dist/src/lib/function-db-types.d.ts +149 -0
- package/dist/src/lib/function-db-types.js +587 -0
- package/dist/src/lib/function-db-types.js.map +1 -0
- package/dist/src/lib/function-grants-preflight.d.ts +64 -0
- package/dist/src/lib/function-grants-preflight.js +100 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -0
- package/dist/src/lib/function-sync.d.ts +23 -0
- package/dist/src/lib/function-sync.js +65 -1
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/function-triggers.d.ts +6 -0
- package/dist/src/lib/function-triggers.js +63 -4
- package/dist/src/lib/function-triggers.js.map +1 -1
- package/dist/src/lib/generated-config-surfaces.d.ts +340 -0
- package/dist/src/lib/generated-config-surfaces.js +669 -17
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +12 -0
- package/dist/src/lib/generated-sdk-types.js +13 -0
- package/dist/src/lib/generated-sdk-types.js.map +1 -0
- package/dist/src/lib/init-adopt.js +2 -2
- package/dist/src/lib/init-adopt.js.map +1 -1
- package/dist/src/lib/init-assets.js +3 -3
- package/dist/src/lib/init-config.d.ts +1 -1
- package/dist/src/lib/init-ios-links.js +1 -1
- package/dist/src/lib/init-ios-links.js.map +1 -1
- package/dist/src/lib/init-plan.d.ts +1 -1
- package/dist/src/lib/init-plan.js +1 -1
- package/dist/src/lib/init-xcode.d.ts +2 -2
- package/dist/src/lib/init-xcode.js +6 -5
- package/dist/src/lib/init-xcode.js.map +1 -1
- package/dist/src/lib/local-state.d.ts +1 -1
- package/dist/src/lib/local-state.js +1 -1
- package/dist/src/lib/local-test-cases.d.ts +1 -1
- package/dist/src/lib/local-test-cases.js +2 -1
- package/dist/src/lib/local-test-cases.js.map +1 -1
- package/dist/src/lib/migration-nag.d.ts +2 -2
- package/dist/src/lib/migration-nag.js +4 -3
- package/dist/src/lib/migration-nag.js.map +1 -1
- package/dist/src/lib/project-config.d.ts +23 -20
- package/dist/src/lib/project-config.js +27 -45
- package/dist/src/lib/project-config.js.map +1 -1
- package/dist/src/lib/snapshots.d.ts +2 -2
- package/dist/src/lib/snapshots.js +2 -2
- package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
- package/dist/src/lib/swift-codegen/generator.js +1 -1
- package/dist/src/lib/sync-dir-selector.d.ts +1 -1
- package/dist/src/lib/sync-dir-selector.js +1 -1
- package/dist/src/lib/sync-paths.d.ts +52 -16
- package/dist/src/lib/sync-paths.js +68 -22
- package/dist/src/lib/sync-paths.js.map +1 -1
- package/dist/src/lib/sync-resource-types.d.ts +2 -2
- package/dist/src/lib/sync-resource-types.js +2 -2
- package/dist/src/lib/template.d.ts +2 -2
- package/dist/src/lib/template.js +2 -2
- package/dist/src/lib/test-case-keys.d.ts +1 -1
- package/dist/src/lib/test-case-keys.js +1 -1
- package/dist/src/lib/toml-database-config.js +19 -2
- package/dist/src/lib/toml-database-config.js.map +1 -1
- package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
- package/dist/src/lib/workflow-codegen/generator.js +1 -1
- package/package.json +3 -3
|
@@ -9,7 +9,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
|
|
|
9
9
|
import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
|
|
10
10
|
import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
|
|
11
11
|
import { getServerUrl, resolveAppId } from "../lib/config.js";
|
|
12
|
-
import {
|
|
12
|
+
import { assertTreeApp } from "../lib/app-match-guard.js";
|
|
13
|
+
import { resolveSyncDir, resolveSnapshotsRoot, assertSyncStateLayout, syncStatePath, SYNC_STATE_FILENAME, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
|
|
13
14
|
import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
|
|
14
15
|
import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
|
|
15
16
|
import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
|
|
@@ -36,6 +37,12 @@ import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfac
|
|
|
36
37
|
// `config push` sends, and the bytes `config pull` writes back.
|
|
37
38
|
import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
|
|
38
39
|
import { FunctionBuildError } from "../lib/function-bundle.js";
|
|
40
|
+
// #3182 — the local half of capability validation. The grammar is the vendored
|
|
41
|
+
// module the server also imports; this is where an author's own file is read.
|
|
42
|
+
import { configObjectKeysFromTree, functionGrantErrors, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
|
|
43
|
+
// #3182 — the typed handle's generated declarations and the compiler wiring
|
|
44
|
+
// that makes an editor load them.
|
|
45
|
+
import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
|
|
39
46
|
import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
|
|
40
47
|
import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
|
|
41
48
|
import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
|
|
@@ -68,7 +75,7 @@ function printStaleInstanceWarning(updated) {
|
|
|
68
75
|
}
|
|
69
76
|
}
|
|
70
77
|
function loadSyncState(configDir) {
|
|
71
|
-
const syncFile =
|
|
78
|
+
const syncFile = syncStatePath(configDir);
|
|
72
79
|
if (!existsSync(syncFile)) {
|
|
73
80
|
return null;
|
|
74
81
|
}
|
|
@@ -80,7 +87,7 @@ function loadSyncState(configDir) {
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
function saveSyncState(configDir, state) {
|
|
83
|
-
const syncFile =
|
|
90
|
+
const syncFile = syncStatePath(configDir);
|
|
84
91
|
writeFileSync(syncFile, JSON.stringify(state, null, 2));
|
|
85
92
|
}
|
|
86
93
|
/**
|
|
@@ -3202,6 +3209,114 @@ export function diffProjectedRecords(local, remote) {
|
|
|
3202
3209
|
}
|
|
3203
3210
|
return diffs;
|
|
3204
3211
|
}
|
|
3212
|
+
/**
|
|
3213
|
+
* The paths at which two projected records disagree, named the way the FILE
|
|
3214
|
+
* spells them (#3267) — what a `config diff` Modified row prints.
|
|
3215
|
+
*
|
|
3216
|
+
* `diffProjectedRecords` answers at the granularity of the projection, and
|
|
3217
|
+
* for a database type that is too coarse to act on: the 27 operations arrive
|
|
3218
|
+
* as one `operations` extra, the whole `[models.*]` tree as one `schema`
|
|
3219
|
+
* string. A row that read Modified for a reason the operator could not see —
|
|
3220
|
+
* an empty params set spelled two ways, an operation block the file declares
|
|
3221
|
+
* twice — was undiagnosable from the CLI. So this walks INTO each differing
|
|
3222
|
+
* field: a keyed child collection (`spec.childKeys`) is compared row by row
|
|
3223
|
+
* and named `operations["getInstitution"].params`, with a row only one side
|
|
3224
|
+
* has or that the file repeats named as such; a TOML-string field
|
|
3225
|
+
* (`spec.tomlFields`) is parsed on both sides and named by the path inside it
|
|
3226
|
+
* (`schema (models.Institution.fields.name.required)`); everything else is
|
|
3227
|
+
* walked as a value to its first differing leaf.
|
|
3228
|
+
*
|
|
3229
|
+
* Paths come back in projected-key order, keyed rows in key order, so the
|
|
3230
|
+
* list is stable across runs, and every leaf is its own entry — a schema
|
|
3231
|
+
* that differs in seven model fields is seven paths, so the caller's "+N
|
|
3232
|
+
* more" cap counts them the way it counts operations. A value the walk
|
|
3233
|
+
* cannot descend into (an array of a different length, a scalar against an
|
|
3234
|
+
* object) is named at the level it stopped.
|
|
3235
|
+
*/
|
|
3236
|
+
export function describeProjectedDifferences(spec, local, remote) {
|
|
3237
|
+
const paths = [];
|
|
3238
|
+
for (const { field, local: l, server: r } of diffProjectedRecords(local, remote)) {
|
|
3239
|
+
const childKey = spec.childKeys?.[field];
|
|
3240
|
+
if (childKey && Array.isArray(l) && Array.isArray(r)) {
|
|
3241
|
+
paths.push(...keyedChildDifferences(field, childKey, l, r));
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
if (spec.tomlFields?.includes(field) && typeof l === "string" && typeof r === "string") {
|
|
3245
|
+
let inner;
|
|
3246
|
+
try {
|
|
3247
|
+
inner = valueDifferences("", parseConfigToml(l), parseConfigToml(r));
|
|
3248
|
+
}
|
|
3249
|
+
catch {
|
|
3250
|
+
// A side that does not parse is named as the whole field below.
|
|
3251
|
+
}
|
|
3252
|
+
if (inner) {
|
|
3253
|
+
if (inner.length === 0) {
|
|
3254
|
+
paths.push(field);
|
|
3255
|
+
}
|
|
3256
|
+
else {
|
|
3257
|
+
paths.push(...inner.map((path) => `${field} (${path})`));
|
|
3258
|
+
}
|
|
3259
|
+
continue;
|
|
3260
|
+
}
|
|
3261
|
+
}
|
|
3262
|
+
paths.push(...valueDifferences(field, l, r));
|
|
3263
|
+
}
|
|
3264
|
+
return paths;
|
|
3265
|
+
}
|
|
3266
|
+
/** The leaf paths under `path` at which two values differ (see above). */
|
|
3267
|
+
function valueDifferences(path, a, b) {
|
|
3268
|
+
if (canonicalJsonStringify(a) === canonicalJsonStringify(b))
|
|
3269
|
+
return [];
|
|
3270
|
+
const join = (key) => (path ? `${path}.${key}` : key);
|
|
3271
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
3272
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
3273
|
+
return keys.flatMap((key) => valueDifferences(join(key), a[key], b[key]));
|
|
3274
|
+
}
|
|
3275
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) {
|
|
3276
|
+
return a.flatMap((item, i) => valueDifferences(`${path}[${i}]`, item, b[i]));
|
|
3277
|
+
}
|
|
3278
|
+
return [path || "(document)"];
|
|
3279
|
+
}
|
|
3280
|
+
/**
|
|
3281
|
+
* A keyed child collection's differences, row by row. A key the file repeats
|
|
3282
|
+
* is named as the cause rather than compared: the server keys rows by it, so
|
|
3283
|
+
* only one of the blocks landed and no push can make the two sides agree
|
|
3284
|
+
* while the file says two things under one name.
|
|
3285
|
+
*/
|
|
3286
|
+
function keyedChildDifferences(field, key, local, remote) {
|
|
3287
|
+
const label = (k) => `${field}[${JSON.stringify(k)}]`;
|
|
3288
|
+
const localByKey = new Map();
|
|
3289
|
+
for (const row of local) {
|
|
3290
|
+
const k = String(row?.[key] ?? "");
|
|
3291
|
+
localByKey.set(k, [...(localByKey.get(k) ?? []), row]);
|
|
3292
|
+
}
|
|
3293
|
+
const remoteByKey = new Map(remote.map((row) => [String(row?.[key] ?? ""), row]));
|
|
3294
|
+
const keys = [...new Set([...localByKey.keys(), ...remoteByKey.keys()])].sort();
|
|
3295
|
+
const paths = [];
|
|
3296
|
+
for (const k of keys) {
|
|
3297
|
+
const rows = localByKey.get(k);
|
|
3298
|
+
const remoteRow = remoteByKey.get(k);
|
|
3299
|
+
if (rows && rows.length > 1) {
|
|
3300
|
+
paths.push(`${label(k)} (declared ${rows.length} times locally)`);
|
|
3301
|
+
}
|
|
3302
|
+
else if (!rows) {
|
|
3303
|
+
paths.push(`${label(k)} (server only)`);
|
|
3304
|
+
}
|
|
3305
|
+
else if (remoteRow === undefined) {
|
|
3306
|
+
paths.push(`${label(k)} (local only)`);
|
|
3307
|
+
}
|
|
3308
|
+
else {
|
|
3309
|
+
paths.push(...valueDifferences(label(k), rows[0], remoteRow));
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
return paths;
|
|
3313
|
+
}
|
|
3314
|
+
function isPlainObject(value) {
|
|
3315
|
+
return (value !== null &&
|
|
3316
|
+
typeof value === "object" &&
|
|
3317
|
+
!Array.isArray(value) &&
|
|
3318
|
+
typeof value.toJSON !== "function");
|
|
3319
|
+
}
|
|
3205
3320
|
/**
|
|
3206
3321
|
* The differing fields the definition says an UPDATE does not accept (#2731 B8).
|
|
3207
3322
|
*
|
|
@@ -3402,6 +3517,12 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3402
3517
|
entry: entity.entry ?? null,
|
|
3403
3518
|
durable: entity.durable === true,
|
|
3404
3519
|
limits: entity.limits ?? null,
|
|
3520
|
+
// #3182 — the grants are version-carried too, and a widened grant set is
|
|
3521
|
+
// exactly the change a reviewer must not read as Synced. Sorted because
|
|
3522
|
+
// the row stores a set.
|
|
3523
|
+
capabilities: Array.isArray(entity.capabilities)
|
|
3524
|
+
? [...entity.capabilities].map((c) => String(c)).sort()
|
|
3525
|
+
: [],
|
|
3405
3526
|
}),
|
|
3406
3527
|
describe: (entity) => `function "${entity?.functionKey}"`,
|
|
3407
3528
|
},
|
|
@@ -3678,6 +3799,8 @@ const CONFIG_DIFF_SPECS = {
|
|
|
3678
3799
|
subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
|
|
3679
3800
|
_unresolvedRuleSetName: entity._unresolvedRuleSetName,
|
|
3680
3801
|
}),
|
|
3802
|
+
childKeys: { operations: "name", subscriptions: "subscriptionKey" },
|
|
3803
|
+
tomlFields: ["schema"],
|
|
3681
3804
|
ruleSetRef: true,
|
|
3682
3805
|
describe: (entity) => `database type "${entity?.databaseType}"`,
|
|
3683
3806
|
},
|
|
@@ -3772,9 +3895,11 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3772
3895
|
const localHash = computeExpandedContentHash(pair.local);
|
|
3773
3896
|
if (remoteHash === localHash)
|
|
3774
3897
|
return { status: "exists" };
|
|
3898
|
+
// #3267 — WHICH field, ahead of the remedy, so a row that differs for a
|
|
3899
|
+
// reason the operator cannot see in the file is diagnosable from the CLI.
|
|
3775
3900
|
return {
|
|
3776
3901
|
status: "modified",
|
|
3777
|
-
hint: "run `config pull` to rewrite the local TOML to match running state",
|
|
3902
|
+
hint: namedDifferences(describeProjectedDifferences(spec, pair.local, pair.remote)) + "run `config pull` to rewrite the local TOML to match running state",
|
|
3778
3903
|
};
|
|
3779
3904
|
}
|
|
3780
3905
|
catch {
|
|
@@ -3783,6 +3908,24 @@ export function compareLocalToRemote(spec, localParsed, remoteRecord, maps = {
|
|
|
3783
3908
|
return { status: "exists", hint: "content not compared (comparison failed)" };
|
|
3784
3909
|
}
|
|
3785
3910
|
}
|
|
3911
|
+
/**
|
|
3912
|
+
* How many differing paths a Modified row names before saying "+N more"
|
|
3913
|
+
* (#3267). Enough to see the shape of a difference at a glance; the row is
|
|
3914
|
+
* one line of a whole-tree diff, not the place to print 27 operations.
|
|
3915
|
+
*/
|
|
3916
|
+
const NAMED_DIFFERENCE_LIMIT = 5;
|
|
3917
|
+
/**
|
|
3918
|
+
* The `differs in …; ` prefix of a Modified row's hint (#3267), or nothing
|
|
3919
|
+
* when the walk could name no path — the hash still differs, so the row is
|
|
3920
|
+
* still Modified, but the hint then says only what it always said.
|
|
3921
|
+
*/
|
|
3922
|
+
function namedDifferences(paths) {
|
|
3923
|
+
if (paths.length === 0)
|
|
3924
|
+
return "";
|
|
3925
|
+
const shown = paths.slice(0, NAMED_DIFFERENCE_LIMIT).join(", ");
|
|
3926
|
+
const more = paths.length - NAMED_DIFFERENCE_LIMIT;
|
|
3927
|
+
return `differs in ${shown}${more > 0 ? ` (+${more} more)` : ""}; `;
|
|
3928
|
+
}
|
|
3786
3929
|
/** Hash a LOCAL config file's definition-projected field set (#2644). */
|
|
3787
3930
|
export function hashLocalConfigForDiff(spec, parsedToml, maps, extra) {
|
|
3788
3931
|
return computeExpandedContentHash(projectLocalConfig(spec, parsedToml, maps, extra));
|
|
@@ -4222,7 +4365,7 @@ export function configSemanticHash(label, filePath, maps) {
|
|
|
4222
4365
|
/**
|
|
4223
4366
|
* The sync state `config push` writes into (#2731 B6, closing #2374).
|
|
4224
4367
|
*
|
|
4225
|
-
* `loadSyncState` answers `null` for an absent
|
|
4368
|
+
* `loadSyncState` answers `null` for an absent sync-state baseline, and
|
|
4226
4369
|
* every per-entity state write in the push loops is guarded by `if (syncState)`
|
|
4227
4370
|
* — so a first push created every resource and recorded none of them. The next
|
|
4228
4371
|
* push then re-entered the create path for everything and depended on 409
|
|
@@ -6276,7 +6419,7 @@ function readSelection(options) {
|
|
|
6276
6419
|
* `registerConfigCommands` created rather than making one of its own.
|
|
6277
6420
|
*
|
|
6278
6421
|
* The module keeps its name and its ~50 exported helpers: `sync.ts`,
|
|
6279
|
-
* `resolveSyncDir`,
|
|
6422
|
+
* `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
|
|
6280
6423
|
* are internal identifiers, deliberately left alone (#2759 scope).
|
|
6281
6424
|
*/
|
|
6282
6425
|
export function registerConfigSyncCommands(sync) {
|
|
@@ -6288,8 +6431,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6288
6431
|
.option("--app <app-id>", "App ID")
|
|
6289
6432
|
.option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
|
|
6290
6433
|
.action(async (appId, options) => {
|
|
6434
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
6435
|
+
// path no longer partitions by app.
|
|
6436
|
+
assertTreeApp(appId || options.app, "config init");
|
|
6291
6437
|
const resolvedAppId = resolveAppId(appId, options);
|
|
6292
|
-
const configDir = resolveSyncDir({
|
|
6438
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
6439
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
6440
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
6441
|
+
assertSyncStateLayout(configDir);
|
|
6293
6442
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
6294
6443
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6295
6444
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -6336,8 +6485,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
6336
6485
|
(value, previous) => [...(previous ?? []), value], [])
|
|
6337
6486
|
.option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
|
|
6338
6487
|
.action(async (appId, options) => {
|
|
6488
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
6489
|
+
// path no longer partitions by app.
|
|
6490
|
+
assertTreeApp(appId || options.app, "config pull");
|
|
6339
6491
|
const resolvedAppId = resolveAppId(appId, options);
|
|
6340
|
-
const configDir = resolveSyncDir({
|
|
6492
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
6493
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
6494
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
6495
|
+
assertSyncStateLayout(configDir);
|
|
6341
6496
|
if (isAutoResolvedSyncDir(options.dir) && !options.json) {
|
|
6342
6497
|
info(`Using per-environment config directory: ${configDir}`);
|
|
6343
6498
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -7125,6 +7280,16 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7125
7280
|
};
|
|
7126
7281
|
info(` Wrote database-type-configs/${filename}`);
|
|
7127
7282
|
}
|
|
7283
|
+
// #3182 — the typed-handle artifacts are REGENERATED, never pulled:
|
|
7284
|
+
// they are not in the envelope, and a fresh clone must end up with the
|
|
7285
|
+
// same three files a push would have written. Written here, after the
|
|
7286
|
+
// database types, so the declaration describes what this pull wrote.
|
|
7287
|
+
{
|
|
7288
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
7289
|
+
if (artifacts.length > 0) {
|
|
7290
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7128
7293
|
// Write rule sets
|
|
7129
7294
|
const ruleSetEntities = {};
|
|
7130
7295
|
for (const ruleSet of ruleSets) {
|
|
@@ -7618,8 +7783,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
7618
7783
|
.option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
|
|
7619
7784
|
.option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
|
|
7620
7785
|
.action(async (appId, options) => {
|
|
7786
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
7787
|
+
// path no longer partitions by app.
|
|
7788
|
+
assertTreeApp(appId || options.app, "config push");
|
|
7621
7789
|
const resolvedAppId = resolveAppId(appId, options);
|
|
7622
|
-
const configDir = resolveSyncDir({
|
|
7790
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
7791
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
7792
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
7793
|
+
assertSyncStateLayout(configDir);
|
|
7623
7794
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
7624
7795
|
info(`Using per-environment config directory: ${configDir}`);
|
|
7625
7796
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -8086,7 +8257,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8086
8257
|
// known-bad sibling file (e.g. an invalid workflow TOML) could let an
|
|
8087
8258
|
// earlier create (a cron trigger) be applied, then abort. The
|
|
8088
8259
|
// `process.exit` also bypassed the save-on-failure catch below, so the
|
|
8089
|
-
// orphaned create never reached
|
|
8260
|
+
// orphaned create never reached the sync-state baseline and the retry
|
|
8090
8261
|
// re-issued a CREATE that the server rejected with 409 forever.
|
|
8091
8262
|
//
|
|
8092
8263
|
// Running every validation up-front means a known-bad file aborts
|
|
@@ -8268,6 +8439,56 @@ export function registerConfigSyncCommands(sync) {
|
|
|
8268
8439
|
preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
|
|
8269
8440
|
}
|
|
8270
8441
|
}
|
|
8442
|
+
// #3182 — a function's capabilities, checked against the author's own
|
|
8443
|
+
// file before anything is applied. The grammar is purely local (and
|
|
8444
|
+
// since #3279 it keeps three kinds: `integration:<key>`,
|
|
8445
|
+
// `secret:<NAME>` and the high-blast opt-ins — a retired grant string
|
|
8446
|
+
// is refused here with the retirement); whether an `integration:`
|
|
8447
|
+
// names an integration that will exist is answered against the
|
|
8448
|
+
// tree's own `integrations/` (applied BEFORE functions) merged with
|
|
8449
|
+
// the integrations the server already has. If the live listing cannot
|
|
8450
|
+
// be fetched and the tree declares none, the target check is skipped
|
|
8451
|
+
// and the server — which is authoritative — has the last word.
|
|
8452
|
+
const preflightFunctionsDir = join(configDir, "functions");
|
|
8453
|
+
if (existsSync(preflightFunctionsDir)) {
|
|
8454
|
+
const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
|
|
8455
|
+
if (functionFiles.length > 0) {
|
|
8456
|
+
const treeTargets = configObjectKeysFromTree(configDir);
|
|
8457
|
+
let knownTargets = null;
|
|
8458
|
+
try {
|
|
8459
|
+
const integrations = await client.listIntegrations(resolvedAppId, {
|
|
8460
|
+
status: "active",
|
|
8461
|
+
});
|
|
8462
|
+
knownTargets = mergeKnownGrantTargets({
|
|
8463
|
+
integrations: new Set((integrations?.items ?? [])
|
|
8464
|
+
.map((row) => String(row?.integrationKey ?? ""))
|
|
8465
|
+
.filter(Boolean)),
|
|
8466
|
+
}, treeTargets);
|
|
8467
|
+
}
|
|
8468
|
+
catch {
|
|
8469
|
+
knownTargets =
|
|
8470
|
+
treeTargets.integrations.size > 0 ? treeTargets : null;
|
|
8471
|
+
}
|
|
8472
|
+
for (const file of functionFiles) {
|
|
8473
|
+
const filePath = join(preflightFunctionsDir, file);
|
|
8474
|
+
let tomlData;
|
|
8475
|
+
try {
|
|
8476
|
+
tomlData = parseTomlFile(filePath);
|
|
8477
|
+
}
|
|
8478
|
+
catch (err) {
|
|
8479
|
+
preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
|
|
8480
|
+
continue;
|
|
8481
|
+
}
|
|
8482
|
+
for (const message of functionGrantErrors({
|
|
8483
|
+
filePath,
|
|
8484
|
+
tomlData,
|
|
8485
|
+
knownTargets,
|
|
8486
|
+
})) {
|
|
8487
|
+
preflightValidationErrors.push(` ${message}`);
|
|
8488
|
+
}
|
|
8489
|
+
}
|
|
8490
|
+
}
|
|
8491
|
+
}
|
|
8271
8492
|
// #2644 — reject any key the vendored definition does not recognize,
|
|
8272
8493
|
// for every migrated configuration type. Same posture the `[workflow]`
|
|
8273
8494
|
// check above has, generalized: a mistyped key fails here, in the
|
|
@@ -10865,332 +11086,77 @@ export function registerConfigSyncCommands(sync) {
|
|
|
10865
11086
|
}
|
|
10866
11087
|
}
|
|
10867
11088
|
}
|
|
10868
|
-
// Process
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
10874
|
-
|
|
10875
|
-
|
|
10876
|
-
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10882
|
-
|
|
10883
|
-
|
|
10884
|
-
|
|
10885
|
-
|
|
10886
|
-
|
|
10887
|
-
|
|
10888
|
-
|
|
10889
|
-
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
10893
|
-
|
|
10894
|
-
|
|
10895
|
-
|
|
10896
|
-
|
|
10897
|
-
|
|
10898
|
-
|
|
11089
|
+
// Process workflows
|
|
11090
|
+
const workflowsDir = join(configDir, "workflows");
|
|
11091
|
+
if (existsSync(workflowsDir)) {
|
|
11092
|
+
const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
|
|
11093
|
+
// #2896 — pairing for a clone with no sync state. A workflow the
|
|
11094
|
+
// manifest has no row for is not thereby a create: the create path
|
|
11095
|
+
// below already adopts an existing workflow by key on the server's
|
|
11096
|
+
// 409 (#1006/#1174), so letting live state answer the same question
|
|
11097
|
+
// one step earlier is that same adopt — reached before a doomed
|
|
11098
|
+
// create is sent, which is what the prompt reconcile does (#2909).
|
|
11099
|
+
// Without it a stateless clone plans "create workflow" for every
|
|
11100
|
+
// workflow the server already holds, and its `.tests/` sidecars —
|
|
11101
|
+
// addressed through the sync-state id the plan never records —
|
|
11102
|
+
// cascade into duplicate test-case creates. Fetched once, and only
|
|
11103
|
+
// when a file is actually untracked; a failed listing degrades to
|
|
11104
|
+
// the create-then-409 path, which still converges.
|
|
11105
|
+
let liveWorkflowIdsByKey;
|
|
11106
|
+
const lookupLiveWorkflowId = async (workflowKey) => {
|
|
11107
|
+
if (liveWorkflowIdsByKey === undefined) {
|
|
11108
|
+
try {
|
|
11109
|
+
const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
11110
|
+
liveWorkflowIdsByKey = new Map(live
|
|
11111
|
+
.filter((w) => w?.workflowKey && w?.workflowId)
|
|
11112
|
+
.map((w) => [
|
|
11113
|
+
w.workflowKey,
|
|
11114
|
+
w.workflowId,
|
|
11115
|
+
]));
|
|
11116
|
+
}
|
|
11117
|
+
catch (err) {
|
|
11118
|
+
liveWorkflowIdsByKey = null;
|
|
11119
|
+
warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
|
|
10899
11120
|
}
|
|
10900
11121
|
}
|
|
10901
|
-
|
|
10902
|
-
functionsLiveOk = false;
|
|
10903
|
-
warnLiveUnavailable("functions", err);
|
|
10904
|
-
}
|
|
10905
|
-
}
|
|
10906
|
-
/** The version a function currently runs, or null. */
|
|
10907
|
-
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
10908
|
-
if (!activeConfigId)
|
|
10909
|
-
return null;
|
|
10910
|
-
const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
|
|
10911
|
-
return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
|
|
11122
|
+
return liveWorkflowIdsByKey?.get(workflowKey);
|
|
10912
11123
|
};
|
|
10913
11124
|
for (const file of files) {
|
|
10914
|
-
const filePath = join(
|
|
11125
|
+
const filePath = join(workflowsDir, file);
|
|
10915
11126
|
const tomlData = parseTomlFile(filePath);
|
|
10916
|
-
|
|
10917
|
-
|
|
10918
|
-
|
|
10919
|
-
|
|
10920
|
-
//
|
|
10921
|
-
//
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
|
|
10925
|
-
|
|
10926
|
-
|
|
10927
|
-
filePath,
|
|
10928
|
-
tomlData,
|
|
10929
|
-
});
|
|
10930
|
-
}
|
|
10931
|
-
catch (err) {
|
|
10932
|
-
if (!(err instanceof FunctionBuildError))
|
|
10933
|
-
throw err;
|
|
10934
|
-
applyFailures.push({
|
|
10935
|
-
type: "function",
|
|
10936
|
-
key,
|
|
10937
|
-
message: err.message,
|
|
10938
|
-
});
|
|
11127
|
+
// #1660 — skip platform-owned workflows (the reserved `__internal.*`
|
|
11128
|
+
// namespace). Resolve the key FIRST and skip BEFORE the workflow
|
|
11129
|
+
// TOML validation below, so a hollow exported `__internal.*.toml`
|
|
11130
|
+
// (steps = [], no active config) is skipped, not failed by
|
|
11131
|
+
// `validateWorkflowToml`, and does not abort the whole push trying
|
|
11132
|
+
// to activate a configuration-less workflow. Match on the resolved
|
|
11133
|
+
// `[workflow].key` (not filename) so a renamed file is still caught
|
|
11134
|
+
// (edge case 2).
|
|
11135
|
+
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
11136
|
+
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
11137
|
+
info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
|
|
10939
11138
|
continue;
|
|
10940
11139
|
}
|
|
10941
|
-
//
|
|
10942
|
-
//
|
|
10943
|
-
//
|
|
10944
|
-
|
|
10945
|
-
|
|
10946
|
-
|
|
10947
|
-
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
const gate = decidePushForConfig({
|
|
10958
|
-
spec: functionSpec,
|
|
10959
|
-
maps: pushDiffMaps,
|
|
10960
|
-
localParsed: tomlData,
|
|
10961
|
-
live: liveFunction
|
|
10962
|
-
? {
|
|
10963
|
-
record: liveFunction,
|
|
10964
|
-
// The version-carried keys the remote side is projected
|
|
10965
|
-
// through; the header row does not hold them.
|
|
10966
|
-
extra: activeVersion ?? undefined,
|
|
10967
|
-
modifiedAt: liveFunction.modifiedAt,
|
|
10968
|
-
}
|
|
10969
|
-
: null,
|
|
10970
|
-
entry: existingEntry,
|
|
10971
|
-
localFileHash: computeFileHash(filePath),
|
|
10972
|
-
force: options.force,
|
|
10973
|
-
// The create leg adopts by key on the server's 409 (#1006), so
|
|
10974
|
-
// an untracked function the server already holds is that same
|
|
10975
|
-
// adopt reached one step earlier.
|
|
10976
|
-
adoptsUntrackedByKey: true,
|
|
10977
|
-
});
|
|
10978
|
-
if (gate.action === "drift" ||
|
|
10979
|
-
gate.action === "conflict" ||
|
|
10980
|
-
gate.action === "immutable") {
|
|
10981
|
-
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
10982
|
-
continue;
|
|
10983
|
-
}
|
|
10984
|
-
headerAction = gate.action === "skip"
|
|
10985
|
-
? "skip"
|
|
10986
|
-
: liveFunction
|
|
10987
|
-
? "update"
|
|
10988
|
-
: "create";
|
|
11140
|
+
// Reject misnested headers (#685, e.g. [steps.<id>.request]) and
|
|
11141
|
+
// the #1447 runAs="system" + accessRule dead-config combo before
|
|
11142
|
+
// pushing. The runtime silently ignores fields outside the
|
|
11143
|
+
// allowlist, so this is the only place to catch the footgun, and
|
|
11144
|
+
// the server rejects a system accessRule (#1258) — flagging it
|
|
11145
|
+
// here keeps the push all-or-nothing. Validation happens BEFORE the
|
|
11146
|
+
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
11147
|
+
// file gets a clear diagnostic on every push attempt.
|
|
11148
|
+
// Issue #976: validation is now front-loaded before any apply
|
|
11149
|
+
// (see the pre-flight pass above). This in-loop check remains as
|
|
11150
|
+
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
11151
|
+
// single save-on-failure catch is the only exit path — never
|
|
11152
|
+
// bypassing it and orphaning an already-created resource.
|
|
11153
|
+
const tomlErrors = validateWorkflowToml(tomlData);
|
|
11154
|
+
if (tomlErrors.length > 0) {
|
|
11155
|
+
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
10989
11156
|
}
|
|
10990
|
-
|
|
10991
|
-
|
|
10992
|
-
|
|
10993
|
-
storedContentHash: existingEntry?.contentHash,
|
|
10994
|
-
currentFileHash: computeFileHash(filePath),
|
|
10995
|
-
});
|
|
10996
|
-
if (degraded.action === "decline") {
|
|
10997
|
-
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
10998
|
-
continue;
|
|
10999
|
-
}
|
|
11000
|
-
headerAction = degraded.action === "skip"
|
|
11001
|
-
? "skip"
|
|
11002
|
-
: liveFunction
|
|
11003
|
-
? "update"
|
|
11004
|
-
: "create";
|
|
11005
|
-
}
|
|
11006
|
-
// The code axis. `envelopeHash` is the server's own no-op test, so
|
|
11007
|
-
// matching it here means the call would create nothing.
|
|
11008
|
-
const codeUnchanged = !!liveFunction &&
|
|
11009
|
-
!!activeVersion &&
|
|
11010
|
-
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
11011
|
-
if (headerAction === "skip" && codeUnchanged) {
|
|
11012
|
-
skippedCount++;
|
|
11013
|
-
if (syncState && liveFunction?.functionId) {
|
|
11014
|
-
if (!syncState.entities.functions)
|
|
11015
|
-
syncState.entities.functions = {};
|
|
11016
|
-
syncState.entities.functions[key] = {
|
|
11017
|
-
...(existingEntry ?? {}),
|
|
11018
|
-
id: liveFunction.functionId,
|
|
11019
|
-
modifiedAt: liveFunction.modifiedAt ||
|
|
11020
|
-
existingEntry?.modifiedAt ||
|
|
11021
|
-
new Date().toISOString(),
|
|
11022
|
-
contentHash: computeFileHash(filePath),
|
|
11023
|
-
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
11024
|
-
envelopeHash: push.envelopeHash,
|
|
11025
|
-
activeConfigId: activeVersion?.configId,
|
|
11026
|
-
sources: push.sources.map((src) => src.path),
|
|
11027
|
-
};
|
|
11028
|
-
}
|
|
11029
|
-
continue;
|
|
11030
|
-
}
|
|
11031
|
-
changes.push({
|
|
11032
|
-
type: "function",
|
|
11033
|
-
action: liveFunction ? "update" : "create",
|
|
11034
|
-
key,
|
|
11035
|
-
});
|
|
11036
|
-
if (options.dryRun)
|
|
11037
|
-
continue;
|
|
11038
|
-
// Every server refusal below is recorded against THIS function and
|
|
11039
|
-
// the loop moves on. A single archived tombstone — or a key the
|
|
11040
|
-
// app's namespace already holds — must not abort a push carrying
|
|
11041
|
-
// unrelated changes: the run reports what it could not apply, exits
|
|
11042
|
-
// non-zero, and everything else in the tree still lands. That is
|
|
11043
|
-
// the same contract `applyFailures` gives every other type; a throw
|
|
11044
|
-
// here would let one refusal block a whole repository.
|
|
11045
|
-
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
11046
|
-
let pushedVersion;
|
|
11047
|
-
try {
|
|
11048
|
-
if (!functionId) {
|
|
11049
|
-
const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
|
|
11050
|
-
// #1006 — a function the server already holds under this key
|
|
11051
|
-
// is adopted rather than re-created. The conflict may name a
|
|
11052
|
-
// WORKFLOW, a script or a webhook instead: those are the
|
|
11053
|
-
// single key namespace refusing the key, and they are not
|
|
11054
|
-
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
11055
|
-
// error with its holder named.
|
|
11056
|
-
const existing = await adoptByKeyOnCreate409({
|
|
11057
|
-
err,
|
|
11058
|
-
kind: "function",
|
|
11059
|
-
key,
|
|
11060
|
-
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
11061
|
-
matchKey: (f) => f?.functionKey === key,
|
|
11062
|
-
});
|
|
11063
|
-
return existing;
|
|
11064
|
-
});
|
|
11065
|
-
functionId = created.functionId;
|
|
11066
|
-
info(` Created function: ${key}`);
|
|
11067
|
-
}
|
|
11068
|
-
else if (headerAction !== "skip") {
|
|
11069
|
-
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
11070
|
-
if (Object.keys(updatePayload).length > 0) {
|
|
11071
|
-
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
11072
|
-
}
|
|
11073
|
-
info(` Updated function: ${key}`);
|
|
11074
|
-
}
|
|
11075
|
-
// The version. `status` is NEVER in this body: pushing code to a
|
|
11076
|
-
// disabled function updates it and leaves it out of service
|
|
11077
|
-
// (#2803's resurrection guard), and a push to an archived one is
|
|
11078
|
-
// refused by the server with the archived-holder message — which
|
|
11079
|
-
// names the remedies that release its key.
|
|
11080
|
-
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
11081
|
-
}
|
|
11082
|
-
catch (err) {
|
|
11083
|
-
applyFailures.push({
|
|
11084
|
-
type: "function",
|
|
11085
|
-
key,
|
|
11086
|
-
message: String(err?.message ?? err),
|
|
11087
|
-
});
|
|
11088
|
-
continue;
|
|
11089
|
-
}
|
|
11090
|
-
if (pushedVersion?.created === false) {
|
|
11091
|
-
info(` Function ${key}: code unchanged (no new version)`);
|
|
11092
|
-
}
|
|
11093
|
-
else {
|
|
11094
|
-
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
11095
|
-
}
|
|
11096
|
-
if (syncState) {
|
|
11097
|
-
if (!syncState.entities.functions)
|
|
11098
|
-
syncState.entities.functions = {};
|
|
11099
|
-
let refreshed = liveFunction;
|
|
11100
|
-
try {
|
|
11101
|
-
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
11102
|
-
}
|
|
11103
|
-
catch {
|
|
11104
|
-
// Non-fatal: the baseline falls back to what the listing said.
|
|
11105
|
-
// The next push reconciles it.
|
|
11106
|
-
}
|
|
11107
|
-
syncState.entities.functions[key] = {
|
|
11108
|
-
id: String(functionId),
|
|
11109
|
-
modifiedAt: refreshed?.modifiedAt ||
|
|
11110
|
-
existingEntry?.modifiedAt ||
|
|
11111
|
-
new Date().toISOString(),
|
|
11112
|
-
contentHash: computeFileHash(filePath),
|
|
11113
|
-
semanticHash: refreshed
|
|
11114
|
-
? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
|
|
11115
|
-
: undefined,
|
|
11116
|
-
envelopeHash: push.envelopeHash,
|
|
11117
|
-
activeConfigId: pushedVersion?.configId,
|
|
11118
|
-
sources: push.sources.map((src) => src.path),
|
|
11119
|
-
};
|
|
11120
|
-
}
|
|
11121
|
-
}
|
|
11122
|
-
}
|
|
11123
|
-
// Process workflows
|
|
11124
|
-
const workflowsDir = join(configDir, "workflows");
|
|
11125
|
-
if (existsSync(workflowsDir)) {
|
|
11126
|
-
const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
|
|
11127
|
-
// #2896 — pairing for a clone with no sync state. A workflow the
|
|
11128
|
-
// manifest has no row for is not thereby a create: the create path
|
|
11129
|
-
// below already adopts an existing workflow by key on the server's
|
|
11130
|
-
// 409 (#1006/#1174), so letting live state answer the same question
|
|
11131
|
-
// one step earlier is that same adopt — reached before a doomed
|
|
11132
|
-
// create is sent, which is what the prompt reconcile does (#2909).
|
|
11133
|
-
// Without it a stateless clone plans "create workflow" for every
|
|
11134
|
-
// workflow the server already holds, and its `.tests/` sidecars —
|
|
11135
|
-
// addressed through the sync-state id the plan never records —
|
|
11136
|
-
// cascade into duplicate test-case creates. Fetched once, and only
|
|
11137
|
-
// when a file is actually untracked; a failed listing degrades to
|
|
11138
|
-
// the create-then-409 path, which still converges.
|
|
11139
|
-
let liveWorkflowIdsByKey;
|
|
11140
|
-
const lookupLiveWorkflowId = async (workflowKey) => {
|
|
11141
|
-
if (liveWorkflowIdsByKey === undefined) {
|
|
11142
|
-
try {
|
|
11143
|
-
const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
|
|
11144
|
-
liveWorkflowIdsByKey = new Map(live
|
|
11145
|
-
.filter((w) => w?.workflowKey && w?.workflowId)
|
|
11146
|
-
.map((w) => [
|
|
11147
|
-
w.workflowKey,
|
|
11148
|
-
w.workflowId,
|
|
11149
|
-
]));
|
|
11150
|
-
}
|
|
11151
|
-
catch (err) {
|
|
11152
|
-
liveWorkflowIdsByKey = null;
|
|
11153
|
-
warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
|
|
11154
|
-
}
|
|
11155
|
-
}
|
|
11156
|
-
return liveWorkflowIdsByKey?.get(workflowKey);
|
|
11157
|
-
};
|
|
11158
|
-
for (const file of files) {
|
|
11159
|
-
const filePath = join(workflowsDir, file);
|
|
11160
|
-
const tomlData = parseTomlFile(filePath);
|
|
11161
|
-
// #1660 — skip platform-owned workflows (the reserved `__internal.*`
|
|
11162
|
-
// namespace). Resolve the key FIRST and skip BEFORE the workflow
|
|
11163
|
-
// TOML validation below, so a hollow exported `__internal.*.toml`
|
|
11164
|
-
// (steps = [], no active config) is skipped, not failed by
|
|
11165
|
-
// `validateWorkflowToml`, and does not abort the whole push trying
|
|
11166
|
-
// to activate a configuration-less workflow. Match on the resolved
|
|
11167
|
-
// `[workflow].key` (not filename) so a renamed file is still caught
|
|
11168
|
-
// (edge case 2).
|
|
11169
|
-
const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
|
|
11170
|
-
if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
|
|
11171
|
-
info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
|
|
11172
|
-
continue;
|
|
11173
|
-
}
|
|
11174
|
-
// Reject misnested headers (#685, e.g. [steps.<id>.request]) and
|
|
11175
|
-
// the #1447 runAs="system" + accessRule dead-config combo before
|
|
11176
|
-
// pushing. The runtime silently ignores fields outside the
|
|
11177
|
-
// allowlist, so this is the only place to catch the footgun, and
|
|
11178
|
-
// the server rejects a system accessRule (#1258) — flagging it
|
|
11179
|
-
// here keeps the push all-or-nothing. Validation happens BEFORE the
|
|
11180
|
-
// skip-if-unchanged check so a previously-pushed-but-broken
|
|
11181
|
-
// file gets a clear diagnostic on every push attempt.
|
|
11182
|
-
// Issue #976: validation is now front-loaded before any apply
|
|
11183
|
-
// (see the pre-flight pass above). This in-loop check remains as
|
|
11184
|
-
// defense-in-depth, but THROWS rather than `process.exit(1)` so the
|
|
11185
|
-
// single save-on-failure catch is the only exit path — never
|
|
11186
|
-
// bypassing it and orphaning an already-created resource.
|
|
11187
|
-
const tomlErrors = validateWorkflowToml(tomlData);
|
|
11188
|
-
if (tomlErrors.length > 0) {
|
|
11189
|
-
throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
|
|
11190
|
-
}
|
|
11191
|
-
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
11192
|
-
if (unknownKeyError) {
|
|
11193
|
-
throw new Error(unknownKeyError);
|
|
11157
|
+
const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
|
|
11158
|
+
if (unknownKeyError) {
|
|
11159
|
+
throw new Error(unknownKeyError);
|
|
11194
11160
|
}
|
|
11195
11161
|
const workflow = tomlData.workflow || {};
|
|
11196
11162
|
const key = workflow.key || basename(file, ".toml");
|
|
@@ -12509,7 +12475,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12509
12475
|
//
|
|
12510
12476
|
// But the 409 also fires when the local sync state is merely
|
|
12511
12477
|
// ABSENT for a type that another admin has already FULLY
|
|
12512
|
-
// configured (or whose
|
|
12478
|
+
// configured (or whose sync-state baseline was not copied
|
|
12513
12479
|
// alongside the TOML). PATCHing that without
|
|
12514
12480
|
// `expectedModifiedAt` would silently overwrite the existing
|
|
12515
12481
|
// triggers/access/schema with the local TOML — with no
|
|
@@ -12953,6 +12919,279 @@ export function registerConfigSyncCommands(sync) {
|
|
|
12953
12919
|
}
|
|
12954
12920
|
}
|
|
12955
12921
|
}
|
|
12922
|
+
// Process server functions — #3179.
|
|
12923
|
+
//
|
|
12924
|
+
// A function pushes on TWO axes and they are deliberately independent:
|
|
12925
|
+
//
|
|
12926
|
+
// - the HEADER (`[function]`'s gate, schemas, plus the three
|
|
12927
|
+
// version-carried keys, all compared through the shared gate), and
|
|
12928
|
+
// - the CODE — the authored TOML bytes, every source file and the
|
|
12929
|
+
// built bundle, sent as one immutable config VERSION.
|
|
12930
|
+
//
|
|
12931
|
+
// Independent because a comment-only edit to a source file leaves the
|
|
12932
|
+
// header identical and must still make a version (F-003): gating the
|
|
12933
|
+
// code push on the header comparison would silently discard it. The
|
|
12934
|
+
// code axis compares `envelopeHash`, which is also what the SERVER
|
|
12935
|
+
// dedupes on, so an unchanged push makes no version on either side's
|
|
12936
|
+
// reckoning.
|
|
12937
|
+
//
|
|
12938
|
+
// The bundle is built FIRST, so a build error, a missing entry or an
|
|
12939
|
+
// import that escapes the config tree aborts this function before
|
|
12940
|
+
// anything is sent for it (behavior 12).
|
|
12941
|
+
const functionsPushDir = join(configDir, "functions");
|
|
12942
|
+
if (existsSync(functionsPushDir)) {
|
|
12943
|
+
// #3182 — the typed handle. Written BEFORE the bundles are built, so
|
|
12944
|
+
// an author who runs `config push` after editing a schema gets the
|
|
12945
|
+
// declaration their next edit is typed against, and written from the
|
|
12946
|
+
// TREE rather than from the server: the types describe the schemas
|
|
12947
|
+
// this push is applying. Regenerated every time and never part of the
|
|
12948
|
+
// envelope — `config pull` recreates them the same way.
|
|
12949
|
+
if (!options.dryRun) {
|
|
12950
|
+
const artifacts = writeFunctionTypeArtifacts(configDir);
|
|
12951
|
+
if (artifacts.length > 0) {
|
|
12952
|
+
info(` Wrote function types: ${artifacts.join(", ")}`);
|
|
12953
|
+
}
|
|
12954
|
+
}
|
|
12955
|
+
// A tsconfig push will not rewrite (comments are the author's) or
|
|
12956
|
+
// cannot read is still a tsconfig no compiler loads the handle
|
|
12957
|
+
// through. Say so rather than report success over it (CR3182-003).
|
|
12958
|
+
for (const problem of functionTypeWiringProblems(configDir)) {
|
|
12959
|
+
warn(` functions/tsconfig.json: ${problem}`);
|
|
12960
|
+
}
|
|
12961
|
+
const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
|
|
12962
|
+
const functionSpec = configDiffSpec("function");
|
|
12963
|
+
const liveFunctions = new Map();
|
|
12964
|
+
let functionsLiveOk = true;
|
|
12965
|
+
if (files.length > 0) {
|
|
12966
|
+
try {
|
|
12967
|
+
const listed = await client.listFunctions(resolvedAppId);
|
|
12968
|
+
for (const f of (listed?.items ?? [])) {
|
|
12969
|
+
if (f?.functionKey)
|
|
12970
|
+
liveFunctions.set(f.functionKey, f);
|
|
12971
|
+
}
|
|
12972
|
+
}
|
|
12973
|
+
catch (err) {
|
|
12974
|
+
functionsLiveOk = false;
|
|
12975
|
+
warnLiveUnavailable("functions", err);
|
|
12976
|
+
}
|
|
12977
|
+
}
|
|
12978
|
+
/** The version a function currently runs, or null. */
|
|
12979
|
+
const liveActiveVersion = async (functionId, activeConfigId) => {
|
|
12980
|
+
if (!activeConfigId)
|
|
12981
|
+
return null;
|
|
12982
|
+
const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
|
|
12983
|
+
return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
|
|
12984
|
+
};
|
|
12985
|
+
for (const file of files) {
|
|
12986
|
+
const filePath = join(functionsPushDir, file);
|
|
12987
|
+
const tomlData = parseTomlFile(filePath);
|
|
12988
|
+
const key = tomlData.function?.key || basename(file, ".toml");
|
|
12989
|
+
withDerivedIdentity(tomlData, "function", { key });
|
|
12990
|
+
const existingEntry = syncState?.entities?.functions?.[key];
|
|
12991
|
+
const liveFunction = liveFunctions.get(key);
|
|
12992
|
+
// Build first: nothing is sent for a function whose code does not
|
|
12993
|
+
// compile or whose imports leave the tree.
|
|
12994
|
+
let push;
|
|
12995
|
+
try {
|
|
12996
|
+
push = await buildFunctionConfigPush({
|
|
12997
|
+
configDir,
|
|
12998
|
+
functionKey: key,
|
|
12999
|
+
filePath,
|
|
13000
|
+
tomlData,
|
|
13001
|
+
});
|
|
13002
|
+
}
|
|
13003
|
+
catch (err) {
|
|
13004
|
+
if (!(err instanceof FunctionBuildError))
|
|
13005
|
+
throw err;
|
|
13006
|
+
applyFailures.push({
|
|
13007
|
+
type: "function",
|
|
13008
|
+
key,
|
|
13009
|
+
message: err.message,
|
|
13010
|
+
});
|
|
13011
|
+
continue;
|
|
13012
|
+
}
|
|
13013
|
+
// The active version, needed by BOTH axes: the header comparison
|
|
13014
|
+
// reads `entry`/`durable`/`limits` off it, and the code axis
|
|
13015
|
+
// compares its `envelopeHash`.
|
|
13016
|
+
let activeVersion = null;
|
|
13017
|
+
let versionOk = functionsLiveOk;
|
|
13018
|
+
if (functionsLiveOk && liveFunction?.functionId) {
|
|
13019
|
+
try {
|
|
13020
|
+
activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
|
|
13021
|
+
}
|
|
13022
|
+
catch (err) {
|
|
13023
|
+
versionOk = false;
|
|
13024
|
+
warnLiveUnavailable(`function ${key}`, err);
|
|
13025
|
+
}
|
|
13026
|
+
}
|
|
13027
|
+
let headerAction = "skip";
|
|
13028
|
+
if (versionOk && functionsLiveOk) {
|
|
13029
|
+
const gate = decidePushForConfig({
|
|
13030
|
+
spec: functionSpec,
|
|
13031
|
+
maps: pushDiffMaps,
|
|
13032
|
+
localParsed: tomlData,
|
|
13033
|
+
live: liveFunction
|
|
13034
|
+
? {
|
|
13035
|
+
record: liveFunction,
|
|
13036
|
+
// The version-carried keys the remote side is projected
|
|
13037
|
+
// through; the header row does not hold them.
|
|
13038
|
+
extra: activeVersion ?? undefined,
|
|
13039
|
+
modifiedAt: liveFunction.modifiedAt,
|
|
13040
|
+
}
|
|
13041
|
+
: null,
|
|
13042
|
+
entry: existingEntry,
|
|
13043
|
+
localFileHash: computeFileHash(filePath),
|
|
13044
|
+
force: options.force,
|
|
13045
|
+
// The create leg adopts by key on the server's 409 (#1006), so
|
|
13046
|
+
// an untracked function the server already holds is that same
|
|
13047
|
+
// adopt reached one step earlier.
|
|
13048
|
+
adoptsUntrackedByKey: true,
|
|
13049
|
+
});
|
|
13050
|
+
if (gate.action === "drift" ||
|
|
13051
|
+
gate.action === "conflict" ||
|
|
13052
|
+
gate.action === "immutable") {
|
|
13053
|
+
recordDeclined("function", key, gate, existingEntry?.modifiedAt);
|
|
13054
|
+
continue;
|
|
13055
|
+
}
|
|
13056
|
+
headerAction = gate.action === "skip"
|
|
13057
|
+
? "skip"
|
|
13058
|
+
: liveFunction
|
|
13059
|
+
? "update"
|
|
13060
|
+
: "create";
|
|
13061
|
+
}
|
|
13062
|
+
else {
|
|
13063
|
+
const degraded = decideDegradedPush({
|
|
13064
|
+
force: options.force,
|
|
13065
|
+
storedContentHash: existingEntry?.contentHash,
|
|
13066
|
+
currentFileHash: computeFileHash(filePath),
|
|
13067
|
+
});
|
|
13068
|
+
if (degraded.action === "decline") {
|
|
13069
|
+
recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
|
|
13070
|
+
continue;
|
|
13071
|
+
}
|
|
13072
|
+
headerAction = degraded.action === "skip"
|
|
13073
|
+
? "skip"
|
|
13074
|
+
: liveFunction
|
|
13075
|
+
? "update"
|
|
13076
|
+
: "create";
|
|
13077
|
+
}
|
|
13078
|
+
// The code axis. `envelopeHash` is the server's own no-op test, so
|
|
13079
|
+
// matching it here means the call would create nothing.
|
|
13080
|
+
const codeUnchanged = !!liveFunction &&
|
|
13081
|
+
!!activeVersion &&
|
|
13082
|
+
String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
|
|
13083
|
+
if (headerAction === "skip" && codeUnchanged) {
|
|
13084
|
+
skippedCount++;
|
|
13085
|
+
if (syncState && liveFunction?.functionId) {
|
|
13086
|
+
if (!syncState.entities.functions)
|
|
13087
|
+
syncState.entities.functions = {};
|
|
13088
|
+
syncState.entities.functions[key] = {
|
|
13089
|
+
...(existingEntry ?? {}),
|
|
13090
|
+
id: liveFunction.functionId,
|
|
13091
|
+
modifiedAt: liveFunction.modifiedAt ||
|
|
13092
|
+
existingEntry?.modifiedAt ||
|
|
13093
|
+
new Date().toISOString(),
|
|
13094
|
+
contentHash: computeFileHash(filePath),
|
|
13095
|
+
semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
|
|
13096
|
+
envelopeHash: push.envelopeHash,
|
|
13097
|
+
activeConfigId: activeVersion?.configId,
|
|
13098
|
+
sources: push.sources.map((src) => src.path),
|
|
13099
|
+
};
|
|
13100
|
+
}
|
|
13101
|
+
continue;
|
|
13102
|
+
}
|
|
13103
|
+
changes.push({
|
|
13104
|
+
type: "function",
|
|
13105
|
+
action: liveFunction ? "update" : "create",
|
|
13106
|
+
key,
|
|
13107
|
+
});
|
|
13108
|
+
if (options.dryRun)
|
|
13109
|
+
continue;
|
|
13110
|
+
// Every server refusal below is recorded against THIS function and
|
|
13111
|
+
// the loop moves on. A single archived tombstone — or a key the
|
|
13112
|
+
// app's namespace already holds — must not abort a push carrying
|
|
13113
|
+
// unrelated changes: the run reports what it could not apply, exits
|
|
13114
|
+
// non-zero, and everything else in the tree still lands. That is
|
|
13115
|
+
// the same contract `applyFailures` gives every other type; a throw
|
|
13116
|
+
// here would let one refusal block a whole repository.
|
|
13117
|
+
let functionId = liveFunction?.functionId ?? existingEntry?.id;
|
|
13118
|
+
let pushedVersion;
|
|
13119
|
+
try {
|
|
13120
|
+
if (!functionId) {
|
|
13121
|
+
const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
|
|
13122
|
+
// #1006 — a function the server already holds under this key
|
|
13123
|
+
// is adopted rather than re-created. The conflict may name a
|
|
13124
|
+
// WORKFLOW, a script or a webhook instead: those are the
|
|
13125
|
+
// single key namespace refusing the key, and they are not
|
|
13126
|
+
// adoptable, so `adoptByKeyOnCreate409` re-throws the original
|
|
13127
|
+
// error with its holder named.
|
|
13128
|
+
const existing = await adoptByKeyOnCreate409({
|
|
13129
|
+
err,
|
|
13130
|
+
kind: "function",
|
|
13131
|
+
key,
|
|
13132
|
+
lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
|
|
13133
|
+
matchKey: (f) => f?.functionKey === key,
|
|
13134
|
+
});
|
|
13135
|
+
return existing;
|
|
13136
|
+
});
|
|
13137
|
+
functionId = created.functionId;
|
|
13138
|
+
info(` Created function: ${key}`);
|
|
13139
|
+
}
|
|
13140
|
+
else if (headerAction !== "skip") {
|
|
13141
|
+
const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
|
|
13142
|
+
if (Object.keys(updatePayload).length > 0) {
|
|
13143
|
+
await client.updateFunction(resolvedAppId, functionId, updatePayload);
|
|
13144
|
+
}
|
|
13145
|
+
info(` Updated function: ${key}`);
|
|
13146
|
+
}
|
|
13147
|
+
// The version. `status` is NEVER in this body: pushing code to a
|
|
13148
|
+
// disabled function updates it and leaves it out of service
|
|
13149
|
+
// (#2803's resurrection guard), and a push to an archived one is
|
|
13150
|
+
// refused by the server with the archived-holder message — which
|
|
13151
|
+
// names the remedies that release its key.
|
|
13152
|
+
pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
|
|
13153
|
+
}
|
|
13154
|
+
catch (err) {
|
|
13155
|
+
applyFailures.push({
|
|
13156
|
+
type: "function",
|
|
13157
|
+
key,
|
|
13158
|
+
message: String(err?.message ?? err),
|
|
13159
|
+
});
|
|
13160
|
+
continue;
|
|
13161
|
+
}
|
|
13162
|
+
if (pushedVersion?.created === false) {
|
|
13163
|
+
info(` Function ${key}: code unchanged (no new version)`);
|
|
13164
|
+
}
|
|
13165
|
+
else {
|
|
13166
|
+
info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
|
|
13167
|
+
}
|
|
13168
|
+
if (syncState) {
|
|
13169
|
+
if (!syncState.entities.functions)
|
|
13170
|
+
syncState.entities.functions = {};
|
|
13171
|
+
let refreshed = liveFunction;
|
|
13172
|
+
try {
|
|
13173
|
+
refreshed = await client.getFunction(resolvedAppId, String(functionId));
|
|
13174
|
+
}
|
|
13175
|
+
catch {
|
|
13176
|
+
// Non-fatal: the baseline falls back to what the listing said.
|
|
13177
|
+
// The next push reconciles it.
|
|
13178
|
+
}
|
|
13179
|
+
syncState.entities.functions[key] = {
|
|
13180
|
+
id: String(functionId),
|
|
13181
|
+
modifiedAt: refreshed?.modifiedAt ||
|
|
13182
|
+
existingEntry?.modifiedAt ||
|
|
13183
|
+
new Date().toISOString(),
|
|
13184
|
+
contentHash: computeFileHash(filePath),
|
|
13185
|
+
semanticHash: refreshed
|
|
13186
|
+
? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
|
|
13187
|
+
: undefined,
|
|
13188
|
+
envelopeHash: push.envelopeHash,
|
|
13189
|
+
activeConfigId: pushedVersion?.configId,
|
|
13190
|
+
sources: push.sources.map((src) => src.path),
|
|
13191
|
+
};
|
|
13192
|
+
}
|
|
13193
|
+
}
|
|
13194
|
+
}
|
|
12956
13195
|
// Process group type configs
|
|
12957
13196
|
const groupTypeConfigsDir = join(configDir, "group-type-configs");
|
|
12958
13197
|
if (existsSync(groupTypeConfigsDir)) {
|
|
@@ -13134,7 +13373,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
13134
13373
|
// this, that CREATE re-hits the server's 409 "Config for this
|
|
13135
13374
|
// group type already exists"
|
|
13136
13375
|
// (group-type-config-controller.ts) on every subsequent push
|
|
13137
|
-
// and the operator has to hand-edit
|
|
13376
|
+
// and the operator has to hand-edit the sync-state baseline to
|
|
13138
13377
|
// recover. The shared helper detects the conflict (409 status /
|
|
13139
13378
|
// "already exists" message), lists the app's configs — the list
|
|
13140
13379
|
// route drains every page server-side (#1668), so the candidate
|
|
@@ -14275,8 +14514,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
14275
14514
|
.option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
|
|
14276
14515
|
.option("--json", "Output as JSON (app.toml field diff, with --only app)")
|
|
14277
14516
|
.action(async (appId, options) => {
|
|
14517
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
14518
|
+
// path no longer partitions by app.
|
|
14519
|
+
assertTreeApp(appId || options.app, "config diff");
|
|
14278
14520
|
const resolvedAppId = resolveAppId(appId, options);
|
|
14279
|
-
const configDir = resolveSyncDir({
|
|
14521
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
14522
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
14523
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
14524
|
+
assertSyncStateLayout(configDir);
|
|
14280
14525
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
14281
14526
|
info(`Using per-environment config directory: ${configDir}`);
|
|
14282
14527
|
const legacyHint = checkLegacySyncMigration(configDir);
|
|
@@ -15262,7 +15507,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15262
15507
|
// below). Content comparison is a preview of `config pull` (pull would
|
|
15263
15508
|
// rewrite the local TOML to match running state), exactly like the
|
|
15264
15509
|
// workflow/transform blocks.
|
|
15265
|
-
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities,
|
|
15510
|
+
const compareConfigType = (typeLabel, slot, localParsed, remoteEntities, compare, contentComparable) => {
|
|
15266
15511
|
for (const [key, parsed] of localParsed) {
|
|
15267
15512
|
if (!remoteEntities.has(key)) {
|
|
15268
15513
|
differences.push({ type: typeLabel, key, status: localOnlyStatus(slot, key) });
|
|
@@ -15277,16 +15522,10 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15277
15522
|
hint = "content not compared (rule sets unavailable)";
|
|
15278
15523
|
}
|
|
15279
15524
|
else {
|
|
15280
|
-
|
|
15281
|
-
|
|
15282
|
-
|
|
15283
|
-
|
|
15284
|
-
}
|
|
15285
|
-
}
|
|
15286
|
-
catch {
|
|
15287
|
-
// A malformed local file must not abort the whole diff.
|
|
15288
|
-
hint = "content not compared (comparison failed)";
|
|
15289
|
-
}
|
|
15525
|
+
// The shared verdict names the differing field (#3267) and
|
|
15526
|
+
// degrades a malformed local file to "not compared" rather than
|
|
15527
|
+
// aborting the whole diff.
|
|
15528
|
+
({ status, hint } = compare(parsed, remoteEntities.get(key)));
|
|
15290
15529
|
}
|
|
15291
15530
|
differences.push({ type: typeLabel, key, status, hint });
|
|
15292
15531
|
}
|
|
@@ -15305,7 +15544,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15305
15544
|
};
|
|
15306
15545
|
const compareByDefinition = (label, slot, localParsed, remoteEntities, contentComparable) => {
|
|
15307
15546
|
const spec = configDiffSpec(label);
|
|
15308
|
-
compareConfigType(label, slot, localParsed, remoteEntities, (p) =>
|
|
15547
|
+
compareConfigType(label, slot, localParsed, remoteEntities, (p, e) => compareLocalToRemote(spec, p, e, diffMaps), contentComparable);
|
|
15309
15548
|
};
|
|
15310
15549
|
compareByDefinition("rule-set", "ruleSets", localRuleSetsParsed, remoteRuleSets, true);
|
|
15311
15550
|
compareByDefinition("group-type-config", "groupTypeConfigs", localGroupTypeConfigsParsed, remoteGroupTypeConfigs, ruleSetsOk);
|
|
@@ -15337,20 +15576,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15337
15576
|
hint = "content not compared (fetch failed)";
|
|
15338
15577
|
}
|
|
15339
15578
|
else {
|
|
15340
|
-
|
|
15341
|
-
|
|
15342
|
-
|
|
15343
|
-
|
|
15344
|
-
|
|
15345
|
-
|
|
15346
|
-
|
|
15347
|
-
|
|
15348
|
-
hint = "run `config pull` to rewrite the local TOML to match running state";
|
|
15349
|
-
}
|
|
15350
|
-
}
|
|
15351
|
-
catch {
|
|
15352
|
-
hint = "content not compared (comparison failed)";
|
|
15353
|
-
}
|
|
15579
|
+
const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
|
|
15580
|
+
const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
|
|
15581
|
+
// The shared verdict (#3267): a Modified row names the
|
|
15582
|
+
// operation, subscription, model field or `[type]` scalar it
|
|
15583
|
+
// differs in, and a comparison that throws degrades this one
|
|
15584
|
+
// row to "not compared". The ops/subscriptions are the remote
|
|
15585
|
+
// side's extra only — the file already carries its own.
|
|
15586
|
+
({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
|
|
15354
15587
|
}
|
|
15355
15588
|
}
|
|
15356
15589
|
differences.push({ type: "database-type-config", key, status, hint });
|
|
@@ -15650,6 +15883,29 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15650
15883
|
console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
|
|
15651
15884
|
}
|
|
15652
15885
|
}
|
|
15886
|
+
// #3182 — the generated function types are part of what a push would
|
|
15887
|
+
// change, so `config diff` is where their freshness is answered: an
|
|
15888
|
+
// editor or a CI job asks the same command it already asks "what would
|
|
15889
|
+
// push do?", and gets these named alongside everything else.
|
|
15890
|
+
const staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
|
|
15891
|
+
if (staleFunctionTypes.length > 0) {
|
|
15892
|
+
console.log();
|
|
15893
|
+
info("Function types (regenerated by `config push`):");
|
|
15894
|
+
for (const relative of staleFunctionTypes) {
|
|
15895
|
+
console.log(` ${chalk.yellow("~")} ${relative}`);
|
|
15896
|
+
}
|
|
15897
|
+
}
|
|
15898
|
+
// Freshness is not the only way the handle stops working: a tsconfig
|
|
15899
|
+
// push will not rewrite can be perfectly current and still load none of
|
|
15900
|
+
// it. `config diff` is where CI asks, so it answers both (CR3182-003).
|
|
15901
|
+
const wiringProblems = functionTypeWiringProblems(configDir);
|
|
15902
|
+
if (wiringProblems.length > 0) {
|
|
15903
|
+
console.log();
|
|
15904
|
+
info("Function types (compiler wiring `config push` cannot repair):");
|
|
15905
|
+
for (const problem of wiringProblems) {
|
|
15906
|
+
console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
|
|
15907
|
+
}
|
|
15908
|
+
}
|
|
15653
15909
|
divider();
|
|
15654
15910
|
keyValue("Local only (new)", localOnly.length);
|
|
15655
15911
|
keyValue("Local only (absent from export)", localOnlyAbsent.length);
|
|
@@ -15698,8 +15954,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15698
15954
|
// Resolve appId so we land in the per-env sync dir even when the user
|
|
15699
15955
|
// didn't pass `--dir`. The app ID itself isn't used for any API calls
|
|
15700
15956
|
// — `migrate-toml` is a purely local rewrite.
|
|
15957
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
15958
|
+
// path no longer partitions by app.
|
|
15959
|
+
assertTreeApp(appId || options.app, "config migrate-toml");
|
|
15701
15960
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15702
|
-
const configDir = resolveSyncDir({
|
|
15961
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
15962
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
15963
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
15964
|
+
assertSyncStateLayout(configDir);
|
|
15703
15965
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15704
15966
|
info(`Using per-environment config directory: ${configDir}`);
|
|
15705
15967
|
}
|
|
@@ -15862,8 +16124,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15862
16124
|
// Resolve appId so we land in the per-env sync dir even when the user
|
|
15863
16125
|
// didn't pass `--dir`. The app ID itself isn't used for any API calls —
|
|
15864
16126
|
// migrate-v2 is a purely local rewrite (like migrate-toml).
|
|
16127
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
16128
|
+
// path no longer partitions by app.
|
|
16129
|
+
assertTreeApp(appId || options.app, "config migrate-v2");
|
|
15865
16130
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15866
|
-
const configDir = resolveSyncDir({
|
|
16131
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
16132
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
16133
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
16134
|
+
assertSyncStateLayout(configDir);
|
|
15867
16135
|
if (isAutoResolvedSyncDir(options.dir)) {
|
|
15868
16136
|
info(`Using per-environment config directory: ${configDir}`);
|
|
15869
16137
|
}
|
|
@@ -15913,11 +16181,14 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15913
16181
|
.option("--list", "List available snapshots without restoring")
|
|
15914
16182
|
.option("-y, --yes", "Skip the confirmation prompt")
|
|
15915
16183
|
.action(async (appId, options) => {
|
|
16184
|
+
// Refused before any tree read/write or request (#3153, F1): the tree
|
|
16185
|
+
// path no longer partitions by app.
|
|
16186
|
+
assertTreeApp(appId || options.app, "config revert");
|
|
15916
16187
|
const resolvedAppId = resolveAppId(appId, options);
|
|
15917
|
-
const configDir = resolveSyncDir({
|
|
15918
|
-
|
|
15919
|
-
|
|
15920
|
-
|
|
16188
|
+
const configDir = resolveSyncDir({ userDir: options.dir });
|
|
16189
|
+
// A baseline that was never renamed means an unmigrated tree; refused
|
|
16190
|
+
// here, before any request, snapshot or directory is made (#3153).
|
|
16191
|
+
assertSyncStateLayout(configDir);
|
|
15921
16192
|
const snapshotsRoot = resolveSnapshotsRoot({
|
|
15922
16193
|
appId: resolvedAppId,
|
|
15923
16194
|
userDir: options.dir,
|
|
@@ -15992,7 +16263,7 @@ export function registerConfigSyncCommands(sync) {
|
|
|
15992
16263
|
error(`Restore failed: ${err?.message ?? err}`);
|
|
15993
16264
|
process.exit(1);
|
|
15994
16265
|
}
|
|
15995
|
-
success(`Restored snapshot ${target.id} into ${configDir} (including
|
|
16266
|
+
success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
|
|
15996
16267
|
info("Run 'primitive config diff' to inspect the restored state versus the server.");
|
|
15997
16268
|
});
|
|
15998
16269
|
}
|