primitive-admin 1.1.0-alpha.82 → 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 (86) hide show
  1. package/dist/src/commands/documents.js +333 -42
  2. package/dist/src/commands/documents.js.map +1 -1
  3. package/dist/src/commands/functions.js +302 -120
  4. package/dist/src/commands/functions.js.map +1 -1
  5. package/dist/src/commands/locks.js +15 -1
  6. package/dist/src/commands/locks.js.map +1 -1
  7. package/dist/src/commands/sync.d.ts +50 -0
  8. package/dist/src/commands/sync.js +560 -312
  9. package/dist/src/commands/sync.js.map +1 -1
  10. package/dist/src/lib/api-client.d.ts +75 -2
  11. package/dist/src/lib/api-client.js +122 -7
  12. package/dist/src/lib/api-client.js.map +1 -1
  13. package/dist/src/lib/config-object-descriptor.js +5 -5
  14. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  15. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  16. package/dist/src/lib/document-ingest-artifact.js +505 -0
  17. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  18. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  19. package/dist/src/lib/document-ingest-input.js +132 -0
  20. package/dist/src/lib/document-ingest-input.js.map +1 -0
  21. package/dist/src/lib/document-ingest.d.ts +78 -0
  22. package/dist/src/lib/document-ingest.js +231 -0
  23. package/dist/src/lib/document-ingest.js.map +1 -0
  24. package/dist/src/lib/function-bundle.d.ts +6 -0
  25. package/dist/src/lib/function-bundle.js +8 -0
  26. package/dist/src/lib/function-bundle.js.map +1 -1
  27. package/dist/src/lib/function-collect.d.ts +1 -1
  28. package/dist/src/lib/function-collect.js +16 -0
  29. package/dist/src/lib/function-collect.js.map +1 -1
  30. package/dist/src/lib/function-db-types.d.ts +4 -0
  31. package/dist/src/lib/function-db-types.js +31 -2
  32. package/dist/src/lib/function-db-types.js.map +1 -1
  33. package/dist/src/lib/function-document-types.d.ts +83 -0
  34. package/dist/src/lib/function-document-types.js +133 -0
  35. package/dist/src/lib/function-document-types.js.map +1 -1
  36. package/dist/src/lib/function-grants-preflight.js +5 -0
  37. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  38. package/dist/src/lib/function-log-lines.d.ts +76 -0
  39. package/dist/src/lib/function-log-lines.js +160 -0
  40. package/dist/src/lib/function-log-lines.js.map +1 -0
  41. package/dist/src/lib/function-log-row.d.ts +29 -0
  42. package/dist/src/lib/function-log-row.js +73 -0
  43. package/dist/src/lib/function-log-row.js.map +1 -0
  44. package/dist/src/lib/function-run.d.ts +53 -35
  45. package/dist/src/lib/function-run.js +55 -44
  46. package/dist/src/lib/function-run.js.map +1 -1
  47. package/dist/src/lib/function-schema-codegen.d.ts +6 -9
  48. package/dist/src/lib/function-schema-codegen.js +22 -35
  49. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  50. package/dist/src/lib/function-sync.d.ts +226 -35
  51. package/dist/src/lib/function-sync.js +731 -99
  52. package/dist/src/lib/function-sync.js.map +1 -1
  53. package/dist/src/lib/function-versions.d.ts +122 -0
  54. package/dist/src/lib/function-versions.js +182 -0
  55. package/dist/src/lib/function-versions.js.map +1 -0
  56. package/dist/src/lib/generated-config-surfaces.d.ts +390 -146
  57. package/dist/src/lib/generated-config-surfaces.js +1207 -401
  58. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  59. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  60. package/dist/src/lib/generated-sdk-types.js +1 -1
  61. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  62. package/dist/src/lib/log-inspection.d.ts +2 -0
  63. package/dist/src/lib/log-inspection.js +4 -0
  64. package/dist/src/lib/log-inspection.js.map +1 -1
  65. package/dist/src/lib/output.d.ts +15 -0
  66. package/dist/src/lib/output.js +28 -0
  67. package/dist/src/lib/output.js.map +1 -1
  68. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  69. package/dist/src/lib/snapshot-audit-source.js +58 -0
  70. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  71. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  72. package/dist/src/lib/snapshot-audit-store.js +196 -0
  73. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  74. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  75. package/dist/src/lib/snapshot-audit.js +431 -0
  76. package/dist/src/lib/snapshot-audit.js.map +1 -0
  77. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  78. package/dist/src/lib/snapshot-build-rows.js +87 -0
  79. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  80. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +23 -16
  81. package/dist/src/lib/swift-codegen/functionGenerator.js +34 -33
  82. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  83. package/dist/src/lib/sync-selectors.d.ts +45 -2
  84. package/dist/src/lib/sync-selectors.js +66 -5
  85. package/dist/src/lib/sync-selectors.js.map +1 -1
  86. 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";
