primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
  2. package/dist/src/commands/documents.js +455 -22
  3. package/dist/src/commands/documents.js.map +1 -1
  4. package/dist/src/commands/functions.js +889 -76
  5. package/dist/src/commands/functions.js.map +1 -1
  6. package/dist/src/commands/locks.js +15 -1
  7. package/dist/src/commands/locks.js.map +1 -1
  8. package/dist/src/commands/prompts.js +19 -2
  9. package/dist/src/commands/prompts.js.map +1 -1
  10. package/dist/src/commands/sync.d.ts +50 -0
  11. package/dist/src/commands/sync.js +587 -316
  12. package/dist/src/commands/sync.js.map +1 -1
  13. package/dist/src/commands/workflows.d.ts +1 -29
  14. package/dist/src/commands/workflows.js +8 -109
  15. package/dist/src/commands/workflows.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +191 -2
  17. package/dist/src/lib/api-client.js +283 -7
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +12 -4
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  22. package/dist/src/lib/document-ingest-artifact.js +505 -0
  23. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  24. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  25. package/dist/src/lib/document-ingest-input.js +132 -0
  26. package/dist/src/lib/document-ingest-input.js.map +1 -0
  27. package/dist/src/lib/document-ingest.d.ts +78 -0
  28. package/dist/src/lib/document-ingest.js +231 -0
  29. package/dist/src/lib/document-ingest.js.map +1 -0
  30. package/dist/src/lib/env-resolver-core.d.ts +2 -2
  31. package/dist/src/lib/env-resolver-core.js +2 -2
  32. package/dist/src/lib/function-bundle.d.ts +6 -0
  33. package/dist/src/lib/function-bundle.js +8 -0
  34. package/dist/src/lib/function-bundle.js.map +1 -1
  35. package/dist/src/lib/function-collect.d.ts +1 -1
  36. package/dist/src/lib/function-collect.js +16 -0
  37. package/dist/src/lib/function-collect.js.map +1 -1
  38. package/dist/src/lib/function-db-types.d.ts +4 -0
  39. package/dist/src/lib/function-db-types.js +46 -2
  40. package/dist/src/lib/function-db-types.js.map +1 -1
  41. package/dist/src/lib/function-document-types.d.ts +83 -0
  42. package/dist/src/lib/function-document-types.js +137 -0
  43. package/dist/src/lib/function-document-types.js.map +1 -1
  44. package/dist/src/lib/function-grants-preflight.js +5 -0
  45. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  46. package/dist/src/lib/function-log-lines.d.ts +76 -0
  47. package/dist/src/lib/function-log-lines.js +160 -0
  48. package/dist/src/lib/function-log-lines.js.map +1 -0
  49. package/dist/src/lib/function-log-row.d.ts +29 -0
  50. package/dist/src/lib/function-log-row.js +73 -0
  51. package/dist/src/lib/function-log-row.js.map +1 -0
  52. package/dist/src/lib/function-log-tail.d.ts +51 -2
  53. package/dist/src/lib/function-log-tail.js +160 -13
  54. package/dist/src/lib/function-log-tail.js.map +1 -1
  55. package/dist/src/lib/function-run.d.ts +289 -0
  56. package/dist/src/lib/function-run.js +389 -0
  57. package/dist/src/lib/function-run.js.map +1 -0
  58. package/dist/src/lib/function-schema-codegen.d.ts +17 -9
  59. package/dist/src/lib/function-schema-codegen.js +30 -29
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -30
  62. package/dist/src/lib/function-sync.js +731 -77
  63. package/dist/src/lib/function-sync.js.map +1 -1
  64. package/dist/src/lib/function-versions.d.ts +122 -0
  65. package/dist/src/lib/function-versions.js +182 -0
  66. package/dist/src/lib/function-versions.js.map +1 -0
  67. package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
  68. package/dist/src/lib/generated-config-surfaces.js +1858 -287
  69. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  70. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  71. package/dist/src/lib/generated-sdk-types.js +1 -1
  72. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  73. package/dist/src/lib/log-inspection.d.ts +2 -0
  74. package/dist/src/lib/log-inspection.js +4 -0
  75. package/dist/src/lib/log-inspection.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +15 -0
  77. package/dist/src/lib/output.js +28 -0
  78. package/dist/src/lib/output.js.map +1 -1
  79. package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
  80. package/dist/src/lib/prompt-schema-codegen.js +212 -0
  81. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  82. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  83. package/dist/src/lib/snapshot-audit-source.js +58 -0
  84. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  85. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  86. package/dist/src/lib/snapshot-audit-store.js +196 -0
  87. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  89. package/dist/src/lib/snapshot-audit.js +431 -0
  90. package/dist/src/lib/snapshot-audit.js.map +1 -0
  91. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  92. package/dist/src/lib/snapshot-build-rows.js +87 -0
  93. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  94. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  95. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  96. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  97. package/dist/src/lib/step-run-table.d.ts +43 -0
  98. package/dist/src/lib/step-run-table.js +129 -0
  99. package/dist/src/lib/step-run-table.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
  102. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  103. package/dist/src/lib/sync-selectors.d.ts +45 -2
  104. package/dist/src/lib/sync-selectors.js +66 -5
  105. package/dist/src/lib/sync-selectors.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
  107. package/dist/src/lib/workflow-toml-validator.js +18 -10
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/package.json +2 -2
@@ -37,7 +37,8 @@ import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, toml
37
37
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
38
38
  // #3179 — the function's two halves: the header payloads and the envelope
39
39
  // `config push` sends, and the bytes `config pull` writes back.
40
- import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlPreflightErrors, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
40
+ import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
41
+ import { newerVersionLine, pullNewerVersionLine, rollbackDiffHint, } from "../lib/function-versions.js";
41
42
  import { FunctionBuildError } from "../lib/function-bundle.js";
42
43
  // #3182 — the local half of capability validation. The grammar is the vendored
43
44
  // module the server also imports; this is where an author's own file is read.
@@ -48,6 +49,9 @@ import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTy
48
49
  // #3281 — the functions declaration and the per-key invokers ride the same
49
50
  // plan; a malformed schema is their one refusal, named by file.
50
51
  import { FunctionSchemaCodegenError } from "../lib/function-schema-codegen.js";
52
+ // #3455 — the one resolution rule push, pull and diff share for the project's
53
+ // document schema.
54
+ import { isFunctionDeclarationFile, resolveDocumentSchema, } from "../lib/function-document-types.js";
51
55
  // #3331 — and the compiler is actually asked: a push holds the code it ships
52
56
  // to the declarations it just generated.
53
57
  import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
@@ -60,7 +64,7 @@ import { slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-fi
60
64
  // diff and their tests have always imported them from this module.
61
65
  export { resolveSlugCollisions, slugifyTestCaseName, testCaseFileBasenames, } from "../lib/test-case-file-names.js";
62
66
  import { success, error, printApiError, info, warn, keyValue, json, divider, } from "../lib/output.js";
63
- import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, unmatchedSelectors, } from "../lib/sync-selectors.js";
67
+ import { filterFilesBySelection, parseSyncSelectors, SelectorError, selectionIncludesKey, selectionIncludesType, selectionIncludesApp, selectionIncludesAnyVar, trackedSelectorKeys, unmatchedSelectors, } from "../lib/sync-selectors.js";
64
68
  import { confirmPrompt, ConfirmPromptError } from "../lib/confirm-prompt.js";
65
69
  import { signingSecretStatusWarning, signingSecretHealthWarning, previousSigningSecretWarning, } from "../lib/signing-secret-status.js";
