primitive-admin 1.1.0-alpha.74 → 1.1.0-alpha.75

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 (115) hide show
  1. package/dist/bin/primitive.js +12 -11
  2. package/dist/bin/primitive.js.map +1 -1
  3. package/dist/src/commands/collection-type-configs.js +1 -1
  4. package/dist/src/commands/collection-type-configs.js.map +1 -1
  5. package/dist/src/commands/config.js +8 -4
  6. package/dist/src/commands/config.js.map +1 -1
  7. package/dist/src/commands/database-type-configs.js +1 -1
  8. package/dist/src/commands/database-type-configs.js.map +1 -1
  9. package/dist/src/commands/databases.js +25 -31
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/documents.js +9 -24
  12. package/dist/src/commands/documents.js.map +1 -1
  13. package/dist/src/commands/env.d.ts +1 -1
  14. package/dist/src/commands/env.js +14 -11
  15. package/dist/src/commands/env.js.map +1 -1
  16. package/dist/src/commands/functions.js +135 -1
  17. package/dist/src/commands/functions.js.map +1 -1
  18. package/dist/src/commands/groups.js +1 -1
  19. package/dist/src/commands/groups.js.map +1 -1
  20. package/dist/src/commands/init.d.ts +12 -15
  21. package/dist/src/commands/init.js +36 -77
  22. package/dist/src/commands/init.js.map +1 -1
  23. package/dist/src/commands/rule-sets.js +1 -1
  24. package/dist/src/commands/rule-sets.js.map +1 -1
  25. package/dist/src/commands/scripts.js +17 -4
  26. package/dist/src/commands/scripts.js.map +1 -1
  27. package/dist/src/commands/sync-app-settings.js +2 -4
  28. package/dist/src/commands/sync-app-settings.js.map +1 -1
  29. package/dist/src/commands/sync.d.ts +2 -2
  30. package/dist/src/commands/sync.js +544 -346
  31. package/dist/src/commands/sync.js.map +1 -1
  32. package/dist/src/commands/workflows.js +1 -1
  33. package/dist/src/lib/api-client.d.ts +7 -1
  34. package/dist/src/lib/api-client.js +8 -2
  35. package/dist/src/lib/api-client.js.map +1 -1
  36. package/dist/src/lib/app-match-guard.d.ts +44 -0
  37. package/dist/src/lib/app-match-guard.js +72 -0
  38. package/dist/src/lib/app-match-guard.js.map +1 -0
  39. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +11 -8
  40. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +108 -54
  41. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js.map +1 -1
  42. package/dist/src/lib/config.d.ts +2 -2
  43. package/dist/src/lib/config.js +2 -2
  44. package/dist/src/lib/credentials-store.d.ts +3 -3
  45. package/dist/src/lib/credentials-store.js +3 -3
  46. package/dist/src/lib/credentials-store.js.map +1 -1
  47. package/dist/src/lib/document-export-permissions.d.ts +30 -0
  48. package/dist/src/lib/document-export-permissions.js +54 -0
  49. package/dist/src/lib/document-export-permissions.js.map +1 -0
  50. package/dist/src/lib/env-resolver-core.d.ts +41 -9
  51. package/dist/src/lib/env-resolver-core.js +84 -13
  52. package/dist/src/lib/env-resolver-core.js.map +1 -1
  53. package/dist/src/lib/env-resolver.d.ts +3 -3
  54. package/dist/src/lib/env-resolver.js +2 -2
  55. package/dist/src/lib/function-bundle.d.ts +11 -1
  56. package/dist/src/lib/function-bundle.js +13 -1
  57. package/dist/src/lib/function-bundle.js.map +1 -1
  58. package/dist/src/lib/function-collect.d.ts +101 -0
  59. package/dist/src/lib/function-collect.js +339 -0
  60. package/dist/src/lib/function-collect.js.map +1 -0
  61. package/dist/src/lib/function-db-types.d.ts +149 -0
  62. package/dist/src/lib/function-db-types.js +587 -0
  63. package/dist/src/lib/function-db-types.js.map +1 -0
  64. package/dist/src/lib/function-grants-preflight.d.ts +118 -0
  65. package/dist/src/lib/function-grants-preflight.js +213 -0
  66. package/dist/src/lib/function-grants-preflight.js.map +1 -0
  67. package/dist/src/lib/function-sync.d.ts +34 -0
  68. package/dist/src/lib/function-sync.js +103 -0
  69. package/dist/src/lib/function-sync.js.map +1 -1
  70. package/dist/src/lib/generated-config-surfaces.d.ts +388 -0
  71. package/dist/src/lib/generated-config-surfaces.js +757 -7
  72. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  73. package/dist/src/lib/generated-sdk-types.d.ts +12 -0
  74. package/dist/src/lib/generated-sdk-types.js +13 -0
  75. package/dist/src/lib/generated-sdk-types.js.map +1 -0
  76. package/dist/src/lib/init-adopt.js +2 -2
  77. package/dist/src/lib/init-adopt.js.map +1 -1
  78. package/dist/src/lib/init-assets.js +3 -3
  79. package/dist/src/lib/init-config.d.ts +1 -1
  80. package/dist/src/lib/init-ios-links.js +1 -1
  81. package/dist/src/lib/init-ios-links.js.map +1 -1
  82. package/dist/src/lib/init-plan.d.ts +1 -1
  83. package/dist/src/lib/init-plan.js +1 -1
  84. package/dist/src/lib/init-xcode.d.ts +2 -2
  85. package/dist/src/lib/init-xcode.js +6 -5
  86. package/dist/src/lib/init-xcode.js.map +1 -1
  87. package/dist/src/lib/local-state.d.ts +1 -1
  88. package/dist/src/lib/local-state.js +1 -1
  89. package/dist/src/lib/local-test-cases.d.ts +1 -1
  90. package/dist/src/lib/local-test-cases.js +2 -1
  91. package/dist/src/lib/local-test-cases.js.map +1 -1
  92. package/dist/src/lib/migration-nag.d.ts +2 -2
  93. package/dist/src/lib/migration-nag.js +4 -3
  94. package/dist/src/lib/migration-nag.js.map +1 -1
  95. package/dist/src/lib/project-config.d.ts +23 -20
  96. package/dist/src/lib/project-config.js +27 -45
  97. package/dist/src/lib/project-config.js.map +1 -1
  98. package/dist/src/lib/snapshots.d.ts +2 -2
  99. package/dist/src/lib/snapshots.js +2 -2
  100. package/dist/src/lib/swift-codegen/generator.d.ts +1 -1
  101. package/dist/src/lib/swift-codegen/generator.js +1 -1
  102. package/dist/src/lib/sync-dir-selector.d.ts +1 -1
  103. package/dist/src/lib/sync-dir-selector.js +1 -1
  104. package/dist/src/lib/sync-paths.d.ts +52 -16
  105. package/dist/src/lib/sync-paths.js +68 -22
  106. package/dist/src/lib/sync-paths.js.map +1 -1
  107. package/dist/src/lib/sync-resource-types.d.ts +2 -2
  108. package/dist/src/lib/sync-resource-types.js +2 -2
  109. package/dist/src/lib/template.d.ts +2 -2
  110. package/dist/src/lib/template.js +2 -2
  111. package/dist/src/lib/test-case-keys.d.ts +1 -1
  112. package/dist/src/lib/test-case-keys.js +1 -1
  113. package/dist/src/lib/workflow-codegen/generator.d.ts +1 -1
  114. package/dist/src/lib/workflow-codegen/generator.js +1 -1
  115. package/package.json +3 -3
@@ -9,7 +9,8 @@ import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMet
9
9
  import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
10
10
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
11
11
  import { getServerUrl, resolveAppId } from "../lib/config.js";