@@ -2185,6 +2189,348 @@ function localConfigRowKey(label, tomlData, file) {
2185
2189
  return fallback;
2186
2190
  }
2187
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
+ }
2188
2534
  /**
2189
2535
  * Every per-entity config file whose TOML `config push` would refuse (#2880
2190
2536
  * criterion 1, symmetry).
@@ -2206,7 +2552,11 @@ export function collectConfigFileValidationErrors(configDir) {
2206
2552
  const typeDir = join(configDir, dir);
2207
2553
  if (!existsSync(typeDir))
2208
2554
  continue;
2209
- 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"))) {
2210
2560
  const filePath = join(typeDir, file);
2211
2561
  let tomlData;
2212
2562
  try {
@@ -2264,7 +2614,7 @@ export function collectFunctionTomlValidationErrors(configDir) {
2264
2614
  const functionsDir = join(configDir, "functions");
2265
2615
  if (!existsSync(functionsDir))
2266
2616
  return found;
2267
- for (const file of readdirSync(functionsDir).filter((f) => f.endsWith(".toml"))) {
2617
+ for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
2268
2618
  const filePath = join(functionsDir, file);
2269
2619
  let tomlData;
2270
2620
  try {
@@ -2287,6 +2637,35 @@ export function collectFunctionTomlValidationErrors(configDir) {
2287
2637
  }
2288
2638
  return found;
2289
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
+ }
2290
2669
  /**
2291
2670
  * The collectors' rows, one per diff row (#2880 symmetry, #3320).
2292
2671
  *
@@ -4968,6 +5347,8 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
4968
5347
  const functionsDir = join(configDir, "functions");
4969
5348
  ensureDir(functionsDir);
4970
5349
  const functionEntities = {};
5350
+ /** What each pulled version says about the document schema — CR3455-001. */
5351
+ const pulledSchemas = [];
4971
5352
  let items = [];
4972
5353
  try {
4973
5354
  const result = await client.listFunctions(appId);
@@ -5015,11 +5396,21 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
5015
5396
  }
5016
5397
  let active;
5017
5398
  let envelope;
5399
+ let newest = null;
5018
5400
  try {
5019
- const configs = await client.listFunctionConfigs(appId, record.functionId);
5020
- 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;
5021
5408
  if (active) {
5022
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;
5023
5414
  }
5024
5415
  }
5025
5416
  catch {
@@ -5032,9 +5423,30 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
5032
5423
  logger(` Skipped functions/${key}.toml (active version not readable)`);
5033
5424
  continue;
5034
5425
  }
5035
- 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
+ });
5036
5443
  for (const refusal of plan.refusals)
5037
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}`);
5038
5450
  const applied = applyFunctionPull(plan);
5039
5451
  for (const refusal of applied.refusals)
5040
5452
  logger(` ${refusal}`);
@@ -5048,11 +5460,34 @@ export async function pullFunctions(client, appId, configDir, logger = () => { }
5048
5460
  functionEntities[key] = entity;
5049
5461
  written += 1;
5050
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
+ }
5051
5472
  }
5052
5473
  const serverKeys = items
5053
5474
  .filter((f) => String(f?.status ?? "active") !== "archived")
5054
5475
  .map((f) => f?.functionKey)
5055
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}`);
5056
5491
  const presence = detailsOk