66
70
  import chalk from "chalk";
@@ -1942,15 +1946,38 @@ export function seededOnlyPromptConfigFields(firstConfig, seededConfigName = "de
1942
1946
  */
1943
1947
  export function buildPromptConfigPayload(tomlConfig, { mode = "update" } = {}) {
1944
1948
  const create = mode === "create";
1949
+ /**
1950
+ * Issue #1567, generalized at #3358 (DSO-3358-005): the field is TOML-OWNED
1951
+ * on update — send `?? null` so removing the line from a `[[configs]]` block
1952
+ * CLEARS it on the server (the PATCH nulls it on an explicit `null` and
1953
+ * preserves it on omit). Without this an absent key is `undefined`, which
1954
+ * `JSON.stringify` drops, so a deleted line left the stored value live and
1955
+ * the file stopped describing what runs.
1956
+ *
1957
+ * `create` still omits: a create body that nulled every unset key would
1958
+ * differ from the one the server has always received.
1959
+ */
1960
+ const tomlOwned = (value) => (create ? value : (value ?? null));
1945
1961
  return buildPayloadFromToml(PROMPT_CONFIG_TABLE, tomlConfig, {
1946
1962
  mode,
1947
1963
  values: {
1948
1964
  provider: (value) => (create ? value || "openrouter" : value),
1949
1965
  model: (value) => create ? value || "google/gemini-2.0-flash-001" : value,
1950
- // Issue #1567: `description` is TOML-owned on update — send `?? null` so
1951
- // removing it from a `[[configs]]` block clears it on the server (the
1952
- // PATCH nulls it on an explicit `null`, preserves on omit).
1953
- description: (value) => (create ? value : (value ?? null)),
1966
+ description: tomlOwned,
1967
+ systemPrompt: tomlOwned,
1968
+ userPromptTemplate: tomlOwned,
1969
+ temperature: tomlOwned,
1970
+ topP: tomlOwned,
1971
+ maxTokens: tomlOwned,
1972
+ outputFormat: tomlOwned,
1973
+ outputSchema: tomlOwned,
1974
+ providerConfig: tomlOwned,
1975
+ // #3358 — the two reasoning keys are what made the gap load-bearing:
1976
+ // they are mutually exclusive, so switching between them is a deleted
1977
+ // line plus an added one, and a deleted line that does not clear leaves
1978
+ // the merged config refused as "both set".
1979
+ reasoningEffort: tomlOwned,
1980
+ reasoningBudget: tomlOwned,
1954
1981
  },
1955
1982
  });
1956
1983
  }
@@ -2162,6 +2189,348 @@ function localConfigRowKey(label, tomlData, file) {
2162
2189
  return fallback;
2163
2190
  }
2164
2191
  }
