primitive-admin 1.2.0-alpha.3 → 1.2.0-alpha.5

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 (143) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
  2. package/dist/src/commands/agent-sessions.js +4 -0
  3. package/dist/src/commands/agent-sessions.js.map +1 -1
  4. package/dist/src/commands/auth.js +7 -4
  5. package/dist/src/commands/auth.js.map +1 -1
  6. package/dist/src/commands/connections.js +11 -12
  7. package/dist/src/commands/connections.js.map +1 -1
  8. package/dist/src/commands/database-type-configs.js +2 -76
  9. package/dist/src/commands/database-type-configs.js.map +1 -1
  10. package/dist/src/commands/database-types.js +313 -13
  11. package/dist/src/commands/database-types.js.map +1 -1
  12. package/dist/src/commands/databases.d.ts +9 -0
  13. package/dist/src/commands/databases.js +78 -403
  14. package/dist/src/commands/databases.js.map +1 -1
  15. package/dist/src/commands/documents.d.ts +32 -2
  16. package/dist/src/commands/documents.js +344 -711
  17. package/dist/src/commands/documents.js.map +1 -1
  18. package/dist/src/commands/functions.js +2 -2
  19. package/dist/src/commands/functions.js.map +1 -1
  20. package/dist/src/commands/llm.js +6 -9
  21. package/dist/src/commands/llm.js.map +1 -1
  22. package/dist/src/commands/prompts.d.ts +7 -0
  23. package/dist/src/commands/prompts.js +44 -0
  24. package/dist/src/commands/prompts.js.map +1 -1
  25. package/dist/src/commands/settings.d.ts +9 -12
  26. package/dist/src/commands/settings.js +79 -31
  27. package/dist/src/commands/settings.js.map +1 -1
  28. package/dist/src/commands/sync.d.ts +43 -110
  29. package/dist/src/commands/sync.js +356 -1251
  30. package/dist/src/commands/sync.js.map +1 -1
  31. package/dist/src/lib/aggregate-output.d.ts +25 -0
  32. package/dist/src/lib/aggregate-output.js +53 -0
  33. package/dist/src/lib/aggregate-output.js.map +1 -0
  34. package/dist/src/lib/api-client.d.ts +48 -204
  35. package/dist/src/lib/api-client.js +57 -321
  36. package/dist/src/lib/api-client.js.map +1 -1
  37. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
  38. package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
  39. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  40. package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
  41. package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
  42. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
  43. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
  44. package/dist/src/lib/config-object-descriptor.js +15 -10
  45. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  46. package/dist/src/lib/config-surface.d.ts +2 -1
  47. package/dist/src/lib/config-surface.js +4 -1
  48. package/dist/src/lib/config-surface.js.map +1 -1
  49. package/dist/src/lib/connections-list.d.ts +10 -0
  50. package/dist/src/lib/connections-list.js +18 -0
  51. package/dist/src/lib/connections-list.js.map +1 -0
  52. package/dist/src/lib/csv.d.ts +8 -9
  53. package/dist/src/lib/csv.js +3 -4
  54. package/dist/src/lib/csv.js.map +1 -1
  55. package/dist/src/lib/db-codegen/dbGenerator.js +5 -16
  56. package/dist/src/lib/db-codegen/dbGenerator.js.map +1 -1
  57. package/dist/src/lib/db-codegen/dbNaming.d.ts +5 -13
  58. package/dist/src/lib/db-codegen/dbNaming.js +5 -17
  59. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  60. package/dist/src/lib/db-codegen/dbTemplates.d.ts +7 -56
  61. package/dist/src/lib/db-codegen/dbTemplates.js +25 -125
  62. package/dist/src/lib/db-codegen/dbTemplates.js.map +1 -1
  63. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
  64. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  65. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +60 -2
  66. package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
  67. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  68. package/dist/src/lib/document-export-record-log.d.ts +57 -0
  69. package/dist/src/lib/document-export-record-log.js +215 -0
  70. package/dist/src/lib/document-export-record-log.js.map +1 -0
  71. package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
  72. package/dist/src/lib/document-ingest-artifact.js +7 -3
  73. package/dist/src/lib/document-ingest-artifact.js.map +1 -1
  74. package/dist/src/lib/document-ingest-rows.d.ts +16 -0
  75. package/dist/src/lib/document-ingest-rows.js +17 -0
  76. package/dist/src/lib/document-ingest-rows.js.map +1 -1
  77. package/dist/src/lib/document-ingest.d.ts +22 -0
  78. package/dist/src/lib/document-ingest.js +33 -0
  79. package/dist/src/lib/document-ingest.js.map +1 -1
  80. package/dist/src/lib/function-sync.d.ts +3 -16
  81. package/dist/src/lib/function-sync.js +14 -18
  82. package/dist/src/lib/function-sync.js.map +1 -1
  83. package/dist/src/lib/function-triggers.d.ts +3 -15
  84. package/dist/src/lib/function-triggers.js +10 -72
  85. package/dist/src/lib/function-triggers.js.map +1 -1
  86. package/dist/src/lib/generated-allowlist.js +0 -20
  87. package/dist/src/lib/generated-allowlist.js.map +1 -1
  88. package/dist/src/lib/generated-config-surfaces.d.ts +212 -49
  89. package/dist/src/lib/generated-config-surfaces.js +759 -184
  90. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  91. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  92. package/dist/src/lib/generated-sdk-types.js +1 -1
  93. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  94. package/dist/src/lib/login-scope-lines.d.ts +15 -0
  95. package/dist/src/lib/login-scope-lines.js +22 -0
  96. package/dist/src/lib/login-scope-lines.js.map +1 -0
  97. package/dist/src/lib/paginate.d.ts +0 -5
  98. package/dist/src/lib/paginate.js +0 -5
  99. package/dist/src/lib/paginate.js.map +1 -1
  100. package/dist/src/lib/prompt-schema-codegen.js +5 -2
  101. package/dist/src/lib/prompt-schema-codegen.js.map +1 -1
  102. package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
  103. package/dist/src/lib/refresh-admin-credentials.js +16 -4
  104. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  105. package/dist/src/lib/root-import-decision.d.ts +15 -14
  106. package/dist/src/lib/root-import-decision.js +44 -51
  107. package/dist/src/lib/root-import-decision.js.map +1 -1
  108. package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
  109. package/dist/src/lib/snapshot-build-rows.js +22 -0
  110. package/dist/src/lib/snapshot-build-rows.js.map +1 -1
  111. package/dist/src/lib/snapshot-build.js +8 -3
  112. package/dist/src/lib/snapshot-build.js.map +1 -1
  113. package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
  114. package/dist/src/lib/swift-codegen/banners.js +37 -7
  115. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  116. package/dist/src/lib/swift-codegen/dbGenerator.d.ts +4 -3
  117. package/dist/src/lib/swift-codegen/dbGenerator.js +14 -117
  118. package/dist/src/lib/swift-codegen/dbGenerator.js.map +1 -1
  119. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
  120. package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
  121. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  122. package/dist/src/lib/swift-codegen/generator.d.ts +5 -5
  123. package/dist/src/lib/swift-codegen/generator.js +35 -40
  124. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  125. package/dist/src/lib/toml-database-config.d.ts +14 -75
  126. package/dist/src/lib/toml-database-config.js +14 -305
  127. package/dist/src/lib/toml-database-config.js.map +1 -1
  128. package/dist/src/lib/workflow-codegen/generator.d.ts +7 -7
  129. package/dist/src/lib/workflow-codegen/generator.js +16 -23
  130. package/dist/src/lib/workflow-codegen/generator.js.map +1 -1
  131. package/dist/src/lib/workflow-codegen/invokerIR.d.ts +6 -12
  132. package/dist/src/lib/workflow-codegen/invokerIR.js +4 -5
  133. package/dist/src/lib/workflow-codegen/invokerIR.js.map +1 -1
  134. package/dist/src/lib/workflow-toml-validator.d.ts +2 -0
  135. package/dist/src/lib/workflow-toml-validator.js +28 -1
  136. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  137. package/package.json +3 -5
  138. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  139. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  140. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  141. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  142. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  143. package/dist/src/lib/workflow-field-descriptor.js.map +0 -1
@@ -2,15 +2,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlink
2
2
  import { join, basename } from "path";
3
3
  import { createHash } from "crypto";
4
4
  import { lookup as mimeLookup } from "mime-types";
5
- import { ApiClient, ApiError, ConflictError, SchemaRequiredError, OperationRefError, SchemaBreaksOpsError, SchemaHasUncheckableOpsError, TomlParseError, OpsExistError, } from "../lib/api-client.js";
5
+ import { ApiClient, ApiError, ConflictError, TomlParseError, } from "../lib/api-client.js";
6
6
  import { retryOnStoragePending } from "../lib/storage-pending-retry.js";
7
- import { buildDatabaseTypeTomlData, detectExistingOperationForms, normalizeOperationFromToml, normalizeSubscriptionFromToml, SubscriptionAccessKeyConflictError, } from "../lib/toml-database-config.js";
7
+ import { buildDatabaseTypeTomlData } from "../lib/toml-database-config.js";
8
8
  import { canEmitNative } from "../lib/toml-native-form.js";
9
9
  // #1446's `type: "json"` emission rule, shared with `serializeFunction` since
10
10
  // #3317 — see `config-json-field.ts`.
11
11
  import { serializeJsonField } from "../lib/config-json-field.js";
12
12
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, serializeDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
13
- import { validateOperations, formatIssue, } from "../lib/toml-params-validator.js";
14
13
  import { isPlatformOwnedWorkflow, isPlatformOwnedWorkflowKey, } from "../lib/platform-owned.js";
15
14
  import { getCurrentAppId, getServerUrl } from "../lib/config.js";
16
15
  import { requireCurrentEnvironment } from "../lib/env-resolver.js";
@@ -35,14 +34,17 @@ import { buildWorkflowPayloadFromToml, parseWorkflowExpressionsToml, } from "../
35
34
  // payload builder so the diff normalizer and the wire cannot disagree.
36
35
  import { WORKFLOW_MODEL_DEFAULTS } from "../lib/workflow-defaults.js";
37
36
  // #2644 — the one definition per configuration object, vendored from the server.
38
- import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
37
+ import { BLOB_BUCKET_TABLE, COLLECTION_TYPE_CONFIG_TABLE, CRON_TRIGGER_TABLE, DATABASE_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, GROUP_TYPE_CONFIG_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, WEBHOOK_TABLE, WORKFLOW_TABLE, configDeclaredTypeErrors, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownServerKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, retiredDocumentKey, spellsDecimalNumber, unrecognizedPullKeysMessage, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
39
38
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
40
39
  import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, } from "../lib/generated-config-surfaces.js";
41
40
  // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
42
41
  // preflight refuses exactly what the admin routes refuse.