5057
5492
  ? { ok: true, serverKeys }
5058
5493
  : { ok: false };
@@ -7949,14 +8384,6 @@ What a push guarantees (two stages):
7949
8384
  // `syncCallable` PATCH ordering a second implementation has broken before.
7950
8385
  // Everything else about push is unchanged.
7951
8386
  const pushSelection = readSelection(options);
7952
- if (pushSelection) {
7953
- const missing = unmatchedSelectors(pushSelection, configDir);
7954
- if (missing.length > 0) {
7955
- error(`No local file declares: ${missing.join(", ")}. Run 'primitive config pull', or 'primitive config create <type> <key>' to create one.`);
7956
- process.exit(1);
7957
- }
7958
- info(`Applying only: ${pushSelection.raw.join(", ")}`);
7959
- }
7960
8387
  /** Narrow a type's file list to the `--only` selection. */
7961
8388
  const selectFiles = (label, dir, files) => filterFilesBySelection(pushSelection, label, dir, files);
7962
8389
  /**
@@ -7990,6 +8417,29 @@ What a push guarantees (two stages):
7990
8417
  entities: {},
7991
8418
  };
7992
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
+ }
7993
8443
  info(`Pushing configuration for app ${resolvedAppId}...`);
7994
8444
  // The state this push STARTS from, so the failure path below can tell a
7995
8445
  // run that recorded something from one that recorded nothing (#2936).
@@ -8377,297 +8827,25 @@ What a push guarantees (two stages):
8377
8827
  pushTestCaseLookupMaps.configIdToName.set(id, scoped.slice(scoped.indexOf("#") + 1));
8378
8828
  }
8379
8829
  };
8380
- // ── Issue #976 (fix A): front-load ALL client-side TOML validation
8381
- // before the first mutating call. Push is apply-as-you-go and processes
8382
- // entities in a fixed sequence (cron triggers are created *before*
8383
- // workflows are validated). Previously each entity's validation lived
8384
- // inside its own apply loop and aborted via `process.exit(1)` — so a
8385
- // known-bad sibling file (e.g. an invalid workflow TOML) could let an
8386
- // earlier create (a cron trigger) be applied, then abort. The
8387
- // `process.exit` also bypassed the save-on-failure catch below, so the
8388
- // orphaned create never reached the sync-state baseline and the retry
8389
- // re-issued a CREATE that the server rejected with 409 forever.
8390
- //
8391
- // Running every validation up-front means a known-bad file aborts
8392
- // BEFORE anything is created. We collect all errors (not just the first)
8393
- // and THROW — so the single save-on-failure catch is the only exit path
8394
- // (no more `process.exit` bypass class). The per-loop validators below
8395
- // remain as defense-in-depth but are converted to throws too.
8396
- const preflightValidationErrors = [];
8397
- // Validate all workflow TOMLs (#685 misnested-header check and the
8398
- // #1447 system + accessRule lint).
8399
- const preflightWorkflowsDir = join(configDir, "workflows");
8400
- if (existsSync(preflightWorkflowsDir)) {
8401
- // Under `--only` the preflight validates just the selected files:
8402
- // an unrelated broken workflow elsewhere in the slot must not block
8403
- // applying the one object the operator asked for.
8404
- for (const file of selectFiles("workflow", preflightWorkflowsDir, readdirSync(preflightWorkflowsDir).filter((f) => f.endsWith(".toml")))) {
8405
- const filePath = join(preflightWorkflowsDir, file);
8406
- try {
8407
- const tomlData = parseTomlFile(filePath);
8408
- // #1660 — skip platform-owned workflows (the reserved
8409
- // `__internal.*` namespace) BEFORE validating, mirroring the
8410
- // apply loop below. `config push` validates every workflow TOML
8411
- // here, up-front, before reaching that skip — so without this
8412
- // second skip a stale/hollow `__internal.*.toml` with an invalid
8413
- // `steps` shape would abort the whole push in preflight. Resolve
8414
- // the key the same way the apply loop does (`[workflow].key`,
8415
- // falling back to the filename) and match on the resolved key,
8416
- // never the filename alone, so a renamed file is still caught.
8417
- const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
8418
- if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
8419
- continue;
8420
- }
8421
- const tomlErrors = validateWorkflowToml(tomlData);
8422
- if (tomlErrors.length > 0) {
8423
- preflightValidationErrors.push(formatWorkflowTomlErrors(filePath, tomlErrors));
8424
- }
8425
- // #2643 — non-blocking notes (e.g. `strict` / `strictParams` are
8426
- // now no-ops). Printed once per file at pre-flight so the author
8427
- // sees them whether or not the push goes on to apply.
8428
- for (const note of collectWorkflowTomlNotes(tomlData)) {
8429
- console.log(` ${filePath}: ${note}`);
8430
- }
8431
- const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
8432
- if (unknownKeyError) {
8433
- preflightValidationErrors.push(` ${unknownKeyError}`);
8434
- }
8435
- // #2743 — the always-send update payload cannot carry a missing
8436
- // `[workflow].name`. That is a local TOML error like every other
8437
- // one here, so it belongs in the front-loaded pass (#976): caught
8438
- // in the apply loop it would abort AFTER earlier resources (app
8439
- // settings, cron triggers, …) had already been written, and
8440
- // `--dry-run` — which never reaches the apply — would plan an
8441
- // update the real push then refuses.
8442
- //
8443
- // Scoped to the workflows this push will actually UPDATE. A
8444
- // create falls back to the manifest key, so an absent name is
8445
- // legitimate authoring there — and once that create lands, the
8446
- // same unchanged file must keep pushing clean (the apply loop
8447
- // skips it on its content hash) instead of failing a push that
8448
- // was never going to send it.
8449
- if (workflowPushSendsUpdate(configDir, tomlData.workflow?.key || basename(file, ".toml"), tomlData, syncState, options.force)) {
8450
- const nameError = workflowUpdateNameError(filePath, tomlData.workflow || {});
8451
- if (nameError) {
8452
- preflightValidationErrors.push(` ${nameError}`);
8453
- }
8454
- }
8455
- }
8456
- catch (err) {
8457
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8458
- }
8459
- }
8460
- }
8461
- // Validate all database-type TOMLs (issue #803 subscription
8462
- // access/accessRule conflict + issue #752 $params references).
8463
- const preflightDbTypesDir = join(configDir, "database-type-configs");
8464
- if (existsSync(preflightDbTypesDir)) {
8465
- for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
8466
- const filePath = join(preflightDbTypesDir, file);
8467
- let rawToml;
8468
- let tomlData;
8469
- try {
8470
- rawToml = readFileSync(filePath, "utf-8");
8471
- tomlData = parseTomlFile(filePath);
8472
- }
8473
- catch (err) {
8474
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8475
- continue;
8476
- }
8477
- let operations = [];
8478
- try {
8479
- ({ operations } = parseDatabaseTypeToml(tomlData));
8480
- }
8481
- catch (err) {
8482
- if (err instanceof SubscriptionAccessKeyConflictError) {
8483
- preflightValidationErrors.push(` ${file}: ${err.message}`);
8484
- continue;
8485
- }
8486
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
8487
- continue;
8488
- }
8489
- const validation = validateOperations({
8490
- filePath,
8491
- rawToml,
8492
- operations,
8493
- });
8494
- for (const e of validation.errors) {
8495
- preflightValidationErrors.push(` ${formatIssue(e)}`);
8496
- }
8497
- // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
8498
- // / `metadataAccess` gate on a database type config is a single CEL
8499
- // expression that gates BOTH read and update of the database CEL
8500
- // context — granting read implicitly grants update. Steer operators
8501
- // to resource metadata categories (separate readRule/writeRule).
8502
- // This is advisory only: the push continues and the runtime gate is
8503
- // unchanged. It fires only when the deprecated key is actually
8504
- // present, so metadata-category-only configs never trip it.
8505
- const typeSection = tomlData.type || {};
8506
- if (typeSection.celContextAccess || typeSection.metadataAccess) {
8507
- const usedKey = typeSection.celContextAccess
8508
- ? "celContextAccess"
8509
- : "metadataAccess";
8510
- 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.`);
8511
- }
8512
- }
8513
- }
8514
- // Validate app.toml settings up-front (issue #1033). A mistyped value
8515
- // is a hard error that must abort BEFORE any mutation — same
8516
- // fail-before-mutate contract as the vars preflight below. The apply
8517
- // block re-parses for the payload.
8518
- // #2645 — bounded by `--only`, like the workflow and database-type
8519
- // preflights above. `config push --only workflow/x` is the replacement for
8520
- // `workflows update --from-file`, so it must work on a tree where
8521
- // app.toml happens to be mid-edit.
8522
- const preflightAppTomlPath = join(configDir, "app.toml");
8523
- if (selectionIncludesApp(pushSelection) && existsSync(preflightAppTomlPath)) {
8524
- try {
8525
- const parsedApp = parseTomlFile(preflightAppTomlPath);
8526
- const appErrors = collectAppSettingsPushErrors(parsedApp, {
8527
- // #2880 DSO-001 — the same exclusion the apply block computes, so
8528
- // the preflight validates the body that will actually be sent.
8529
- excludeName: appSettingsNameExcluded({
8530
- isCrossAppPush: !!isCrossAppPush,
8531
- recordedExcludedName: syncState?.entities?.app?.excludedName,
8532
- fileName: parsedApp?.app?.name,
8533
- }),
8534
- });
8535
- for (const e of appErrors) {
8536
- preflightValidationErrors.push(` app.toml: ${e}`);
8537
- }
8538
- }
8539
- catch (err) {
8540
- preflightValidationErrors.push(` app.toml: ${err?.message || String(err)}`);
8541
- }
8542
- }
8543
- // Validate config vars up-front (issue #1423 review). Parse vars.toml
8544
- // and check the same key/value constraints the server enforces here, in
8545
- // the preflight pass, so an invalid entry aborts BEFORE any mutation
8546
- // (app settings or a var upsert) is applied. Previously validation ran
8547
- // mid-push, after app settings could already have been updated.
8548
- // #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.
8549
8847
  const preflightVarsTomlPath = join(configDir, "vars.toml");
8550
8848
  const varsSelected = selectionIncludesAnyVar(pushSelection);
8551
- let preflightParsedVars = null;
8552
- if (varsSelected && existsSync(preflightVarsTomlPath)) {
8553
- try {
8554
- const parsed = parseTomlFile(preflightVarsTomlPath);
8555
- // #2645 — under `--only var/<key>` only the selected entries are
8556
- // checked. The rest of the file is not being written, and aborting
8557
- // over an entry this push will not touch is the same over-reach as
8558
- // running the leg at all for an unselected singleton.
8559
- for (const e of validateVarsFile(parsed, {
8560
- onlyKeys: pushSelection ? pushSelection.varKeys : undefined,
8561
- })) {
8562
- preflightValidationErrors.push(` ${e}`);
8563
- }
8564
- preflightParsedVars = parsed;
8565
- }
8566
- catch (err) {
8567
- preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
8568
- }
8569
- }
8570
- // #3182 — a function's capabilities, checked against the author's own
8571
- // file before anything is applied. The grammar is purely local (and
8572
- // since #3279 it keeps three kinds: `integration:<key>`,
8573
- // `secret:<NAME>` and the high-blast opt-ins — a retired grant string
8574
- // is refused here with the retirement); whether an `integration:`
8575
- // names an integration that will exist is answered against the
8576
- // tree's own `integrations/` (applied BEFORE functions) merged with
8577
- // the integrations the server already has. If the live listing cannot
8578
- // be fetched and the tree declares none, the target check is skipped
8579
- // and the server — which is authoritative — has the last word.
8580
- const preflightFunctionsDir = join(configDir, "functions");
8581
- if (existsSync(preflightFunctionsDir)) {
8582
- const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
8583
- if (functionFiles.length > 0) {
8584
- const treeTargets = configObjectKeysFromTree(configDir);
8585
- let knownTargets = null;
8586
- try {
8587
- const integrations = await client.listIntegrations(resolvedAppId, {
8588
- status: "active",
8589
- });
8590
- knownTargets = mergeKnownGrantTargets({
8591
- integrations: new Set((integrations?.items ?? [])
8592
- .map((row) => String(row?.integrationKey ?? ""))
8593
- .filter(Boolean)),
8594
- }, treeTargets);
8595
- }
8596
- catch {
8597
- knownTargets =
8598
- treeTargets.integrations.size > 0 ? treeTargets : null;
8599
- }
8600
- for (const file of functionFiles) {
8601
- const filePath = join(preflightFunctionsDir, file);
8602
- let tomlData;
8603
- try {
8604
- tomlData = parseTomlFile(filePath);
8605
- }
8606
- catch (err) {
8607
- preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
8608
- continue;
8609
- }
8610
- for (const message of functionGrantErrors({
8611
- filePath,
8612
- tomlData,
8613
- knownTargets,
8614
- })) {
8615
- preflightValidationErrors.push(` ${message}`);
8616
- }
8617
- // #3320 — and everything else the file decides on its own: the
8618
- // `entry`, the mode the two spellings resolve to, the trigger
8619
- // rules, and a webhook trigger's signing-secret requirement and
8620
- // reference shape. These used to be raised inside the apply loop,
8621
- // after webhooks, integrations, vars and database types had
8622
- // already been written — the partial tree this issue is about.
8623
- // Bounded by the same `selectFiles` above, so `--only` keeps its
8624
- // posture (#2645).
8625
- const functionKey = tomlData?.function?.key || basename(file, ".toml");
8626
- for (const message of functionTomlPreflightErrors({
8627
- filePath,
8628
- functionKey,
8629
- tomlData,
8630
- })) {
8631
- preflightValidationErrors.push(` ${filePath}: ${message}`);
8632
- }
8633
- }
8634
- }
8635
- }
8636
- // #2644 — reject any key the vendored definition does not recognize,
8637
- // for every migrated configuration type. Same posture the `[workflow]`
8638
- // check above has, generalized: a mistyped key fails here, in the
8639
- // preflight pass, so nothing is applied and no request is issued —
8640
- // rather than being dropped on the way to the server and reading as a
8641
- // successful push that changed nothing.
8642
- // #2880 — the same collector `config diff` runs, so the two commands
8643
- // cannot disagree about which files are pushable.
8644
- for (const rejected of collectConfigFileValidationErrors(configDir)) {
8645
- for (const message of rejected.messages) {
8646
- preflightValidationErrors.push(` ${message}`);
8647
- }
8648
- }
8649
- // The `<key>.tests/*.toml` sidecars, which sit beside the block they
8650
- // test rather than in a type directory of their own. Same posture,
8651
- // reached through the registry's `test-case` surface — and through the
8652
- // same collector `config diff` runs per sidecar (#2880), so neither
8653
- // command can refuse a file the other calls Synced.
8654
- for (const filePath of testCaseTomlFiles(configDir)) {
8655
- let tomlData;
8656
- try {
8657
- tomlData = parseTomlFile(filePath);
8658
- }
8659
- catch {
8660
- // Reported by the per-block test-case loop with its own message.
8661
- continue;
8662
- }
8663
- for (const message of testCaseSidecarPreflightErrors(filePath, tomlData)) {
8664
- preflightValidationErrors.push(` ${message}`);
8665
- }
8666
- }
8667
- if (preflightValidationErrors.length > 0) {
8668
- throw new Error(`Aborting push: ${preflightValidationErrors.length} TOML validation error(s) — no changes were applied.\n` +
8669
- preflightValidationErrors.join("\n"));
8670
- }
8671
8849
  // Fetch the current remote config vars up-front — before ANY mutation
8672
8850
  // (issue #1423 review pass 3). `planVarsPush` needs this snapshot both
8673
8851
  // to detect concurrent remote edits and to decide which already-absent
@@ -13128,7 +13306,7 @@ What a push guarantees (two stages):
13128
13306
  }
13129
13307
  return typecheckSetup.checker;
13130
13308
  };
13131
- const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
13309
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter(isFunctionDeclarationFile));
13132
13310
  const functionSpec = configDiffSpec("function");
13133
13311
  const liveFunctions = new Map();
13134
13312
  let functionsLiveOk = true;
@@ -13149,9 +13327,27 @@ What a push guarantees (two stages):
13149
13327
  const liveActiveVersion = async (functionId, activeConfigId) => {
13150
13328
  if (!activeConfigId)
13151
13329
  return null;
13152
- const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
13153
- 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));
13154
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
+ });
13155
13351
  for (const file of files) {
13156
13352
  const filePath = join(functionsPushDir, file);
13157
13353
  const tomlData = parseTomlFile(filePath);
@@ -13180,6 +13376,15 @@ What a push guarantees (two stages):
13180
13376
  });
13181
13377
  continue;
13182
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
+ }
13183
13388
  // #3331 — and then the types. esbuild erases them, so a build that
13184
13389
  // succeeded says nothing about whether the code holds against the
13185
13390
  // declarations this same push generated: `ctx.user` going nullable
@@ -13356,6 +13561,23 @@ What a push guarantees (two stages):
13356
13561
  });
13357
13562
  continue;
13358
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);
13359
13581
  if (pushedVersion?.created === false) {
13360
13582
  info(` Function ${key}: code unchanged (no new version)`);
13361
13583
  }
@@ -15108,7 +15330,7 @@ What a push guarantees (two stages):
15108
15330
  const localFunctionsParsed = new Map();
15109
15331
  const functionsDirPath = join(configDir, "functions");
15110
15332
  if (functionsOutcomeDiff.ok && existsSync(functionsDirPath)) {
15111
- for (const file of readdirSync(functionsDirPath).filter((f) => f.endsWith(".toml"))) {
15333
+ for (const file of readdirSync(functionsDirPath).filter(isFunctionDeclarationFile)) {
15112
15334
  const filePath = join(functionsDirPath, file);
15113
15335
  const tomlData = parseTomlFile(filePath);
15114
15336
  const key = tomlData.function?.key || basename(file, ".toml");
@@ -15402,8 +15624,14 @@ What a push guarantees (two stages):
15402
15624
  const versionOutcome = await attemptFetch(async () => {
15403
15625
  if (!remote.activeConfigId)
15404
15626
  return null;
15405
- const { items } = await client.listFunctionConfigs(resolvedAppId, remote.functionId);
15406
- 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;
15407
15635
  });
15408
15636
  if (!versionOutcome.ok) {
15409
15637
  hint = "content not compared (fetch failed)";
@@ -15427,9 +15655,22 @@ What a push guarantees (two stages):
15427
15655
  if (!active ||
15428
15656
  String(active.envelopeHash ?? "") !== built.envelopeHash) {
15429
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;
15430
15666
  hint =
15431
- "the built bundle or a source file differs — `config push` " +
15432
- "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";
15433
15674
  }
15434
15675
  }
15435
15676
  catch (err) {
@@ -15922,6 +16163,13 @@ What a push guarantees (two stages):
15922
16163
  // calls Synced, Modified or "local only — new".
15923
16164
  // Merged first: a file both collectors reject keeps every message push
15924
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
+ }
15925
16173
  for (const rejected of mergeConfigFileValidationErrors([
15926
16174
  ...collectConfigFileValidationErrors(configDir),
15927
16175
  ...collectFunctionTomlValidationErrors(configDir),