2192
+ /**
2193
+ * `config push`'s preflight — every check the authored files decide on their
2194
+ * own, run before the FIRST mutating call (#976 fix A, #3320, #3375).
2195
+ *
2196
+ * The block below was inline inside the push command's `.action()` callback
2197
+ * until #3375 lifted it here unchanged. It had to become an exported symbol
2198
+ * for the registry's promise to be checkable at all: a rule declared
2199
+ * `preflight` says it runs before `config push` mutates anything, and
2200
+ * `src/config-surface/preflight-reachability.ts` resolves that claim
2201
+ * statically from the declared `<module>#<export>` against exactly this entry
2202
+ * point. With the preflight anonymous, "reached from the preflight" had
2203
+ * nothing to resolve against, and the stage string was satisfied by typing it.
2204
+ *
2205
+ * Its boundary is therefore precise and load-bearing: this function is what
2206
+ * contributes to `preflightValidationErrors` and aborts. The advisory reads
2207
+ * that follow the abort in the action — the remote vars snapshot and both caps
2208
+ * — stay there and are declared `apply`, because the server is what enforces
2209
+ * them (`intent` §Non-goals, "Emptying the apply stage").
2210
+ *
2211
+ * A network call inside a preflight is not disqualifying: `listIntegrations`
2212
+ * was already here, answering "will this `integration:` grant have a target?"
2213
+ * against the live listing merged with the tree, and falling back to the tree
2214
+ * alone when the listing cannot be fetched.
2215
+ *
2216
+ * `input` names what the block used to close over. `client` is narrowed to the
2217
+ * one method it calls, so an apply call cannot drift in here later without
2218
+ * that being a visible change to this signature.
2219
+ */
2220
+ export async function runConfigPushPreflight(input) {
2221
+ const { configDir, resolvedAppId, pushSelection, syncState, force, isCrossAppPush, client, } = input;
2222
+ /**
2223
+ * Narrow a type's file list to the `--only` selection — the same one-line
2224
+ * closure the action holds, rebuilt from the selection it passed in rather
2225
+ * than handed across the boundary as a function.
2226
+ */
2227
+ const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
2228
+ // ── Issue #976 (fix A): front-load ALL client-side TOML validation
2229
+ // before the first mutating call. Push is apply-as-you-go and processes
2230
+ // entities in a fixed sequence (cron triggers are created *before*
2231
+ // workflows are validated). Previously each entity's validation lived
2232
+ // inside its own apply loop and aborted via `process.exit(1)` — so a
2233
+ // known-bad sibling file (e.g. an invalid workflow TOML) could let an
2234
+ // earlier create (a cron trigger) be applied, then abort. The
2235
+ // `process.exit` also bypassed the save-on-failure catch below, so the
2236
+ // orphaned create never reached the sync-state baseline and the retry
2237
+ // re-issued a CREATE that the server rejected with 409 forever.
2238
+ //
2239
+ // Running every validation up-front means a known-bad file aborts
2240
+ // BEFORE anything is created. We collect all errors (not just the first)
2241
+ // and THROW — so the single save-on-failure catch is the only exit path
2242
+ // (no more `process.exit` bypass class). The per-loop validators below
2243
+ // remain as defense-in-depth but are converted to throws too.
2244
+ const preflightValidationErrors = [];
2245
+ // Validate all workflow TOMLs (#685 misnested-header check and the
2246
+ // #1447 system + accessRule lint).
2247
+ const preflightWorkflowsDir = join(configDir, "workflows");
2248
+ if (existsSync(preflightWorkflowsDir)) {
2249
+ // Under `--only` the preflight validates just the selected files:
2250
+ // an unrelated broken workflow elsewhere in the slot must not block
2251
+ // applying the one object the operator asked for.
2252
+ for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
2253
+ const filePath = join(preflightWorkflowsDir, file);
2254
+ try {
2255
+ const tomlData = parseTomlFile(filePath);
2256
+ // #1660 — skip platform-owned workflows (the reserved
2257
+ // `__internal.*` namespace) BEFORE validating, mirroring the
2258
+ // apply loop below. `config push` validates every workflow TOML
2259
+ // here, up-front, before reaching that skip — so without this
2260
+ // second skip a stale/hollow `__internal.*.toml` with an invalid
2261
+ // `steps` shape would abort the whole push in preflight. Resolve
2262
+ // the key the same way the apply loop does (`[workflow].key`,
2263
+ // falling back to the filename) and match on the resolved key,
2264
+ // never the filename alone, so a renamed file is still caught.
2265
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
2266
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
2267
+ continue;
2268
+ }
2269
+ const tomlErrors = validateWorkflowToml(tomlData);
2270
+ if (tomlErrors.length > 0) {
2271
+ preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
2272
+ }
2273
+ // #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
2274
+ // now no-ops). Printed once per file at pre-flight so the author
2275
+ // sees them whether or not the push goes on to apply.
2276
+ for (const note of collectWorkflowTomlNotes(tomlData)) {
2277
+ console.log(` ${filePath}: ${note}`);
2278
+ }
2279
+ const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
2280
+ if (unknownKeyError) {
2281
+ preflightValidationErrors.push(` ${unknownKeyError}`);
2282
+ }
2283
+ // #2743 — the always-send update payload cannot carry a missing
2284
+ // `[workflow].name`. That is a local TOML error like every other
2285
+ // one here, so it belongs in the front-loaded pass (#976): caught
2286
+ // in the apply loop it would abort AFTER earlier resources (app
2287
+ // settings, cron triggers, …) had already been written, and
2288
+ // `--dry-run` — which never reaches the apply — would plan an
2289
+ // update the real push then refuses.
2290
+ //
2291
+ // Scoped to the workflows this push will actually UPDATE. A
2292
+ // create falls back to the manifest key, so an absent name is
2293
+ // legitimate authoring there — and once that create lands, the
2294
+ // same unchanged file must keep pushing clean (the apply loop
2295
+ // skips it on its content hash) instead of failing a push that
2296
+ // was never going to send it.
2297
+ if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, force)) {
2298
+ const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
2299
+ if (nameError) {
2300
+ preflightValidationErrors.push(` ${nameError}`);
2301
+ }
2302
+ }
2303
+ }
2304
+ catch (err) {
2305
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2306
+ }
2307
+ }
2308
+ }
2309
+ // Validate all database-type TOMLs (issue #803 subscription
2310
+ // access/accessRule conflict + issue #752 $params references).
2311
+ const preflightDbTypesDir = join(configDir, "database-type-configs");
2312
+ if (existsSync(preflightDbTypesDir)) {
2313
+ for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
2314
+ const filePath = join(preflightDbTypesDir, file);
2315
+ let rawToml;
2316
+ let tomlData;
2317
+ try {
2318
+ rawToml = readFileSync(filePath, "utf-8");
2319
+ tomlData = parseTomlFile(filePath);
2320
+ }
2321
+ catch (err) {
2322
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2323
+ continue;
2324
+ }
2325
+ let operations = [];
2326
+ try {
2327
+ ({ operations } = parseDatabaseTypeToml(tomlData));
2328
+ }
2329
+ catch (err) {
2330
+ if (err instanceof SubscriptionAccessKeyConflictError) {
2331
+ preflightValidationErrors.push(` ${file}: ${err.message}`);
2332
+ continue;
2333
+ }
2334
+ preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2335
+ continue;
2336
+ }
2337
+ const validation = validateOperations({
2338
+ filePath,
2339
+ rawToml,
2340
+ operations,
2341
+ });
2342
+ for (const e of validation.errors) {
2343
+ preflightValidationErrors.push(` ${formatIssue(e)}`);
2344
+ }
2345
+ // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
2346
+ // / `metadataAccess` gate on a database type config is a single CEL
2347
+ // expression that gates BOTH read and update of the database CEL
2348
+ // context — granting read implicitly grants update. Steer operators
2349
+ // to resource metadata categories (separate readRule/writeRule).
2350
+ // This is advisory only: the push continues and the runtime gate is
2351
+ // unchanged. It fires only when the deprecated key is actually
2352
+ // present, so metadata-category-only configs never trip it.
2353
+ const typeSection = tomlData.type || {};
2354
+ if (typeSection.celContextAccess || typeSection.metadataAccess) {
2355
+ const usedKey = typeSection.celContextAccess
2356
+ ? "celContextAccess"
2357
+ : "metadataAccess";
2358
+ 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.`);
2359
+ }
2360
+ }
2361
+ }
2362
+ // Validate app.toml settings up-front (issue #1033). A mistyped value
2363
+ // is a hard error that must abort BEFORE any mutation — same
2364
+ // fail-before-mutate contract as the vars preflight below. The apply
2365
+ // block re-parses for the payload.
2366
+ // #2645 — bounded by `--only`, like the workflow and database-type
2367
+ // preflights above. `config push --only workflow/x` is the replacement for
2368
+ // `workflows update --from-file`, so it must work on a tree where
2369
+ // app.toml happens to be mid-edit.
2370
+ const preflightAppTomlPath = join(configDir, "app.toml");
2371
+ if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
2372
+ try {
2373
+ const parsedApp = parseTomlFile(preflightAppTomlPath);
2374
+ const appErrors = collectAppSettingsPushErrors(parsedApp, {
2375
+ // #2880 DSO-001 — the same exclusion the apply block computes, so
2376
+ // the preflight validates the body that will actually be sent.
2377
+ excludeName: appSettingsNameExcluded({
2378
+ isCrossAppPush: !!isCrossAppPush,
2379
+ recordedExcludedName: syncState?.entities?.app?.excludedName,
2380
+ fileName: parsedApp?.app?.name,
2381
+ }),
2382
+ });
2383
+ for (const e of appErrors) {
2384
+ preflightValidationErrors.push(` app.toml: ${e}`);
2385
+ }
2386
+ }
2387
+ catch (err) {
2388
+ preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
2389
+ }
2390
+ }
2391
+ // Validate config vars up-front (issue #1423 review). Parse vars.toml
2392
+ // and check the same key/value constraints the server enforces here, in
2393
+ // the preflight pass, so an invalid entry aborts BEFORE any mutation
2394
+ // (app settings or a var upsert) is applied. Previously validation ran
2395
+ // mid-push, after app settings could already have been updated.
2396
+ // #2645 — bounded by `--only`, for the same reason as app.toml above.
2397
+ const preflightVarsTomlPath = join(configDir, "vars.toml");
2398
+ const varsSelected = selectionIncludesAnyVar(pushSelection);
2399
+ let preflightParsedVars = null;
2400
+ if (varsSelected && existsSync(preflightVarsTomlPath)) {
2401
+ try {
2402
+ const parsed = parseTomlFile(preflightVarsTomlPath);
2403
+ // #2645 — under `--only var/<key>` only the selected entries are
2404
+ // checked. The rest of the file is not being written, and aborting
2405
+ // over an entry this push will not touch is the same over-reach as
2406
+ // running the leg at all for an unselected singleton.
2407
+ for (const e of validateVarsFile(parsed, {
2408
+ onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
2409
+ })) {
2410
+ preflightValidationErrors.push(` ${e}`);
2411
+ }
2412
+ preflightParsedVars = parsed;
2413
+ }
2414
+ catch (err) {
2415
+ preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
2416
+ }
2417
+ }
2418
+ // #3182 — a function's capabilities, checked against the author's own
2419
+ // file before anything is applied. The grammar is purely local (and
2420
+ // since #3279 it keeps three kinds: `integration:<key>`,
2421
+ // `secret:<NAME>` and the high-blast opt-ins — a retired grant string
2422
+ // is refused here with the retirement); whether an `integration:`
2423
+ // names an integration that will exist is answered against the
2424
+ // tree's own `integrations/` (applied BEFORE functions) merged with
2425
+ // the integrations the server already has. If the live listing cannot
2426
+ // be fetched and the tree declares none, the target check is skipped
2427
+ // and the server — which is authoritative — has the last word.
2428
+ const preflightFunctionsDir = join(configDir, "functions");
2429
+ if (existsSync(preflightFunctionsDir)) {
2430
+ const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter(isFunctionDeclarationFile));
2431
+ if (functionFiles.length > 0) {
2432
+ const treeTargets = configObjectKeysFromTree(configDir);
2433
+ let knownTargets = null;
2434
+ try {
2435
+ const integrations = await client.listIntegrations(resolvedAppId, {
2436
+ status: "active",
2437
+ });
2438
+ knownTargets = mergeKnownGrantTargets({
2439
+ integrations: new Set((integrations?.items ?? [])
2440
+ .map((row) => String(row?.integrationKey ?? ""))
2441
+ .filter(Boolean)),
2442
+ }, treeTargets);
2443
+ }
2444
+ catch {
2445
+ knownTargets =
2446
+ treeTargets.integrations.size > 0 ? treeTargets : null;
2447
+ }
2448
+ for (const file of functionFiles) {
2449
+ const filePath = join(preflightFunctionsDir, file);
2450
+ let tomlData;
2451
+ try {
2452
+ tomlData = parseTomlFile(filePath);
2453
+ }
2454
+ catch (err) {
2455
+ preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
2456
+ continue;
2457
+ }
2458
+ for (const message of functionGrantErrors({
2459
+ filePath,
2460
+ tomlData,
2461
+ knownTargets,
2462
+ })) {
2463
+ preflightValidationErrors.push(` ${message}`);
2464
+ }
2465
+ // #3320 — and everything else the file decides on its own: the
2466
+ // `entry`, the mode the two spellings resolve to, the trigger
2467
+ // rules, and a webhook trigger's signing-secret requirement and
2468
+ // reference shape. These used to be raised inside the apply loop,
2469
+ // after webhooks, integrations, vars and database types had
2470
+ // already been written — the partial tree this issue is about.
2471
+ // Bounded by the same `selectFiles` above, so `--only` keeps its
2472
+ // posture (#2645).
2473
+ const functionKey = tomlData?.function?.key || basename(file, ".toml");
2474
+ for (const message of functionTomlPreflightErrors({
2475
+ filePath,
2476
+ functionKey,
2477
+ tomlData,
2478
+ })) {
2479
+ preflightValidationErrors.push(` ${filePath}: ${message}`);
2480
+ }
2481
+ // #3482 — the retired runtime keys. NOT refusals through phase 4:
2482
+ // the file pushes with every other key applied, and the author
2483
+ // hears which line to delete before phase 5 refuses it. On
2484
+ // stderr, like every other advisory, so a scripted push reading
2485
+ // stdout is unaffected. Inside this loop rather than over the
2486
+ // whole tree, so `--only` bounds the advice as it bounds the
2487
+ // refusals (#2645).
2488
+ for (const message of functionTomlIgnoredKeyWarnings({
2489
+ functionKey,
2490
+ tomlData,
2491
+ })) {
2492
+ warn(` ${message}`);
2493
+ }
2494
+ }
2495
+ }
2496
+ }
2497
+ // #2644 — reject any key the vendored definition does not recognize,
2498
+ // for every migrated configuration type. Same posture the `[workflow]`
2499
+ // check above has, generalized: a mistyped key fails here, in the
2500
+ // preflight pass, so nothing is applied and no request is issued —
2501
+ // rather than being dropped on the way to the server and reading as a
2502
+ // successful push that changed nothing.
2503
+ // #2880 — the same collector `config diff` runs, so the two commands
2504
+ // cannot disagree about which files are pushable.
2505
+ for (const rejected of collectConfigFileValidationErrors(configDir)) {
2506
+ for (const message of rejected.messages) {
2507
+ preflightValidationErrors.push(` ${message}`);
2508
+ }
2509
+ }
2510
+ // The `<key>.tests/*.toml` sidecars, which sit beside the block they
2511
+ // test rather than in a type directory of their own. Same posture,
2512
+ // reached through the registry's `test-case` surface — and through the
2513
+ // same collector `config diff` runs per sidecar (#2880), so neither
2514
+ // command can refuse a file the other calls Synced.
2515
+ for (const filePath of testCaseTomlFiles(configDir)) {
2516
+ let tomlData;
2517
+ try {
2518
+ tomlData = parseTomlFile(filePath);
2519
+ }
2520
+ catch {
2521
+ // Reported by the per-block test-case loop with its own message.
2522
+ continue;
2523
+ }
2524
+ for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
2525
+ preflightValidationErrors.push(` ${message}`);
2526
+ }
2527
+ }
2528
+ if (preflightValidationErrors.length > 0) {
2529
+ throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
2530
+ preflightValidationErrors.join("\n"));
2531
+ }
2532
+ return { parsedVars: preflightParsedVars };
2533
+ }
2165
2534
  /**
2166
2535
  * Every per-entity config file whose TOML `config push` would refuse (#2880
2167
2536
  * criterion 1, symmetry).
@@ -2183,7 +2552,11 @@ export function collectConfigFileValidationErrors(configDir) {
2183
2552
  const typeDir = join(configDir, dir);
2184
2553
  if (!existsSync(typeDir))
2185
2554
  continue;
2186
- for (const file of readdirSync(typeDir).filter((f) => f.endsWith(".toml"))) {
2555
+ for (const file of readdirSync(typeDir).filter((f) =>
2556
+ // #3455 — `functions/` holds one GENERATED `.toml` beside the
2557
+ // declarations: the project's document schema, which is not a config
2558
+ // object and would be read as a function named after the file.
2559
+ dir === "functions" ? isFunctionDeclarationFile(f) : f.endsWith(".toml"))) {
2187
2560
  const filePath = join(typeDir, file);
2188
2561
  let tomlData;
2189
2562
  try {
@@ -2241,7 +2614,7 @@ export function collectFunctionTomlValidationErrors(configDir) {
2241
2614
  const functionsDir = join(configDir, "functions");
2242
2615
  if (!existsSync(functionsDir))
2243
2616
  return found;
2244
- for (const file of readdirSync(functionsDir).filter((f) => f.endsWith(".toml"))) {
2617
+ for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
2245
2618
  const filePath = join(functionsDir, file);
2246
2619
  let tomlData;
2247
2620
  try {
@@ -2264,6 +2637,35 @@ export function collectFunctionTomlValidationErrors(configDir) {
2264
2637
  }
2265
2638
  return found;
2266
2639
  }
2640
+ /**
2641
+ * The retired runtime keys every `functions/*.toml` in the tree still carries
2642
+ * — #3482, the warning half of the collector above.
2643
+ *
2644
+ * Separate from the errors because it IS separate: a file carrying one of
2645
+ * these pushes, with every other key applied, so it is not a row `config diff`
2646
+ * calls invalid. It is a line an author reads on stderr from both commands, so
2647
+ * the two keep reading identically (the #2880 symmetry the errors collector
2648
+ * exists for, applied to advice rather than refusals).
2649
+ */
2650
+ export function collectFunctionTomlIgnoredKeyWarnings(configDir) {
2651
+ const found = [];
2652
+ const functionsDir = join(configDir, "functions");
2653
+ if (!existsSync(functionsDir))
2654
+ return found;
2655
+ for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
2656
+ const filePath = join(functionsDir, file);
2657
+ let tomlData;
2658
+ try {
2659
+ tomlData = parseTomlFile(filePath);
2660
+ }
2661
+ catch {
2662
+ continue;
2663
+ }
2664
+ const key = tomlData?.function?.key || basename(file, ".toml");
2665
+ found.push(...functionTomlIgnoredKeyWarnings({ functionKey: key, tomlData }));
2666
+ }
2667
+ return found;
2668
+ }
2267
2669
  /**
2268
2670
  * The collectors' rows, one per diff row (#2880 symmetry, #3320).
2269
2671
  *
@@ -4945,6 +5347,8 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
4945
5347
  const functionsDir = join(configDir, "functions");
4946
5348
  ensureDir(functionsDir);
4947
5349
  const functionEntities = {};
5350
+ /** What each pulled version says about the document schema — CR3455-001. */
5351
+ const pulledSchemas = [];
4948
5352
  let items = [];
4949
5353
  try {
4950
5354
  const result = await client.listFunctions(appId);
@@ -4992,11 +5396,21 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
4992
5396
  }
4993
5397
  let active;
4994
5398
  let envelope;
5399
+ let newest = null;
4995
5400
  try {
4996
- const configs = await client.listFunctionConfigs(appId, record.functionId);
4997
- active = (configs.items || []).find((c) => String(c.configId) === String(record.activeConfigId));
5401
+ // #3462 — the point read. The listing is a page now, so scanning it for
5402
+ // the id would miss a rolled-back function's active version past page
5403
+ // one; the separate one-row read below is only asking whether a NEWER
5404
+ // version exists, which is what the message after the write says.
5405
+ active = record.activeConfigId
5406
+ ? await client.getFunctionConfig(appId, record.functionId, String(record.activeConfigId))
5407
+ : null;
4998
5408
  if (active) {
4999
5409
  envelope = await client.getFunctionConfigEnvelope(appId, record.functionId, active.configId);
5410
+ const page = await client.listFunctionConfigs(appId, record.functionId, {
5411
+ limit: 1,
5412
+ });
5413
+ newest = (page.items || [])[0] ?? null;
5000
5414
  }
5001
5415
  }
5002
5416
  catch {
@@ -5009,9 +5423,30 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
5009
5423
  logger(` Skipped functions/${key}.toml (active version not readable)`);
5010
5424
  continue;
5011
5425
  }
5012
- const plan = planFunctionPull({ configDir, functionKey: key, envelope });
5426
+ // #3455 / CR3455-001 — the generated schema copy is ONE file the whole
5427
+ // tree resolves through, so this plan does not decide it. Every pulled
5428
+ // version's answer is collected and the copy is settled once, after the
5429
+ // loop; otherwise the last function applied would delete or overwrite the
5430
+ // copy an earlier one needs.
5431
+ const plan = planFunctionPull({
5432
+ configDir,
5433
+ functionKey: key,
5434
+ envelope,
5435
+ includeDocumentSchema: false,
5436
+ });
5437
+ pulledSchemas.push({
5438
+ functionKey: key,
5439
+ documentSchema: typeof envelope?.documentSchema === "string" && envelope.documentSchema
5440
+ ? envelope.documentSchema
5441
+ : null,
5442
+ });
5013
5443
  for (const refusal of plan.refusals)
5014
5444
  logger(` ${refusal}`);
5445
+ // #3482 — the retired runtime keys this pull removed on the way out. Not
5446
+ // a refusal: the file IS written, with every other byte unchanged, and
5447
+ // this is what says so rather than editing a developer's file in silence.
5448
+ for (const note of plan.notes)
5449
+ logger(` ${note}`);
5015
5450
  const applied = applyFunctionPull(plan);
5016
5451
  for (const refusal of applied.refusals)
5017
5452
  logger(` ${refusal}`);
@@ -5025,11 +5460,34 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
5025
5460
  functionEntities[key] = entity;
5026
5461
  written += 1;
5027
5462
  logger(` Wrote functions/${key}.toml and ${applied.writtenSources.length} source file(s)`);
5463
+ // #3462 — pull writes the ACTIVE version's bytes, which is right: the tree
5464
+ // should describe what runs. On a rolled-back function that silently
5465
+ // discards a newer version's code from the tree, so the line names both
5466
+ // versions and both remedies rather than leaving the author to discover it
5467
+ // at the next push.
5468
+ const newerLine = newerVersionLine(newest, active.configId, (value) => value);
5469
+ if (newerLine) {
5470
+ logger(` ${pullNewerVersionLine(key, String(active.configId), newest, (value) => value)}`);
5471
+ }
5028
5472
  }