43
42
  import { promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
44
43
  // #3798 — the `[prompt.agent]` declaration rule, vendored the same way.
45
- import { agentDeclarationFunctionKeys, agentTestCaseRefusal, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
44
+ import { agentDeclarationFunctionKeys, resolveAgentDeclaration, } from "../lib/generated-config-surfaces.js";
45
+ // #4262 — the agent test case's shape rule (`[test.agent]`), vendored the same
46
+ // way, so the preflight refuses exactly the sidecars the admin routes refuse.
47
+ import { AGENT_TEST_CASE_FIELDS, resolveAgentTestCase, } from "../lib/generated-config-surfaces.js";
46
48
  // #3799 — the meaning-preserving schema translation, vendored the same way, so
47
49
  // the preflight refuses exactly the schemas the admin routes refuse.
48
50
  import { agentSchemaTranslationRefusals } from "../lib/generated-config-surfaces.js";
@@ -51,7 +53,7 @@ import { agentSchemaTranslationRefusals } from "../lib/generated-config-surfaces
51
53
  import { resolvePromptStrictOutput } from "../lib/generated-config-surfaces.js";
52
54
  // #3179 — the function's two halves: the header payloads and the envelope
53
55
  // `config push` sends, and the bytes `config pull` writes back.
54
- import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlIgnoredKeyWarnings, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
56
+ import { applyFunctionPull, buildFunctionConfigPush, createDocumentSchemaDropWarner, documentSchemaRetentionFailure, buildFunctionCreatePayload, buildFunctionUpdatePayload, functionDiffEntity, functionTomlPreflightErrors, planDocumentSchemaPull, planFunctionPull, serializeFunction, } from "../lib/function-sync.js";
55
57
  import { newerVersionLine, pullNewerVersionLine, rollbackDiffHint, } from "../lib/function-versions.js";
56
58
  import { FunctionBuildError } from "../lib/function-bundle.js";
57
59
  // #3182 — the local half of capability validation. The grammar is the vendored
@@ -1672,9 +1674,9 @@ export function serializeBlobBucket(bucket, ruleSetIdToName = new Map(), logger)
1672
1674
  * `config push` builds its bodies from and `config diff` compares.
1673
1675
  *
1674
1676
  * Read in CREATE mode, so the immutable `ttlTier` stays nameable. The rule set
1675
- * is left as `_ruleSetName` for `resolveRuleSetReference`; the deprecated
1676
- * `ruleSetId` spelling is forwarded as the id it already is. A file carrying
1677
- * both is refused by the preflight (`blobBucketFileErrors`) before this runs.
1677
+ * is left as `_ruleSetName` for `resolveRuleSetReference`. The id spelling
1678
+ * `ruleSetId` is a retired key (#4283): the preflight refuses a file carrying
1679
+ * it, and this parse never reads it.
1678
1680
  *
1679
1681
  * An omitted `name` stays omitted: the update leaves the server's name alone,
1680
1682
  * and only the create body falls back to the key.
@@ -1697,43 +1699,8 @@ export function parseBlobBucketToml(doc) {
1697
1699
  // Trimmed, as `applyRuleSetReference` does for the type configs.
1698
1700
  entity._ruleSetName = section.ruleSetName.trim();
1699
1701
  }
1700
- else if (typeof section.ruleSetId === "string" && section.ruleSetId !== "") {
1701
- entity.ruleSetId = section.ruleSetId;
1702
- }
1703
1702
  return entity;
1704
1703
  }
1705
- /**
1706
- * The advisory `config push` prints for a bucket file still naming its rule set
1707
- * by id (#4116). The key is deprecated, not retired: the file pushes.
1708
- */
1709
- export function blobBucketDeprecatedKeyWarning(filePath, tomlData) {
1710
- const bucket = tomlData?.bucket;
1711
- if (!bucket || bucket.ruleSetId === undefined)
1712
- return null;
1713
- // Beside `ruleSetName` it is a refusal instead (`blobBucketFileErrors`).
1714
- if (bucket.ruleSetName !== undefined)
1715
- return null;
1716
- return (`${filePath}: bucket.ruleSetId is deprecated — a rule-set id belongs to one app, ` +
1717
- `so a copied tree cannot resolve it; write ruleSetName = "<name>" ` +
1718
- "(config pull rewrites the file). Retiring the key is a later change.");
1719
- }
1720
- /** A bucket file's own refusals, decided from the file alone (#4116). */
1721
- export function blobBucketFileErrors(filePath, tomlData) {
1722
- const bucket = tomlData?.bucket;
1723
- if (bucket && bucket.ruleSetId !== undefined && bucket.ruleSetName !== undefined) {
1724
- return [`${filePath}: bucket.ruleSetId is set beside bucket.ruleSetName; keep ruleSetName`];
1725
- }
1726
- // The deprecated spelling is a TOML-only key, so the declared-type check
1727
- // reads only `ruleSetName`; the old key keeps the field's type under its
1728
- // own name, or a mistyped id would be dropped from the body unreported.
1729
- const field = BLOB_BUCKET_TABLE.fields.find((f) => f.field === "ruleSetId");
1730
- if (!field)
1731
- return [];
1732
- return configDeclaredTypeErrors(filePath, tomlData, {
1733
- ...BLOB_BUCKET_TABLE,
1734
- fields: [{ ...field, tomlKey: "ruleSetId" }],
1735
- });
1736
- }
1737
1704
  /**
1738
1705
  * Build the PATCH payload for a blob-bucket update from local TOML.
1739
1706
  *
@@ -1947,33 +1914,33 @@ export function serializePrompt(prompt, logger) {
1947
1914
  return stringifyConfigToml(data);
1948
1915
  }
1949
1916
  /**
1950
- * The `config push` warning for a resource pushed with no `accessRule` (#2652).
1917
+ * The `config push` warning for a caller workflow pushed with no `accessRule`
1918
+ * (#2652).
1951
1919
  *
1952
1920
  * Returns `null` when a rule is declared — and for a `runAs = "system"`
1953
1921
  * workflow, which rejects any rule and cannot be started by a member at all.
1954
- * Otherwise it names the resource, the consequence, and the one-line restore,
1955
- * because after the #2652 flip a ruleless prompt or caller workflow denies
1956
- * every non-admin caller from the deploy onward.
1922
+ * Otherwise it names the workflow, the consequence, and the one-line restore,
1923
+ * because after the #2652 flip a ruleless caller workflow denies every
1924
+ * non-admin caller from the deploy onward.
1925
+ *
1926
+ * A prompt gets no warning (#4368): a server function runs it on its
1927
+ * `prompt:<key>` grant, which never consults the rule, so a ruleless prompt is
1928
+ * the ordinary case, not a lockout.
1957
1929
  *
1958
- * Both kinds now push `accessRule` as value-or-`null` — the prompt since #1567,
1959
- * the workflow since #2743's always-send conversion — so for either one an
1960
- * omitted key really does leave the resource ruleless on the server. The
1961
- * workflow wording says so explicitly, because it used to say the opposite
1962
- * (the update path preserved a stored rule) and an operator who learned that
1963
- * caveat needs to see it retracted.
1930
+ * The workflow pushes `accessRule` as value-or-`null` since #2743's
1931
+ * always-send conversion, so an omitted key really does leave it ruleless on
1932
+ * the server. The wording says so explicitly, because it used to say the
1933
+ * opposite (the update path preserved a stored rule) and an operator who
1934
+ * learned that caveat needs to see it retracted.
1964
1935
  */
1965
1936
  export function missingAccessRuleWarning(opts) {
1966
1937
  if (typeof opts.accessRule === "string" && opts.accessRule.trim() !== "") {
1967
1938
  return null;
1968
1939
  }
1969
- if (opts.kind === "workflow" && opts.runAs === "system")
1940
+ if (opts.runAs === "system")
1970
1941
  return null;
1971
1942
  const restore = `Add accessRule = "true" to allow any app member, or a CEL rule ` +
1972
1943
  `naming who may call it.`;
1973
- if (opts.kind === "prompt") {
1974
- return (`prompt '${opts.key}' has no accessRule — all non-admin executions are ` +
1975
- `denied. ${restore}`);
1976
- }
1977
1944
  return (`workflow '${opts.key}' declares no accessRule — pushing this file clears ` +
1978
1945
  `any rule stored server-side, and a workflow with no stored rule denies ` +
1979
1946
  `all non-admin starts. ${restore}`);
@@ -2360,7 +2327,16 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2360
2327
  */
2361
2328
  export function configDocumentShapeErrors(filePath, tomlData, surface) {
2362
2329
  const errors = [];
2363
- const unknown = findUnknownDocumentKeys(tomlData, surface);
2330
+ const found = findUnknownDocumentKeys(tomlData, surface);
2331
+ // #3189 — a table this file type retired on purpose gets its own sentence,
2332
+ // stated once beside the definitions, not the "check the spelling, or
2333
+ // upgrade the CLI" hint, which is backwards for it.
2334
+ for (const key of found) {
2335
+ const retired = retiredDocumentKey(surface.label, key);
2336
+ if (retired)
2337
+ errors.push(`${filePath}: [${key}] — ${retired.toml}`);
2338
+ }
2339
+ const unknown = found.filter((key) => !retiredDocumentKey(surface.label, key));
2364
2340
  if (unknown.length > 0) {
2365
2341
  errors.push(unrecognizedPushTablesMessage(filePath, unknown));
2366
2342
  }
@@ -2595,77 +2571,22 @@ export async function runConfigPushPreflight(input) {
2595
2571
  }
2596
2572
  }
2597
2573
  }
2598
- // Validate all database-type TOMLs (issue #803 subscription
2599
- // access/accessRule conflict + issue #752 $params references).
2574
+ // Validate all database-type TOMLs: the parse itself refuses an invalid
2575
+ // declared-access manifest. (#3189 — the `[[operations]]` `$params` and
2576
+ // `[[subscriptions]]` checks retired with the two tables, which the
2577
+ // document-shape preflight now refuses by name.)
2600
2578
  const preflightDbTypesDir = join(configDir, "database-type-configs");
2601
2579
  if (existsSync(preflightDbTypesDir)) {
2602
2580
  for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
2603
2581
  const filePath = join(preflightDbTypesDir, file);
2604
- let rawToml;
2605
- let tomlData;
2606
2582
  try {
2607
- rawToml = readFileSync(filePath, "utf-8");
2608
- tomlData = parseTomlFile(filePath);
2583
+ parseDatabaseTypeToml(parseTomlFile(filePath));
2609
2584
  }
2610
2585
  catch (err) {
2611
2586
  preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2612
- continue;
2613
- }
2614
- let operations = [];
2615
- try {
2616
- ({ operations } = parseDatabaseTypeToml(tomlData));
2617
- }
2618
- catch (err) {
2619
- if (err instanceof SubscriptionAccessKeyConflictError) {
2620
- preflightValidationErrors.push(` ${file}: ${err.message}`);
2621
- continue;
2622
- }
2623
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2624
- continue;
2625
- }
2626
- const validation = validateOperations({
2627
- filePath,
2628
- rawToml,
2629
- operations,
2630
- });
2631
- for (const e of validation.errors) {
2632
- preflightValidationErrors.push(` ${formatIssue(e)}`);
2633
- }
2634
- // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
2635
- // / `metadataAccess` gate on a database type config is a single CEL
2636
- // expression that gates BOTH read and update of the database CEL
2637
- // context — granting read implicitly grants update. Steer operators
2638
- // to resource metadata categories (separate readRule/writeRule).
2639
- // This is advisory only: the push continues and the runtime gate is
2640
- // unchanged. It fires only when the deprecated key is actually
2641
- // present, so metadata-category-only configs never trip it.
2642
- const typeSection = tomlData.type || {};
2643
- if (typeSection.celContextAccess || typeSection.metadataAccess) {
2644
- const usedKey = typeSection.celContextAccess
2645
- ? "celContextAccess"
2646
- : "metadataAccess";
2647
- 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.`);
2648
2587
  }
2649
2588
  }
2650
2589
  }
2651
- // #4116 — the deprecated `ruleSetId` spelling in a bucket file. Advisory
2652
- // only: the file pushes. Bounded by `--only`, like the notice above.
2653
- const preflightBucketsDir = join(configDir, "blob-buckets");
2654
- if (existsSync(preflightBucketsDir)) {
2655
- for (const file of selectFiles("blob-bucket", preflightBucketsDir, readdirSync(preflightBucketsDir).filter((f) => f.endsWith(".toml")))) {
2656
- let tomlData;
2657
- try {
2658
- tomlData = parseTomlFile(join(preflightBucketsDir, file));
2659
- }
2660
- catch {
2661
- // A file that does not parse is reported by the collector below.
2662
- continue;
2663
- }
2664
- const advice = blobBucketDeprecatedKeyWarning(`blob-buckets/${file}`, tomlData);
2665
- if (advice)
2666
- warn(` ${advice}`);
2667
- }
2668
- }
2669
2590
  // Validate app.toml settings up-front (issue #1033). A mistyped value
2670
2591
  // is a hard error that must abort BEFORE any mutation — same
2671
2592
  // fail-before-mutate contract as the vars preflight below. The apply
@@ -2781,7 +2702,7 @@ export async function runConfigPushPreflight(input) {
2781
2702
  preflightValidationErrors.push(` ${message}`);
2782
2703
  }
2783
2704
  // #3320 — and everything else the file decides on its own: the
2784
- // `entry`, the mode the two spellings resolve to, the trigger
2705
+ // `entry`, the retired runtime keys it refuses (#3188), the trigger
2785
2706
  // rules, and a webhook trigger's signing-secret requirement and
2786
2707
  // reference shape. These used to be raised inside the apply loop,
2787
2708
  // after webhooks, integrations, vars and database types had
@@ -2796,19 +2717,6 @@ export async function runConfigPushPreflight(input) {
2796
2717
  })) {
2797
2718
  preflightValidationErrors.push(` ${filePath}: ${message}`);
2798
2719
  }
2799
- // #3482 — the retired runtime keys. NOT refusals through phase 4:
2800
- // the file pushes with every other key applied, and the author
2801
- // hears which line to delete before phase 5 refuses it. On
2802
- // stderr, like every other advisory, so a scripted push reading
2803
- // stdout is unaffected. Inside this loop rather than over the
2804
- // whole tree, so `--only` bounds the advice as it bounds the
2805
- // refusals (#2645).
2806
- for (const message of functionTomlIgnoredKeyWarnings({
2807
- functionKey,
2808
- tomlData,
2809
- })) {
2810
- warn(` ${message}`);
2811
- }
2812
2720
  }
2813
2721
  }
2814
2722
  }
@@ -2868,8 +2776,10 @@ export async function runConfigPushPreflight(input) {
2868
2776
  }
2869
2777
  }
2870
2778
  }
2871
- // #3798 — a test case on an agent is refused by the admin routes; say so
2872
- // here, before any other file is applied.
2779
+ // #3798, #4262 — a prompt's sidecar against its prompt's kind: an agent's
2780
+ // case is checked by the agent test case's shape rule, and `[test.agent]`
2781
+ // on a chat or decisions prompt's case is refused, before any file is
2782
+ // applied.
2873
2783
  for (const message of promptAgentSidecarErrors(configDir)) {
2874
2784
  preflightValidationErrors.push(` ${message}`);
2875
2785
  }
@@ -3239,10 +3149,74 @@ export function partitionPromptFilesForPush(promptsDir, files) {
3239
3149
  return { beforeFunctions, afterFunctions };
3240
3150
  }
3241
3151
  /**
3242
- * Every `prompts/<key>.tests/*.toml` sidecar whose prompt is an agent — #3798
3243
- * (§D1: prompt test cases are refused for the kind in v1). The admin test-case
3244
- * routes refuse the same case with `AGENT_TEST_CASE_NOT_SUPPORTED`; this says
3245
- * so before anything is applied.
3152
+ * The deferred agent files `config push` can still apply — #4437. An agent
3153
+ * that names a function this push failed to apply, and that the server does
3154
+ * not already hold, would only be refused as naming a function the app does
3155
+ * not have. It is held back instead, with a failure that names the function as
3156
+ * the cause; the function's own failure row says why. A file that does not
3157
+ * parse is left for the prompt leg to report.
3158
+ */
3159
+ export function holdAgentPromptFilesForUnappliedFunctions(promptsDir, files, unappliedFunctionKeys) {
3160
+ const apply = [];
3161
+ const held = [];
3162
+ for (const file of files) {
3163
+ let prompt;
3164
+ try {
3165
+ prompt = parseTomlFile(join(promptsDir, file))?.prompt;
3166
+ }
3167
+ catch {
3168
+ apply.push(file);
3169
+ continue;
3170
+ }
3171
+ const failed = agentDeclarationFunctionKeys(prompt?.agent).filter((key) => unappliedFunctionKeys.has(key));
3172
+ if (failed.length === 0) {
3173
+ apply.push(file);
3174
+ continue;
3175
+ }
3176
+ const names = failed.map((key) => `"${key}"`).join(", ");
3177
+ held.push({
3178
+ key: prompt?.key || basename(file, ".toml"),
3179
+ message: `[prompt.agent] names function${failed.length > 1 ? "s" : ""} ${names}, ` +
3180
+ "which this push could not apply (see the function's failure), so the " +
3181
+ "agent was not pushed. Fix the function and push again.",
3182
+ });
3183
+ }
3184
+ return { apply, held };
3185
+ }
3186
+ /**
3187
+ * The kind of the prompt a `prompts/<key>.tests/` sidecar belongs to, read
3188
+ * from the prompt's own file — `undefined` for another block type, or when the
3189
+ * file is absent or does not parse (the prompt leg reports that).
3190
+ */
3191
+ function testCaseOwnerPromptKind(configDir, blockType, blockKey) {
3192
+ if (blockType !== "prompt")
3193
+ return undefined;
3194
+ const owner = join(configDir, "prompts", `${blockKey}.toml`);
3195
+ if (!existsSync(owner))
3196
+ return undefined;
3197
+ try {
3198
+ return promptKindOf(parseTomlFile(owner)?.prompt);
3199
+ }
3200
+ catch {
3201
+ return undefined;
3202
+ }
3203
+ }
3204
+ /** A sidecar's agent block and `expectedJsonSubset`, in wire spelling. */
3205
+ function agentTestCaseWireFields(payload) {
3206
+ const fields = {};
3207
+ for (const key of AGENT_TEST_CASE_FIELDS)
3208
+ fields[key] = payload?.[key];
3209
+ fields.expectedJsonSubset = payload?.expectedJsonSubset;
3210
+ return fields;
3211
+ }
3212
+ /**
3213
+ * Every `prompts/<key>.tests/*.toml` sidecar the agent test case's rule
3214
+ * refuses — #4262 (#3798 refused every sidecar on an agent). An agent's case
3215
+ * is checked by the vendored shape rule (`resolveAgentTestCase`), which the
3216
+ * admin routes run too; a `[test.agent]` block on a chat or decisions
3217
+ * prompt's case is refused by name. One line per file, naming it. What needs
3218
+ * the app (the input, context and event schemas, the declared names) is the
3219
+ * routes' to decide at apply.
3246
3220
  */
3247
3221
  export function promptAgentSidecarErrors(configDir) {
3248
3222
  const promptsDir = join(configDir, "prompts");
@@ -3252,21 +3226,25 @@ export function promptAgentSidecarErrors(configDir) {
3252
3226
  if (!filePath.startsWith(promptsDir) || !parent.endsWith(".tests"))
3253
3227
  continue;
3254
3228
  const key = parent.slice(0, -".tests".length);
3255
- const owner = join(promptsDir, `${key}.toml`);
3256
- if (!existsSync(owner))
3229
+ const kind = testCaseOwnerPromptKind(configDir, "prompt", key);
3230
+ if (kind === undefined)
3257
3231
  continue;
3258
- let tomlData;
3232
+ let sidecar;
3259
3233
  try {
3260
- tomlData = parseTomlFile(owner);
3234
+ sidecar = parseTomlFile(filePath);
3261
3235
  }
3262
3236
  catch {
3237
+ // The test-case collector reports a file that does not parse.
3263
3238
  continue;
3264
3239
  }
3265
- if (promptKindOf(tomlData?.prompt) !== "agent")
3240
+ const resolved = resolveAgentTestCase({
3241
+ kind,
3242
+ fields: agentTestCaseWireFields(parseTestCaseToml(sidecar)),
3243
+ });
3244
+ if (!("refusals" in resolved))
3266
3245
  continue;
3267
- const promptKey = tomlData?.prompt?.key || key;
3268
3246
  errors.push(`${filePath.slice(configDir.length + 1)}: ` +
3269
- agentTestCaseRefusal(promptKey, "subject").message);
3247
+ resolved.refusals.map((refusal) => refusal.message).join("; "));
3270
3248
  }
3271
3249
  return errors;
3272
3250
  }
@@ -3339,18 +3317,12 @@ export function configFileValidationMessages(filePath, tomlData, surface, tables
3339
3317
  if (surface.label === "prompt") {
3340
3318
  messages.push(...promptKindFileErrors(filePath, tomlData));
3341
3319
  }
3342
- // #4116 — a bucket naming its rule set both ways.
3343
- if (surface.label === "blob-bucket") {
3344
- messages.push(...blobBucketFileErrors(filePath, tomlData));
3345
- }
3346
3320
  return messages;
3347
3321
  }
3348
3322
  /**
3349
3323
  * Whether `config pull` must rewrite a local file whatever it says (#4160):
3350
- * `config push` would refuse it, or it spells something in a deprecated form
3351
- * pull exists to migrate (a bucket naming its rule set by id). Keeping such a
3352
- * file because its VALUES match would leave the tree unpushable, or the
3353
- * deprecation unresolved, after a pull that reported success.
3324
+ * `config push` would refuse it. Keeping such a file because its VALUES match
3325
+ * would leave the tree unpushable after a pull that reported success.
3354
3326
  */
3355
3327
  export function pullMustRewrite(label, tomlData) {
3356
3328
  // A test-case sidecar is not one of the preflight's per-directory targets;
@@ -3365,7 +3337,7 @@ export function pullMustRewrite(label, tomlData) {
3365
3337
  return true;
3366
3338
  }
3367
3339
  }
3368
- return (label === "blob-bucket" && blobBucketDeprecatedKeyWarning("", tomlData) !== null);
3340
+ return false;
3369
3341
  }
3370
3342
  export function collectConfigFileValidationErrors(configDir) {
3371
3343
  const found = [];
@@ -3443,35 +3415,6 @@ export function collectFunctionTomlValidationErrors(configDir) {
3443
3415
  }
3444
3416
  return found;
3445
3417
  }
3446
- /**
3447
- * The retired runtime keys every `functions/*.toml` in the tree still carries
3448
- * — #3482, the warning half of the collector above.
3449
- *
3450
- * Separate from the errors because it IS separate: a file carrying one of
3451
- * these pushes, with every other key applied, so it is not a row `config diff`
3452
- * calls invalid. It is a line an author reads on stderr from both commands, so
3453
- * the two keep reading identically (the #2880 symmetry the errors collector
3454
- * exists for, applied to advice rather than refusals).
3455
- */
3456
- export function collectFunctionTomlIgnoredKeyWarnings(configDir) {
3457
- const found = [];
3458
- const functionsDir = join(configDir, "functions");
3459
- if (!existsSync(functionsDir))
3460
- return found;
3461
- for (const file of readdirSync(functionsDir).filter(isFunctionDeclarationFile)) {
3462
- const filePath = join(functionsDir, file);
3463
- let tomlData;
3464
- try {
3465
- tomlData = parseTomlFile(filePath);
3466
- }
3467
- catch {
3468
- continue;
3469
- }
3470
- const key = tomlData?.function?.key || basename(file, ".toml");
3471
- found.push(...functionTomlIgnoredKeyWarnings({ functionKey: key, tomlData }));
3472
- }
3473
- return found;
3474
- }
3475
3418
  /**
3476
3419
  * The collectors' rows, one per diff row (#2880 symmetry, #3320).
3477
3420
  *
@@ -3726,26 +3669,15 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
3726
3669
  };
3727
3670
  return stringifyConfigToml(data);
3728
3671
  }
3729
- // Database config serialization helpers
3730
- //
3731
- // Native-TOML form (issue #752): when emitting a database-type config, we
3732
- // prefer nested `[operations.definition]` tables and `[[operations.params]]`
3733
- // rows over JSON-strings stuffed into a single field. The form is sticky
3734
- // per file — if the existing file uses the legacy JSON-string form, we
3735
- // preserve it so we don't generate surprise diffs. New files default to
3736
- // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
3737
- // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
3738
- // genuinely can't represent without data loss (null / undefined / an array
3739
- // containing null) fall back to a JSON string per field with a log message.
3740
- //
3741
- // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
3742
- // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
3743
- // object so the result round-trips as `[models.<Name>.fields.<field>]`.
3744
- export function serializeDatabaseType(typeConfig, operations, ruleSetIdToName, options = {}) {
3672
+ // Serialize a database type's config file: the `[type]` table, the `[models.*]`
3673
+ // blocks re-parsed from the stored `schema` (issue #666), `[triggers]` and the
3674
+ // declared-access manifest. #3189 — no `[[operations]]` or `[[subscriptions]]`:
3675
+ // both retired.
3676
+ export function serializeDatabaseType(typeConfig, ruleSetIdToName, options = {}) {
3745
3677
  // #2644 — name any server key this CLI version does not know, rather than
3746
3678
  // dropping it silently on the way into the file.
3747
3679
  warnUnrecognizedServerKeys(typeConfig, DATABASE_TYPE_TABLE, `database-type-configs/${typeConfig?.databaseType}.toml`, options.logger);
3748
- const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
3680
+ const data = buildDatabaseTypeTomlData(typeConfig, ruleSetIdToName);
3749
3681
  return stringifyConfigToml(data);
3750
3682
  }
3751
3683
  /**
@@ -3965,10 +3897,9 @@ export function dbTypeOwnedScalars(table = DATABASE_TYPE_TABLE) {
3965
3897
  ];
3966
3898
  }
3967
3899
  export const DB_TYPE_OWNED_SCALARS = dbTypeOwnedScalars();
3968
- // User-facing TOML key names for the "Cleared <field>" push output. The wire
3969
- // field differs from the key the user writes for two of them.
3900
+ // User-facing TOML key names for the "Cleared <field>" push output, for the
3901
+ // field whose wire name differs from the key the user writes.
3970
3902
  const DB_TYPE_FIELD_LABELS = {
3971
- metadataAccess: "celContextAccess",
3972
3903
  ruleSetId: "ruleSetName",
3973
3904
  };
3974
3905
  /** The user-facing TOML key for a wire field (for push output). */
@@ -4041,13 +3972,6 @@ export function ownedScalarsBeingCleared(updateData, serverConfig) {
4041
3972
  * still comes from the handler that knows what is wrong with it.
4042
3973
  */
4043
3974
  function canonicalizeDatabaseTypeConfig(typeConfig) {
4044
- // CEL fields: trimmed by the handler, and a whitespace-only expression is
4045
- // stored as none at all — which the projection reads as unset either way.
4046
- for (const key of ["defaultAccess", "metadataAccess"]) {
4047
- if (typeof typeConfig[key] === "string") {
4048
- typeConfig[key] = typeConfig[key].trim();
4049
- }
4050
- }
4051
3975
  // #750 — `"user.userId"` is shorthand for `{ value: "user.userId", on:
4052
3976
  // ["create"] }`, and a scalar `on` for a one-element list.
4053
3977
  const auto = typeConfig.autoPopulatedFields;
@@ -4094,70 +4018,15 @@ function canonicalizeDatabaseTypeConfig(typeConfig) {
4094
4018
  }
4095
4019
  }
4096
4020
  }
4097
- /**
4098
- * What this push's operation set will BE, stated to the server (issues #813,
4099
- * #1336, #2732).
4100
- *
4101
- * The type-config PATCH runs its gates BEFORE the same push's operation calls,
4102
- * so without a look-ahead every gate judges the pre-push operations: a push
4103
- * that removes a model is blocked by the very references it is deleting. The
4104
- * file IS the target state (config-as-code), so the claims are derived from it:
4105
- *
4106
- * - `finalOpNames` — the names the type ends with. Both other claims are
4107
- * verified against it server-side, which is what keeps them from being a
4108
- * gate bypass for a direct-API caller.
4109
- * - `pendingOpDeletes` — its complement among the ops last sync recorded, so
4110
- * an op this push deletes is excluded from the schema / OPS_EXIST gates.
4111
- * - `pendingOpUpserts` — the post-push BODY of every declared op, so the
4112
- * schema-edit gate lints a rewritten op as rewritten and a model removal
4113
- * plus its operation rewrites lands in one push (#2732).
4114
- * - `pendingOpUpdates` — the rule-only form (`access`/`params`) the manifest
4115
- * re-lint reads (#1336).
4116
- *
4117
- * All four are built here and sent together, on the dry-run and on the real
4118
- * PATCH alike. They are never conditioned on the push having a deletion: a push
4119
- * that only REWRITES operations is exactly the case #2732 exists for.
4120
- */
4121
- export function buildPendingOpClaims(operations, existingOpNames) {
4122
- const finalOpNames = operations.map((op) => op.name);
4123
- const declared = new Set(finalOpNames);
4124
- return {
4125
- pendingOpDeletes: existingOpNames.filter((n) => !declared.has(n)),
4126
- finalOpNames,
4127
- pendingOpUpdates: operations.map((op) => ({
4128
- name: op.name,
4129
- access: typeof op.access === "string" ? op.access : null,
4130
- params: op.params ?? null,
4131
- })),
4132
- // The same body the op create/update call sends, so the gate lints what
4133
- // this push is about to store.
4134
- pendingOpUpserts: operations.map((op) => ({
4135
- name: op.name,
4136
- type: op.type,
4137
- modelName: op.modelName,
4138
- access: op.access,
4139
- definition: op.definition,
4140
- params: op.params,
4141
- })),
4142
- };
4143
- }
4144
4021
  export function parseDatabaseTypeToml(tomlData) {
4145
4022
  const typeSection = tomlData.type || {};
4146
4023
  // #2644 — the `[type]` field list comes from the vendored definition, so a
4147
4024
  // scalar added there is read off the file with no edit here. The values the
4148
4025
  // handler rewrites before storing — `autoPopulatedFields` (#750), its
4149
- // `timestamps` sibling (#748), the CEL access expressions — are canonicalized
4026
+ // `timestamps` sibling (#748) — are canonicalized
4150
4027
  // below so the comparison reads the spelling the server keeps (#2731 B1).
4151
4028
  const typeConfig = dropUndefined(buildPayloadFromToml(DATABASE_TYPE_TABLE, typeSection, {
4152
4029
  mode: "create",
4153
- values: {
4154
- // Accept both `celContextAccess` (new user-facing key, the definition's
4155
- // tomlKey) and `metadataAccess` (legacy, declared in `tomlOnlyKeys`).
4156
- // The newer key wins when both appear. Issue #1567: a defined-check,
4157
- // not a truthy guard, so `celContextAccess = ""` is honored as an
4158
- // explicit clear rather than falling through to the legacy key.
4159
- metadataAccess: (value, _mode, section) => value !== undefined ? value : section?.metadataAccess,
4160
- },
4161
4030
  }));
4162
4031
  canonicalizeDatabaseTypeConfig(typeConfig);
4163
4032
  // The `ruleSetName` reference; it resolves to an id later.
@@ -4196,27 +4065,11 @@ export function parseDatabaseTypeToml(tomlData) {
4196
4065
  }
4197
4066
  }
4198
4067
  typeConfig.schema = schemaSerialized;
4199
- // `normalizeOperationFromToml` accepts both forms:
4200
- // - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
4201
- // - native: `[operations.definition]` table, `[[operations.params]]` rows
4202
- // and returns the JS shape the server expects.
4203
- const operations = (tomlData.operations || []).map((op) => {
4204
- const normalized = normalizeOperationFromToml(op);
4205
- // #2731 B1 — the handler trims `name` before storing, and the trimmed name
4206
- // is half the row's key (`<databaseType>#<name>`). Comparing or sending the
4207
- // padded spelling names a row the server never holds: the child reconcile
4208
- // would read it as a different operation and plan it forever.
4209
- if (typeof normalized.name === "string") {
4210
- normalized.name = normalized.name.trim();
4211
- }
4212
- return normalized;
4213
- });
4214
- // Subscriptions (issue #803): `[[subscriptions]]` array-of-tables.
4215
- // `normalizeSubscriptionFromToml` maps `accessRule`→`access` (throwing on a
4216
- // conflicting both-present declaration) and forwards `select`/`emit`/`params`
4217
- // in the wire shape. Until #803 this block was dropped at parse time.
4218
- const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
4219
- return { typeConfig, operations, subscriptions };
4068
+ // #3189 — `[[operations]]` and `[[subscriptions]]` retired with database
4069
+ // operations and type-scoped subscriptions. The preflight refuses a file
4070
+ // that still carries either (`RETIRED_DOCUMENT_KEYS`), so nothing here reads
4071
+ // them.
4072
+ return { typeConfig };
4220
4073
  }
4221
4074
  /**
4222
4075
  * The parsed shape of a rule-set TOML: the definition's fields plus the
@@ -5001,12 +4854,11 @@ const CONFIG_DIFF_SPECS = {
5001
4854
  keys.push("name");
5002
4855
  if (!bucket.preset)
5003
4856
  keys.push("preset");
5004
- // Either spelling manages the rule set (#4116) — read as the parse reads
5005
- // it, so a blank name is no reference rather than a difference the
5006
- // update cannot carry.
4857
+ // A name manages the rule set (#4116) — read as the parse reads it, so a
4858
+ // blank name is no reference rather than a difference the update cannot
4859
+ // carry. The retired id spelling (#4283) manages nothing.
5007
4860
  const named = typeof bucket.ruleSetName === "string" && bucket.ruleSetName.trim() !== "";
5008
- const legacy = typeof bucket.ruleSetId === "string" && bucket.ruleSetId !== "";
5009
- if (!named && !legacy)
4861
+ if (!named)
5010
4862
  keys.push("ruleSetId");
5011
4863
  return keys;
5012
4864
  },
@@ -5045,29 +4897,17 @@ const CONFIG_DIFF_SPECS = {
5045
4897
  "database-type-config": {
5046
4898
  label: "database-type-config",
5047
4899
  table: DATABASE_TYPE_TABLE,
5048
- parse: (doc) => {
5049
- const { typeConfig, operations, subscriptions } = parseDatabaseTypeToml(doc);
5050
- // The three sub-trees ride ON the type config, so they travel with it and
5051
- // are pulled back out as extras below.
5052
- typeConfig.__operations = operations;
5053
- typeConfig.__subscriptions = subscriptions;
5054
- return typeConfig;
5055
- },
5056
- serialize: (record, maps, extra) => serializeDatabaseType(record, extra?.operations ?? [], maps.ruleSetIdToName, {
5057
- defaultForm: "native",
5058
- subscriptions: extra?.subscriptions ?? [],
5059
- }),
4900
+ parse: (doc) => parseDatabaseTypeToml(doc).typeConfig,
4901
+ serialize: (record, maps) => serializeDatabaseType(record, maps.ruleSetIdToName),
5060
4902
  extras: (entity) => ({
5061
4903
  // Structural, per the definition: the `[models.*]` schema, the
5062
- // `[triggers]` tree, the manifest, and the two sibling models.
4904
+ // `[triggers]` tree and the manifest. (#3189 — the operation and
4905
+ // subscription rows that used to ride beside them are retired.)
5063
4906
  schema: entity.schema ?? null,
5064
4907
  triggers: entity.triggers ?? null,
5065
4908
  metadataManifest: entity.metadataManifest ?? null,
5066
- operations: byKey(entity.__operations, "name"),
5067
- subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
5068
4909
  _unresolvedRuleSetName: entity._unresolvedRuleSetName,
5069
4910
  }),
5070
- childKeys: { operations: "name", subscriptions: "subscriptionKey" },
5071
4911
  tomlFields: ["schema"],
5072
4912
  ruleSetRef: true,
5073
4913
  describe: (entity) => `database type "${entity?.databaseType}"`,
@@ -5406,36 +5246,6 @@ export function withDerivedIdentity(doc, tomlPath, identity) {
5406
5246
  export function resolveExistingId(input) {
5407
5247
  return input.liveOk ? input.liveId : input.manifestId;
5408
5248
  }
5409
- /**
5410
- * Which CHILD rows a database type's push reconciles against (#2731 B3).
5411
- *
5412
- * `resolveExistingId` answers this for a whole entity; a database type's
5413
- * operations and subscriptions are rows with the same question and the same
5414
- * two possible answers. The manifest can name an operation the server no
5415
- * longer has (the update PUTs a row that is gone) and be silent about one it
5416
- * does have (the create POSTs a duplicate key) — both abort the push with a
5417
- * server error, which is exactly the recovery `--force` is supposed to
5418
- * perform.
5419
- *
5420
- * When the reconcile read the rows, they ARE the baseline: existence, the
5421
- * timestamp each update guards with, and — as the complement of the file's own
5422
- * list — which rows this push deletes. `undefined` means the read did not
5423
- * happen (the degraded path, or a type live state says is absent), and the
5424
- * manifest stays what it has always been.
5425
- */
5426
- export function liveChildBaseline(live, manifestChildren) {
5427
- if (!live)
5428
- return undefined;
5429
- const baseline = {};
5430
- for (const [key, row] of live) {
5431
- baseline[key] = {
5432
- // The live timestamp is the one an update guards with; the manifest's is
5433
- // a stand-in only for a row the listing returned without one.
5434
- modifiedAt: row?.modifiedAt || manifestChildren?.[key]?.modifiedAt || "",
5435
- };
5436
- }
5437
- return baseline;
5438
- }
5439
5249
  /**
5440
5250
  * The app's live rule sets, indexed the three ways push reads them (#2731 B3).
5441
5251
  *
@@ -5974,6 +5784,17 @@ function testCaseSidecarTable(testCase, lookupMaps, unresolvedSpelling) {
5974
5784
  expectedOutputPattern: (record) => record.expectedOutputPattern || "",
5975
5785
  expectedOutputContains: (record) => jsonFieldText(record.expectedOutputContains, "[]"),
5976
5786
  expectedJsonSubset: (record) => jsonFieldText(record.expectedJsonSubset, "{}"),
5787
+ // #4262 — `[test.agent]`, written only when a key is set, so a chat case's
5788
+ // sidecar is the one it always was. The history is the text the author
5789
+ // pushed; the two JSON fields come back parsed and are written as text.
5790
+ agentHistory: (record) => agentJsonText(record.agentHistory),
5791
+ agentContext: (record) => agentJsonText(record.agentContext),
5792
+ agentTimezone: (record) => record.agentTimezone || undefined,
5793
+ agentLocale: (record) => record.agentLocale || undefined,
5794
+ agentExpectedToolCall: (record) => agentJsonText(record.agentExpectedToolCall),
5795
+ agentExpectedNoToolCall: (record) => typeof record.agentExpectedNoToolCall === "boolean"
5796
+ ? record.agentExpectedNoToolCall
5797
+ : undefined,
5977
5798
  });
5978
5799
  return { test, unresolved };
5979
5800
  }
@@ -6028,6 +5849,38 @@ function jsonFieldText(value, empty) {
6028
5849
  return empty;
6029
5850
  }
6030
5851
  }
5852
+ /**
5853
+ * #4262 — an agent block field as the sidecar's JSON text, or `undefined`
5854
+ * (omitted) when unset: `[test.agent]` has no empty spellings.
5855
+ */
5856
+ function agentJsonText(value) {
5857
+ if (value === undefined || value === null)
5858
+ return undefined;
5859
+ if (typeof value === "string")
5860
+ return value === "" ? undefined : value;
5861
+ try {
5862
+ return JSON.stringify(value);
5863
+ }
5864
+ catch {
5865
+ return undefined;
5866
+ }
5867
+ }
5868
+ /**
5869
+ * #4262 — an agent block JSON field on the wire: parsed when the text parses,
5870
+ * the text itself otherwise (the shape rule names it), `undefined` when unset.
5871
+ */
5872
+ function agentJsonWireValue(value) {
5873
+ if (value === undefined || value === null || value === "")
5874
+ return undefined;
5875
+ if (typeof value !== "string")
5876
+ return value;
5877
+ try {
5878
+ return JSON.parse(value);
5879
+ }
5880
+ catch {
5881
+ return value;
5882
+ }
5883
+ }
6031
5884
  export function parseTestCaseToml(tomlData) {
6032
5885
  const test = tomlData.test || {};
6033
5886
  /** A JSON-string TOML value, or `undefined` when it is absent/the default. */
@@ -6068,6 +5921,15 @@ export function parseTestCaseToml(tomlData) {
6068
5921
  expectedOutputPattern: (value) => (value ? value : undefined),
6069
5922
  expectedOutputContains: (value) => parseJsonText(value, ["[]"]),
6070
5923
  expectedJsonSubset: (value) => parseJsonText(value, ["{}"]),
5924
+ // #4262 — `[test.agent]`. The history travels as the JSON text it is
5925
+ // authored as; context and expectedToolCall travel parsed; `false` is a
5926
+ // value, not an absence.
5927
+ agentHistory: (value) => (value === "" ? undefined : value),
5928
+ agentContext: (value) => agentJsonWireValue(value),
5929
+ agentTimezone: (value) => (value === "" ? undefined : value),
5930
+ agentLocale: (value) => (value === "" ? undefined : value),
5931
+ agentExpectedToolCall: (value) => agentJsonWireValue(value),
5932
+ agentExpectedNoToolCall: (value) => value,
6071
5933
  },
6072
5934
  });