12
- import { resolveSyncDir, resolveSnapshotsRoot, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
12
+ import { assertTreeApp } from "../lib/app-match-guard.js";
13
+ import { resolveSyncDir, resolveSnapshotsRoot, assertSyncStateLayout, syncStatePath, SYNC_STATE_FILENAME, isAutoResolvedSyncDir, checkLegacySyncMigration, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
13
14
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
14
15
  import { validateWorkflowToml, collectWorkflowTomlNotes, formatWorkflowTomlErrors, validateWorkflowExpressionsToml, validateWorkflowExpressionsRawToml, } from "../lib/workflow-toml-validator.js";
15
16
  import { expandWorkflowTomlData } from "../lib/workflow-fragments.js";
@@ -34,8 +35,14 @@ import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, toml
34
35
  import { CONFIG_SURFACES, isEmptyForEmit, } from "../lib/generated-config-surfaces.js";
35
36
  // #3179 — the function's two halves: the header payloads and the envelope
36
37
  // `config push` sends, and the bytes `config pull` writes back.
37
- import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
38
+ import { applyFunctionPull, buildFunctionConfigPush, buildFunctionCreatePayload, buildFunctionUpdatePayload, collectManifestForPreflight, functionDiffEntity, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
38
39
  import { FunctionBuildError } from "../lib/function-bundle.js";
40
+ // #3182 — the local half of grant validation. The grammar is the vendored
41
+ // module the server also imports; this is where an author's own file is read.
42
+ import { configObjectKeysFromTree, databaseTypesFromServer, databaseTypesFromTree, functionGrantErrors, functionNeedsManifest, mergeKnownDatabaseTypes, mergeKnownGrantTargets, } from "../lib/function-grants-preflight.js";
43
+ // #3182 — the typed handle's generated declarations and the compiler wiring
44
+ // that makes an editor load them.
45
+ import { functionTypeWiringProblems, staleFunctionTypeArtifacts, writeFunctionTypeArtifacts, } from "../lib/function-db-types.js";
39
46
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
40
47
  import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, isSafeSyncFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
41
48
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
@@ -68,7 +75,7 @@ function printStaleInstanceWarning(updated) {
68
75
  }
69
76
  }
70
77
  function loadSyncState(configDir) {
71
- const syncFile = join(configDir, ".primitive-sync.json");
78
+ const syncFile = syncStatePath(configDir);
72
79
  if (!existsSync(syncFile)) {
73
80
  return null;
74
81
  }
@@ -80,7 +87,7 @@ function loadSyncState(configDir) {
80
87
  }
81
88
  }
82
89
  function saveSyncState(configDir, state) {
83
- const syncFile = join(configDir, ".primitive-sync.json");
90
+ const syncFile = syncStatePath(configDir);
84
91
  writeFileSync(syncFile, JSON.stringify(state, null, 2));
85
92
  }
86
93
  /**
@@ -3402,6 +3409,13 @@ const CONFIG_DIFF_SPECS = {
3402
3409
  entry: entity.entry ?? null,
3403
3410
  durable: entity.durable === true,
3404
3411
  limits: entity.limits ?? null,
3412
+ // #3182 — the grants are version-carried too, and a widened grant set is
3413
+ // exactly the change a reviewer must not read as Synced. Sorted because
3414
+ // the row stores a set.
3415
+ capabilities: Array.isArray(entity.capabilities)
3416
+ ? [...entity.capabilities].map((c) => String(c)).sort()
3417
+ : [],
3418
+ unscopedReads: entity.unscopedReads === true,
3405
3419
  }),
3406
3420
  describe: (entity) => `function "${entity?.functionKey}"`,
3407
3421
  },
@@ -4222,7 +4236,7 @@ export function configSemanticHash(label, filePath, maps) {
4222
4236
  /**
4223
4237
  * The sync state `config push` writes into (#2731 B6, closing #2374).
4224
4238
  *
4225
- * `loadSyncState` answers `null` for an absent `.primitive-sync.json`, and
4239
+ * `loadSyncState` answers `null` for an absent sync-state baseline, and
4226
4240
  * every per-entity state write in the push loops is guarded by `if (syncState)`
4227
4241
  * — so a first push created every resource and recorded none of them. The next
4228
4242
  * push then re-entered the create path for everything and depended on 409
@@ -6276,7 +6290,7 @@ function readSelection(options) {
6276
6290
  * `registerConfigCommands` created rather than making one of its own.
6277
6291
  *
6278
6292
  * The module keeps its name and its ~50 exported helpers: `sync.ts`,
6279
- * `resolveSyncDir`, `.primitive-sync.json` and `.primitive/sync/<env>/<appId>/`
6293
+ * `resolveSyncDir`, the sync-state baseline and `primitive/<env>/`
6280
6294
  * are internal identifiers, deliberately left alone (#2759 scope).
6281
6295
  */
6282
6296
  export function registerConfigSyncCommands(sync) {
@@ -6288,8 +6302,14 @@ export function registerConfigSyncCommands(sync) {
6288
6302
  .option("--app <app-id>", "App ID")
6289
6303
  .option("--dir <path>", "Config directory (overrides the auto-resolved per-env path)")
6290
6304
  .action(async (appId, options) => {
6305
+ // Refused before any tree read/write or request (#3153, F1): the tree
6306
+ // path no longer partitions by app.
6307
+ assertTreeApp(appId || options.app, "config init");
6291
6308
  const resolvedAppId = resolveAppId(appId, options);
6292
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6309
+ const configDir = resolveSyncDir({ userDir: options.dir });
6310
+ // A baseline that was never renamed means an unmigrated tree; refused
6311
+ // here, before any request, snapshot or directory is made (#3153).
6312
+ assertSyncStateLayout(configDir);
6293
6313
  if (isAutoResolvedSyncDir(options.dir)) {
6294
6314
  info(`Using per-environment config directory: ${configDir}`);
6295
6315
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -6336,8 +6356,14 @@ export function registerConfigSyncCommands(sync) {
6336
6356
  (value, previous) => [...(previous ?? []), value], [])
6337
6357
  .option("--no-prune", "Keep local files for entities that are absent from the sync export (the next push will re-create them)")
6338
6358
  .action(async (appId, options) => {
6359
+ // Refused before any tree read/write or request (#3153, F1): the tree
6360
+ // path no longer partitions by app.
6361
+ assertTreeApp(appId || options.app, "config pull");
6339
6362
  const resolvedAppId = resolveAppId(appId, options);
6340
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
6363
+ const configDir = resolveSyncDir({ userDir: options.dir });
6364
+ // A baseline that was never renamed means an unmigrated tree; refused
6365
+ // here, before any request, snapshot or directory is made (#3153).
6366
+ assertSyncStateLayout(configDir);
6341
6367
  if (isAutoResolvedSyncDir(options.dir) && !options.json) {
6342
6368
  info(`Using per-environment config directory: ${configDir}`);
6343
6369
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -7125,6 +7151,16 @@ export function registerConfigSyncCommands(sync) {
7125
7151
  };
7126
7152
  info(` Wrote database-type-configs/${filename}`);
7127
7153
  }
7154
+ // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
7155
+ // they are not in the envelope, and a fresh clone must end up with the
7156
+ // same three files a push would have written. Written here, after the
7157
+ // database types, so the declaration describes what this pull wrote.
7158
+ {
7159
+ const artifacts = writeFunctionTypeArtifacts(configDir);
7160
+ if (artifacts.length > 0) {
7161
+ info(` Wrote function types: ${artifacts.join(", ")}`);
7162
+ }
7163
+ }
7128
7164
  // Write rule sets
7129
7165
  const ruleSetEntities = {};
7130
7166
  for (const ruleSet of ruleSets) {
@@ -7618,8 +7654,14 @@ export function registerConfigSyncCommands(sync) {
7618
7654
  .option("-y, --yes", "Skip the interactive confirmation before pruning (for CI / automation)")
7619
7655
  .option("--accept-warnings", "Commit schema diffs that have operations with dynamic refs (issue #666 SCHEMA_HAS_UNCHECKABLE_OPS escape hatch)")
7620
7656
  .action(async (appId, options) => {
7657
+ // Refused before any tree read/write or request (#3153, F1): the tree
7658
+ // path no longer partitions by app.
7659
+ assertTreeApp(appId || options.app, "config push");
7621
7660
  const resolvedAppId = resolveAppId(appId, options);
7622
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
7661
+ const configDir = resolveSyncDir({ userDir: options.dir });
7662
+ // A baseline that was never renamed means an unmigrated tree; refused
7663
+ // here, before any request, snapshot or directory is made (#3153).
7664
+ assertSyncStateLayout(configDir);
7623
7665
  if (isAutoResolvedSyncDir(options.dir)) {
7624
7666
  info(`Using per-environment config directory: ${configDir}`);
7625
7667
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -8086,7 +8128,7 @@ export function registerConfigSyncCommands(sync) {
8086
8128
  // known-bad sibling file (e.g. an invalid workflow TOML) could let an
8087
8129
  // earlier create (a cron trigger) be applied, then abort. The
8088
8130
  // `process.exit` also bypassed the save-on-failure catch below, so the
8089
- // orphaned create never reached `.primitive-sync.json` and the retry
8131
+ // orphaned create never reached the sync-state baseline and the retry
8090
8132
  // re-issued a CREATE that the server rejected with 409 forever.
8091
8133
  //
8092
8134
  // Running every validation up-front means a known-bad file aborts
@@ -8268,6 +8310,100 @@ export function registerConfigSyncCommands(sync) {
8268
8310
  preflightValidationErrors.push(` vars.toml: ${err?.message || String(err)}`);
8269
8311
  }
8270
8312
  }
8313
+ // #3182 — a function's database grants, checked against the author's
8314
+ // own file before anything is applied. The grammar and the
8315
+ // unscoped-read rule are purely local; whether a grant names a type and
8316
+ // model that will exist is answered against the tree's own
8317
+ // `database-type-configs/` (applied BEFORE functions since this child)
8318
+ // merged with the types the server already has. If the live listing
8319
+ // cannot be fetched and the tree declares no types, the target check is
8320
+ // skipped and the server — which is authoritative — has the last word.
8321
+ const preflightFunctionsDir = join(configDir, "functions");
8322
+ if (existsSync(preflightFunctionsDir)) {
8323
+ const functionFiles = selectFiles("function", preflightFunctionsDir, readdirSync(preflightFunctionsDir).filter((f) => f.endsWith(".toml")));
8324
+ if (functionFiles.length > 0) {
8325
+ const treeTypes = databaseTypesFromTree(configDir);
8326
+ let knownTypes = null;
8327
+ try {
8328
+ const live = await client.listDatabaseTypeConfigs(resolvedAppId);
8329
+ knownTypes = mergeKnownDatabaseTypes(databaseTypesFromServer(Array.isArray(live) ? live : []), treeTypes);
8330
+ }
8331
+ catch {
8332
+ knownTypes = treeTypes.size > 0 ? treeTypes : null;
8333
+ }
8334
+ // #3183 — the same question for the two grant families that name
8335
+ // config-tree objects. `secret:` and `var:` are absent on purpose
8336
+ // (D3183-002): their values are provisioned per environment, out
8337
+ // of band, so a tree can never answer for them.
8338
+ const treeTargets = configObjectKeysFromTree(configDir);
8339
+ let knownTargets = null;
8340
+ try {
8341
+ const [integrations, prompts] = await Promise.all([
8342
+ client.listIntegrations(resolvedAppId, { status: "active" }),
8343
+ client.listPrompts(resolvedAppId, { status: "active" }),
8344
+ ]);
8345
+ knownTargets = mergeKnownGrantTargets({
8346
+ integrations: new Set((integrations?.items ?? [])
8347
+ .map((row) => String(row?.integrationKey ?? ""))
8348
+ .filter(Boolean)),
8349
+ prompts: new Set((prompts?.items ?? [])
8350
+ .map((row) => String(row?.promptKey ?? ""))
8351
+ .filter(Boolean)),
8352
+ }, treeTargets);
8353
+ }
8354
+ catch {
8355
+ knownTargets =
8356
+ treeTargets.integrations.size > 0 || treeTargets.prompts.size > 0
8357
+ ? treeTargets
8358
+ : null;
8359
+ }
8360
+ for (const file of functionFiles) {
8361
+ const filePath = join(preflightFunctionsDir, file);
8362
+ let tomlData;
8363
+ try {
8364
+ tomlData = parseTomlFile(filePath);
8365
+ }
8366
+ catch (err) {
8367
+ preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
8368
+ continue;
8369
+ }
8370
+ // #3187 — a read grant declared without `unscopedReads = true`
8371
+ // is accepted when the tree's registered reads are all bound to
8372
+ // `$caller`, and only the MANIFEST says whether they are. So the
8373
+ // functions whose decision depends on it are built and collected
8374
+ // here, before anything is applied; the result is memoized
8375
+ // against the built bundle, so the push below does not run the
8376
+ // tree's module scope a second time (CR3187-001).
8377
+ let manifest = null;
8378
+ if (functionNeedsManifest(tomlData)) {
8379
+ try {
8380
+ manifest = await collectManifestForPreflight({
8381
+ configDir,
8382
+ // The same key the push loop derives, so a refusal names
8383
+ // the function the way the rest of the run does.
8384
+ functionKey: tomlData.function?.key || basename(file, ".toml"),
8385
+ tomlData,
8386
+ });
8387
+ }
8388
+ catch (err) {
8389
+ // Fail-closed and early: the push would refuse this tree for
8390
+ // the same reason, naming the same error.
8391
+ preflightValidationErrors.push(` ${filePath}: ${err?.message || String(err)}`);
8392
+ continue;
8393
+ }
8394
+ }
8395
+ for (const message of functionGrantErrors({
8396
+ filePath,
8397
+ tomlData,
8398
+ knownTypes,
8399
+ knownTargets,
8400
+ manifest,
8401
+ })) {
8402
+ preflightValidationErrors.push(` ${message}`);
8403
+ }
8404
+ }
8405
+ }
8406
+ }
8271
8407
  // #2644 — reject any key the vendored definition does not recognize,
8272
8408
  // for every migrated configuration type. Same posture the `[workflow]`
8273
8409
  // check above has, generalized: a mistyped key fails here, in the
@@ -10865,341 +11001,86 @@ export function registerConfigSyncCommands(sync) {
10865
11001
  }
10866
11002
  }
10867
11003
  }
10868
- // Process server functions — #3179.
10869
- //
10870
- // A function pushes on TWO axes and they are deliberately independent:
10871
- //
10872
- // - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
10873
- // version-carried keys, all compared through the shared gate), and
10874
- // - the CODE — the authored TOML bytes, every source file and the
10875
- // built bundle, sent as one immutable config VERSION.
10876
- //
10877
- // Independent because a comment-only edit to a source file leaves the
10878
- // header identical and must still make a version (F-003): gating the
10879
- // code push on the header comparison would silently discard it. The
10880
- // code axis compares `envelopeHash`, which is also what the SERVER
10881
- // dedupes on, so an unchanged push makes no version on either side's
10882
- // reckoning.
10883
- //
10884
- // The bundle is built FIRST, so a build error, a missing entry or an
10885
- // import that escapes the config tree aborts this function before
10886
- // anything is sent for it (behavior 12).
10887
- const functionsPushDir = join(configDir, "functions");
10888
- if (existsSync(functionsPushDir)) {
10889
- const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
10890
- const functionSpec = configDiffSpec("function");
10891
- const liveFunctions = new Map();
10892
- let functionsLiveOk = true;
10893
- if (files.length > 0) {
10894
- try {
10895
- const listed = await client.listFunctions(resolvedAppId);
10896
- for (const f of (listed?.items ?? [])) {
10897
- if (f?.functionKey)
10898
- liveFunctions.set(f.functionKey, f);
11004
+ // Process workflows
11005
+ const workflowsDir = join(configDir, "workflows");
11006
+ if (existsSync(workflowsDir)) {
11007
+ const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
11008
+ // #2896 — pairing for a clone with no sync state. A workflow the
11009
+ // manifest has no row for is not thereby a create: the create path
11010
+ // below already adopts an existing workflow by key on the server's
11011
+ // 409 (#1006/#1174), so letting live state answer the same question
11012
+ // one step earlier is that same adopt — reached before a doomed
11013
+ // create is sent, which is what the prompt reconcile does (#2909).
11014
+ // Without it a stateless clone plans "create workflow" for every
11015
+ // workflow the server already holds, and its `.tests/` sidecars —
11016
+ // addressed through the sync-state id the plan never records —
11017
+ // cascade into duplicate test-case creates. Fetched once, and only
11018
+ // when a file is actually untracked; a failed listing degrades to
11019
+ // the create-then-409 path, which still converges.
11020
+ let liveWorkflowIdsByKey;
11021
+ const lookupLiveWorkflowId = async (workflowKey) => {
11022
+ if (liveWorkflowIdsByKey === undefined) {
11023
+ try {
11024
+ const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
11025
+ liveWorkflowIdsByKey = new Map(live
11026
+ .filter((w) => w?.workflowKey && w?.workflowId)
11027
+ .map((w) => [
11028
+ w.workflowKey,
11029
+ w.workflowId,
11030
+ ]));
11031
+ }
11032
+ catch (err) {
11033
+ liveWorkflowIdsByKey = null;
11034
+ warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
10899
11035
  }
10900
11036
  }
10901
- catch (err) {
10902
- functionsLiveOk = false;
10903
- warnLiveUnavailable("functions", err);
10904
- }
10905
- }
10906
- /** The version a function currently runs, or null. */
10907
- const liveActiveVersion = async (functionId, activeConfigId) => {
10908
- if (!activeConfigId)
10909
- return null;
10910
- const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
10911
- return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
11037
+ return liveWorkflowIdsByKey?.get(workflowKey);
10912
11038
  };
10913
11039
  for (const file of files) {
10914
- const filePath = join(functionsPushDir, file);
11040
+ const filePath = join(workflowsDir, file);
10915
11041
  const tomlData = parseTomlFile(filePath);
10916
- const key = tomlData.function?.key || basename(file, ".toml");
10917
- withDerivedIdentity(tomlData, "function", { key });
10918
- const existingEntry = syncState?.entities?.functions?.[key];
10919
- const liveFunction = liveFunctions.get(key);
10920
- // Build first: nothing is sent for a function whose code does not
10921
- // compile or whose imports leave the tree.
10922
- let push;
10923
- try {
10924
- push = await buildFunctionConfigPush({
10925
- configDir,
10926
- functionKey: key,
10927
- filePath,
10928
- tomlData,
10929
- });
10930
- }
10931
- catch (err) {
10932
- if (!(err instanceof FunctionBuildError))
10933
- throw err;
10934
- applyFailures.push({
10935
- type: "function",
10936
- key,
10937
- message: err.message,
10938
- });
11042
+ // #1660 — skip platform-owned workflows (the reserved `__internal.*`
11043
+ // namespace). Resolve the key FIRST and skip BEFORE the workflow
11044
+ // TOML validation below, so a hollow exported `__internal.*.toml`
11045
+ // (steps = [], no active config) is skipped, not failed by
11046
+ // `validateWorkflowToml`, and does not abort the whole push trying
11047
+ // to activate a configuration-less workflow. Match on the resolved
11048
+ // `[workflow].key` (not filename) so a renamed file is still caught
11049
+ // (edge case 2).
11050
+ const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
11051
+ if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
11052
+ info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
10939
11053
  continue;
10940
11054
  }
10941
- // The active version, needed by BOTH axes: the header comparison
10942
- // reads `entry`/`durable`/`limits` off it, and the code axis
10943
- // compares its `envelopeHash`.
10944
- let activeVersion = null;
10945
- let versionOk = functionsLiveOk;
10946
- if (functionsLiveOk && liveFunction?.functionId) {
10947
- try {
10948
- activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
10949
- }
10950
- catch (err) {
10951
- versionOk = false;
10952
- warnLiveUnavailable(`function ${key}`, err);
10953
- }
10954
- }
10955
- let headerAction = "skip";
10956
- if (versionOk && functionsLiveOk) {
10957
- const gate = decidePushForConfig({
10958
- spec: functionSpec,
10959
- maps: pushDiffMaps,
10960
- localParsed: tomlData,
10961
- live: liveFunction
10962
- ? {
10963
- record: liveFunction,
10964
- // The version-carried keys the remote side is projected
10965
- // through; the header row does not hold them.
10966
- extra: activeVersion ?? undefined,
10967
- modifiedAt: liveFunction.modifiedAt,
10968
- }
10969
- : null,
10970
- entry: existingEntry,
10971
- localFileHash: computeFileHash(filePath),
10972
- force: options.force,
10973
- // The create leg adopts by key on the server's 409 (#1006), so
10974
- // an untracked function the server already holds is that same
10975
- // adopt reached one step earlier.
10976
- adoptsUntrackedByKey: true,
10977
- });
10978
- if (gate.action === "drift" ||
10979
- gate.action === "conflict" ||
10980
- gate.action === "immutable") {
10981
- recordDeclined("function", key, gate, existingEntry?.modifiedAt);
10982
- continue;
10983
- }
10984
- headerAction = gate.action === "skip"
10985
- ? "skip"
10986
- : liveFunction
10987
- ? "update"
10988
- : "create";
10989
- }
10990
- else {
10991
- const degraded = decideDegradedPush({
10992
- force: options.force,
10993
- storedContentHash: existingEntry?.contentHash,
10994
- currentFileHash: computeFileHash(filePath),
10995
- });
10996
- if (degraded.action === "decline") {
10997
- recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
10998
- continue;
10999
- }
11000
- headerAction = degraded.action === "skip"
11001
- ? "skip"
11002
- : liveFunction
11003
- ? "update"
11004
- : "create";
11055
+ // Reject misnested headers (#685, e.g. [steps.<id>.request]) and
11056
+ // the #1447 runAs="system" + accessRule dead-config combo before
11057
+ // pushing. The runtime silently ignores fields outside the
11058
+ // allowlist, so this is the only place to catch the footgun, and
11059
+ // the server rejects a system accessRule (#1258) — flagging it
11060
+ // here keeps the push all-or-nothing. Validation happens BEFORE the
11061
+ // skip-if-unchanged check so a previously-pushed-but-broken
11062
+ // file gets a clear diagnostic on every push attempt.
11063
+ // Issue #976: validation is now front-loaded before any apply
11064
+ // (see the pre-flight pass above). This in-loop check remains as
11065
+ // defense-in-depth, but THROWS rather than `process.exit(1)` so the
11066
+ // single save-on-failure catch is the only exit path — never
11067
+ // bypassing it and orphaning an already-created resource.
11068
+ const tomlErrors = validateWorkflowToml(tomlData);
11069
+ if (tomlErrors.length > 0) {
11070
+ throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
11005
11071
  }
11006
- // The code axis. `envelopeHash` is the server's own no-op test, so
11007
- // matching it here means the call would create nothing.
11008
- const codeUnchanged = !!liveFunction &&
11009
- !!activeVersion &&
11010
- String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
11011
- if (headerAction === "skip" && codeUnchanged) {
11012
- skippedCount++;
11013
- if (syncState && liveFunction?.functionId) {
11014
- if (!syncState.entities.functions)
11015
- syncState.entities.functions = {};
11016
- syncState.entities.functions[key] = {
11017
- ...(existingEntry ?? {}),
11018
- id: liveFunction.functionId,
11019
- modifiedAt: liveFunction.modifiedAt ||
11020
- existingEntry?.modifiedAt ||
11021
- new Date().toISOString(),
11022
- contentHash: computeFileHash(filePath),
11023
- semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
11024
- envelopeHash: push.envelopeHash,
11025
- activeConfigId: activeVersion?.configId,
11026
- sources: push.sources.map((src) => src.path),
11027
- };
11028
- }
11029
- continue;
11072
+ const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
11073
+ if (unknownKeyError) {
11074
+ throw new Error(unknownKeyError);
11030
11075
  }
11031
- changes.push({
11032
- type: "function",
11033
- action: liveFunction ? "update" : "create",
11034
- key,
11035
- });
11036
- if (options.dryRun)
11037
- continue;
11038
- // Every server refusal below is recorded against THIS function and
11039
- // the loop moves on. A single archived tombstone — or a key the
11040
- // app's namespace already holds — must not abort a push carrying
11041
- // unrelated changes: the run reports what it could not apply, exits
11042
- // non-zero, and everything else in the tree still lands. That is
11043
- // the same contract `applyFailures` gives every other type; a throw
11044
- // here would let one refusal block a whole repository.
11045
- let functionId = liveFunction?.functionId ?? existingEntry?.id;
11046
- let pushedVersion;
11047
- try {
11048
- if (!functionId) {
11049
- const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
11050
- // #1006 — a function the server already holds under this key
11051
- // is adopted rather than re-created. The conflict may name a
11052
- // WORKFLOW, a script or a webhook instead: those are the
11053
- // single key namespace refusing the key, and they are not
11054
- // adoptable, so `adoptByKeyOnCreate409` re-throws the original
11055
- // error with its holder named.
11056
- const existing = await adoptByKeyOnCreate409({
11057
- err,
11058
- kind: "function",
11059
- key,
11060
- lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
11061
- matchKey: (f) => f?.functionKey === key,
11062
- });
11063
- return existing;
11064
- });
11065
- functionId = created.functionId;
11066
- info(` Created function: ${key}`);
11067
- }
11068
- else if (headerAction !== "skip") {
11069
- const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
11070
- if (Object.keys(updatePayload).length > 0) {
11071
- await client.updateFunction(resolvedAppId, functionId, updatePayload);
11072
- }
11073
- info(` Updated function: ${key}`);
11074
- }
11075
- // The version. `status` is NEVER in this body: pushing code to a
11076
- // disabled function updates it and leaves it out of service
11077
- // (#2803's resurrection guard), and a push to an archived one is
11078
- // refused by the server with the archived-holder message — which
11079
- // names the remedies that release its key.
11080
- pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
11081
- }
11082
- catch (err) {
11083
- applyFailures.push({
11084
- type: "function",
11085
- key,
11086
- message: String(err?.message ?? err),
11087
- });
11088
- continue;
11089
- }
11090
- if (pushedVersion?.created === false) {
11091
- info(` Function ${key}: code unchanged (no new version)`);
11092
- }
11093
- else {
11094
- info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
11095
- }
11096
- if (syncState) {
11097
- if (!syncState.entities.functions)
11098
- syncState.entities.functions = {};
11099
- let refreshed = liveFunction;
11100
- try {
11101
- refreshed = await client.getFunction(resolvedAppId, String(functionId));
11102
- }
11103
- catch {
11104
- // Non-fatal: the baseline falls back to what the listing said.
11105
- // The next push reconciles it.
11106
- }
11107
- syncState.entities.functions[key] = {
11108
- id: String(functionId),
11109
- modifiedAt: refreshed?.modifiedAt ||
11110
- existingEntry?.modifiedAt ||
11111
- new Date().toISOString(),
11112
- contentHash: computeFileHash(filePath),
11113
- semanticHash: refreshed
11114
- ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
11115
- : undefined,
11116
- envelopeHash: push.envelopeHash,
11117
- activeConfigId: pushedVersion?.configId,
11118
- sources: push.sources.map((src) => src.path),
11119
- };
11120
- }
11121
- }
11122
- }
11123
- // Process workflows
11124
- const workflowsDir = join(configDir, "workflows");
11125
- if (existsSync(workflowsDir)) {
11126
- const files = selectFiles("workflow", workflowsDir, readdirSync(workflowsDir).filter((f) => f.endsWith(".toml")));
11127
- // #2896 — pairing for a clone with no sync state. A workflow the
11128
- // manifest has no row for is not thereby a create: the create path
11129
- // below already adopts an existing workflow by key on the server's
11130
- // 409 (#1006/#1174), so letting live state answer the same question
11131
- // one step earlier is that same adopt — reached before a doomed
11132
- // create is sent, which is what the prompt reconcile does (#2909).
11133
- // Without it a stateless clone plans "create workflow" for every
11134
- // workflow the server already holds, and its `.tests/` sidecars —
11135
- // addressed through the sync-state id the plan never records —
11136
- // cascade into duplicate test-case creates. Fetched once, and only
11137
- // when a file is actually untracked; a failed listing degrades to
11138
- // the create-then-409 path, which still converges.
11139
- let liveWorkflowIdsByKey;
11140
- const lookupLiveWorkflowId = async (workflowKey) => {
11141
- if (liveWorkflowIdsByKey === undefined) {
11142
- try {
11143
- const live = await fetchAll((p) => client.listWorkflows(resolvedAppId, p));
11144
- liveWorkflowIdsByKey = new Map(live
11145
- .filter((w) => w?.workflowKey && w?.workflowId)
11146
- .map((w) => [
11147
- w.workflowKey,
11148
- w.workflowId,
11149
- ]));
11150
- }
11151
- catch (err) {
11152
- liveWorkflowIdsByKey = null;
11153
- warn(` workflows: live state unavailable (${err instanceof Error ? err.message : String(err)}) — an untracked workflow plans a create and adopts on the server's conflict`);
11154
- }
11155
- }
11156
- return liveWorkflowIdsByKey?.get(workflowKey);
11157
- };
11158
- for (const file of files) {
11159
- const filePath = join(workflowsDir, file);
11160
- const tomlData = parseTomlFile(filePath);
11161
- // #1660 — skip platform-owned workflows (the reserved `__internal.*`
11162
- // namespace). Resolve the key FIRST and skip BEFORE the workflow
11163
- // TOML validation below, so a hollow exported `__internal.*.toml`
11164
- // (steps = [], no active config) is skipped, not failed by
11165
- // `validateWorkflowToml`, and does not abort the whole push trying
11166
- // to activate a configuration-less workflow. Match on the resolved
11167
- // `[workflow].key` (not filename) so a renamed file is still caught
11168
- // (edge case 2).
11169
- const platformOwnedKey = tomlData.workflow?.key || basename(file, ".toml");
11170
- if (isPlatformOwnedWorkflowKey(platformOwnedKey)) {
11171
- info(` Skipped workflows/${file} — platform-owned workflow "${platformOwnedKey}" (managed by the platform)`);
11172
- continue;
11173
- }
11174
- // Reject misnested headers (#685, e.g. [steps.<id>.request]) and
11175
- // the #1447 runAs="system" + accessRule dead-config combo before
11176
- // pushing. The runtime silently ignores fields outside the
11177
- // allowlist, so this is the only place to catch the footgun, and
11178
- // the server rejects a system accessRule (#1258) — flagging it
11179
- // here keeps the push all-or-nothing. Validation happens BEFORE the
11180
- // skip-if-unchanged check so a previously-pushed-but-broken
11181
- // file gets a clear diagnostic on every push attempt.
11182
- // Issue #976: validation is now front-loaded before any apply
11183
- // (see the pre-flight pass above). This in-loop check remains as
11184
- // defense-in-depth, but THROWS rather than `process.exit(1)` so the
11185
- // single save-on-failure catch is the only exit path — never
11186
- // bypassing it and orphaning an already-created resource.
11187
- const tomlErrors = validateWorkflowToml(tomlData);
11188
- if (tomlErrors.length > 0) {
11189
- throw new Error(formatWorkflowTomlErrors(filePath, tomlErrors));
11190
- }
11191
- const unknownKeyError = workflowUnknownKeyError(filePath, tomlData);
11192
- if (unknownKeyError) {
11193
- throw new Error(unknownKeyError);
11194
- }
11195
- const workflow = tomlData.workflow || {};
11196
- const key = workflow.key || basename(file, ".toml");
11197
- const steps = tomlData.steps || [];
11198
- // #2652 — a caller workflow with no rule denies every non-admin
11199
- // start from this release onward. A `runAs = "system"` workflow is
11200
- // exempt (it rejects rules and members cannot start it).
11201
- const workflowRuleWarning = missingAccessRuleWarning({
11202
- kind: "workflow",
11076
+ const workflow = tomlData.workflow || {};
11077
+ const key = workflow.key || basename(file, ".toml");
11078
+ const steps = tomlData.steps || [];
11079
+ // #2652 — a caller workflow with no rule denies every non-admin
11080
+ // start from this release onward. A `runAs = "system"` workflow is
11081
+ // exempt (it rejects rules and members cannot start it).
11082
+ const workflowRuleWarning = missingAccessRuleWarning({
11083
+ kind: "workflow",
11203
11084
  key,
11204
11085
  accessRule: workflow.accessRule,
11205
11086
  runAs: workflow.runAs,
@@ -12509,7 +12390,7 @@ export function registerConfigSyncCommands(sync) {
12509
12390
  //
12510
12391
  // But the 409 also fires when the local sync state is merely
12511
12392
  // ABSENT for a type that another admin has already FULLY
12512
- // configured (or whose `.primitive-sync.json` was not copied
12393
+ // configured (or whose sync-state baseline was not copied
12513
12394
  // alongside the TOML). PATCHing that without
12514
12395
  // `expectedModifiedAt` would silently overwrite the existing
12515
12396
  // triggers/access/schema with the local TOML — with no
@@ -12953,6 +12834,279 @@ export function registerConfigSyncCommands(sync) {
12953
12834
  }
12954
12835
  }
12955
12836
  }
12837
+ // Process server functions — #3179.
12838
+ //
12839
+ // A function pushes on TWO axes and they are deliberately independent:
12840
+ //
12841
+ // - the HEADER (`[function]`'s gate, runAs, schemas, plus the three
12842
+ // version-carried keys, all compared through the shared gate), and
12843
+ // - the CODE — the authored TOML bytes, every source file and the
12844
+ // built bundle, sent as one immutable config VERSION.
12845
+ //
12846
+ // Independent because a comment-only edit to a source file leaves the
12847
+ // header identical and must still make a version (F-003): gating the
12848
+ // code push on the header comparison would silently discard it. The
12849
+ // code axis compares `envelopeHash`, which is also what the SERVER
12850
+ // dedupes on, so an unchanged push makes no version on either side's
12851
+ // reckoning.
12852
+ //
12853
+ // The bundle is built FIRST, so a build error, a missing entry or an
12854
+ // import that escapes the config tree aborts this function before
12855
+ // anything is sent for it (behavior 12).
12856
+ const functionsPushDir = join(configDir, "functions");
12857
+ if (existsSync(functionsPushDir)) {
12858
+ // #3182 — the typed handle. Written BEFORE the bundles are built, so
12859
+ // an author who runs `config push` after editing a schema gets the
12860
+ // declaration their next edit is typed against, and written from the
12861
+ // TREE rather than from the server: the types describe the schemas
12862
+ // this push is applying. Regenerated every time and never part of the
12863
+ // envelope — `config pull` recreates them the same way.
12864
+ if (!options.dryRun) {
12865
+ const artifacts = writeFunctionTypeArtifacts(configDir);
12866
+ if (artifacts.length > 0) {
12867
+ info(` Wrote function types: ${artifacts.join(", ")}`);
12868
+ }
12869
+ }
12870
+ // A tsconfig push will not rewrite (comments are the author's) or
12871
+ // cannot read is still a tsconfig no compiler loads the handle
12872
+ // through. Say so rather than report success over it (CR3182-003).
12873
+ for (const problem of functionTypeWiringProblems(configDir)) {
12874
+ warn(` functions/tsconfig.json: ${problem}`);
12875
+ }
12876
+ const files = selectFiles("function", functionsPushDir, readdirSync(functionsPushDir).filter((f) => f.endsWith(".toml")));
12877
+ const functionSpec = configDiffSpec("function");
12878
+ const liveFunctions = new Map();
12879
+ let functionsLiveOk = true;
12880
+ if (files.length > 0) {
12881
+ try {
12882
+ const listed = await client.listFunctions(resolvedAppId);
12883
+ for (const f of (listed?.items ?? [])) {
12884
+ if (f?.functionKey)
12885
+ liveFunctions.set(f.functionKey, f);
12886
+ }
12887
+ }
12888
+ catch (err) {
12889
+ functionsLiveOk = false;
12890
+ warnLiveUnavailable("functions", err);
12891
+ }
12892
+ }
12893
+ /** The version a function currently runs, or null. */
12894
+ const liveActiveVersion = async (functionId, activeConfigId) => {
12895
+ if (!activeConfigId)
12896
+ return null;
12897
+ const { items } = await client.listFunctionConfigs(resolvedAppId, functionId);
12898
+ return ((items || []).find((c) => String(c.configId) === String(activeConfigId)) ?? null);
12899
+ };
12900
+ for (const file of files) {
12901
+ const filePath = join(functionsPushDir, file);
12902
+ const tomlData = parseTomlFile(filePath);
12903
+ const key = tomlData.function?.key || basename(file, ".toml");
12904
+ withDerivedIdentity(tomlData, "function", { key });
12905
+ const existingEntry = syncState?.entities?.functions?.[key];
12906
+ const liveFunction = liveFunctions.get(key);
12907
+ // Build first: nothing is sent for a function whose code does not
12908
+ // compile or whose imports leave the tree.
12909
+ let push;
12910
+ try {
12911
+ push = await buildFunctionConfigPush({
12912
+ configDir,
12913
+ functionKey: key,
12914
+ filePath,
12915
+ tomlData,
12916
+ });
12917
+ }
12918
+ catch (err) {
12919
+ if (!(err instanceof FunctionBuildError))
12920
+ throw err;
12921
+ applyFailures.push({
12922
+ type: "function",
12923
+ key,
12924
+ message: err.message,
12925
+ });
12926
+ continue;
12927
+ }
12928
+ // The active version, needed by BOTH axes: the header comparison
12929
+ // reads `entry`/`durable`/`limits` off it, and the code axis
12930
+ // compares its `envelopeHash`.
12931
+ let activeVersion = null;
12932
+ let versionOk = functionsLiveOk;
12933
+ if (functionsLiveOk && liveFunction?.functionId) {
12934
+ try {
12935
+ activeVersion = await liveActiveVersion(liveFunction.functionId, liveFunction.activeConfigId);
12936
+ }
12937
+ catch (err) {
12938
+ versionOk = false;
12939
+ warnLiveUnavailable(`function ${key}`, err);
12940
+ }
12941
+ }
12942
+ let headerAction = "skip";
12943
+ if (versionOk && functionsLiveOk) {
12944
+ const gate = decidePushForConfig({
12945
+ spec: functionSpec,
12946
+ maps: pushDiffMaps,
12947
+ localParsed: tomlData,
12948
+ live: liveFunction
12949
+ ? {
12950
+ record: liveFunction,
12951
+ // The version-carried keys the remote side is projected
12952
+ // through; the header row does not hold them.
12953
+ extra: activeVersion ?? undefined,
12954
+ modifiedAt: liveFunction.modifiedAt,
12955
+ }
12956
+ : null,
12957
+ entry: existingEntry,
12958
+ localFileHash: computeFileHash(filePath),
12959
+ force: options.force,
12960
+ // The create leg adopts by key on the server's 409 (#1006), so
12961
+ // an untracked function the server already holds is that same
12962
+ // adopt reached one step earlier.
12963
+ adoptsUntrackedByKey: true,
12964
+ });
12965
+ if (gate.action === "drift" ||
12966
+ gate.action === "conflict" ||
12967
+ gate.action === "immutable") {
12968
+ recordDeclined("function", key, gate, existingEntry?.modifiedAt);
12969
+ continue;
12970
+ }
12971
+ headerAction = gate.action === "skip"
12972
+ ? "skip"
12973
+ : liveFunction
12974
+ ? "update"
12975
+ : "create";
12976
+ }
12977
+ else {
12978
+ const degraded = decideDegradedPush({
12979
+ force: options.force,
12980
+ storedContentHash: existingEntry?.contentHash,
12981
+ currentFileHash: computeFileHash(filePath),
12982
+ });
12983
+ if (degraded.action === "decline") {
12984
+ recordDeclined("function", key, { action: "live-unavailable" }, existingEntry?.modifiedAt);
12985
+ continue;
12986
+ }
12987
+ headerAction = degraded.action === "skip"
12988
+ ? "skip"
12989
+ : liveFunction
12990
+ ? "update"
12991
+ : "create";
12992
+ }
12993
+ // The code axis. `envelopeHash` is the server's own no-op test, so
12994
+ // matching it here means the call would create nothing.
12995
+ const codeUnchanged = !!liveFunction &&
12996
+ !!activeVersion &&
12997
+ String(activeVersion.envelopeHash ?? "") === push.envelopeHash;
12998
+ if (headerAction === "skip" && codeUnchanged) {
12999
+ skippedCount++;
13000
+ if (syncState && liveFunction?.functionId) {
13001
+ if (!syncState.entities.functions)
13002
+ syncState.entities.functions = {};
13003
+ syncState.entities.functions[key] = {
13004
+ ...(existingEntry ?? {}),
13005
+ id: liveFunction.functionId,
13006
+ modifiedAt: liveFunction.modifiedAt ||
13007
+ existingEntry?.modifiedAt ||
13008
+ new Date().toISOString(),
13009
+ contentHash: computeFileHash(filePath),
13010
+ semanticHash: hashRemoteConfigForDiff(functionSpec, liveFunction, pushDiffMaps, activeVersion ?? undefined),
13011
+ envelopeHash: push.envelopeHash,
13012
+ activeConfigId: activeVersion?.configId,
13013
+ sources: push.sources.map((src) => src.path),
13014
+ };
13015
+ }
13016
+ continue;
13017
+ }
13018
+ changes.push({
13019
+ type: "function",
13020
+ action: liveFunction ? "update" : "create",
13021
+ key,
13022
+ });
13023
+ if (options.dryRun)
13024
+ continue;
13025
+ // Every server refusal below is recorded against THIS function and
13026
+ // the loop moves on. A single archived tombstone — or a key the
13027
+ // app's namespace already holds — must not abort a push carrying
13028
+ // unrelated changes: the run reports what it could not apply, exits
13029
+ // non-zero, and everything else in the tree still lands. That is
13030
+ // the same contract `applyFailures` gives every other type; a throw
13031
+ // here would let one refusal block a whole repository.
13032
+ let functionId = liveFunction?.functionId ?? existingEntry?.id;
13033
+ let pushedVersion;
13034
+ try {
13035
+ if (!functionId) {
13036
+ const created = await client.createFunction(resolvedAppId, buildFunctionCreatePayload(key, tomlData.function ?? {})).catch(async (err) => {
13037
+ // #1006 — a function the server already holds under this key
13038
+ // is adopted rather than re-created. The conflict may name a
13039
+ // WORKFLOW, a script or a webhook instead: those are the
13040
+ // single key namespace refusing the key, and they are not
13041
+ // adoptable, so `adoptByKeyOnCreate409` re-throws the original
13042
+ // error with its holder named.
13043
+ const existing = await adoptByKeyOnCreate409({
13044
+ err,
13045
+ kind: "function",
13046
+ key,
13047
+ lookup: async () => (await client.listFunctions(resolvedAppId)).items || [],
13048
+ matchKey: (f) => f?.functionKey === key,
13049
+ });
13050
+ return existing;
13051
+ });
13052
+ functionId = created.functionId;
13053
+ info(` Created function: ${key}`);
13054
+ }
13055
+ else if (headerAction !== "skip") {
13056
+ const updatePayload = buildFunctionUpdatePayload(tomlData.function ?? {});
13057
+ if (Object.keys(updatePayload).length > 0) {
13058
+ await client.updateFunction(resolvedAppId, functionId, updatePayload);
13059
+ }
13060
+ info(` Updated function: ${key}`);
13061
+ }
13062
+ // The version. `status` is NEVER in this body: pushing code to a
13063
+ // disabled function updates it and leaves it out of service
13064
+ // (#2803's resurrection guard), and a push to an archived one is
13065
+ // refused by the server with the archived-holder message — which
13066
+ // names the remedies that release its key.
13067
+ pushedVersion = await client.pushFunctionConfig(resolvedAppId, String(functionId), push);
13068
+ }
13069
+ catch (err) {
13070
+ applyFailures.push({
13071
+ type: "function",
13072
+ key,
13073
+ message: String(err?.message ?? err),
13074
+ });
13075
+ continue;
13076
+ }
13077
+ if (pushedVersion?.created === false) {
13078
+ info(` Function ${key}: code unchanged (no new version)`);
13079
+ }
13080
+ else {
13081
+ info(` Pushed function ${key} version ${pushedVersion?.configId ?? ""}`.trim());
13082
+ }
13083
+ if (syncState) {
13084
+ if (!syncState.entities.functions)
13085
+ syncState.entities.functions = {};
13086
+ let refreshed = liveFunction;
13087
+ try {
13088
+ refreshed = await client.getFunction(resolvedAppId, String(functionId));
13089
+ }
13090
+ catch {
13091
+ // Non-fatal: the baseline falls back to what the listing said.
13092
+ // The next push reconciles it.
13093
+ }
13094
+ syncState.entities.functions[key] = {
13095
+ id: String(functionId),
13096
+ modifiedAt: refreshed?.modifiedAt ||
13097
+ existingEntry?.modifiedAt ||
13098
+ new Date().toISOString(),
13099
+ contentHash: computeFileHash(filePath),
13100
+ semanticHash: refreshed
13101
+ ? hashRemoteConfigForDiff(functionSpec, refreshed, pushDiffMaps, pushedVersion ?? undefined)
13102
+ : undefined,
13103
+ envelopeHash: push.envelopeHash,
13104
+ activeConfigId: pushedVersion?.configId,
13105
+ sources: push.sources.map((src) => src.path),
13106
+ };
13107
+ }
13108
+ }
13109
+ }
12956
13110
  // Process group type configs
12957
13111
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
12958
13112
  if (existsSync(groupTypeConfigsDir)) {
@@ -13134,7 +13288,7 @@ export function registerConfigSyncCommands(sync) {
13134
13288
  // this, that CREATE re-hits the server's 409 "Config for this
13135
13289
  // group type already exists"
13136
13290
  // (group-type-config-controller.ts) on every subsequent push
13137
- // and the operator has to hand-edit `.primitive-sync.json` to
13291
+ // and the operator has to hand-edit the sync-state baseline to
13138
13292
  // recover. The shared helper detects the conflict (409 status /
13139
13293
  // "already exists" message), lists the app's configs — the list
13140
13294
  // route drains every page server-side (#1668), so the candidate
@@ -14275,8 +14429,14 @@ export function registerConfigSyncCommands(sync) {
14275
14429
  .option("--only <selectors>", "Compare just these objects: <type>/<key>, app, or var/<key> (repeatable, or comma-separated).", (value, previous) => [...(previous ?? []), value], [])
14276
14430
  .option("--json", "Output as JSON (app.toml field diff, with --only app)")
14277
14431
  .action(async (appId, options) => {
14432
+ // Refused before any tree read/write or request (#3153, F1): the tree
14433
+ // path no longer partitions by app.
14434
+ assertTreeApp(appId || options.app, "config diff");
14278
14435
  const resolvedAppId = resolveAppId(appId, options);
14279
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
14436
+ const configDir = resolveSyncDir({ userDir: options.dir });
14437
+ // A baseline that was never renamed means an unmigrated tree; refused
14438
+ // here, before any request, snapshot or directory is made (#3153).
14439
+ assertSyncStateLayout(configDir);
14280
14440
  if (isAutoResolvedSyncDir(options.dir)) {
14281
14441
  info(`Using per-environment config directory: ${configDir}`);
14282
14442
  const legacyHint = checkLegacySyncMigration(configDir);
@@ -15650,6 +15810,29 @@ export function registerConfigSyncCommands(sync) {
15650
15810
  console.log(` ${chalk.dim("=")} ${d.blockType}/${d.blockKey} test-case: ${d.slug}${hint}`);
15651
15811
  }
15652
15812
  }
15813
+ // #3182 — the generated function types are part of what a push would
15814
+ // change, so `config diff` is where their freshness is answered: an
15815
+ // editor or a CI job asks the same command it already asks "what would
15816
+ // push do?", and gets these named alongside everything else.
15817
+ const staleFunctionTypes = staleFunctionTypeArtifacts(configDir);
15818
+ if (staleFunctionTypes.length > 0) {
15819
+ console.log();
15820
+ info("Function types (regenerated by `config push`):");
15821
+ for (const relative of staleFunctionTypes) {
15822
+ console.log(` ${chalk.yellow("~")} ${relative}`);
15823
+ }
15824
+ }
15825
+ // Freshness is not the only way the handle stops working: a tsconfig
15826
+ // push will not rewrite can be perfectly current and still load none of
15827
+ // it. `config diff` is where CI asks, so it answers both (CR3182-003).
15828
+ const wiringProblems = functionTypeWiringProblems(configDir);
15829
+ if (wiringProblems.length > 0) {
15830
+ console.log();
15831
+ info("Function types (compiler wiring `config push` cannot repair):");
15832
+ for (const problem of wiringProblems) {
15833
+ console.log(` ${chalk.yellow("!")} functions/tsconfig.json: ${problem}`);
15834
+ }
15835
+ }
15653
15836
  divider();
15654
15837
  keyValue("Local only (new)", localOnly.length);
15655
15838
  keyValue("Local only (absent from export)", localOnlyAbsent.length);
@@ -15698,8 +15881,14 @@ export function registerConfigSyncCommands(sync) {
15698
15881
  // Resolve appId so we land in the per-env sync dir even when the user
15699
15882
  // didn't pass `--dir`. The app ID itself isn't used for any API calls
15700
15883
  // — `migrate-toml` is a purely local rewrite.
15884
+ // Refused before any tree read/write or request (#3153, F1): the tree
15885
+ // path no longer partitions by app.
15886
+ assertTreeApp(appId || options.app, "config migrate-toml");
15701
15887
  const resolvedAppId = resolveAppId(appId, options);
15702
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
15888
+ const configDir = resolveSyncDir({ userDir: options.dir });
15889
+ // A baseline that was never renamed means an unmigrated tree; refused
15890
+ // here, before any request, snapshot or directory is made (#3153).
15891
+ assertSyncStateLayout(configDir);
15703
15892
  if (isAutoResolvedSyncDir(options.dir)) {
15704
15893
  info(`Using per-environment config directory: ${configDir}`);
15705
15894
  }
@@ -15862,8 +16051,14 @@ export function registerConfigSyncCommands(sync) {
15862
16051
  // Resolve appId so we land in the per-env sync dir even when the user
15863
16052
  // didn't pass `--dir`. The app ID itself isn't used for any API calls —
15864
16053
  // migrate-v2 is a purely local rewrite (like migrate-toml).
16054
+ // Refused before any tree read/write or request (#3153, F1): the tree
16055
+ // path no longer partitions by app.
16056
+ assertTreeApp(appId || options.app, "config migrate-v2");
15865
16057
  const resolvedAppId = resolveAppId(appId, options);
15866
- const configDir = resolveSyncDir({ appId: resolvedAppId, userDir: options.dir });
16058
+ const configDir = resolveSyncDir({ userDir: options.dir });
16059
+ // A baseline that was never renamed means an unmigrated tree; refused
16060
+ // here, before any request, snapshot or directory is made (#3153).
16061
+ assertSyncStateLayout(configDir);
15867
16062
  if (isAutoResolvedSyncDir(options.dir)) {
15868
16063
  info(`Using per-environment config directory: ${configDir}`);
15869
16064
  }
@@ -15913,11 +16108,14 @@ export function registerConfigSyncCommands(sync) {
15913
16108
  .option("--list", "List available snapshots without restoring")
15914
16109
  .option("-y, --yes", "Skip the confirmation prompt")
15915
16110
  .action(async (appId, options) => {
16111
+ // Refused before any tree read/write or request (#3153, F1): the tree
16112
+ // path no longer partitions by app.
16113
+ assertTreeApp(appId || options.app, "config revert");
15916
16114
  const resolvedAppId = resolveAppId(appId, options);
15917
- const configDir = resolveSyncDir({
15918
- appId: resolvedAppId,
15919
- userDir: options.dir,
15920
- });
16115
+ const configDir = resolveSyncDir({ userDir: options.dir });
16116
+ // A baseline that was never renamed means an unmigrated tree; refused
16117
+ // here, before any request, snapshot or directory is made (#3153).
16118
+ assertSyncStateLayout(configDir);
15921
16119
  const snapshotsRoot = resolveSnapshotsRoot({
15922
16120
  appId: resolvedAppId,
15923
16121
  userDir: options.dir,
@@ -15992,7 +16190,7 @@ export function registerConfigSyncCommands(sync) {
15992
16190
  error(`Restore failed: ${err?.message ?? err}`);
15993
16191
  process.exit(1);
15994
16192
  }
15995
- success(`Restored snapshot ${target.id} into ${configDir} (including .primitive-sync.json).`);
16193
+ success(`Restored snapshot ${target.id} into ${configDir} (including ${SYNC_STATE_FILENAME}).`);
15996
16194
  info("Run 'primitive config diff' to inspect the restored state versus the server.");
15997
16195
  });
15998
16196
  }