5029
5473
  const serverKeys = items
5030
5474
  .filter((f) => String(f?.status ?? "active") !== "archived")
5031
5475
  .map((f) => f?.functionKey)
5032
5476
  .filter(Boolean);
5477
+ // #3455 / CR3455-001 — the tree's one generated schema copy, settled from
5478
+ // every version this pull read. Removing it is only safe when the pull read
5479
+ // them ALL: a selector, or a function whose version could not be fetched,
5480
+ // leaves a version whose schema this pull knows nothing about.
5481
+ const schemaPlan = planDocumentSchemaPull({
5482
+ configDir,
5483
+ functions: pulledSchemas,
5484
+ coversEveryFunction: detailsOk && serverKeys.every((k) => selects(k)),
5485
+ });
5486
+ for (const warning of schemaPlan.warnings)
5487
+ logger(` ${warning}`);
5488
+ const schemaApplied = applyFunctionPull(schemaPlan);
5489
+ for (const refusal of schemaApplied.refusals)
5490
+ logger(` ${refusal}`);
5033
5491
  const presence = detailsOk
5034
5492
  ? { ok: true, serverKeys }
5035
5493
  : { ok: false };
@@ -7926,14 +8384,6 @@ What a push guarantees (two stages):
7926
8384
  // `syncCallable` PATCH ordering a second implementation has broken before.