6073
5935
  // An omitted field must be absent from the body, not present-and-undefined:
@@ -7293,6 +7155,15 @@ export async function pushTestCasesForBlock(params) {
7293
7155
  evaluatorPromptId: testPayload.evaluatorPromptId ?? null,
7294
7156
  evaluatorConfigId: testPayload.evaluatorConfigId ?? null,
7295
7157
  };
7158
+ // #4262 — an agent prompt's case carries its `[test.agent]` block as
7159
+ // full state too: a key the file no longer sets goes as `null`, so
7160
+ // removing an assertion clears it. A chat or decisions prompt's case
7161
+ // sends none, so its body is the one it always was.
7162
+ if (testCaseOwnerPromptKind(configDir, blockType, blockKey) === "agent") {
7163
+ for (const key of AGENT_TEST_CASE_FIELDS) {
7164
+ updatePayload[key] = testPayload[key] ?? null;
7165
+ }
7166
+ }
7296
7167
  // #2896 — the identity travels with the content: this is the lazy
7297
7168
  // migration of a legacy case and the second half of a rename.
7298
7169
  if (pendingKey !== undefined)
@@ -8318,30 +8189,10 @@ export function registerConfigSyncCommands(sync) {
8318
8189
  const metadataCategoryConfigsResult = metadataCategoryConfigsOutcome.ok
8319
8190
  ? metadataCategoryConfigsOutcome.value
8320
8191
  : [];
8321
- // Fetch operations + subscriptions for each database type. Issue #803:
8322
- // subscriptions are pulled symmetrically with operations so a
8323
- // pull → push cycle round-trips `[[subscriptions]]` blocks without a
8324
- // spurious diff. The list endpoint already excludes archived rows.
8325
- //
8326
- // A failed operation/subscription fetch also disqualifies the type from
8327
- // pruning (#1659): the file we write for it is then incomplete, so the
8328
- // pull's picture of that type is not one to reconcile against.
8329
- let databaseTypeDetailsOk = true;
8330
- const databaseTypesWithOps = await Promise.all(selectPull("database-type-config", Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : [], (t) => t.databaseType).map(async (typeConfig) => {
8331
- const [opsOutcome, subsOutcome] = await Promise.all([
8332
- attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
8333
- attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
8334
- ]);
8335
- if (!opsOutcome.ok || !subsOutcome.ok)
8336
- databaseTypeDetailsOk = false;
8337
- const ops = opsOutcome.ok ? opsOutcome.value : [];
8338
- const subs = subsOutcome.ok ? subsOutcome.value : [];
8339
- return {
8340
- typeConfig,
8341
- operations: Array.isArray(ops) ? ops : [],
8342
- subscriptions: Array.isArray(subs) ? subs : [],
8343
- };
8344
- }));
8192
+ // #3189 — a database type's file is its type config alone: the
8193
+ // operations and subscriptions that used to be fetched beside it are
8194
+ // retired, so there is nothing more to read per type.
8195
+ const databaseTypesToWrite = selectPull("database-type-config", Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : [], (t) => t.databaseType);
8345
8196
  // Snapshot the pre-pull sync tree BEFORE we touch any local file
8346
8197
  // (issue #578, Phase 1). All server data has been fetched and
8347
8198
  // validated at this point — a failed pull already threw above, so we
@@ -8937,46 +8788,16 @@ export function registerConfigSyncCommands(sync) {
8937
8788
  const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
8938
8789
  // Write database types
8939
8790
  const databaseTypeEntities = {};
8940
- for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
8791
+ for (const typeConfig of databaseTypesToWrite) {
8941
8792
  const filename = `${typeConfig.databaseType}.toml`;
8942
8793
  const filePath = join(configDir, "database-type-configs", filename);
8943
- // Preserve the existing file's per-op form (issue #752): if the
8944
- // file already exists, read it raw and detect which operations
8945
- // currently use JSON-string vs native nested-table form.
8946
- const hints = existsSync(filePath)
8947
- ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
8948
- : undefined;
8949
- const content = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8950
- hints,
8951
- defaultForm: "native",
8794
+ const content = serializeDatabaseType(typeConfig, ruleSetIdToName, {
8952
8795
  logger: (msg) => info(` ${msg}`),
8953
- // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
8954
- // cycle round-trips subscriptions without a spurious diff.
8955
- subscriptions,
8956
8796
  });
8957
8797
  const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("database-type-config", content, filePath, pullDiffMaps));
8958
- const opsEntities = {};
8959
- for (const op of operations) {
8960
- opsEntities[op.name] = {
8961
- modifiedAt: op.modifiedAt || new Date().toISOString(),
8962
- };
8963
- }
8964
- // Track per-subscription modifiedAt in SyncState (issue #803),
8965
- // exactly like operations, so the next push skips unchanged
8966
- // subscriptions and detects removals.
8967
- const subsEntities = {};
8968
- for (const sub of subscriptions || []) {
8969
- if (!sub?.subscriptionKey)
8970
- continue;
8971
- subsEntities[sub.subscriptionKey] = {
8972
- modifiedAt: sub.modifiedAt || new Date().toISOString(),
8973
- };
8974
- }
8975
8798
  databaseTypeEntities[typeConfig.databaseType] = {
8976
8799
  databaseType: typeConfig.databaseType,
8977
8800
  modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
8978
- operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
8979
- subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
8980
8801
  contentHash: computeFileHash(filePath),
8981
8802
  semanticHash: pulledSemanticHash("database-type-config", content, pullDiffMaps),
8982
8803
  hasSchema: typeof typeConfig.schema === "string" &&
@@ -9101,8 +8922,8 @@ export function registerConfigSyncCommands(sync) {
9101
8922
  scripts: scriptPresence,
9102
8923
  functions: functionPresence,
9103
8924
  emailTemplates: emailTemplatePresence,
9104
- databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
9105
- ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
8925
+ databaseTypes: databaseTypeConfigsOutcome.ok
8926
+ ? { ok: true, serverKeys: databaseTypesToWrite.map((t) => t.databaseType) }
9106
8927
  : { ok: false },
9107
8928
  ruleSets: ruleSetsOutcome.ok
9108
8929
  ? { ok: true, serverKeys: ruleSets.map((rs) => ruleSetFileKey(rs)) }
@@ -9462,7 +9283,7 @@ export function registerConfigSyncCommands(sync) {
9462
9283
  if (unwrittenTestCases > 0) {
9463
9284
  keyValue("Test Cases not written", unwrittenTestCases);
9464
9285
  }
9465
- keyValue("Database Types", databaseTypesWithOps.length);
9286
+ keyValue("Database Types", databaseTypesToWrite.length);
9466
9287
  keyValue("Rule Sets", ruleSets.length);
9467
9288
  keyValue("Group Type Configs", groupTypeConfigs.length);
9468
9289
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
@@ -11784,16 +11605,6 @@ What a push guarantees (two stages):
11784
11605
  catch (err) {
11785
11606
  throw new Error(`${filePath}: ${err.message}`);
11786
11607
  }
11787
- // #2652 — a prompt with no rule denies every non-admin execution.
11788
- // Warn per file so an operator pushing an unmigrated app can see
11789
- // exactly which prompts are locked down and how to open them.
11790
- const promptRuleWarning = missingAccessRuleWarning({
11791
- kind: "prompt",
11792
- key,
11793
- accessRule: prompt.accessRule,
11794
- });
11795
- if (promptRuleWarning)
11796
- warn(` ${promptRuleWarning}`);
11797
11608
  const existingEntry = syncState?.entities?.prompts?.[key];
11798
11609
  const livePromptId = livePromptIds.get(key);
11799
11610
  // A detail read that fails degrades THIS prompt to the byte-hash
@@ -12808,7 +12619,6 @@ What a push guarantees (two stages):
12808
12619
  // start from this release onward. A `runAs = "system"` workflow is
12809
12620
  // exempt (it rejects rules and members cannot start it).
12810
12621
  const workflowRuleWarning = missingAccessRuleWarning({
12811
- kind: "workflow",
12812
12622
  key,
12813
12623
  accessRule: workflow.accessRule,
12814
12624
  runAs: workflow.runAs,
@@ -13260,12 +13070,11 @@ What a push guarantees (two stages):
13260
13070
  const dbTypesDir = join(configDir, "database-type-configs");
13261
13071
  if (existsSync(dbTypesDir)) {
13262
13072
  const files = selectFiles("database-type-config", dbTypesDir, readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml")));
13263
- // #2731 B3 — live reconcile for the FILE-LEVEL gate only. The
13264
- // per-operation and per-subscription machinery below (the #813
13265
- // validate-first pass, #915 hash clearing, #803 post-ops refresh) is
13266
- // untouched; this just replaces the byte-hash short-circuit in front
13267
- // of it. Like `config diff`, the comparison also needs the rule-set
13268
- // id→name map, so a failed rule-set fetch degrades this type too.
13073
+ // #2731 B3 — live reconcile for the FILE-LEVEL gate. Like `config
13074
+ // diff`, the comparison also needs the rule-set id→name map, so a
13075
+ // failed rule-set fetch degrades this type too. (#3189 — a type's
13076
+ // file is its config alone; the operation and subscription rows the
13077
+ // gate used to read beside it are retired.)
13269
13078
  const databaseTypeSpec = configDiffSpec("database-type-config");
13270
13079
  const liveDatabaseTypes = new Map();
13271
13080
  let databaseTypesLiveOk = (await loadLiveRuleSets()).ok;
@@ -13287,28 +13096,8 @@ What a push guarantees (two stages):
13287
13096
  }
13288
13097
  for (const file of files) {
13289
13098
  const filePath = join(dbTypesDir, file);
13290
- const rawToml = readFileSync(filePath, "utf-8");
13291
13099
  const tomlData = parseTomlFile(filePath);
13292
- let typeConfig;
13293
- let operations;
13294
- let subscriptions;
13295
- try {
13296
- ({ typeConfig, operations, subscriptions } =
13297
- parseDatabaseTypeToml(tomlData));
13298
- }
13299
- catch (err) {
13300
- // The only structured parse error today is the
13301
- // `accessRule`/`access` conflict in a `[[subscriptions]]` block
13302
- // (issue #803). Surface it with the file name and abort the
13303
- // push rather than crashing with an opaque stack.
13304
- if (err instanceof SubscriptionAccessKeyConflictError) {
13305
- // Issue #976: throw (not `process.exit`) so the save-on-failure
13306
- // catch is the only exit path. Validation is also front-loaded
13307
- // above, so this is defense-in-depth.
13308
- throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
13309
- }
13310
- throw err;
13311
- }
13100
+ const { typeConfig } = parseDatabaseTypeToml(tomlData);
13312
13101
  const dbType = typeConfig.databaseType || basename(file, ".toml");
13313
13102
  // The gate re-parses this document, so the name-derived identity
13314
13103
  // has to be in it (see `withDerivedIdentity`).
@@ -13317,63 +13106,21 @@ What a push guarantees (two stages):
13317
13106
  resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`, {
13318
13107
  hint: ruleSetSelectionHint,
13319
13108
  });
13320
- // $params validator (issue #752): every `$params.X` reference
13321
- // inside `definition` must correspond to a declared param in
13322
- // `[[operations.params]]`. Catches typos like `$params.proectId`
13323
- // at push time with the TOML file:line of the offending op block.
13324
- const validation = validateOperations({
13325
- filePath,
13326
- rawToml,
13327
- operations,
13328
- });
13329
- for (const w of validation.warnings) {
13330
- warn(` ${formatIssue(w)}`);
13331
- }
13332
- if (validation.errors.length > 0) {
13333
- // Issue #976: throw (not `process.exit`) so the save-on-failure
13334
- // catch is the only exit path. Validation is also front-loaded
13335
- // above, so this is defense-in-depth.
13336
- throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
13337
- `\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
13338
- }
13339
13109
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
13340
13110
  /**
13341
- * This type as the server holds it NOW — header, operations and
13342
- * subscriptions, which the comparison treats as one unit.
13111
+ * This type as the server holds it NOW.
13343
13112
  *
13344
13113
  * Two callers: the baseline stamped after everything this file
13345
13114
  * writes has landed (B4), and the report for a 409 that arrived
13346
13115
  * after the reconcile fetch (B5).
13347
13116
  */
13348
13117
  const refetchLiveDatabaseType = async () => {
13349
- const [record, ops, subs] = await Promise.all([
13350
- client.getDatabaseTypeConfig(resolvedAppId, dbType),
13351
- client.listDatabaseTypeOperations(resolvedAppId, dbType),
13352
- client.listDatabaseTypeSubscriptions(resolvedAppId, dbType),
13353
- ]);
13354
- return record
13355
- ? {
13356
- record,
13357
- extra: {
13358
- operations: Array.isArray(ops) ? ops : [],
13359
- subscriptions: Array.isArray(subs) ? subs : [],
13360
- },
13361
- }
13362
- : null;
13118
+ const record = await client.getDatabaseTypeConfig(resolvedAppId, dbType);
13119
+ return record ? { record, extra: {} } : null;
13363
13120
  };
13364
- // Issue #915 (defect b): track whether the apply path rejected any
13365
- // op for THIS file via the schema gate (OperationRefError /
13366
- // SchemaRequiredError). On a non-transactional fresh-type push the
13367
- // db-type lands but the bad op is blocked and the loop continues; if
13368
- // we then wrote the file-level contentHash, the next push's
13369
- // `shouldPushFile` would short-circuit and the missing op would be
13370
- // invisible drift. When this flag is set we skip the contentHash
13371
- // write so the drift stays visible and a corrected re-push converges.
13372
- let fileHadGateRejectedOp = false;
13373
13121
  // #2731 B4: a 409 means the write was REJECTED, so this file did
13374
- // not land either. Tracked like a gate-rejected op so the baseline
13375
- // stamp below cannot claim convergence for content the server
13376
- // refused — a baseline that lied would make the next push read the
13122
+ // not land. Tracked so the baseline stamp below cannot claim
13123
+ // convergence for content the server refused — a baseline that lied would make the next push read the
13377
13124
  // still-unapplied local edit as server drift and refuse to retry.
13378
13125
  let fileHadConflict = false;
13379
13126
  const liveDatabaseType = databaseTypesLiveOk
@@ -13386,9 +13133,6 @@ What a push guarantees (two stages):
13386
13133
  const typeExistsOnServer = databaseTypesLiveOk
13387
13134
  ? !!liveDatabaseType
13388
13135
  : !!existingEntry;
13389
- /** Live operations/subscriptions, keyed by name, when read. */
13390
- let liveOpsByName;
13391
- let liveSubsByName;
13392
13136
  let dbTypeGate;
13393
13137
  if (databaseTypesLiveOk && !liveDatabaseType) {
13394
13138
  // The listing succeeded and this type is not in it. That is a
@@ -13397,87 +13141,47 @@ What a push guarantees (two stages):
13397
13141
  dbTypeGate = { action: "create" };
13398
13142
  }
13399
13143
  else if (databaseTypesLiveOk && liveDatabaseType) {
13400
- // The file carries its operations and subscriptions, so the
13401
- // comparison fetches them too — an op-only or subscription-only
13402
- // edit must not read as unchanged. A failure on either degrades
13403
- // this one type to the byte-hash gate.
13404
- let liveOps = null;
13405
- let liveSubs = null;
13406
- try {
13407
- [liveOps, liveSubs] = await Promise.all([
13408
- client.listDatabaseTypeOperations(resolvedAppId, dbType),
13409
- client.listDatabaseTypeSubscriptions(resolvedAppId, dbType),
13410
- ]);
13411
- }
13412
- catch (err) {
13413
- warnLiveUnavailable(`database-type-config ${dbType}`, err);
13414
- }
13415
- if (liveOps && liveSubs) {
13416
- liveOpsByName = new Map((Array.isArray(liveOps) ? liveOps : [])
13417
- .filter((op) => op?.name)
13418
- .map((op) => [op.name, op]));
13419
- liveSubsByName = new Map((Array.isArray(liveSubs) ? liveSubs : [])
13420
- .filter((sub) => sub?.subscriptionKey)
13421
- .map((sub) => [sub.subscriptionKey, sub]));
13422
- dbTypeGate = decidePushForConfig({
13423
- spec: databaseTypeSpec,
13424
- maps: pushDiffMaps,
13425
- localParsed: tomlData,
13426
- live: {
13427
- record: liveDatabaseType,
13428
- extra: {
13429
- operations: Array.isArray(liveOps) ? liveOps : [],
13430
- subscriptions: Array.isArray(liveSubs) ? liveSubs : [],
13431
- },
13432
- modifiedAt: liveDatabaseType.modifiedAt,
13433
- },
13434
- entry: existingEntry,
13435
- localFileHash: computeFileHash(filePath),
13436
- force: options.force,
13437
- });
13438
- if (dbTypeGate.action === "skip") {
13439
- skippedCount++;
13440
- if (syncState) {
13441
- // Written even when the manifest has never seen this type:
13442
- // an adopt that records nothing is repeated on every push,
13443
- // and its next local edit would have no baseline (and no
13444
- // op/subscription rows) to reconcile against.
13445
- if (!syncState.entities.databaseTypes) {
13446
- syncState.entities.databaseTypes = {};
13447
- }
13448
- const opsEntities = {};
13449
- for (const [opName, op] of liveOpsByName ?? []) {
13450
- opsEntities[opName] = {
13451
- modifiedAt: op?.modifiedAt || new Date().toISOString(),
13452
- };
13453
- }
13454
- const subsEntities = {};
13455
- for (const [subKey, sub] of liveSubsByName ?? []) {
13456
- subsEntities[subKey] = {
13457
- modifiedAt: sub?.modifiedAt || new Date().toISOString(),
13458
- };
13459
- }
13460
- syncState.entities.databaseTypes[dbType] = {
13461
- databaseType: dbType,
13462
- modifiedAt: liveDatabaseType.modifiedAt ||
13463
- existingEntry?.modifiedAt ||
13464
- new Date().toISOString(),
13465
- operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
13466
- subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
13467
- contentHash: computeFileHash(filePath),
13468
- semanticHash: dbTypeGate.remoteHash,
13469
- hasSchema: typeof liveDatabaseType.schema === "string" &&
13470
- liveDatabaseType.schema.trim().length > 0,
13471
- };
13144
+ dbTypeGate = decidePushForConfig({
13145
+ spec: databaseTypeSpec,
13146
+ maps: pushDiffMaps,
13147
+ localParsed: tomlData,
13148
+ live: {
13149
+ record: liveDatabaseType,
13150
+ extra: {},
13151
+ modifiedAt: liveDatabaseType.modifiedAt,
13152
+ },
13153
+ entry: existingEntry,
13154
+ localFileHash: computeFileHash(filePath),
13155
+ force: options.force,
13156
+ });
13157
+ if (dbTypeGate.action === "skip") {
13158
+ skippedCount++;
13159
+ if (syncState) {
13160
+ // Written even when the manifest has never seen this type:
13161
+ // an adopt that records nothing is repeated on every push,
13162
+ // and its next local edit would have no baseline to
13163
+ // reconcile against.
13164
+ if (!syncState.entities.databaseTypes) {
13165
+ syncState.entities.databaseTypes = {};
13472
13166
  }
13473
- continue;
13474
- }
13475
- if (dbTypeGate.action === "drift" ||
13476
- dbTypeGate.action === "conflict" ||
13477
- dbTypeGate.action === "immutable") {
13478
- recordDeclined("database-type-config", dbType, dbTypeGate, existingEntry?.modifiedAt);
13479
- continue;
13167
+ syncState.entities.databaseTypes[dbType] = {
13168
+ databaseType: dbType,
13169
+ modifiedAt: liveDatabaseType.modifiedAt ||
13170
+ existingEntry?.modifiedAt ||
13171
+ new Date().toISOString(),
13172
+ contentHash: computeFileHash(filePath),
13173
+ semanticHash: dbTypeGate.remoteHash,
13174
+ hasSchema: typeof liveDatabaseType.schema === "string" &&
13175
+ liveDatabaseType.schema.trim().length > 0,
13176
+ };
13480
13177
  }
13178
+ continue;
13179
+ }
13180
+ if (dbTypeGate.action === "drift" ||
13181
+ dbTypeGate.action === "conflict" ||
13182
+ dbTypeGate.action === "immutable") {
13183
+ recordDeclined("database-type-config", dbType, dbTypeGate, existingEntry?.modifiedAt);
13184
+ continue;
13481
13185
  }
13482
13186
  }
13483
13187
  // Fallback gate: the manifest byte hash — and, since #2880, a
@@ -13497,26 +13201,6 @@ What a push guarantees (two stages):
13497
13201
  continue;
13498
13202
  }
13499
13203
  }
13500
- // ── Issues #813 / #1336 / #2732: state what this push's operation
13501
- // set will BE, then run a validate-first pass before any mutating
13502
- // call for this type. The type-config PATCH gates run ahead of the
13503
- // operation calls, so the claims are what let them judge the
13504
- // post-push state (see `buildPendingOpClaims`). ──
13505
- // The op rows the server holds (#2731). When the reconcile read
13506
- // them, they ARE the inventory (`liveChildBaseline`): an op created
13507
- // out-of-band is UPDATEd rather than POSTed into a duplicate-key
13508
- // failure, and one deleted out-of-band is re-CREATEd rather than
13509
- // PATCHed at a row that is gone — and the claims' delete complement
13510
- // is computed against that live inventory, not the manifest's. When
13511
- // live state says the TYPE is gone, its ops went with it — so the
13512
- // manifest's op list is not a baseline either, and this push
13513
- // creates them alongside the re-created type. Only the degraded
13514
- // path still reads the manifest, where it is what the byte-hash
13515
- // gate has always used.
13516
- const existingOpsForType = liveChildBaseline(liveOpsByName, existingEntry?.operations) ??
13517
- ((typeExistsOnServer ? existingEntry?.operations : undefined) || {});
13518
- const pendingOpClaims = buildPendingOpClaims(operations, Object.keys(existingOpsForType));
13519
- const { pendingOpDeletes } = pendingOpClaims;
13520
13204
  // Whether the SERVER holds a schema right now. Live state answers
13521
13205
  // when it was read (so an adopted type's schema is not silently
13522
13206
  // preserved by an omitted key); the manifest's `hasSchema` is the
@@ -13575,8 +13259,9 @@ What a push guarantees (two stages):
13575
13259
  // declares an owned scalar (matches the pre-#1567 PATCH trigger), a
13576
13260
  // schema set/delete is in the payload, or an owned scalar is being
13577
13261
  // cleared. When false, the type-config PATCH is skipped entirely so
13578
- // an operations-only edit does not spuriously report the type as
13579
- // "Updated" (issue #369 regression) and no idle clear-report fires.
13262
+ // an edit with no type-level effect does not spuriously report the
13263
+ // type as "Updated" (issue #369 regression) and no idle
13264
+ // clear-report fires.
13580
13265
  const declaresOwnedField = DB_TYPE_OWNED_SCALARS.some((k) => k in typeConfig);
13581
13266
  const schemaInUpdateData = "schema" in computeTypeUpdateData();
13582
13267
  const typeLevelChange = declaresOwnedField ||
@@ -13588,19 +13273,12 @@ What a push guarantees (two stages):
13588
13273
  info(` ${prefix}${dbTypeFieldLabel(key)} on ${dbType}`);
13589
13274
  }
13590
13275
  };
13591
- // Validate-first pass (decision 4a): run every gate for this type
13592
- // BEFORE issuing any mutating call. If a gate fails, the whole
13593
- // type's mutating calls (op creates/updates/deletes AND the schema
13594
- // PATCH) are skipped — eliminating the gate-failure partial commit.
13595
- // The same pass IS the dry-run path for `config push --dry-run`:
13596
- // it runs the real server gates without persisting (Option 1A).
13276
+ // Validate-first pass (decision 4a): run the type's gates BEFORE
13277
+ // issuing any mutating call. If a gate fails, the type's mutating
13278
+ // calls are skipped. The same pass IS the dry-run path for
13279
+ // `config push --dry-run`: it runs the real server gates without
13280
+ // persisting (Option 1A).
13597
13281
  let typeGateBlocked = false;
13598
- // Issue #684 regression guard: track which op the op-edit gate is
13599
- // dry-running so an unrecognized server error (e.g. a plain
13600
- // validation 400 like `"limit" must be 1000 or less`) can be
13601
- // attributed to that specific operation in the catch-all below,
13602
- // instead of being coarsened to the db-type level.
13603
- let opGateContext = null;
13604
13282
  // #2731 B4 — the concurrency guard is the LIVE timestamp the
13605
13283
  // reconcile fetch just read (the gate carries it), not the
13606
13284
  // manifest's, which can be arbitrarily stale and 409 on a write
@@ -13612,54 +13290,28 @@ What a push guarantees (two stages):
13612
13290
  const validateExpectedModifiedAt = options.force
13613
13291
  ? undefined
13614
13292
  : dbTypeExpectedModifiedAt;
13615
- // Issue #1336 (codex round-1 P1/P2): the declared-access manifest
13616
- // this push proposes (the parsed object, or null when the type
13617
- // declares none / is clearing it). Forwarded as
13618
- // `metadataManifestOverride` on the op dry-runs so op-access lints
13619
- // run against the manifest the same push is landing, not the stale
13620
- // stored one (existing type: a newly-declared key is honored; fresh
13621
- // type: an undeclared ref is caught instead of skipped).
13622
- const proposedManifest = typeConfig.metadataManifest ??
13623
- null;
13624
13293
  try {
13625
- // 1. Schema-edit gate (type-config PATCH dry-run) — only when the
13626
- // type already exists on the server and we'd actually PATCH a
13627
- // type-level field. A fresh type has no ops to break, so
13628
- // there's nothing to gate yet. #2731 B3: "already exists" is
13629
- // live state's answer, so a type the manifest still names but
13630
- // the server no longer has dry-runs its CREATE, not a PATCH.
13631
- // Issue #1567: `computeTypeUpdateData` now always returns the
13632
- // owned scalars, so gate on `typeLevelChange` (a genuine
13633
- // declared/schema/clear change) rather than "payload
13634
- // non-empty" — an operations-only edit must not run the
13635
- // type-config dry-run at all.
13294
+ // The type-config PATCH dry-run — only when the type already
13295
+ // exists on the server and we'd actually PATCH a type-level
13296
+ // field. #2731 B3: "already exists" is live state's answer, so a
13297
+ // type the manifest still names but the server no longer has
13298
+ // dry-runs its CREATE, not a PATCH.
13636
13299
  if (typeExistsOnServer && typeLevelChange) {
13637
- const validateUpdateData = computeTypeUpdateData();
13638
- await client.updateDatabaseTypeConfig(resolvedAppId, dbType,
13639
- // Forward the whole look-ahead (#813 / #1336 / #2732) so the
13640
- // dry-run gates judge the post-push operation set — the same
13641
- // payload the mutating PATCH below sends.
13642
- { ...validateUpdateData, ...pendingOpClaims }, validateExpectedModifiedAt, {
13300
+ await client.updateDatabaseTypeConfig(resolvedAppId, dbType, computeTypeUpdateData(), validateExpectedModifiedAt, {
13643
13301
  dryRun: true,
13644
13302
  acceptWarnings: !!options.acceptWarnings,
13645
13303
  });
13646
13304
  }
13647
13305
  else if (!typeExistsOnServer) {
13648
13306
  // Fresh type: run the config CREATE dry-run (issue #1336, codex
13649
- // round-1 P2). Before this, a brand-new type's inline config
13650
- // rules (`defaultAccess`, `metadataAccess`, autoPopulatedFields,
13651
- // triggers) and its manifest were never dry-run — a malformed
13652
- // rule reported a clean `config push --dry-run` then failed only on
13653
- // the real POST. Mirror the apply-path create payload below.
13307
+ // round-1 P2), so a malformed inline rule (autoPopulatedFields,
13308
+ // triggers) or manifest fails `config push --dry-run` as the
13309
+ // real POST would. Mirrors the apply-path create payload below.
13654
13310
  const createDryRunData = { databaseType: dbType };
13655
13311
  if (typeConfig.ruleSetId)
13656
13312
  createDryRunData.ruleSetId = typeConfig.ruleSetId;
13657
13313
  if (typeConfig.triggers)
13658
13314
  createDryRunData.triggers = typeConfig.triggers;
13659
- if (typeConfig.metadataAccess)
13660
- createDryRunData.metadataAccess = typeConfig.metadataAccess;
13661
- if (typeConfig.defaultAccess)
13662
- createDryRunData.defaultAccess = typeConfig.defaultAccess;
13663
13315
  if (typeConfig.autoPopulatedFields)
13664
13316
  createDryRunData.autoPopulatedFields =
13665
13317
  typeConfig.autoPopulatedFields;
@@ -13682,175 +13334,12 @@ What a push guarantees (two stages):
13682
13334
  throw createErr;
13683
13335
  }
13684
13336
  }
13685
- // 2. Op-edit gate (op create/update dry-run) for every op in the
13686
- // TOML. The server runs `runSchemaGate` and short-circuits
13687
- // before persisting.
13688
- //
13689
- // The op gate is run against the schema THIS push is landing
13690
- // (`schemaOverride`), not the stale stored schema — the real
13691
- // apply path PATCHes the schema before creating/updating ops,
13692
- // so an op that depends on a field this push adds must NOT be
13693
- // falsely rejected (plan edge case: "op create that depends on
13694
- // a schema landed earlier in the same push"). When the push
13695
- // isn't changing the schema, leave the override unset so the
13696
- // server uses the stored schema.
13697
- //
13698
- // Issue #915 (defect a): this gate now runs for FRESH types too
13699
- // (previously guarded by `if (existingEntry)`). For a brand-new
13700
- // db-type the server has no stored config, so we gate the op
13701
- // against the PROPOSED local schema via `schemaOverride`. The
13702
- // server's op-create dry-run accepts a missing type when a
13703
- // `schemaOverride` is present (see
13704
- // database-type-operations-controller.ts). A fresh schemaless
13705
- // type (#666 bootstrap) sends an empty/absent override, and
13706
- // `runSchemaGate` is a no-op without a schema — so that path
13707
- // still succeeds.
13708
- {
13709
- const localHasSchema = typeof typeConfig.schema === "string" &&
13710
- typeConfig.schema.trim().length > 0;
13711
- let proposedSchema;
13712
- if (typeExistsOnServer) {
13713
- const validateUpdateData = computeTypeUpdateData();
13714
- proposedSchema =
13715
- "schema" in validateUpdateData
13716
- ? validateUpdateData.schema
13717
- : undefined;
13718
- }
13719
- else {
13720
- // Fresh type: the proposed schema is whatever the local TOML
13721
- // declares. A schemaless fresh type (no `[models.*]`) sends an
13722
- // empty-string override so the server takes the no-schema =
13723
- // no-op gate path rather than falling back to a (nonexistent)
13724
- // stored config — keeping the #666 bootstrap convergent.
13725
- proposedSchema = localHasSchema ? typeConfig.schema : "";
13726
- }
13727
- for (const op of operations) {
13728
- const existingOp = existingOpsForType[op.name];
13729
- opGateContext = {
13730
- action: existingOp ? "update" : "create",
13731
- key: `${dbType}/${op.name}`,
13732
- };
13733
- if (existingOp) {
13734
- await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
13735
- type: op.type,
13736
- modelName: op.modelName,
13737
- access: op.access,
13738
- definition: op.definition,
13739
- params: op.params,
13740
- }, options.force ? undefined : existingOp.modifiedAt, {
13741
- dryRun: true,
13742
- schemaOverride: proposedSchema,
13743
- // Issue #1336 (P1/P2): gate the op-access lint against
13744
- // the manifest THIS push is landing, not the stale
13745
- // stored one.
13746
- metadataManifestOverride: proposedManifest,
13747
- });
13748
- }
13749
- else {
13750
- await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
13751
- name: op.name,
13752
- type: op.type,
13753
- modelName: op.modelName,
13754
- access: op.access,
13755
- definition: op.definition,
13756
- params: op.params,
13757
- }, {
13758
- dryRun: true,
13759
- schemaOverride: proposedSchema,
13760
- // Issue #1336 (P1/P2): as above — a fresh-type op dry-run
13761
- // now lints against the proposed manifest instead of
13762
- // skipping the lint entirely.
13763
- metadataManifestOverride: proposedManifest,
13764
- // Issue #915 (defect a, follow-up): for a fresh type the
13765
- // server has no stored config to read `defaultAccess`
13766
- // from, so an op that omits `access` to inherit the
13767
- // TOML-declared type-level `defaultAccess` would be
13768
- // falsely rejected. Thread the proposed value so the
13769
- // fresh-type dry-run gates the same way the real push
13770
- // (which lands the type first) will.
13771
- defaultAccess: typeof typeConfig.defaultAccess === "string"
13772
- ? typeConfig.defaultAccess
13773
- : undefined,
13774
- });
13775
- }
13776
- }
13777
- // Every op gate passed; clear the context so a later (non-op)
13778
- // throw inside this try isn't misattributed to the last op.
13779
- opGateContext = null;
13780
- }
13781
13337
  }
13782
13338
  catch (err) {
13783
- // A gate failure during validate aborts the whole type. Bucket it
13784
- // into the same `schemaErrors` collectors used by the reporting
13785
- // path so the summary renders it identically, and record a
13786
- // disclosure entry. Conflicts and other errors fall through to
13787
- // the apply path's handling (validate-first doesn't change
13788
- // conflict semantics).
13789
- if (err instanceof SchemaBreaksOpsError) {
13790
- schemaErrors.schemaBreaks.push({
13791
- type: "database-type-config",
13792
- key: dbType,
13793
- operations: err.operations,
13794
- message: err.message,
13795
- });
13796
- blockedDatabaseTypes.push({
13797
- databaseType: dbType,
13798
- reason: `schema change blocked: ${err.operations.length} kept operation(s) would break under the new schema`,
13799
- });
13800
- typeGateBlocked = true;
13801
- }
13802
- else if (err instanceof SchemaHasUncheckableOpsError) {
13803
- schemaErrors.uncheckableOps.push({
13804
- type: "database-type-config",
13805
- key: dbType,
13806
- operations: err.operations,
13807
- message: err.message,
13808
- });
13809
- blockedDatabaseTypes.push({
13810
- databaseType: dbType,
13811
- reason: `schema change blocked: operation(s) have dynamic refs (re-run with --accept-warnings to commit)`,
13812
- });
13813
- typeGateBlocked = true;
13814
- }
13815
- else if (err instanceof OpsExistError) {
13816
- schemaErrors.opsExist.push({
13817
- type: "database-type-config",
13818
- key: dbType,
13819
- opCount: err.opCount,
13820
- message: err.message,
13821
- });
13822
- blockedDatabaseTypes.push({
13823
- databaseType: dbType,
13824
- reason: `schema deletion blocked: ${err.opCount} operation(s) still registered after this push's deletes`,
13825
- });
13826
- typeGateBlocked = true;
13827
- }
13828
- else if (err instanceof SchemaRequiredError) {
13829
- schemaErrors.schemaRequired.push({
13830
- type: "operation",
13831
- key: `${dbType}/_`,
13832
- message: err.message,
13833
- });
13834
- blockedDatabaseTypes.push({
13835
- databaseType: dbType,
13836
- reason: `operation push blocked: type has no schema set`,
13837
- });
13838
- typeGateBlocked = true;
13839
- }
13840
- else if (err instanceof OperationRefError) {
13841
- schemaErrors.opRefs.push({
13842
- type: "operation",
13843
- key: dbType,
13844
- refs: err.refs,
13845
- message: err.message,
13846
- });
13847
- blockedDatabaseTypes.push({
13848
- databaseType: dbType,
13849
- reason: `operation push blocked: unresolved references in the schema`,
13850
- });
13851
- typeGateBlocked = true;
13852
- }
13853
- else if (err instanceof ConflictError) {
13339
+ // A gate failure during validate aborts the whole type. Conflicts
13340
+ // and other errors fall through to the apply path's handling
13341
+ // (validate-first doesn't change conflict semantics).
13342
+ if (err instanceof ConflictError) {
13854
13343
  // Surface the conflict and skip this type (don't blow away the
13855
13344
  // server with mutating calls). Mirrors the apply path.
13856
13345
  conflicts.push({
@@ -13871,14 +13360,6 @@ What a push guarantees (two stages):
13871
13360
  });
13872
13361
  typeGateBlocked = true;
13873
13362
  }
13874
- else if (opGateContext) {
13875
- // The op-edit gate dry-run threw a plain (non-gate) error —
13876
- // e.g. an invalid op `definition` the server rejects with a
13877
- // 400. The validate-first loop already knows which op failed,
13878
- // so name it (issue #684) instead of coarsening to the
13879
- // db-type level. Mirrors the apply path's per-op wraps.
13880
- throw wrapEntityError(err, opGateContext.action, "operation", opGateContext.key);
13881
- }
13882
13363
  else {
13883
13364
  throw wrapEntityError(err, "update", "database type", dbType);
13884
13365
  }
@@ -13898,17 +13379,6 @@ What a push guarantees (two stages):
13898
13379
  else {
13899
13380
  changes.push({ type: "database-type-config", action: "create", key: dbType });
13900
13381
  }
13901
- for (const op of operations) {
13902
- const existingOp = existingOpsForType[op.name];
13903
- changes.push({
13904
- type: "operation",
13905
- action: existingOp ? "update" : "create",
13906
- key: `${dbType}/${op.name}`,
13907
- });
13908
- }
13909
- for (const delName of pendingOpDeletes) {
13910
- changes.push({ type: "operation", action: "delete", key: `${dbType}/${delName}` });
13911
- }
13912
13382
  continue;
13913
13383
  }
13914
13384
  // A live type the manifest never recorded is about to be updated,
@@ -13934,9 +13404,8 @@ What a push guarantees (two stages):
13934
13404
  // never recorded is updated rather than re-created.
13935
13405
  if (typeExistsOnServer) {
13936
13406
  // Update existing type config — only when there is a genuine
13937
- // type-level change. Operations are handled separately below.
13938
- // Issue #369: skipping the PATCH on an operations-only edit avoids
13939
- // sending an empty body (which the server rejects with HTTP 400)
13407
+ // type-level change. Issue #369: skipping the PATCH on an edit
13408
+ // with no type-level effect avoids sending an empty body (which the server rejects with HTTP 400)
13940
13409
  // and a spurious "Updated database type". Issue #1567: the owned
13941
13410
  // scalars are always in `updateData` now (value-or-`null`), so the
13942
13411
  // skip decision moved from "payload non-empty" to the
@@ -13953,18 +13422,7 @@ What a push guarantees (two stages):
13953
13422
  // `schema: null` clears it server-side; codex review gap on
13954
13423
  // PR #766). Built by the shared helper so the validate pass
13955
13424
  // and apply pass agree (issue #813).
13956
- // The look-ahead goes on the REAL mutating PATCH exactly as it
13957
- // went on the dry-run above — the gates it feeds run on this
13958
- // call, and the operation creates/updates/deletes it
13959
- // describes still happen in step 3 below. It is sent whole
13960
- // and unconditionally: a push that only rewrites operations
13961
- // has no deletes to report, and gating on that was what kept
13962
- // the #2732 case (model removal + operation rewrites) from
13963
- // ever reaching the server as one push.
13964
- const updateData = {
13965
- ...computeTypeUpdateData(),
13966
- ...pendingOpClaims,
13967
- };
13425
+ const updateData = computeTypeUpdateData();
13968
13426
  if (typeLevelChange) {
13969
13427
  changes.push({ type: "database-type-config", action: "update", key: dbType });
13970
13428
  const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
@@ -13973,8 +13431,8 @@ What a push guarantees (two stages):
13973
13431
  });
13974
13432
  info(` Updated database type: ${dbType}`);
13975
13433
  // Issue #1567: name each owned scalar this push cleared, so
13976
- // removing an access gate (celContextAccess / defaultAccess)
13977
- // is never silent.
13434
+ // removing a rule (triggers, autoPopulatedFields) is never
13435
+ // silent.
13978
13436
  reportClearedFields("Cleared ");
13979
13437
  printStaleInstanceWarning(updated);
13980
13438
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
@@ -13991,28 +13449,11 @@ What a push guarantees (two stages):
13991
13449
  if (err instanceof ConflictError) {
13992
13450
  // #2731 B5 — the server moved between the reconcile fetch
13993
13451
  // and this write, so recompute the field diff against the
13994
- // record that is there NOW (type plus its ops and
13995
- // subscriptions, which the comparison treats as one unit).
13452
+ // record that is there NOW.
13996
13453
  conflicts.push(await lateConflict("database-type-config", dbType, err, expectedModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData));
13997
13454
  // The PATCH was rejected: this file did not land.
13998
13455
  fileHadConflict = true;
13999
13456
  }
14000
- else if (err instanceof SchemaBreaksOpsError) {
14001
- schemaErrors.schemaBreaks.push({
14002
- type: "database-type-config",
14003
- key: dbType,
14004
- operations: err.operations,
14005
- message: err.message,
14006
- });
14007
- }
14008
- else if (err instanceof SchemaHasUncheckableOpsError) {
14009
- schemaErrors.uncheckableOps.push({
14010
- type: "database-type-config",
14011
- key: dbType,
14012
- operations: err.operations,
14013
- message: err.message,
14014
- });
14015
- }
14016
13457
  else if (err instanceof TomlParseError) {
14017
13458
  schemaErrors.tomlParse.push({
14018
13459
  type: "database-type-config",
@@ -14022,14 +13463,6 @@ What a push guarantees (two stages):
14022
13463
  message: err.message,
14023
13464
  });
14024
13465
  }
14025
- else if (err instanceof OpsExistError) {
14026
- schemaErrors.opsExist.push({
14027
- type: "database-type-config",
14028
- key: dbType,
14029
- opCount: err.opCount,
14030
- message: err.message,
14031
- });
14032
- }
14033
13466
  else {
14034
13467
  throw wrapEntityError(err, "update", "database type", dbType);
14035
13468
  }
@@ -14056,10 +13489,6 @@ What a push guarantees (two stages):
14056
13489
  createData.ruleSetId = typeConfig.ruleSetId;
14057
13490
  if (typeConfig.triggers)
14058
13491
  createData.triggers = typeConfig.triggers;
14059
- if (typeConfig.metadataAccess)
14060
- createData.metadataAccess = typeConfig.metadataAccess;
14061
- if (typeConfig.defaultAccess)
14062
- createData.defaultAccess = typeConfig.defaultAccess;
14063
13492
  if (typeConfig.autoPopulatedFields)
14064
13493
  createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
14065
13494
  if (typeConfig.timestamps)
@@ -14127,11 +13556,7 @@ What a push guarantees (two stages):
14127
13556
  // remote config and refuse to mutate it without a baseline:
14128
13557
  // surface it as a SKIPPED type (reusing the #813 blocked-type
14129
13558
  // disclosure + non-zero exit) so the user runs `config pull`
14130
- // first. The op sub-loop is skipped via `continue`, so no
14131
- // operations are created against the un-reconciled type.
14132
- const existingOpsRemote = await client
14133
- .listDatabaseTypeOperations(resolvedAppId, dbType)
14134
- .catch(() => []);
13559
+ // first.
14135
13560
  const hasNonDefaultField = (v) => {
14136
13561
  if (v === null || v === undefined)
14137
13562
  return false;
@@ -14141,12 +13566,8 @@ What a push guarantees (two stages):
14141
13566
  return Object.keys(v).length > 0;
14142
13567
  return true;
14143
13568
  };
14144
- const isMinimalAutoCreated = (!Array.isArray(existingOpsRemote) ||
14145
- existingOpsRemote.length === 0) &&
14146
- !hasNonDefaultField(existing?.ruleSetId) &&
13569
+ const isMinimalAutoCreated = !hasNonDefaultField(existing?.ruleSetId) &&
14147
13570
  !hasNonDefaultField(existing?.triggers) &&
14148
- !hasNonDefaultField(existing?.metadataAccess) &&
14149
- !hasNonDefaultField(existing?.defaultAccess) &&
14150
13571
  !hasNonDefaultField(existing?.autoPopulatedFields) &&
14151
13572
  !hasNonDefaultField(existing?.timestamps) &&
14152
13573
  !hasNonDefaultField(existing?.schema);
@@ -14180,13 +13601,6 @@ What a push guarantees (two stages):
14180
13601
  action: "update",
14181
13602
  key: dbType,
14182
13603
  });
14183
- for (const op of operations) {
14184
- changes.push({
14185
- type: "operation",
14186
- action: "create",
14187
- key: `${dbType}/${op.name}`,
14188
- });
14189
- }
14190
13604
  continue;
14191
13605
  }
14192
13606
  info(` reconciled auto-created database type \`${dbType}\``);
@@ -14211,7 +13625,7 @@ What a push guarantees (two stages):
14211
13625
  // "modified since last sync" CONFLICT (Q4). Subsequent
14212
13626
  // pushes carry the baseline just seeded above. Skip the
14213
13627
  // PATCH entirely when the TOML declares no type-level
14214
- // fields (an ops-only file) so we don't send an empty
13628
+ // fields so we don't send an empty
14215
13629
  // body, which the server rejects with HTTP 400 (#369).
14216
13630
  const reconcileData = computeTypeUpdateData();
14217
13631
  if (Object.keys(reconcileData).length > 0) {
@@ -14230,11 +13644,6 @@ What a push guarantees (two stages):
14230
13644
  : syncState.entities.databaseTypes[dbType].hasSchema;
14231
13645
  }
14232
13646
  }
14233
- // Operations declared in the TOML flow through the existing
14234
- // op sub-loop below. `existingOps` is derived from
14235
- // `existingEntry` (the pre-push sync-state snapshot, which is
14236
- // empty here), so every op is CREATEd — matching the empty
14237
- // auto-created type on the server.
14238
13647
  }
14239
13648
  else {
14240
13649
  throw wrapEntityError(err, "create", "database type", dbType);
@@ -14242,315 +13651,22 @@ What a push guarantees (two stages):
14242
13651
  }
14243
13652
  }
14244
13653
  }
14245
- // Process operations for this type. The baseline is the same one
14246
- // the validate pass used, so a type the server no longer has
14247
- // creates its ops rather than PATCHing rows that went with it.
14248
- const existingOps = existingOpsForType;
14249
- const tomlOpNames = new Set(operations.map((op) => op.name));
14250
- for (const op of operations) {
14251
- const existingOp = existingOps[op.name];
14252
- if (existingOp) {
14253
- // Update existing operation
14254
- changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
14255
- if (!options.dryRun) {
14256
- // #2731 B4 — the LIVE row's timestamp when the reconcile read
14257
- // it, the manifest's otherwise (degraded path).
14258
- const expectedOpModifiedAt = options.force
14259
- ? undefined
14260
- : liveOpsByName?.get(op.name)?.modifiedAt ??
14261
- existingOp.modifiedAt;
14262
- try {
14263
- const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
14264
- // Include `type` so the server can detect/apply
14265
- // type-in-place changes (issue #692). When the type
14266
- // matches the stored value, this is a no-op.
14267
- type: op.type,
14268
- modelName: op.modelName,
14269
- access: op.access,
14270
- definition: op.definition,
14271
- params: op.params,
14272
- }, expectedOpModifiedAt);
14273
- info(` Updated operation: ${dbType}/${op.name}`);
14274
- // Recorded even when the manifest had never seen this row:
14275
- // the update targeted a row live state named, and a state
14276
- // that stays silent about it would send this same push
14277
- // again on the next run.
14278
- const opsEntry = syncState?.entities?.databaseTypes?.[dbType];
14279
- if (opsEntry && updated?.modifiedAt) {
14280
- if (!opsEntry.operations)
14281
- opsEntry.operations = {};
14282
- opsEntry.operations[op.name] = { modifiedAt: updated.modifiedAt };
14283
- }
14284
- }
14285
- catch (err) {
14286
- if (err instanceof ConflictError) {
14287
- // #2731 B5 — an operation is part of this file's compared
14288
- // surface, so its 409 is reported like the type's own:
14289
- // against the record that is there NOW, with the field
14290
- // diff (`operations` as a unit) rather than two
14291
- // timestamps the operator cannot act on.
14292
- conflicts.push({
14293
- ...(await lateConflict("operation", `${dbType}/${op.name}`, err, expectedOpModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData)),
14294
- });
14295
- // The op was rejected, so this file did not fully land.
14296
- fileHadConflict = true;
14297
- }
14298
- else if (err instanceof SchemaRequiredError) {
14299
- schemaErrors.schemaRequired.push({
14300
- type: "operation",
14301
- key: `${dbType}/${op.name}`,
14302
- message: err.message,
14303
- });
14304
- // Issue #915 (defect b): a gate-rejected op means this
14305
- // file's state did NOT fully land — don't poison the
14306
- // content hash (see the contentHash write below).
14307
- fileHadGateRejectedOp = true;
14308
- }
14309
- else if (err instanceof OperationRefError) {
14310
- schemaErrors.opRefs.push({
14311
- type: "operation",
14312
- key: `${dbType}/${op.name}`,
14313
- refs: err.refs,
14314
- message: err.message,
14315
- });
14316
- fileHadGateRejectedOp = true;
14317
- }
14318
- else {
14319
- throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
14320
- }
14321
- }
14322
- }
14323
- }
14324
- else {
14325
- // Create new operation
14326
- changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
14327
- if (!options.dryRun) {
14328
- try {
14329
- const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
14330
- name: op.name,
14331
- type: op.type,
14332
- modelName: op.modelName,
14333
- access: op.access,
14334
- definition: op.definition,
14335
- params: op.params,
14336
- });
14337
- info(` Created operation: ${dbType}/${op.name}`);
14338
- if (syncState?.entities?.databaseTypes?.[dbType]) {
14339
- if (!syncState.entities.databaseTypes[dbType].operations) {
14340
- syncState.entities.databaseTypes[dbType].operations = {};
14341
- }
14342
- syncState.entities.databaseTypes[dbType].operations[op.name] = {
14343
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
14344
- };
14345
- }
14346
- }
14347
- catch (err) {
14348
- if (err instanceof SchemaRequiredError) {
14349
- schemaErrors.schemaRequired.push({
14350
- type: "operation",
14351
- key: `${dbType}/${op.name}`,
14352
- message: err.message,
14353
- });
14354
- // Issue #915 (defect b): a gate-rejected op means this
14355
- // file's state did NOT fully land — don't poison the
14356
- // content hash (see the contentHash write below).
14357
- fileHadGateRejectedOp = true;
14358
- }
14359
- else if (err instanceof OperationRefError) {
14360
- schemaErrors.opRefs.push({
14361
- type: "operation",
14362
- key: `${dbType}/${op.name}`,
14363
- refs: err.refs,
14364
- message: err.message,
14365
- });
14366
- fileHadGateRejectedOp = true;
14367
- }
14368
- else {
14369
- throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
14370
- }
14371
- }
14372
- }
14373
- }
14374
- }
14375
- // Delete operations that were removed from TOML
14376
- for (const existingOpName of Object.keys(existingOps)) {
14377
- if (!tomlOpNames.has(existingOpName)) {
14378
- changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
14379
- if (!options.dryRun) {
14380
- try {
14381
- await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
14382
- info(` Deleted operation: ${dbType}/${existingOpName}`);
14383
- if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
14384
- delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
14385
- }
14386
- }
14387
- catch (err) {
14388
- throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
14389
- }
14390
- }
14391
- }
14392
- }
14393
- // Process subscriptions for this type (issue #803). Mirrors the
14394
- // operations reconcile above, keyed on `subscriptionKey`:
14395
- // create new keys, PUT changed/existing keys, delete keys present
14396
- // in SyncState but absent from the TOML. Server errors (409 key
14397
- // collision, `#`-in-key 400, max-20 400, missing-required 400)
14398
- // are surfaced cleanly via `wrapEntityError` — named, not a crash.
14399
- const existingSubs = liveChildBaseline(liveSubsByName, syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) ??
14400
- ((typeExistsOnServer
14401
- ? syncState?.entities?.databaseTypes?.[dbType]?.subscriptions
14402
- : undefined) || {});
14403
- const tomlSubKeys = new Set(subscriptions.map((sub) => sub.subscriptionKey));
14404
- for (const sub of subscriptions) {
14405
- const subKey = sub.subscriptionKey;
14406
- const existingSub = existingSubs[subKey];
14407
- if (existingSub) {
14408
- // Update existing subscription (PUT — idempotent).
14409
- changes.push({ type: "subscription", action: "update", key: `${dbType}/${subKey}` });
14410
- if (!options.dryRun) {
14411
- // #2731 B4 — the LIVE row's timestamp when the reconcile read
14412
- // it, the manifest's otherwise (degraded path). Subscriptions
14413
- // are part of a converted type's push units, so a concurrent
14414
- // server edit has to be a 409 here too, not a silent
14415
- // overwrite.
14416
- const expectedSubModifiedAt = options.force
14417
- ? undefined
14418
- : liveSubsByName?.get(subKey)?.modifiedAt ??
14419
- existingSub.modifiedAt;
14420
- try {
14421
- const updated = await client.updateDatabaseTypeSubscription(resolvedAppId, dbType, subKey, {
14422
- displayName: sub.displayName,
14423
- modelName: sub.modelName,
14424
- filter: sub.filter,
14425
- access: sub.access,
14426
- description: sub.description,
14427
- select: sub.select,
14428
- emit: sub.emit,
14429
- params: sub.params,
14430
- status: sub.status,
14431
- }, expectedSubModifiedAt);
14432
- info(` Updated subscription: ${dbType}/${subKey}`);
14433
- // Gate the SyncState write on a server-returned
14434
- // `modifiedAt`, mirroring the operations precedent above
14435
- // (don't fall back to a fresh local timestamp) — and record
14436
- // a row the manifest had never seen, for the same reason.
14437
- const subsEntry = syncState?.entities?.databaseTypes?.[dbType];
14438
- if (subsEntry && updated?.modifiedAt) {
14439
- if (!subsEntry.subscriptions)
14440
- subsEntry.subscriptions = {};
14441
- subsEntry.subscriptions[subKey] = { modifiedAt: updated.modifiedAt };
14442
- }
14443
- }
14444
- catch (err) {
14445
- if (err instanceof ConflictError) {
14446
- conflicts.push({
14447
- ...(await lateConflict("subscription", `${dbType}/${subKey}`, err, expectedSubModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData)),
14448
- });
14449
- // The subscription was rejected, so this file did not
14450
- // fully land — the baseline stamp below must not claim it
14451
- // did.
14452
- fileHadConflict = true;
14453
- }
14454
- else {
14455
- throw wrapEntityError(err, "update", "subscription", `${dbType}/${subKey}`);
14456
- }
14457
- }
14458
- }
14459
- }
14460
- else {
14461
- // Create new subscription (POST).
14462
- changes.push({ type: "subscription", action: "create", key: `${dbType}/${subKey}` });
14463
- if (!options.dryRun) {
14464
- try {
14465
- const created = await client.createDatabaseTypeSubscription(resolvedAppId, dbType, {
14466
- subscriptionKey: subKey,
14467
- displayName: sub.displayName,
14468
- modelName: sub.modelName,
14469
- filter: sub.filter,
14470
- access: sub.access,
14471
- description: sub.description,
14472
- select: sub.select,
14473
- emit: sub.emit,
14474
- params: sub.params,
14475
- status: sub.status,
14476
- });
14477
- info(` Created subscription: ${dbType}/${subKey}`);
14478
- if (syncState?.entities?.databaseTypes?.[dbType]) {
14479
- if (!syncState.entities.databaseTypes[dbType].subscriptions) {
14480
- syncState.entities.databaseTypes[dbType].subscriptions = {};
14481
- }
14482
- syncState.entities.databaseTypes[dbType].subscriptions[subKey] = {
14483
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
14484
- };
14485
- }
14486
- }
14487
- catch (err) {
14488
- throw wrapEntityError(err, "create", "subscription", `${dbType}/${subKey}`);
14489
- }
14490
- }
14491
- }
14492
- }
14493
- // Delete subscriptions that were removed from TOML (hard-delete on
14494
- // current `main`, PR #787 — frees the key for reuse). A 409 on
14495
- // recreate is impossible here since we delete first; the pull-side
14496
- // "active only" filter (controller `list` excludes archived)
14497
- // agrees because hard-delete leaves no archived row behind.
14498
- for (const existingSubKey of Object.keys(existingSubs)) {
14499
- if (!tomlSubKeys.has(existingSubKey)) {
14500
- changes.push({ type: "subscription", action: "delete", key: `${dbType}/${existingSubKey}` });
14501
- if (!options.dryRun) {
14502
- try {
14503
- await client.deleteDatabaseTypeSubscription(resolvedAppId, dbType, existingSubKey);
14504
- info(` Deleted subscription: ${dbType}/${existingSubKey}`);
14505
- if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) {
14506
- delete syncState.entities.databaseTypes[dbType].subscriptions[existingSubKey];
14507
- }
14508
- }
14509
- catch (err) {
14510
- throw wrapEntityError(err, "delete", "subscription", `${dbType}/${existingSubKey}`);
14511
- }
14512
- }
14513
- }
14514
- }
14515
- // Refresh the file's content hash after processing operations +
14516
- // subscriptions (issue #803, behavior g). The type-config update
14517
- // block only refreshes the hash when a type-level field changed
14518
- // (its `updateData` was non-empty); an operations- or
14519
- // subscriptions-only edit left a stale hash, so the next push
14520
- // would re-PUT every op/sub instead of skipping the unchanged
14521
- // file. Recompute here so a truly-unchanged subsequent push skips
14522
- // the whole file via `shouldPushFile`. `--force` bypasses that
14523
- // skip, so forced re-pushes are unaffected.
14524
- //
14525
- // Issue #915 (defect b): when the gate rejected an op for this
14526
- // file, do NOT record a matching content hash. The db-type itself
14527
- // may have been created (the push is non-transactional), but the
14528
- // blocked op never landed — a matching hash would make
14529
- // `shouldPushFile` short-circuit the next push and hide the missing
14530
- // op as "in sync". Clear the hash instead of writing it: an empty
14531
- // hash forces `shouldPushFile` to re-evaluate the file, so a
14532
- // subsequent `config push --dry-run` still surfaces the op as drift
14533
- // and a corrected re-push converges without manual file edits. We
14534
- // clear (rather than merely skip) so the fresh-type create path's
14535
- // earlier optimistic hash write (see `createDatabaseTypeConfig`
14536
- // above) is also invalidated.
14537
- //
14538
- // #2731 B4: the semantic baseline is refreshed here too, and
14539
- // cleared alongside the byte hash when an op was gate-rejected —
14540
- // a baseline claiming convergence would make the next run read the
14541
- // missing op as server drift rather than as work still to do. A
14542
- // 409 on the type or one of its ops means the same thing: the
14543
- // server refused the write, so this file did not land either.
13654
+ // Refresh the file's content hash and semantic baseline (#2731 B4)
13655
+ // after the type's writes. The type-config update block only
13656
+ // refreshes the hash when a type-level field changed; recomputing
13657
+ // here lets a truly-unchanged next push skip the file. A 409 means
13658
+ // the server refused the write, so this file did not land: clear
13659
+ // both rather than claim convergence.
14544
13660
  if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