7927
8385
  // Everything else about push is unchanged.
7928
8386
  const pushSelection = readSelection(options);
7929
- if (pushSelection) {
7930
- const missing = unmatchedSelectors(pushSelection, configDir);
7931
- if (missing.length > 0) {
7932
- error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
7933
- process.exit(1);
7934
- }
7935
- info(`Applying only: ${pushSelection.raw.join(", ")}`);
7936
- }
7937
8387
  /** Narrow a type's file list to the `--only` selection. */
7938
8388
  const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
7939
8389
  /**
@@ -7967,6 +8417,29 @@ What a push guarantees (two stages):
7967
8417
  entities: {},
7968
8418
  };
7969
8419
  }
8420
+ // A `--only` selector that would apply nothing is refused before push
8421
+ // runs, so `--only workflow/typo` fails loudly instead of iterating empty
8422
+ // lists and reporting success.
8423
+ //
8424
+ // Checked HERE, after the baseline is loaded and after the cross-app reset
8425
+ // above, rather than beside `readSelection` where it used to sit (#3398).
8426
+ // `--prune` acts on keys the sync state tracks and NO local file declares,
8427
+ // so a file-only check refused the one command that is supposed to run with
8428
+ // the file gone: `--only workflow/household-forget --prune` reported `No
8429
+ // local file declares: workflow/household-forget` and left no way to retire
8430
+ // one tracked key without restoring its TOML (which prune would then
8431
+ // decline to consider) or dropping `--only` and pushing the whole tree.
8432
+ // The state has to be in hand to tell that key from a typo, and the
8433
+ // cross-app reset has to have happened first: it clears the entity ids, so
8434
+ // there is nothing for prune to retire and a removed key IS a typo again.
8435
+ if (pushSelection) {
8436
+ const missing = unmatchedSelectors(pushSelection, configDir, trackedSelectorKeys(syncState, { prune: !!options.prune }));
8437
+ if (missing.length > 0) {
8438
+ error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
8439
+ process.exit(1);
8440
+ }
8441
+ info(`Applying only: ${pushSelection.raw.join(", ")}`);
8442
+ }
7970
8443
  info(`Pushing configuration for app ${resolvedAppId}...`);
7971
8444
  // The state this push STARTS from, so the failure path below can tell a
7972
8445
  // run that recorded something from one that recorded nothing (#2936).
@@ -8354,297 +8827,25 @@ What a push guarantees (two stages):
8354
8827
  pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
8355
8828
  }
8356
8829
  };
8357
- // ── Issue #976 (fix A): front-load ALL client-side TOML validation
8358
- // before the first mutating call. Push is apply-as-you-go and processes
8359
- // entities in a fixed sequence (cron triggers are created *before*
8360
- // workflows are validated). Previously each entity's validation lived
8361
- // inside its own apply loop and aborted via `process.exit(1)` — so a
8362
- // known-bad sibling file (e.g. an invalid workflow TOML) could let an
8363
- // earlier create (a cron trigger) be applied, then abort. The
8364
- // `process.exit` also bypassed the save-on-failure catch below, so the
8365
- // orphaned create never reached the sync-state baseline and the retry
8366
- // re-issued a CREATE that the server rejected with 409 forever.
8367
- //
8368
- // Running every validation up-front means a known-bad file aborts
8369
- // BEFORE anything is created. We collect all errors (not just the first)
8370
- // and THROW — so the single save-on-failure catch is the only exit path
8371
- // (no more `process.exit` bypass class). The per-loop validators below
8372
- // remain as defense-in-depth but are converted to throws too.
8373
- const preflightValidationErrors = [];
8374
- // Validate all workflow TOMLs (#685 misnested-header check and the
8375
- // #1447 system + accessRule lint).
8376
- const preflightWorkflowsDir = join(configDir, "workflows");
8377
- if (existsSync(preflightWorkflowsDir)) {
8378
- // Under `--only` the preflight validates just the selected files:
8379
- // an unrelated broken workflow elsewhere in the slot must not block
8380
- // applying the one object the operator asked for.
8381
- for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
8382
- const filePath = join(preflightWorkflowsDir, file);
8383
- try {
8384
- const tomlData = parseTomlFile(filePath);
8385
- // #1660 — skip platform-owned workflows (the reserved
8386
- // `__internal.*` namespace) BEFORE validating, mirroring the
8387
- // apply loop below. `config push` validates every workflow TOML
8388
- // here, up-front, before reaching that skip — so without this
8389
- // second skip a stale/hollow `__internal.*.toml` with an invalid
8390
- // `steps` shape would abort the whole push in preflight. Resolve
8391
- // the key the same way the apply loop does (`[workflow].key`,
8392
- // falling back to the filename) and match on the resolved key,
8393
- // never the filename alone, so a renamed file is still caught.
8394
- const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
8395
- if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
8396
- continue;
8397
- }
8398
- const tomlErrors = validateWorkflowToml(tomlData);
8399
- if (tomlErrors.length > 0) {
8400
- preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
8401
- }
8402
- // #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
8403
- // now no-ops). Printed once per file at pre-flight so the author
8404
- // sees them whether or not the push goes on to apply.
8405
- for (const note of collectWorkflowTomlNotes(tomlData)) {
8406
- console.log(` ${filePath}: ${note}`);
8407
- }
8408
- const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
8409
- if (unknownKeyError) {
8410
- preflightValidationErrors.push(` ${unknownKeyError}`);
8411
- }
8412
- // #2743 — the always-send update payload cannot carry a missing
8413
- // `[workflow].name`. That is a local TOML error like every other
8414
- // one here, so it belongs in the front-loaded pass (#976): caught
8415
- // in the apply loop it would abort AFTER earlier resources (app
8416
- // settings, cron triggers, …) had already been written, and
8417
- // `--dry-run` — which never reaches the apply — would plan an
8418
- // update the real push then refuses.
8419
- //
8420
- // Scoped to the workflows this push will actually UPDATE. A
8421
- // create falls back to the manifest key, so an absent name is
8422
- // legitimate authoring there — and once that create lands, the
8423
- // same unchanged file must keep pushing clean (the apply loop
8424
- // skips it on its content hash) instead of failing a push that
8425
- // was never going to send it.
8426
- if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, options.force)) {
8427
- const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
8428
- if (nameError) {
8429
- preflightValidationErrors.push(` ${nameError}`);
8430
- }
8431
- }
8432
- }
8433
- catch (err) {
8434
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8435
- }
8436
- }
8437
- }
8438
- // Validate all database-type TOMLs (issue #803 subscription
8439
- // access/accessRule conflict + issue #752 $params references).
8440
- const preflightDbTypesDir = join(configDir, "database-type-configs");
8441
- if (existsSync(preflightDbTypesDir)) {
8442
- for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
8443
- const filePath = join(preflightDbTypesDir, file);
8444
- let rawToml;
8445
- let tomlData;
8446
- try {
8447
- rawToml = readFileSync(filePath, "utf-8");
8448
- tomlData = parseTomlFile(filePath);
8449
- }
8450
- catch (err) {
8451
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8452
- continue;
8453
- }
8454
- let operations = [];
8455
- try {
8456
- ({ operations } = parseDatabaseTypeToml(tomlData));
8457
- }
8458
- catch (err) {
8459
- if (err instanceof SubscriptionAccessKeyConflictError) {
8460
- preflightValidationErrors.push(` ${file}: ${err.message}`);
8461
- continue;
8462
- }
8463
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8464
- continue;
8465
- }
8466
- const validation = validateOperations({
8467
- filePath,
8468
- rawToml,
8469
- operations,
8470
- });
8471
- for (const e of validation.errors) {
8472
- preflightValidationErrors.push(` ${formatIssue(e)}`);
8473
- }
8474
- // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
8475
- // / `metadataAccess` gate on a database type config is a single CEL
8476
- // expression that gates BOTH read and update of the database CEL
8477
- // context — granting read implicitly grants update. Steer operators
8478
- // to resource metadata categories (separate readRule/writeRule).
8479
- // This is advisory only: the push continues and the runtime gate is
8480
- // unchanged. It fires only when the deprecated key is actually
8481
- // present, so metadata-category-only configs never trip it.
8482
- const typeSection = tomlData.type || {};
8483
- if (typeSection.celContextAccess || typeSection.metadataAccess) {
8484
- const usedKey = typeSection.celContextAccess
8485
- ? "celContextAccess"
8486
- : "metadataAccess";
8487
- 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.`);
8488
- }
8489
- }
8490
- }
8491
- // Validate app.toml settings up-front (issue #1033). A mistyped value
8492
- // is a hard error that must abort BEFORE any mutation — same
8493
- // fail-before-mutate contract as the vars preflight below. The apply
8494
- // block re-parses for the payload.
8495
- // #2645 — bounded by `--only`, like the workflow and database-type
8496
- // preflights above. `config push --only workflow/x` is the replacement for
8497
- // `workflows update --from-file`, so it must work on a tree where
8498
- // app.toml happens to be mid-edit.
8499
- const preflightAppTomlPath = join(configDir, "app.toml");
8500
- if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
8501
- try {
8502
- const parsedApp = parseTomlFile(preflightAppTomlPath);
8503
- const appErrors = collectAppSettingsPushErrors(parsedApp, {
8504
- // #2880 DSO-001 — the same exclusion the apply block computes, so
8505
- // the preflight validates the body that will actually be sent.
8506
- excludeName: appSettingsNameExcluded({
8507
- isCrossAppPush: !!isCrossAppPush,
8508
- recordedExcludedName: syncState?.entities?.app?.excludedName,
8509
- fileName: parsedApp?.app?.name,
8510
- }),
8511
- });
8512
- for (const e of appErrors) {
8513
- preflightValidationErrors.push(` app.toml: ${e}`);
8514
- }
8515
- }
8516
- catch (err) {
8517
- preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
8518
- }
8519
- }
8520
- // Validate config vars up-front (issue #1423 review). Parse vars.toml
8521
- // and check the same key/value constraints the server enforces here, in
8522
- // the preflight pass, so an invalid entry aborts BEFORE any mutation
8523
- // (app settings or a var upsert) is applied. Previously validation ran
8524
- // mid-push, after app settings could already have been updated.
8525
- // #2645 — bounded by `--only`, for the same reason as app.toml above.
8830
+ // #3375 — the front-loaded pass now lives in one exported entry point,
8831
+ // `runConfigPushPreflight`, so a rule declared `preflight` can be
8832
+ // proved to be reached from it before anything below mutates. Nothing
8833
+ // about what it checks, the order it checks it, or what it prints
8834
+ // moved with it.
8835
+ const { parsedVars: preflightParsedVars } = await runConfigPushPreflight({
8836
+ configDir,
8837
+ resolvedAppId,
8838
+ pushSelection,
8839
+ syncState,
8840
+ force: !!options.force,
8841
+ isCrossAppPush: !!isCrossAppPush,
8842
+ client,
8843
+ });
8844
+ // Re-derived, not returned: both are one-line functions of inputs the
8845
+ // entry point already took, and widening its return type to carry them
8846
+ // would make the vars apply leg depend on the preflight's internals.
8526
8847
  const preflightVarsTomlPath = join(configDir, "vars.toml");
8527
8848
  const varsSelected = selectionIncludesAnyVar(pushSelection);
8528
- let preflightParsedVars = null;
8529
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
8530
- try {
8531
- const parsed = parseTomlFile(preflightVarsTomlPath);
8532
- // #2645 — under `--only var/<key>` only the selected entries are
8533
- // checked. The rest of the file is not being written, and aborting
8534
- // over an entry this push will not touch is the same over-reach as
8535
- // running the leg at all for an unselected singleton.
8536
- for (const e of validateVarsFile(parsed, {
8537
- onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
8538
- })) {
8539
- preflightValidationErrors.push(` ${e}`);
8540
- }
8541
- preflightParsedVars = parsed;
8542
- }
8543
- catch (err) {
8544
- preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
8545
- }
8546
- }
8547
- // #3182 — a function's capabilities, checked against the author's own
8548
- // file before anything is applied. The grammar is purely local (and
8549
- // since #3279 it keeps three kinds: `integration:<key>`,
8550
- // `secret:<NAME>` and the high-blast opt-ins — a retired grant string
8551
- // is refused here with the retirement); whether an `integration:`
8552
- // names an integration that will exist is answered against the
8553
- // tree's own `integrations/` (applied BEFORE functions) merged with
8554
- // the integrations the server already has. If the live listing cannot
8555
- // be fetched and the tree declares none, the target check is skipped
8556
- // and the server — which is authoritative — has the last word.
8557
- const preflightFunctionsDir = join(configDir, "functions");
8558
- if (existsSync(preflightFunctionsDir)) {
8559
- const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
8560
- if (functionFiles.length > 0) {
8561
- const treeTargets = configObjectKeysFromTree(configDir);
8562
- let knownTargets = null;
8563
- try {
8564
- const integrations = await client.listIntegrations(resolvedAppId, {
8565
- status: "active",
8566
- });
8567
- knownTargets = mergeKnownGrantTargets({
8568
- integrations: new Set((integrations?.items ?? [])
8569
- .map((row) => String(row?.integrationKey ?? ""))
8570
- .filter(Boolean)),
8571
- }, treeTargets);
8572
- }
8573
- catch {
8574
- knownTargets =
8575
- treeTargets.integrations.size > 0 ? treeTargets : null;
8576
- }
8577
- for (const file of functionFiles) {
8578
- const filePath = join(preflightFunctionsDir, file);
8579
- let tomlData;
8580
- try {
8581
- tomlData = parseTomlFile(filePath);
8582
- }
8583
- catch (err) {
8584
- preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
8585
- continue;
8586
- }
8587
- for (const message of functionGrantErrors({
8588
- filePath,
8589
- tomlData,
8590
- knownTargets,
8591
- })) {
8592
- preflightValidationErrors.push(` ${message}`);
8593
- }
8594
- // #3320 — and everything else the file decides on its own: the
8595
- // `entry`, the mode the two spellings resolve to, the trigger
8596
- // rules, and a webhook trigger's signing-secret requirement and
8597
- // reference shape. These used to be raised inside the apply loop,
8598
- // after webhooks, integrations, vars and database types had
8599
- // already been written — the partial tree this issue is about.
8600
- // Bounded by the same `selectFiles` above, so `--only` keeps its
8601
- // posture (#2645).
8602
- const functionKey = tomlData?.function?.key || basename(file, ".toml");
8603
- for (const message of functionTomlPreflightErrors({
8604
- filePath,
8605
- functionKey,
8606
- tomlData,
8607
- })) {
8608
- preflightValidationErrors.push(` ${filePath}: ${message}`);
8609
- }
8610
- }
8611
- }
8612
- }
8613
- // #2644 — reject any key the vendored definition does not recognize,
8614
- // for every migrated configuration type. Same posture the `[workflow]`
8615
- // check above has, generalized: a mistyped key fails here, in the
8616
- // preflight pass, so nothing is applied and no request is issued —
8617
- // rather than being dropped on the way to the server and reading as a
8618
- // successful push that changed nothing.
8619
- // #2880 — the same collector `config diff` runs, so the two commands
8620
- // cannot disagree about which files are pushable.
8621
- for (const rejected of collectConfigFileValidationErrors(configDir)) {
8622
- for (const message of rejected.messages) {
8623
- preflightValidationErrors.push(` ${message}`);
8624
- }
8625
- }
8626
- // The `<key>.tests/*.toml` sidecars, which sit beside the block they
8627
- // test rather than in a type directory of their own. Same posture,
8628
- // reached through the registry's `test-case` surface — and through the
8629
- // same collector `config diff` runs per sidecar (#2880), so neither
8630
- // command can refuse a file the other calls Synced.
8631
- for (const filePath of testCaseTomlFiles(configDir)) {
8632
- let tomlData;
8633
- try {
8634
- tomlData = parseTomlFile(filePath);
8635
- }
8636
- catch {
8637
- // Reported by the per-block test-case loop with its own message.
8638
- continue;
8639
- }
8640
- for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
8641
- preflightValidationErrors.push(` ${message}`);
8642
- }
8643
- }
8644
- if (preflightValidationErrors.length > 0) {
8645
- throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
8646
- preflightValidationErrors.join("\n"));
8647
- }
8648
8849
  // Fetch the current remote config vars up-front — before ANY mutation
8649
8850
  // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
8650
8851
  // to detect concurrent remote edits and to decide which already-absent
@@ -13105,7 +13306,7 @@ What a push guarantees (two stages):
13105
13306
  }
13106
13307
  return typecheckSetup.checker;
13107
13308
  };
13108
- const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
13309
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
13109
13310
  const functionSpec = configDiffSpec("function");
13110
13311
  const liveFunctions = new Map();
13111
13312
  let functionsLiveOk = true;
@@ -13126,9 +13327,27 @@ What a push guarantees (two stages):
13126
13327
  const liveActiveVersion = async (functionId, activeConfigId) => {
13127
13328
  if (!activeConfigId)
13128
13329
  return null;
13129
- const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
13130
- return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
13330
+ // #3462 — the point read: one call by id, and correct for a
13331
+ // rolled-back function whose active version is past page one.
13332
+ return client.getFunctionConfig(resolvedAppId, functionId, String(activeConfigId));
13131
13333
  };
13334
+ // #3455 — the tree's document schema is a property of the PROJECT,
13335
+ // not of one function, so an unusable `models/models.toml` is said
13336
+ // once rather than repeated for every function in the tree.
13337
+ const documentSchemaResolution = resolveDocumentSchema(configDir);
13338
+ const documentSchemaWarning = documentSchemaResolution.warning;
13339
+ let documentSchemaWarningShown = false;
13340
+ // Say ONCE that this push drops a document schema a running
13341
+ // version carries — and ask the NEXT function when this one's
13342
+ // version had nothing to lose, so a schema-less function pushed
13343
+ // first cannot silence a schema-bearing sibling. The rule and its
13344
+ // state live in `function-sync.ts`, where they are testable
13345
+ // without a tree and a server.
13346
+ const warnIfDroppingDocumentSchema = createDocumentSchemaDropWarner({
13347
+ treeCarriesSchema: documentSchemaResolution.origin !== "none",
13348
+ readConfig: (functionId, configId) => client.getFunctionConfig(resolvedAppId, functionId, configId),
13349
+ warn: (message) => warn(` ${message}`),
13350
+ });
13132
13351
  for (const file of files) {
13133
13352
  const filePath = join(functionsPushDir, file);
13134
13353
  const tomlData = parseTomlFile(filePath);
@@ -13157,6 +13376,15 @@ What a push guarantees (two stages):
13157
13376
  });
13158
13377
  continue;
13159
13378
  }
13379
+ // #3455 (D3455-004) — the project declares a document schema this
13380
+ // push cannot carry: over the cap, or not parsing. VISIBLE rather
13381
+ // than refused, because a broken `models/models.toml` is not a
13382
+ // reason to stop deploying code — but silence would leave an
13383
+ // author wondering why a first write still cannot seed.
13384
+ if (documentSchemaWarning && !documentSchemaWarningShown) {
13385
+ documentSchemaWarningShown = true;
13386
+ warn(` ${documentSchemaWarning}`);
13387
+ }
13160
13388
  // #3331 — and then the types. esbuild erases them, so a build that
13161
13389
  // succeeded says nothing about whether the code holds against the
13162
13390
  // declarations this same push generated: `ctx.user` going nullable
@@ -13333,6 +13561,23 @@ What a push guarantees (two stages):
13333
13561
  });
13334
13562
  continue;
13335
13563
  }
13564
+ // #3455 (D3455-010) — did the server keep the document schema it
13565
+ // was sent? A server that predates the field drops it silently and
13566
+ // still answers 200, so without this the push reports success over
13567
+ // a version that can never seed. Recorded against THIS function and
13568
+ // NOT reported pushed; the rest of the tree still lands.
13569
+ const retention = documentSchemaRetentionFailure({
13570
+ sentDocumentSchema: typeof push.documentSchema === "string",
13571
+ builtEnvelopeHash: push.envelopeHash,
13572
+ storedEnvelopeHash: pushedVersion?.envelopeHash,
13573
+ });
13574
+ if (retention) {
13575
+ applyFailures.push({ type: "function", key, message: retention });
13576
+ continue;
13577
+ }
13578
+ await warnIfDroppingDocumentSchema(String(functionId), liveFunction?.activeConfigId
13579
+ ? String(liveFunction.activeConfigId)
13580
+ : undefined);
13336
13581
  if (pushedVersion?.created === false) {
13337
13582
  info(` Function ${key}: code unchanged (no new version)`);
13338
13583
  }
@@ -15085,7 +15330,7 @@ What a push guarantees (two stages):
15085
15330
  const localFunctionsParsed = new Map();
15086
15331
  const functionsDirPath = join(configDir, "functions");
15087
15332
  if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
15088
- for (const file of readdirSync(functionsDirPath).filter((f) => f.endsWith(".toml"))) {
15333
+ for (const file of readdirSync(functionsDirPath).filter(isFunctionDeclarationFile)) {
15089
15334
  const filePath = join(functionsDirPath, file);
15090
15335
  const tomlData = parseTomlFile(filePath);
15091
15336
  const key = tomlData.function?.key || basename(file, ".toml");
@@ -15379,8 +15624,14 @@ What a push guarantees (two stages):
15379
15624
  const versionOutcome = await attemptFetch(async () => {
15380
15625
  if (!remote.activeConfigId)
15381
15626
  return null;
15382
- const { items } = await client.listFunctionConfigs(resolvedAppId, remote.functionId);
15383
- return ((items || []).find((c) => String(c.configId) === String(remote.activeConfigId)) ?? null);
15627
+ // #3462 — the point read, for the same reason as pull's.
15628
+ return client.getFunctionConfig(resolvedAppId, remote.functionId, String(remote.activeConfigId));
15629
+ });
15630
+ // #3462 — and the newest version, so a tree that matches something
15631
+ // that EXISTS can say so. One row; the listing answers newest first.
15632
+ const newestOutcome = await attemptFetch(async () => {
15633
+ const page = await client.listFunctionConfigs(resolvedAppId, remote.functionId, { limit: 1 });
15634
+ return (page.items || [])[0] ?? null;
15384
15635
  });
15385
15636
  if (!versionOutcome.ok) {
15386
15637
  hint = "content not compared (fetch failed)";
@@ -15404,9 +15655,22 @@ What a push guarantees (two stages):
15404
15655
  if (!active ||
15405
15656
  String(active.envelopeHash ?? "") !== built.envelopeHash) {
15406
15657
  status = "modified";
15658
+ // #3462 — when the tree matches the NEWEST version rather
15659
+ // than the active one, the function is rolled back and the
15660
+ // generic hint sends an operator looking for an edit they
15661
+ // never made. Naming both versions says what actually
15662
+ // happened and what each command would do about it.
15663
+ const newest = newestOutcome.ok
15664
+ ? newestOutcome.value
15665
+ : null;
15407
15666
  hint =
15408
- "the built bundle or a source file differs — `config push` " +
15409
- "creates a new immutable version";
15667
+ active &&
15668
+ newest &&
15669
+ String(newest.configId) !== String(active.configId) &&
15670
+ String(newest.envelopeHash ?? "") === built.envelopeHash
15671
+ ? rollbackDiffHint(String(newest.configId), String(active.configId))
15672
+ : "the built bundle or a source file differs — `config push` " +
15673
+ "creates a new immutable version";
15410
15674
  }
15411
15675
  }
15412
15676
  catch (err) {
@@ -15899,6 +16163,13 @@ What a push guarantees (two stages):
15899
16163
  // calls Synced, Modified or "local only — new".
15900
16164
  // Merged first: a file both collectors reject keeps every message push
15901
16165
  // prints, rather than one collector's hint overwriting the other's.
16166
+ // #3482 — the same advisory `config push` prints, so an author sees
16167
+ // the retired key before they push rather than only during it. Not a
16168
+ // row status: the file is pushable, and saying otherwise would make
16169
+ // `config diff` disagree with the push it predicts.
16170
+ for (const message of collectFunctionTomlIgnoredKeyWarnings(configDir)) {
16171
+ warn(` ${message}`);
16172
+ }
15902
16173
  for (const rejected of mergeConfigFileValidationErrors([
15903
16174
  ...collectConfigFileValidationErrors(configDir),
15904
16175
  ...collectFunctionTomlValidationErrors(configDir),