14545
- if (fileHadGateRejectedOp || fileHadConflict) {
13661
+ if (fileHadConflict) {
14546
13662
  syncState.entities.databaseTypes[dbType].contentHash = "";
14547
13663
  syncState.entities.databaseTypes[dbType].semanticHash = undefined;
14548
13664
  }
14549
13665
  else {
14550
13666
  syncState.entities.databaseTypes[dbType].contentHash =
14551
13667
  computeFileHash(filePath);
14552
- // B4 — the baseline reads back what the server now holds, type
14553
- // and children together. Taking it from the local file would
13668
+ // B4 — the baseline reads back what the server now holds.
13669
+ // Taking it from the local file would
14554
13670
  // stamp a value the handlers normalized as the state both sides
14555
13671
  // agreed on, and the next local edit would report a conflict
14556
13672
  // against a difference this push itself created. A read that
@@ -14582,6 +13698,9 @@ What a push guarantees (two stages):
14582
13698
  // import that escapes the config tree aborts this function before
14583
13699
  // anything is sent for it (behavior 12).
14584
13700
  const functionsPushDir = join(configDir, "functions");
13701
+ // #4437 — functions this push failed to apply that the server does not
13702
+ // hold either, so the deferred agent leg can name them as the cause.
13703
+ const unappliedFunctionKeys = new Set();
14585
13704
  if (existsSync(functionsPushDir)) {
14586
13705
  // #3182 — the typed handle. Written BEFORE the bundles are built, so
14587
13706
  // an author who runs `config push` after editing a schema gets the
@@ -14676,6 +13795,15 @@ What a push guarantees (two stages):
14676
13795
  withDerivedIdentity(tomlData, "function", { key });
14677
13796
  const existingEntry = syncState?.entities?.functions?.[key];
14678
13797
  const liveFunction = liveFunctions.get(key);
13798
+ // An archived row is a tombstone: the server refuses an agent
13799
+ // naming it exactly as it refuses one naming no function at all.
13800
+ const serverHoldsFunction = liveFunction
13801
+ ? String(liveFunction.status ?? "active") !== "archived"
13802
+ : !functionsLiveOk && !!existingEntry?.id;
13803
+ const markUnapplied = () => {
13804
+ if (!serverHoldsFunction)
13805
+ unappliedFunctionKeys.add(key);
13806
+ };
14679
13807
  // Build first: nothing is sent for a function whose code does not
14680
13808
  // compile or whose imports leave the tree.
14681
13809
  let push;
@@ -14695,6 +13823,7 @@ What a push guarantees (two stages):
14695
13823
  key,
14696
13824
  message: err.message,
14697
13825
  });
13826
+ markUnapplied();
14698
13827
  continue;
14699
13828
  }
14700
13829
  // #3455 (D3455-004) — the project declares a document schema this
@@ -14721,6 +13850,7 @@ What a push guarantees (two stages):
14721
13850
  key,
14722
13851
  message: failure.message,
14723
13852
  });
13853
+ markUnapplied();
14724
13854
  continue;
14725
13855
  }
14726
13856
  }
@@ -14838,6 +13968,7 @@ What a push guarantees (two stages):
14838
13968
  // the same contract `applyFailures` gives every other type; a throw
14839
13969
  // here would let one refusal block a whole repository.
14840
13970
  let functionId = liveFunction?.functionId ?? existingEntry?.id;
13971
+ let createdThisPush = false;
14841
13972
  let pushedVersion;
14842
13973
  try {
14843
13974
  if (!functionId) {
@@ -14862,6 +13993,7 @@ What a push guarantees (two stages):
14862
13993
  return existing;
14863
13994
  });
14864
13995
  functionId = created.functionId;
13996
+ createdThisPush = true;
14865
13997
  info(` Created function: ${key}`);
14866
13998
  }
14867
13999
  else if (headerAction !== "skip") {
@@ -14884,6 +14016,11 @@ What a push guarantees (two stages):
14884
14016
  key,
14885
14017
  message: String(err?.message ?? err),
14886
14018
  });
14019
+ // A refused create, or a refused push to a row the server does
14020
+ // not hold live (an archived tombstone). A row this push created
14021
+ // is held even when its version was refused.
14022
+ if (!createdThisPush)
14023
+ markUnapplied();
14887
14024
  continue;
14888
14025
  }
14889
14026
  // #3455 (D3455-010) — did the server keep the document schema it
@@ -14946,8 +14083,15 @@ What a push guarantees (two stages):
14946
14083
  }
14947
14084
  // #3798 — the agent prompts, now that every function they name that
14948
14085
  // this push applies is on the server (see `pushPromptFiles`).
14086
+ // #4437 — except an agent naming a function that failed above and that
14087
+ // the server does not hold: the server would refuse it as naming a
14088
+ // function the app does not have, hiding the function's own failure.
14949
14089
  if (deferredAgentPromptFiles.length > 0) {
14950
- await pushPromptFiles(deferredAgentPromptFiles);
14090
+ const { apply, held } = holdAgentPromptFilesForUnappliedFunctions(promptsDir, deferredAgentPromptFiles, unappliedFunctionKeys);
14091
+ for (const { key, message } of held) {
14092
+ applyFailures.push({ type: "prompt", key, message });
14093
+ }
14094
+ await pushPromptFiles(apply);
14951
14095
  }
14952
14096
  // Process group type configs
14953
14097
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
@@ -17288,12 +16432,9 @@ What a push guarantees (two stages):
17288
16432
  // Blob buckets — #2880: the list row is the record. #4116: a bucket
17289
16433
  // names its rule set, so its rows take the type configs' gate.
17290
16434
  compareByDefinition("blob-bucket", "blobBuckets", localBlobBucketsParsed, new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b])), blobBucketsOutcomeDiff.ok && ruleSetsOk);
17291
- // database-type-configs: the list carries only the type header, so operations
17292
- // and subscriptions are fetched per type (mirroring pull) before the
17293
- // content comparison. A per-type fetch failure degrades that one row to
17294
- // "content not compared" rather than aborting the whole diff (#1485
17295
- // edge case). Both operations AND subscriptions are included so an
17296
- // op-only or subscription-only change is not falsely reported as Synced.
16435
+ // database-type-configs: the listing carries the whole type, so each
16436
+ // row compares on its own. (#3189 — the operations and subscriptions
16437
+ // that used to be fetched per type beside it are retired.)
17297
16438
  for (const [key, parsed] of localDatabaseTypesParsed) {
17298
16439
  const remote = remoteDatabaseTypes.get(key);
17299
16440
  if (!remote) {
@@ -17306,23 +16447,10 @@ What a push guarantees (two stages):
17306
16447
  hint = "content not compared (rule sets unavailable)";
17307
16448
  }
17308
16449
  else {
17309
- const [opsOutcome, subsOutcome] = await Promise.all([
17310
- attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, key)),
17311
- attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, key)),
17312
- ]);
17313
- if (!opsOutcome.ok || !subsOutcome.ok) {
17314
- hint = "content not compared (fetch failed)";
17315
- }
17316
- else {
17317
- const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
17318
- const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
17319
- // The shared verdict (#3267): a Modified row names the
17320
- // operation, subscription, model field or `[type]` scalar it
17321
- // differs in, and a comparison that throws degrades this one
17322
- // row to "not compared". The ops/subscriptions are the remote
17323
- // side's extra only — the file already carries its own.
17324
- ({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
17325
- }
16450
+ // The shared verdict (#3267): a Modified row names the model field
16451
+ // or `[type]` scalar it differs in, and a comparison that throws
16452
+ // degrades this one row to "not compared".
16453
+ ({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, {}));
17326
16454
  }
17327
16455
  differences.push({ type: "database-type-config", key, status, hint });
17328
16456
  }
@@ -17467,18 +16595,11 @@ What a push guarantees (two stages):
17467
16595
  // verbatim, so the two commands read identically — replaces whatever
17468
16596
  // the comparison had to say about the file.
17469
16597
  // #3320 — the function checks join them. Same posture, same messages:
17470
- // a function file whose `entry`, mode, triggers or webhook
17471
- // signing-secret the push preflight refuses is not a row this command
17472
- // calls Synced, Modified or "local only — new".
16598
+ // a function file whose `entry`, retired runtime keys (#3188),
16599
+ // triggers or webhook signing-secret the push preflight refuses is not
16600
+ // a row this command calls Synced, Modified or "local only — new".
17473
16601
  // Merged first: a file both collectors reject keeps every message push
17474
16602
  // prints, rather than one collector's hint overwriting the other's.
17475
- // #3482 — the same advisory `config push` prints, so an author sees
17476
- // the retired key before they push rather than only during it. Not a
17477
- // row status: the file is pushable, and saying otherwise would make
17478
- // `config diff` disagree with the push it predicts.
17479
- for (const message of collectFunctionTomlIgnoredKeyWarnings(configDir)) {
17480
- warn(` ${message}`);
17481
- }
17482
16603
  for (const rejected of mergeConfigFileValidationErrors([
17483
16604
  ...collectConfigFileValidationErrors(configDir),
17484
16605
  ...collectFunctionTomlValidationErrors(configDir),
@@ -17777,8 +16898,13 @@ What a push guarantees (two stages):
17777
16898
  // next, so an in-loop abort would leave earlier files rewritten. Every
17778
16899
  // offending file is reported, so the author fixes the tree once.
17779
16900
  const retiredKeyErrors = [];
16901
+ const databaseTypeSurface = CONFIG_SURFACES.find((surface) => surface.label === "database-type-config");
17780
16902
  for (const file of dbFiles) {
17781
- const message = configUnknownKeyError(`database-type-configs/${file}`, parseTomlFile(join(dbTypesDir, file)), DATABASE_TYPE_TABLE);
16903
+ const tomlData = parseTomlFile(join(dbTypesDir, file));
16904
+ // #3189 — a retired TABLE too (`[[operations]]`, `[[subscriptions]]`):
16905
+ // the rewrite below would otherwise drop it without a word.
16906
+ retiredKeyErrors.push(...configDocumentShapeErrors(`database-type-configs/${file}`, tomlData, databaseTypeSurface));
16907
+ const message = configUnknownKeyError(`database-type-configs/${file}`, tomlData, DATABASE_TYPE_TABLE);
17782
16908
  if (message)
17783
16909
  retiredKeyErrors.push(message);
17784
16910
  }
@@ -17796,30 +16922,9 @@ What a push guarantees (two stages):
17796
16922
  const filePath = join(dbTypesDir, file);
17797
16923
  const rawBefore = readFileSync(filePath, "utf-8");
17798
16924
  const tomlData = parseTomlFile(filePath);
17799
- // `accessRule`/`access` conflicts surface as a clear error here too,
17800
- // rather than silently dropping the field during a migrate rewrite.
17801
- let typeConfig;
17802
- let operations;
17803
- let subscriptions;
17804
- try {
17805
- ({ typeConfig, operations, subscriptions } =
17806
- parseDatabaseTypeToml(tomlData));
17807
- }
17808
- catch (err) {
17809
- if (err instanceof SubscriptionAccessKeyConflictError) {
17810
- error(` ${file}: ${err.message}`);
17811
- error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
17812
- process.exit(1);
17813
- }
17814
- throw err;
17815
- }
17816
- // Force-native: ignore existing hints so every TOMLable field
17817
- // ends up in nested-table form. Subscriptions are preserved on the
17818
- // rewrite (issue #803) so migrate-toml never drops them.
17819
- const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
17820
- defaultForm: "native",
16925
+ const { typeConfig } = parseDatabaseTypeToml(tomlData);
16926
+ const rewritten = serializeDatabaseType(typeConfig, ruleSetIdToName, {
17821
16927
  logger: (msg) => info(` ${msg}`),
17822
- subscriptions,
17823
16928
  });
17824
16929
  if (rewritten === rawBefore) {
17825
16930
  unchangedCount++;