primitive-admin 1.2.0-alpha.4 → 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 (114) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +17 -0
  2. package/dist/src/commands/auth.js +7 -4
  3. package/dist/src/commands/auth.js.map +1 -1
  4. package/dist/src/commands/connections.js +11 -12
  5. package/dist/src/commands/connections.js.map +1 -1
  6. package/dist/src/commands/database-type-configs.js +2 -76
  7. package/dist/src/commands/database-type-configs.js.map +1 -1
  8. package/dist/src/commands/database-types.js +313 -13
  9. package/dist/src/commands/database-types.js.map +1 -1
  10. package/dist/src/commands/databases.d.ts +9 -0
  11. package/dist/src/commands/databases.js +67 -367
  12. package/dist/src/commands/databases.js.map +1 -1
  13. package/dist/src/commands/documents.d.ts +32 -2
  14. package/dist/src/commands/documents.js +281 -685
  15. package/dist/src/commands/documents.js.map +1 -1
  16. package/dist/src/commands/functions.js +2 -2
  17. package/dist/src/commands/functions.js.map +1 -1
  18. package/dist/src/commands/llm.js +6 -9
  19. package/dist/src/commands/llm.js.map +1 -1
  20. package/dist/src/commands/settings.d.ts +9 -12
  21. package/dist/src/commands/settings.js +79 -31
  22. package/dist/src/commands/settings.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +31 -81
  24. package/dist/src/commands/sync.js +228 -1113
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/lib/api-client.d.ts +30 -199
  27. package/dist/src/lib/api-client.js +31 -297
  28. package/dist/src/lib/api-client.js.map +1 -1
  29. package/dist/src/lib/codegen-shared/generatedFiles.d.ts +6 -7
  30. package/dist/src/lib/codegen-shared/generatedFiles.js +2 -2
  31. package/dist/src/lib/codegen-shared/generatedFiles.js.map +1 -1
  32. package/dist/src/lib/codegen-shared/prettierStable.d.ts +1 -1
  33. package/dist/src/lib/codegen-shared/prettierStable.js +1 -1
  34. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.d.ts +2 -2
  35. package/dist/src/lib/codegen-shared/resolveCodegenSourceDir.js +2 -2
  36. package/dist/src/lib/config-object-descriptor.js +1 -5
  37. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  38. package/dist/src/lib/config-surface.d.ts +2 -1
  39. package/dist/src/lib/config-surface.js +4 -1
  40. package/dist/src/lib/config-surface.js.map +1 -1
  41. package/dist/src/lib/connections-list.d.ts +10 -0
  42. package/dist/src/lib/connections-list.js +18 -0
  43. package/dist/src/lib/connections-list.js.map +1 -0
  44. package/dist/src/lib/csv.d.ts +8 -9
  45. package/dist/src/lib/csv.js +3 -4
  46. package/dist/src/lib/csv.js.map +1 -1
  47. package/dist/src/lib/db-codegen/dbNaming.d.ts +2 -1
  48. package/dist/src/lib/db-codegen/dbNaming.js +2 -1
  49. package/dist/src/lib/db-codegen/dbNaming.js.map +1 -1
  50. package/dist/src/lib/db-codegen/dbTsTypes.d.ts +2 -2
  51. package/dist/src/lib/db-codegen/dbTsTypes.js +2 -2
  52. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +59 -1
  53. package/dist/src/lib/db-codegen/dbTypeIR.js +12 -1
  54. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  55. package/dist/src/lib/document-export-record-log.d.ts +57 -0
  56. package/dist/src/lib/document-export-record-log.js +215 -0
  57. package/dist/src/lib/document-export-record-log.js.map +1 -0
  58. package/dist/src/lib/document-ingest-artifact.d.ts +13 -2
  59. package/dist/src/lib/document-ingest-artifact.js +7 -3
  60. package/dist/src/lib/document-ingest-artifact.js.map +1 -1
  61. package/dist/src/lib/document-ingest-rows.d.ts +16 -0
  62. package/dist/src/lib/document-ingest-rows.js +17 -0
  63. package/dist/src/lib/document-ingest-rows.js.map +1 -1
  64. package/dist/src/lib/document-ingest.d.ts +22 -0
  65. package/dist/src/lib/document-ingest.js +33 -0
  66. package/dist/src/lib/document-ingest.js.map +1 -1
  67. package/dist/src/lib/function-triggers.d.ts +3 -15
  68. package/dist/src/lib/function-triggers.js +10 -72
  69. package/dist/src/lib/function-triggers.js.map +1 -1
  70. package/dist/src/lib/generated-allowlist.js +0 -20
  71. package/dist/src/lib/generated-allowlist.js.map +1 -1
  72. package/dist/src/lib/generated-config-surfaces.d.ts +40 -2
  73. package/dist/src/lib/generated-config-surfaces.js +160 -94
  74. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  75. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  76. package/dist/src/lib/generated-sdk-types.js +1 -1
  77. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  78. package/dist/src/lib/login-scope-lines.d.ts +15 -0
  79. package/dist/src/lib/login-scope-lines.js +22 -0
  80. package/dist/src/lib/login-scope-lines.js.map +1 -0
  81. package/dist/src/lib/paginate.d.ts +0 -5
  82. package/dist/src/lib/paginate.js +0 -5
  83. package/dist/src/lib/paginate.js.map +1 -1
  84. package/dist/src/lib/refresh-admin-credentials.d.ts +7 -0
  85. package/dist/src/lib/refresh-admin-credentials.js +16 -4
  86. package/dist/src/lib/refresh-admin-credentials.js.map +1 -1
  87. package/dist/src/lib/root-import-decision.d.ts +15 -14
  88. package/dist/src/lib/root-import-decision.js +44 -51
  89. package/dist/src/lib/root-import-decision.js.map +1 -1
  90. package/dist/src/lib/snapshot-build-rows.d.ts +21 -0
  91. package/dist/src/lib/snapshot-build-rows.js +22 -0
  92. package/dist/src/lib/snapshot-build-rows.js.map +1 -1
  93. package/dist/src/lib/snapshot-build.js +8 -3
  94. package/dist/src/lib/snapshot-build.js.map +1 -1
  95. package/dist/src/lib/swift-codegen/banners.d.ts +34 -7
  96. package/dist/src/lib/swift-codegen/banners.js +37 -7
  97. package/dist/src/lib/swift-codegen/banners.js.map +1 -1
  98. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +3 -3
  99. package/dist/src/lib/swift-codegen/functionGenerator.js +22 -17
  100. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  101. package/dist/src/lib/swift-codegen/generator.js +27 -27
  102. package/dist/src/lib/swift-codegen/generator.js.map +1 -1
  103. package/dist/src/lib/toml-database-config.d.ts +14 -75
  104. package/dist/src/lib/toml-database-config.js +14 -305
  105. package/dist/src/lib/toml-database-config.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.js +15 -1
  107. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  108. package/package.json +3 -5
  109. package/dist/src/lib/generated-workflow-model-fields.d.ts +0 -20
  110. package/dist/src/lib/generated-workflow-model-fields.js +0 -53
  111. package/dist/src/lib/generated-workflow-model-fields.js.map +0 -1
  112. package/dist/src/lib/workflow-field-descriptor.d.ts +0 -91
  113. package/dist/src/lib/workflow-field-descriptor.js +0 -153
  114. 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,7 +34,7 @@ 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
@@ -1915,33 +1914,33 @@ export function serializePrompt(prompt, logger) {
1915
1914
  return stringifyConfigToml(data);
1916
1915
  }
1917
1916
  /**
1918
- * 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).
1919
1919
  *
1920
1920
  * Returns `null` when a rule is declared — and for a `runAs = "system"`
1921
1921
  * workflow, which rejects any rule and cannot be started by a member at all.
1922
- * Otherwise it names the resource, the consequence, and the one-line restore,
1923
- * because after the #2652 flip a ruleless prompt or caller workflow denies
1924
- * 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
1925
  *
1926
- * Both kinds now push `accessRule` as value-or-`null` — the prompt since #1567,
1927
- * the workflow since #2743's always-send conversion — so for either one an
1928
- * omitted key really does leave the resource ruleless on the server. The
1929
- * workflow wording says so explicitly, because it used to say the opposite
1930
- * (the update path preserved a stored rule) and an operator who learned that
1931
- * caveat needs to see it retracted.
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.
1929
+ *
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.
1932
1935
  */
1933
1936
  export function missingAccessRuleWarning(opts) {
1934
1937
  if (typeof opts.accessRule === "string" && opts.accessRule.trim() !== "") {
1935
1938
  return null;
1936
1939
  }
1937
- if (opts.kind === "workflow" && opts.runAs === "system")
1940
+ if (opts.runAs === "system")
1938
1941
  return null;
1939
1942
  const restore = `Add accessRule = "true" to allow any app member, or a CEL rule ` +
1940
1943
  `naming who may call it.`;
1941
- if (opts.kind === "prompt") {
1942
- return (`prompt '${opts.key}' has no accessRule — all non-admin executions are ` +
1943
- `denied. ${restore}`);
1944
- }
1945
1944
  return (`workflow '${opts.key}' declares no accessRule — pushing this file clears ` +
1946
1945
  `any rule stored server-side, and a workflow with no stored rule denies ` +
1947
1946
  `all non-admin starts. ${restore}`);
@@ -2328,7 +2327,16 @@ export function configUnknownKeyError(filePath, tomlData, table) {
2328
2327
  */
2329
2328
  export function configDocumentShapeErrors(filePath, tomlData, surface) {
2330
2329
  const errors = [];
2331
- 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));
2332
2340
  if (unknown.length > 0) {
2333
2341
  errors.push(unrecognizedPushTablesMessage(filePath, unknown));
2334
2342
  }
@@ -2563,56 +2571,19 @@ export async function runConfigPushPreflight(input) {
2563
2571
  }
2564
2572
  }
2565
2573
  }
2566
- // Validate all database-type TOMLs (issue #803 subscription
2567
- // 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.)
2568
2578
  const preflightDbTypesDir = join(configDir, "database-type-configs");
2569
2579
  if (existsSync(preflightDbTypesDir)) {
2570
2580
  for (const file of selectFiles("database-type-config", preflightDbTypesDir, readdirSync(preflightDbTypesDir).filter((f) => f.endsWith(".toml")))) {
2571
2581
  const filePath = join(preflightDbTypesDir, file);
2572
- let rawToml;
2573
- let tomlData;
2574
- try {
2575
- rawToml = readFileSync(filePath, "utf-8");
2576
- tomlData = parseTomlFile(filePath);
2577
- }
2578
- catch (err) {
2579
- preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2580
- continue;
2581
- }
2582
- let operations = [];
2583
2582
  try {
2584
- ({ operations } = parseDatabaseTypeToml(tomlData));
2583
+ parseDatabaseTypeToml(parseTomlFile(filePath));
2585
2584
  }
2586
2585
  catch (err) {
2587
- if (err instanceof SubscriptionAccessKeyConflictError) {
2588
- preflightValidationErrors.push(` ${file}: ${err.message}`);
2589
- continue;
2590
- }
2591
2586
  preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
2592
- continue;
2593
- }
2594
- const validation = validateOperations({
2595
- filePath,
2596
- rawToml,
2597
- operations,
2598
- });
2599
- for (const e of validation.errors) {
2600
- preflightValidationErrors.push(` ${formatIssue(e)}`);
2601
- }
2602
- // Issue #1422: non-fatal deprecation notice. A `celContextAccess`
2603
- // / `metadataAccess` gate on a database type config is a single CEL
2604
- // expression that gates BOTH read and update of the database CEL
2605
- // context — granting read implicitly grants update. Steer operators
2606
- // to resource metadata categories (separate readRule/writeRule).
2607
- // This is advisory only: the push continues and the runtime gate is
2608
- // unchanged. It fires only when the deprecated key is actually
2609
- // present, so metadata-category-only configs never trip it.
2610
- const typeSection = tomlData.type || {};
2611
- if (typeSection.celContextAccess || typeSection.metadataAccess) {
2612
- const usedKey = typeSection.celContextAccess
2613
- ? "celContextAccess"
2614
- : "metadataAccess";
2615
- 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.`);
2616
2587
  }
2617
2588
  }
2618
2589
  }
@@ -3177,6 +3148,41 @@ export function partitionPromptFilesForPush(promptsDir, files) {
3177
3148
  }
3178
3149
  return { beforeFunctions, afterFunctions };
3179
3150
  }
3151
+ /**
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
+ }
3180
3186
  /**
3181
3187
  * The kind of the prompt a `prompts/<key>.tests/` sidecar belongs to, read
3182
3188
  * from the prompt's own file — `undefined` for another block type, or when the
@@ -3663,26 +3669,15 @@ export function serializeWorkflow(workflow, draft, configs, logger) {
3663
3669
  };
3664
3670
  return stringifyConfigToml(data);
3665
3671
  }
3666
- // Database config serialization helpers
3667
- //
3668
- // Native-TOML form (issue #752): when emitting a database-type config, we
3669
- // prefer nested `[operations.definition]` tables and `[[operations.params]]`
3670
- // rows over JSON-strings stuffed into a single field. The form is sticky
3671
- // per file — if the existing file uses the legacy JSON-string form, we
3672
- // preserve it so we don't generate surprise diffs. New files default to
3673
- // native. The round-trip serializes with `smol-toml` (TOML 1.0), so
3674
- // heterogeneous arrays emit natively (issue #1450); only shapes that TOML
3675
- // genuinely can't represent without data loss (null / undefined / an array
3676
- // containing null) fall back to a JSON string per field with a log message.
3677
- //
3678
- // Issue #666: when the type has a stored `schema` (raw TOML of `[models.*]`
3679
- // blocks), `buildDatabaseTypeTomlData` parses + merges it into the same TOML
3680
- // object so the result round-trips as `[models.<Name>.fields.<field>]`.
3681
- 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 = {}) {
3682
3677
  // #2644 — name any server key this CLI version does not know, rather than
3683
3678
  // dropping it silently on the way into the file.
3684
3679
  warnUnrecognizedServerKeys(typeConfig, DATABASE_TYPE_TABLE, `database-type-configs/${typeConfig?.databaseType}.toml`, options.logger);
3685
- const data = buildDatabaseTypeTomlData(typeConfig, operations, ruleSetIdToName, options);
3680
+ const data = buildDatabaseTypeTomlData(typeConfig, ruleSetIdToName);
3686
3681
  return stringifyConfigToml(data);
3687
3682
  }
3688
3683
  /**
@@ -3902,10 +3897,9 @@ export function dbTypeOwnedScalars(table = DATABASE_TYPE_TABLE) {
3902
3897
  ];
3903
3898
  }
3904
3899
  export const DB_TYPE_OWNED_SCALARS = dbTypeOwnedScalars();
3905
- // User-facing TOML key names for the "Cleared <field>" push output. The wire
3906
- // 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.
3907
3902
  const DB_TYPE_FIELD_LABELS = {
3908
- metadataAccess: "celContextAccess",
3909
3903
  ruleSetId: "ruleSetName",
3910
3904
  };
3911
3905
  /** The user-facing TOML key for a wire field (for push output). */
@@ -3978,13 +3972,6 @@ export function ownedScalarsBeingCleared(updateData, serverConfig) {
3978
3972
  * still comes from the handler that knows what is wrong with it.
3979
3973
  */
3980
3974
  function canonicalizeDatabaseTypeConfig(typeConfig) {
3981
- // CEL fields: trimmed by the handler, and a whitespace-only expression is
3982
- // stored as none at all — which the projection reads as unset either way.
3983
- for (const key of ["defaultAccess", "metadataAccess"]) {
3984
- if (typeof typeConfig[key] === "string") {
3985
- typeConfig[key] = typeConfig[key].trim();
3986
- }
3987
- }
3988
3975
  // #750 — `"user.userId"` is shorthand for `{ value: "user.userId", on:
3989
3976
  // ["create"] }`, and a scalar `on` for a one-element list.
3990
3977
  const auto = typeConfig.autoPopulatedFields;
@@ -4031,70 +4018,15 @@ function canonicalizeDatabaseTypeConfig(typeConfig) {
4031
4018
  }
4032
4019
  }
4033
4020
  }
4034
- /**
4035
- * What this push's operation set will BE, stated to the server (issues #813,
4036
- * #1336, #2732).
4037
- *
4038
- * The type-config PATCH runs its gates BEFORE the same push's operation calls,
4039
- * so without a look-ahead every gate judges the pre-push operations: a push
4040
- * that removes a model is blocked by the very references it is deleting. The
4041
- * file IS the target state (config-as-code), so the claims are derived from it:
4042
- *
4043
- * - `finalOpNames` — the names the type ends with. Both other claims are
4044
- * verified against it server-side, which is what keeps them from being a
4045
- * gate bypass for a direct-API caller.
4046
- * - `pendingOpDeletes` — its complement among the ops last sync recorded, so
4047
- * an op this push deletes is excluded from the schema / OPS_EXIST gates.
4048
- * - `pendingOpUpserts` — the post-push BODY of every declared op, so the
4049
- * schema-edit gate lints a rewritten op as rewritten and a model removal
4050
- * plus its operation rewrites lands in one push (#2732).
4051
- * - `pendingOpUpdates` — the rule-only form (`access`/`params`) the manifest
4052
- * re-lint reads (#1336).
4053
- *
4054
- * All four are built here and sent together, on the dry-run and on the real
4055
- * PATCH alike. They are never conditioned on the push having a deletion: a push
4056
- * that only REWRITES operations is exactly the case #2732 exists for.
4057
- */
4058
- export function buildPendingOpClaims(operations, existingOpNames) {
4059
- const finalOpNames = operations.map((op) => op.name);
4060
- const declared = new Set(finalOpNames);
4061
- return {
4062
- pendingOpDeletes: existingOpNames.filter((n) => !declared.has(n)),
4063
- finalOpNames,
4064
- pendingOpUpdates: operations.map((op) => ({
4065
- name: op.name,
4066
- access: typeof op.access === "string" ? op.access : null,
4067
- params: op.params ?? null,
4068
- })),
4069
- // The same body the op create/update call sends, so the gate lints what
4070
- // this push is about to store.
4071
- pendingOpUpserts: operations.map((op) => ({
4072
- name: op.name,
4073
- type: op.type,
4074
- modelName: op.modelName,
4075
- access: op.access,
4076
- definition: op.definition,
4077
- params: op.params,
4078
- })),
4079
- };
4080
- }
4081
4021
  export function parseDatabaseTypeToml(tomlData) {
4082
4022
  const typeSection = tomlData.type || {};
4083
4023
  // #2644 — the `[type]` field list comes from the vendored definition, so a
4084
4024
  // scalar added there is read off the file with no edit here. The values the
4085
4025
  // handler rewrites before storing — `autoPopulatedFields` (#750), its
4086
- // `timestamps` sibling (#748), the CEL access expressions — are canonicalized
4026
+ // `timestamps` sibling (#748) — are canonicalized
4087
4027
  // below so the comparison reads the spelling the server keeps (#2731 B1).
4088
4028
  const typeConfig = dropUndefined(buildPayloadFromToml(DATABASE_TYPE_TABLE, typeSection, {
4089
4029
  mode: "create",
4090
- values: {
4091
- // Accept both `celContextAccess` (new user-facing key, the definition's
4092
- // tomlKey) and `metadataAccess` (legacy, declared in `tomlOnlyKeys`).
4093
- // The newer key wins when both appear. Issue #1567: a defined-check,
4094
- // not a truthy guard, so `celContextAccess = ""` is honored as an
4095
- // explicit clear rather than falling through to the legacy key.
4096
- metadataAccess: (value, _mode, section) => value !== undefined ? value : section?.metadataAccess,
4097
- },
4098
4030
  }));
4099
4031
  canonicalizeDatabaseTypeConfig(typeConfig);
4100
4032
  // The `ruleSetName` reference; it resolves to an id later.
@@ -4133,27 +4065,11 @@ export function parseDatabaseTypeToml(tomlData) {
4133
4065
  }
4134
4066
  }
4135
4067
  typeConfig.schema = schemaSerialized;
4136
- // `normalizeOperationFromToml` accepts both forms:
4137
- // - legacy: `definition = '{...}'` (JSON string), `params = '{...}'`
4138
- // - native: `[operations.definition]` table, `[[operations.params]]` rows
4139
- // and returns the JS shape the server expects.
4140
- const operations = (tomlData.operations || []).map((op) => {
4141
- const normalized = normalizeOperationFromToml(op);
4142
- // #2731 B1 — the handler trims `name` before storing, and the trimmed name
4143
- // is half the row's key (`<databaseType>#<name>`). Comparing or sending the
4144
- // padded spelling names a row the server never holds: the child reconcile
4145
- // would read it as a different operation and plan it forever.
4146
- if (typeof normalized.name === "string") {
4147
- normalized.name = normalized.name.trim();
4148
- }
4149
- return normalized;
4150
- });
4151
- // Subscriptions (issue #803): `[[subscriptions]]` array-of-tables.
4152
- // `normalizeSubscriptionFromToml` maps `accessRule`→`access` (throwing on a
4153
- // conflicting both-present declaration) and forwards `select`/`emit`/`params`
4154
- // in the wire shape. Until #803 this block was dropped at parse time.
4155
- const subscriptions = (tomlData.subscriptions || []).map((sub) => normalizeSubscriptionFromToml(sub));
4156
- 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 };
4157
4073
  }
4158
4074
  /**
4159
4075
  * The parsed shape of a rule-set TOML: the definition's fields plus the
@@ -4981,29 +4897,17 @@ const CONFIG_DIFF_SPECS = {
4981
4897
  "database-type-config": {
4982
4898
  label: "database-type-config",
4983
4899
  table: DATABASE_TYPE_TABLE,
4984
- parse: (doc) => {
4985
- const { typeConfig, operations, subscriptions } = parseDatabaseTypeToml(doc);
4986
- // The three sub-trees ride ON the type config, so they travel with it and
4987
- // are pulled back out as extras below.
4988
- typeConfig.__operations = operations;
4989
- typeConfig.__subscriptions = subscriptions;
4990
- return typeConfig;
4991
- },
4992
- serialize: (record, maps, extra) => serializeDatabaseType(record, extra?.operations ?? [], maps.ruleSetIdToName, {
4993
- defaultForm: "native",
4994
- subscriptions: extra?.subscriptions ?? [],
4995
- }),
4900
+ parse: (doc) => parseDatabaseTypeToml(doc).typeConfig,
4901
+ serialize: (record, maps) => serializeDatabaseType(record, maps.ruleSetIdToName),
4996
4902
  extras: (entity) => ({
4997
4903
  // Structural, per the definition: the `[models.*]` schema, the
4998
- // `[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.)
4999
4906
  schema: entity.schema ?? null,
5000
4907
  triggers: entity.triggers ?? null,
5001
4908
  metadataManifest: entity.metadataManifest ?? null,
5002
- operations: byKey(entity.__operations, "name"),
5003
- subscriptions: byKey(entity.__subscriptions, "subscriptionKey"),
5004
4909
  _unresolvedRuleSetName: entity._unresolvedRuleSetName,
5005
4910
  }),
5006
- childKeys: { operations: "name", subscriptions: "subscriptionKey" },
5007
4911
  tomlFields: ["schema"],
5008
4912
  ruleSetRef: true,
5009
4913
  describe: (entity) => `database type "${entity?.databaseType}"`,
@@ -5342,36 +5246,6 @@ export function withDerivedIdentity(doc, tomlPath, identity) {
5342
5246
  export function resolveExistingId(input) {
5343
5247
  return input.liveOk ? input.liveId : input.manifestId;
5344
5248
  }
5345
- /**
5346
- * Which CHILD rows a database type's push reconciles against (#2731 B3).
5347
- *
5348
- * `resolveExistingId` answers this for a whole entity; a database type's
5349
- * operations and subscriptions are rows with the same question and the same
5350
- * two possible answers. The manifest can name an operation the server no
5351
- * longer has (the update PUTs a row that is gone) and be silent about one it
5352
- * does have (the create POSTs a duplicate key) — both abort the push with a
5353
- * server error, which is exactly the recovery `--force` is supposed to
5354
- * perform.
5355
- *
5356
- * When the reconcile read the rows, they ARE the baseline: existence, the
5357
- * timestamp each update guards with, and — as the complement of the file's own
5358
- * list — which rows this push deletes. `undefined` means the read did not
5359
- * happen (the degraded path, or a type live state says is absent), and the
5360
- * manifest stays what it has always been.
5361
- */
5362
- export function liveChildBaseline(live, manifestChildren) {
5363
- if (!live)
5364
- return undefined;
5365
- const baseline = {};
5366
- for (const [key, row] of live) {
5367
- baseline[key] = {
5368
- // The live timestamp is the one an update guards with; the manifest's is
5369
- // a stand-in only for a row the listing returned without one.
5370
- modifiedAt: row?.modifiedAt || manifestChildren?.[key]?.modifiedAt || "",
5371
- };
5372
- }
5373
- return baseline;
5374
- }
5375
5249
  /**
5376
5250
  * The app's live rule sets, indexed the three ways push reads them (#2731 B3).
5377
5251
  *
@@ -8315,30 +8189,10 @@ export function registerConfigSyncCommands(sync) {
8315
8189
  const metadataCategoryConfigsResult = metadataCategoryConfigsOutcome.ok
8316
8190
  ? metadataCategoryConfigsOutcome.value
8317
8191
  : [];
8318
- // Fetch operations + subscriptions for each database type. Issue #803:
8319
- // subscriptions are pulled symmetrically with operations so a
8320
- // pull → push cycle round-trips `[[subscriptions]]` blocks without a
8321
- // spurious diff. The list endpoint already excludes archived rows.
8322
- //
8323
- // A failed operation/subscription fetch also disqualifies the type from
8324
- // pruning (#1659): the file we write for it is then incomplete, so the
8325
- // pull's picture of that type is not one to reconcile against.
8326
- let databaseTypeDetailsOk = true;
8327
- const databaseTypesWithOps = await Promise.all(selectPull("database-type-config", Array.isArray(databaseTypeConfigsResult) ? databaseTypeConfigsResult : [], (t) => t.databaseType).map(async (typeConfig) => {
8328
- const [opsOutcome, subsOutcome] = await Promise.all([
8329
- attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, typeConfig.databaseType)),
8330
- attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, typeConfig.databaseType)),
8331
- ]);
8332
- if (!opsOutcome.ok || !subsOutcome.ok)
8333
- databaseTypeDetailsOk = false;
8334
- const ops = opsOutcome.ok ? opsOutcome.value : [];
8335
- const subs = subsOutcome.ok ? subsOutcome.value : [];
8336
- return {
8337
- typeConfig,
8338
- operations: Array.isArray(ops) ? ops : [],
8339
- subscriptions: Array.isArray(subs) ? subs : [],
8340
- };
8341
- }));
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);
8342
8196
  // Snapshot the pre-pull sync tree BEFORE we touch any local file
8343
8197
  // (issue #578, Phase 1). All server data has been fetched and
8344
8198
  // validated at this point — a failed pull already threw above, so we
@@ -8934,46 +8788,16 @@ export function registerConfigSyncCommands(sync) {
8934
8788
  const ruleSets = selectPull("rule-set", Array.isArray(ruleSetsResult) ? ruleSetsResult : [], (rs) => ruleSetFileKey(rs));
8935
8789
  // Write database types
8936
8790
  const databaseTypeEntities = {};
8937
- for (const { typeConfig, operations, subscriptions } of databaseTypesWithOps) {
8791
+ for (const typeConfig of databaseTypesToWrite) {
8938
8792
  const filename = `${typeConfig.databaseType}.toml`;
8939
8793
  const filePath = join(configDir, "database-type-configs", filename);
8940
- // Preserve the existing file's per-op form (issue #752): if the
8941
- // file already exists, read it raw and detect which operations
8942
- // currently use JSON-string vs native nested-table form.
8943
- const hints = existsSync(filePath)
8944
- ? detectExistingOperationForms(readFileSync(filePath, "utf-8"))
8945
- : undefined;
8946
- const content = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
8947
- hints,
8948
- defaultForm: "native",
8794
+ const content = serializeDatabaseType(typeConfig, ruleSetIdToName, {
8949
8795
  logger: (msg) => info(` ${msg}`),
8950
- // Issue #803: emit `[[subscriptions]]` blocks so a pull → push
8951
- // cycle round-trips subscriptions without a spurious diff.
8952
- subscriptions,
8953
8796
  });
8954
8797
  const written = writePulledFile(filePath, content, pullWrites, sameConfigAs("database-type-config", content, filePath, pullDiffMaps));
8955
- const opsEntities = {};
8956
- for (const op of operations) {
8957
- opsEntities[op.name] = {
8958
- modifiedAt: op.modifiedAt || new Date().toISOString(),
8959
- };
8960
- }
8961
- // Track per-subscription modifiedAt in SyncState (issue #803),
8962
- // exactly like operations, so the next push skips unchanged
8963
- // subscriptions and detects removals.
8964
- const subsEntities = {};
8965
- for (const sub of subscriptions || []) {
8966
- if (!sub?.subscriptionKey)
8967
- continue;
8968
- subsEntities[sub.subscriptionKey] = {
8969
- modifiedAt: sub.modifiedAt || new Date().toISOString(),
8970
- };
8971
- }
8972
8798
  databaseTypeEntities[typeConfig.databaseType] = {
8973
8799
  databaseType: typeConfig.databaseType,
8974
8800
  modifiedAt: typeConfig.modifiedAt || new Date().toISOString(),
8975
- operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
8976
- subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
8977
8801
  contentHash: computeFileHash(filePath),
8978
8802
  semanticHash: pulledSemanticHash("database-type-config", content, pullDiffMaps),
8979
8803
  hasSchema: typeof typeConfig.schema === "string" &&
@@ -9098,8 +8922,8 @@ export function registerConfigSyncCommands(sync) {
9098
8922
  scripts: scriptPresence,
9099
8923
  functions: functionPresence,
9100
8924
  emailTemplates: emailTemplatePresence,
9101
- databaseTypes: databaseTypeConfigsOutcome.ok && databaseTypeDetailsOk
9102
- ? { ok: true, serverKeys: databaseTypesWithOps.map((d) => d.typeConfig.databaseType) }
8925
+ databaseTypes: databaseTypeConfigsOutcome.ok
8926
+ ? { ok: true, serverKeys: databaseTypesToWrite.map((t) => t.databaseType) }
9103
8927
  : { ok: false },
9104
8928
  ruleSets: ruleSetsOutcome.ok
9105
8929
  ? { ok: true, serverKeys: ruleSets.map((rs) => ruleSetFileKey(rs)) }
@@ -9459,7 +9283,7 @@ export function registerConfigSyncCommands(sync) {
9459
9283
  if (unwrittenTestCases > 0) {
9460
9284
  keyValue("Test Cases not written", unwrittenTestCases);
9461
9285
  }
9462
- keyValue("Database Types", databaseTypesWithOps.length);
9286
+ keyValue("Database Types", databaseTypesToWrite.length);
9463
9287
  keyValue("Rule Sets", ruleSets.length);
9464
9288
  keyValue("Group Type Configs", groupTypeConfigs.length);
9465
9289
  keyValue("Collection Type Configs", collectionTypeConfigs.length);
@@ -11781,16 +11605,6 @@ What a push guarantees (two stages):
11781
11605
  catch (err) {
11782
11606
  throw new Error(`${filePath}: ${err.message}`);
11783
11607
  }
11784
- // #2652 — a prompt with no rule denies every non-admin execution.
11785
- // Warn per file so an operator pushing an unmigrated app can see
11786
- // exactly which prompts are locked down and how to open them.
11787
- const promptRuleWarning = missingAccessRuleWarning({
11788
- kind: "prompt",
11789
- key,
11790
- accessRule: prompt.accessRule,
11791
- });
11792
- if (promptRuleWarning)
11793
- warn(` ${promptRuleWarning}`);
11794
11608
  const existingEntry = syncState?.entities?.prompts?.[key];
11795
11609
  const livePromptId = livePromptIds.get(key);
11796
11610
  // A detail read that fails degrades THIS prompt to the byte-hash
@@ -12805,7 +12619,6 @@ What a push guarantees (two stages):
12805
12619
  // start from this release onward. A `runAs = "system"` workflow is
12806
12620
  // exempt (it rejects rules and members cannot start it).
12807
12621
  const workflowRuleWarning = missingAccessRuleWarning({
12808
- kind: "workflow",
12809
12622
  key,
12810
12623
  accessRule: workflow.accessRule,
12811
12624
  runAs: workflow.runAs,
@@ -13257,12 +13070,11 @@ What a push guarantees (two stages):
13257
13070
  const dbTypesDir = join(configDir, "database-type-configs");
13258
13071
  if (existsSync(dbTypesDir)) {
13259
13072
  const files = selectFiles("database-type-config", dbTypesDir, readdirSync(dbTypesDir).filter((f) => f.endsWith(".toml")));
13260
- // #2731 B3 — live reconcile for the FILE-LEVEL gate only. The
13261
- // per-operation and per-subscription machinery below (the #813
13262
- // validate-first pass, #915 hash clearing, #803 post-ops refresh) is
13263
- // untouched; this just replaces the byte-hash short-circuit in front
13264
- // of it. Like `config diff`, the comparison also needs the rule-set
13265
- // 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.)
13266
13078
  const databaseTypeSpec = configDiffSpec("database-type-config");
13267
13079
  const liveDatabaseTypes = new Map();
13268
13080
  let databaseTypesLiveOk = (await loadLiveRuleSets()).ok;
@@ -13284,28 +13096,8 @@ What a push guarantees (two stages):
13284
13096
  }
13285
13097
  for (const file of files) {
13286
13098
  const filePath = join(dbTypesDir, file);
13287
- const rawToml = readFileSync(filePath, "utf-8");
13288
13099
  const tomlData = parseTomlFile(filePath);
13289
- let typeConfig;
13290
- let operations;
13291
- let subscriptions;
13292
- try {
13293
- ({ typeConfig, operations, subscriptions } =
13294
- parseDatabaseTypeToml(tomlData));
13295
- }
13296
- catch (err) {
13297
- // The only structured parse error today is the
13298
- // `accessRule`/`access` conflict in a `[[subscriptions]]` block
13299
- // (issue #803). Surface it with the file name and abort the
13300
- // push rather than crashing with an opaque stack.
13301
- if (err instanceof SubscriptionAccessKeyConflictError) {
13302
- // Issue #976: throw (not `process.exit`) so the save-on-failure
13303
- // catch is the only exit path. Validation is also front-loaded
13304
- // above, so this is defense-in-depth.
13305
- throw new Error(` ${file}: ${err.message}\nAborting push: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
13306
- }
13307
- throw err;
13308
- }
13100
+ const { typeConfig } = parseDatabaseTypeToml(tomlData);
13309
13101
  const dbType = typeConfig.databaseType || basename(file, ".toml");
13310
13102
  // The gate re-parses this document, so the name-derived identity
13311
13103
  // has to be in it (see `withDerivedIdentity`).
@@ -13314,63 +13106,21 @@ What a push guarantees (two stages):
13314
13106
  resolveRuleSetReference(typeConfig, ruleSetNameToId, `database type ${dbType}`, {
13315
13107
  hint: ruleSetSelectionHint,
13316
13108
  });
13317
- // $params validator (issue #752): every `$params.X` reference
13318
- // inside `definition` must correspond to a declared param in
13319
- // `[[operations.params]]`. Catches typos like `$params.proectId`
13320
- // at push time with the TOML file:line of the offending op block.
13321
- const validation = validateOperations({
13322
- filePath,
13323
- rawToml,
13324
- operations,
13325
- });
13326
- for (const w of validation.warnings) {
13327
- warn(` ${formatIssue(w)}`);
13328
- }
13329
- if (validation.errors.length > 0) {
13330
- // Issue #976: throw (not `process.exit`) so the save-on-failure
13331
- // catch is the only exit path. Validation is also front-loaded
13332
- // above, so this is defense-in-depth.
13333
- throw new Error(validation.errors.map((e) => ` ${formatIssue(e)}`).join("\n") +
13334
- `\nAborting push: ${validation.errors.length} unresolved $params reference(s) in ${file}.`);
13335
- }
13336
13109
  const existingEntry = syncState?.entities?.databaseTypes?.[dbType];
13337
13110
  /**
13338
- * This type as the server holds it NOW — header, operations and
13339
- * subscriptions, which the comparison treats as one unit.
13111
+ * This type as the server holds it NOW.
13340
13112
  *
13341
13113
  * Two callers: the baseline stamped after everything this file
13342
13114
  * writes has landed (B4), and the report for a 409 that arrived
13343
13115
  * after the reconcile fetch (B5).
13344
13116
  */
13345
13117
  const refetchLiveDatabaseType = async () => {
13346
- const [record, ops, subs] = await Promise.all([
13347
- client.getDatabaseTypeConfig(resolvedAppId, dbType),
13348
- client.listDatabaseTypeOperations(resolvedAppId, dbType),
13349
- client.listDatabaseTypeSubscriptions(resolvedAppId, dbType),
13350
- ]);
13351
- return record
13352
- ? {
13353
- record,
13354
- extra: {
13355
- operations: Array.isArray(ops) ? ops : [],
13356
- subscriptions: Array.isArray(subs) ? subs : [],
13357
- },
13358
- }
13359
- : null;
13118
+ const record = await client.getDatabaseTypeConfig(resolvedAppId, dbType);
13119
+ return record ? { record, extra: {} } : null;
13360
13120
  };
13361
- // Issue #915 (defect b): track whether the apply path rejected any
13362
- // op for THIS file via the schema gate (OperationRefError /
13363
- // SchemaRequiredError). On a non-transactional fresh-type push the
13364
- // db-type lands but the bad op is blocked and the loop continues; if
13365
- // we then wrote the file-level contentHash, the next push's
13366
- // `shouldPushFile` would short-circuit and the missing op would be
13367
- // invisible drift. When this flag is set we skip the contentHash
13368
- // write so the drift stays visible and a corrected re-push converges.
13369
- let fileHadGateRejectedOp = false;
13370
13121
  // #2731 B4: a 409 means the write was REJECTED, so this file did
13371
- // not land either. Tracked like a gate-rejected op so the baseline
13372
- // stamp below cannot claim convergence for content the server
13373
- // 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
13374
13124
  // still-unapplied local edit as server drift and refuse to retry.
13375
13125
  let fileHadConflict = false;
13376
13126
  const liveDatabaseType = databaseTypesLiveOk
@@ -13383,9 +13133,6 @@ What a push guarantees (two stages):
13383
13133
  const typeExistsOnServer = databaseTypesLiveOk
13384
13134
  ? !!liveDatabaseType
13385
13135
  : !!existingEntry;
13386
- /** Live operations/subscriptions, keyed by name, when read. */
13387
- let liveOpsByName;
13388
- let liveSubsByName;
13389
13136
  let dbTypeGate;
13390
13137
  if (databaseTypesLiveOk && !liveDatabaseType) {
13391
13138
  // The listing succeeded and this type is not in it. That is a
@@ -13394,87 +13141,47 @@ What a push guarantees (two stages):
13394
13141
  dbTypeGate = { action: "create" };
13395
13142
  }
13396
13143
  else if (databaseTypesLiveOk && liveDatabaseType) {
13397
- // The file carries its operations and subscriptions, so the
13398
- // comparison fetches them too — an op-only or subscription-only
13399
- // edit must not read as unchanged. A failure on either degrades
13400
- // this one type to the byte-hash gate.
13401
- let liveOps = null;
13402
- let liveSubs = null;
13403
- try {
13404
- [liveOps, liveSubs] = await Promise.all([
13405
- client.listDatabaseTypeOperations(resolvedAppId, dbType),
13406
- client.listDatabaseTypeSubscriptions(resolvedAppId, dbType),
13407
- ]);
13408
- }
13409
- catch (err) {
13410
- warnLiveUnavailable(`database-type-config ${dbType}`, err);
13411
- }
13412
- if (liveOps && liveSubs) {
13413
- liveOpsByName = new Map((Array.isArray(liveOps) ? liveOps : [])
13414
- .filter((op) => op?.name)
13415
- .map((op) => [op.name, op]));
13416
- liveSubsByName = new Map((Array.isArray(liveSubs) ? liveSubs : [])
13417
- .filter((sub) => sub?.subscriptionKey)
13418
- .map((sub) => [sub.subscriptionKey, sub]));
13419
- dbTypeGate = decidePushForConfig({
13420
- spec: databaseTypeSpec,
13421
- maps: pushDiffMaps,
13422
- localParsed: tomlData,
13423
- live: {
13424
- record: liveDatabaseType,
13425
- extra: {
13426
- operations: Array.isArray(liveOps) ? liveOps : [],
13427
- subscriptions: Array.isArray(liveSubs) ? liveSubs : [],
13428
- },
13429
- modifiedAt: liveDatabaseType.modifiedAt,
13430
- },
13431
- entry: existingEntry,
13432
- localFileHash: computeFileHash(filePath),
13433
- force: options.force,
13434
- });
13435
- if (dbTypeGate.action === "skip") {
13436
- skippedCount++;
13437
- if (syncState) {
13438
- // Written even when the manifest has never seen this type:
13439
- // an adopt that records nothing is repeated on every push,
13440
- // and its next local edit would have no baseline (and no
13441
- // op/subscription rows) to reconcile against.
13442
- if (!syncState.entities.databaseTypes) {
13443
- syncState.entities.databaseTypes = {};
13444
- }
13445
- const opsEntities = {};
13446
- for (const [opName, op] of liveOpsByName ?? []) {
13447
- opsEntities[opName] = {
13448
- modifiedAt: op?.modifiedAt || new Date().toISOString(),
13449
- };
13450
- }
13451
- const subsEntities = {};
13452
- for (const [subKey, sub] of liveSubsByName ?? []) {
13453
- subsEntities[subKey] = {
13454
- modifiedAt: sub?.modifiedAt || new Date().toISOString(),
13455
- };
13456
- }
13457
- syncState.entities.databaseTypes[dbType] = {
13458
- databaseType: dbType,
13459
- modifiedAt: liveDatabaseType.modifiedAt ||
13460
- existingEntry?.modifiedAt ||
13461
- new Date().toISOString(),
13462
- operations: Object.keys(opsEntities).length > 0 ? opsEntities : undefined,
13463
- subscriptions: Object.keys(subsEntities).length > 0 ? subsEntities : undefined,
13464
- contentHash: computeFileHash(filePath),
13465
- semanticHash: dbTypeGate.remoteHash,
13466
- hasSchema: typeof liveDatabaseType.schema === "string" &&
13467
- liveDatabaseType.schema.trim().length > 0,
13468
- };
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 = {};
13469
13166
  }
13470
- continue;
13471
- }
13472
- if (dbTypeGate.action === "drift" ||
13473
- dbTypeGate.action === "conflict" ||
13474
- dbTypeGate.action === "immutable") {
13475
- recordDeclined("database-type-config", dbType, dbTypeGate, existingEntry?.modifiedAt);
13476
- 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
+ };
13477
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;
13478
13185
  }
13479
13186
  }
13480
13187
  // Fallback gate: the manifest byte hash — and, since #2880, a
@@ -13494,26 +13201,6 @@ What a push guarantees (two stages):
13494
13201
  continue;
13495
13202
  }
13496
13203
  }
13497
- // ── Issues #813 / #1336 / #2732: state what this push's operation
13498
- // set will BE, then run a validate-first pass before any mutating
13499
- // call for this type. The type-config PATCH gates run ahead of the
13500
- // operation calls, so the claims are what let them judge the
13501
- // post-push state (see `buildPendingOpClaims`). ──
13502
- // The op rows the server holds (#2731). When the reconcile read
13503
- // them, they ARE the inventory (`liveChildBaseline`): an op created
13504
- // out-of-band is UPDATEd rather than POSTed into a duplicate-key
13505
- // failure, and one deleted out-of-band is re-CREATEd rather than
13506
- // PATCHed at a row that is gone — and the claims' delete complement
13507
- // is computed against that live inventory, not the manifest's. When
13508
- // live state says the TYPE is gone, its ops went with it — so the
13509
- // manifest's op list is not a baseline either, and this push
13510
- // creates them alongside the re-created type. Only the degraded
13511
- // path still reads the manifest, where it is what the byte-hash
13512
- // gate has always used.
13513
- const existingOpsForType = liveChildBaseline(liveOpsByName, existingEntry?.operations) ??
13514
- ((typeExistsOnServer ? existingEntry?.operations : undefined) || {});
13515
- const pendingOpClaims = buildPendingOpClaims(operations, Object.keys(existingOpsForType));
13516
- const { pendingOpDeletes } = pendingOpClaims;
13517
13204
  // Whether the SERVER holds a schema right now. Live state answers
13518
13205
  // when it was read (so an adopted type's schema is not silently
13519
13206
  // preserved by an omitted key); the manifest's `hasSchema` is the
@@ -13572,8 +13259,9 @@ What a push guarantees (two stages):
13572
13259
  // declares an owned scalar (matches the pre-#1567 PATCH trigger), a
13573
13260
  // schema set/delete is in the payload, or an owned scalar is being
13574
13261
  // cleared. When false, the type-config PATCH is skipped entirely so
13575
- // an operations-only edit does not spuriously report the type as
13576
- // "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.
13577
13265
  const declaresOwnedField = DB_TYPE_OWNED_SCALARS.some((k) => k in typeConfig);
13578
13266
  const schemaInUpdateData = "schema" in computeTypeUpdateData();
13579
13267
  const typeLevelChange = declaresOwnedField ||
@@ -13585,19 +13273,12 @@ What a push guarantees (two stages):
13585
13273
  info(` ${prefix}${dbTypeFieldLabel(key)} on ${dbType}`);
13586
13274
  }
13587
13275
  };
13588
- // Validate-first pass (decision 4a): run every gate for this type
13589
- // BEFORE issuing any mutating call. If a gate fails, the whole
13590
- // type's mutating calls (op creates/updates/deletes AND the schema
13591
- // PATCH) are skipped — eliminating the gate-failure partial commit.
13592
- // The same pass IS the dry-run path for `config push --dry-run`:
13593
- // 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).
13594
13281
  let typeGateBlocked = false;
13595
- // Issue #684 regression guard: track which op the op-edit gate is
13596
- // dry-running so an unrecognized server error (e.g. a plain
13597
- // validation 400 like `"limit" must be 1000 or less`) can be
13598
- // attributed to that specific operation in the catch-all below,
13599
- // instead of being coarsened to the db-type level.
13600
- let opGateContext = null;
13601
13282
  // #2731 B4 — the concurrency guard is the LIVE timestamp the
13602
13283
  // reconcile fetch just read (the gate carries it), not the
13603
13284
  // manifest's, which can be arbitrarily stale and 409 on a write
@@ -13609,54 +13290,28 @@ What a push guarantees (two stages):
13609
13290
  const validateExpectedModifiedAt = options.force
13610
13291
  ? undefined
13611
13292
  : dbTypeExpectedModifiedAt;
13612
- // Issue #1336 (codex round-1 P1/P2): the declared-access manifest
13613
- // this push proposes (the parsed object, or null when the type
13614
- // declares none / is clearing it). Forwarded as
13615
- // `metadataManifestOverride` on the op dry-runs so op-access lints
13616
- // run against the manifest the same push is landing, not the stale
13617
- // stored one (existing type: a newly-declared key is honored; fresh
13618
- // type: an undeclared ref is caught instead of skipped).
13619
- const proposedManifest = typeConfig.metadataManifest ??
13620
- null;
13621
13293
  try {
13622
- // 1. Schema-edit gate (type-config PATCH dry-run) — only when the
13623
- // type already exists on the server and we'd actually PATCH a
13624
- // type-level field. A fresh type has no ops to break, so
13625
- // there's nothing to gate yet. #2731 B3: "already exists" is
13626
- // live state's answer, so a type the manifest still names but
13627
- // the server no longer has dry-runs its CREATE, not a PATCH.
13628
- // Issue #1567: `computeTypeUpdateData` now always returns the
13629
- // owned scalars, so gate on `typeLevelChange` (a genuine
13630
- // declared/schema/clear change) rather than "payload
13631
- // non-empty" — an operations-only edit must not run the
13632
- // 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.
13633
13299
  if (typeExistsOnServer && typeLevelChange) {
13634
- const validateUpdateData = computeTypeUpdateData();
13635
- await client.updateDatabaseTypeConfig(resolvedAppId, dbType,
13636
- // Forward the whole look-ahead (#813 / #1336 / #2732) so the
13637
- // dry-run gates judge the post-push operation set — the same
13638
- // payload the mutating PATCH below sends.
13639
- { ...validateUpdateData, ...pendingOpClaims }, validateExpectedModifiedAt, {
13300
+ await client.updateDatabaseTypeConfig(resolvedAppId, dbType, computeTypeUpdateData(), validateExpectedModifiedAt, {
13640
13301
  dryRun: true,
13641
13302
  acceptWarnings: !!options.acceptWarnings,
13642
13303
  });
13643
13304
  }
13644
13305
  else if (!typeExistsOnServer) {
13645
13306
  // Fresh type: run the config CREATE dry-run (issue #1336, codex
13646
- // round-1 P2). Before this, a brand-new type's inline config
13647
- // rules (`defaultAccess`, `metadataAccess`, autoPopulatedFields,
13648
- // triggers) and its manifest were never dry-run — a malformed
13649
- // rule reported a clean `config push --dry-run` then failed only on
13650
- // 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.
13651
13310
  const createDryRunData = { databaseType: dbType };
13652
13311
  if (typeConfig.ruleSetId)
13653
13312
  createDryRunData.ruleSetId = typeConfig.ruleSetId;
13654
13313
  if (typeConfig.triggers)
13655
13314
  createDryRunData.triggers = typeConfig.triggers;
13656
- if (typeConfig.metadataAccess)
13657
- createDryRunData.metadataAccess = typeConfig.metadataAccess;
13658
- if (typeConfig.defaultAccess)
13659
- createDryRunData.defaultAccess = typeConfig.defaultAccess;
13660
13315
  if (typeConfig.autoPopulatedFields)
13661
13316
  createDryRunData.autoPopulatedFields =
13662
13317
  typeConfig.autoPopulatedFields;
@@ -13679,175 +13334,12 @@ What a push guarantees (two stages):
13679
13334
  throw createErr;
13680
13335
  }
13681
13336
  }
13682
- // 2. Op-edit gate (op create/update dry-run) for every op in the
13683
- // TOML. The server runs `runSchemaGate` and short-circuits
13684
- // before persisting.
13685
- //
13686
- // The op gate is run against the schema THIS push is landing
13687
- // (`schemaOverride`), not the stale stored schema — the real
13688
- // apply path PATCHes the schema before creating/updating ops,
13689
- // so an op that depends on a field this push adds must NOT be
13690
- // falsely rejected (plan edge case: "op create that depends on
13691
- // a schema landed earlier in the same push"). When the push
13692
- // isn't changing the schema, leave the override unset so the
13693
- // server uses the stored schema.
13694
- //
13695
- // Issue #915 (defect a): this gate now runs for FRESH types too
13696
- // (previously guarded by `if (existingEntry)`). For a brand-new
13697
- // db-type the server has no stored config, so we gate the op
13698
- // against the PROPOSED local schema via `schemaOverride`. The
13699
- // server's op-create dry-run accepts a missing type when a
13700
- // `schemaOverride` is present (see
13701
- // database-type-operations-controller.ts). A fresh schemaless
13702
- // type (#666 bootstrap) sends an empty/absent override, and
13703
- // `runSchemaGate` is a no-op without a schema — so that path
13704
- // still succeeds.
13705
- {
13706
- const localHasSchema = typeof typeConfig.schema === "string" &&
13707
- typeConfig.schema.trim().length > 0;
13708
- let proposedSchema;
13709
- if (typeExistsOnServer) {
13710
- const validateUpdateData = computeTypeUpdateData();
13711
- proposedSchema =
13712
- "schema" in validateUpdateData
13713
- ? validateUpdateData.schema
13714
- : undefined;
13715
- }
13716
- else {
13717
- // Fresh type: the proposed schema is whatever the local TOML
13718
- // declares. A schemaless fresh type (no `[models.*]`) sends an
13719
- // empty-string override so the server takes the no-schema =
13720
- // no-op gate path rather than falling back to a (nonexistent)
13721
- // stored config — keeping the #666 bootstrap convergent.
13722
- proposedSchema = localHasSchema ? typeConfig.schema : "";
13723
- }
13724
- for (const op of operations) {
13725
- const existingOp = existingOpsForType[op.name];
13726
- opGateContext = {
13727
- action: existingOp ? "update" : "create",
13728
- key: `${dbType}/${op.name}`,
13729
- };
13730
- if (existingOp) {
13731
- await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
13732
- type: op.type,
13733
- modelName: op.modelName,
13734
- access: op.access,
13735
- definition: op.definition,
13736
- params: op.params,
13737
- }, options.force ? undefined : existingOp.modifiedAt, {
13738
- dryRun: true,
13739
- schemaOverride: proposedSchema,
13740
- // Issue #1336 (P1/P2): gate the op-access lint against
13741
- // the manifest THIS push is landing, not the stale
13742
- // stored one.
13743
- metadataManifestOverride: proposedManifest,
13744
- });
13745
- }
13746
- else {
13747
- await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
13748
- name: op.name,
13749
- type: op.type,
13750
- modelName: op.modelName,
13751
- access: op.access,
13752
- definition: op.definition,
13753
- params: op.params,
13754
- }, {
13755
- dryRun: true,
13756
- schemaOverride: proposedSchema,
13757
- // Issue #1336 (P1/P2): as above — a fresh-type op dry-run
13758
- // now lints against the proposed manifest instead of
13759
- // skipping the lint entirely.
13760
- metadataManifestOverride: proposedManifest,
13761
- // Issue #915 (defect a, follow-up): for a fresh type the
13762
- // server has no stored config to read `defaultAccess`
13763
- // from, so an op that omits `access` to inherit the
13764
- // TOML-declared type-level `defaultAccess` would be
13765
- // falsely rejected. Thread the proposed value so the
13766
- // fresh-type dry-run gates the same way the real push
13767
- // (which lands the type first) will.
13768
- defaultAccess: typeof typeConfig.defaultAccess === "string"
13769
- ? typeConfig.defaultAccess
13770
- : undefined,
13771
- });
13772
- }
13773
- }
13774
- // Every op gate passed; clear the context so a later (non-op)
13775
- // throw inside this try isn't misattributed to the last op.
13776
- opGateContext = null;
13777
- }
13778
13337
  }
13779
13338
  catch (err) {
13780
- // A gate failure during validate aborts the whole type. Bucket it
13781
- // into the same `schemaErrors` collectors used by the reporting
13782
- // path so the summary renders it identically, and record a
13783
- // disclosure entry. Conflicts and other errors fall through to
13784
- // the apply path's handling (validate-first doesn't change
13785
- // conflict semantics).
13786
- if (err instanceof SchemaBreaksOpsError) {
13787
- schemaErrors.schemaBreaks.push({
13788
- type: "database-type-config",
13789
- key: dbType,
13790
- operations: err.operations,
13791
- message: err.message,
13792
- });
13793
- blockedDatabaseTypes.push({
13794
- databaseType: dbType,
13795
- reason: `schema change blocked: ${err.operations.length} kept operation(s) would break under the new schema`,
13796
- });
13797
- typeGateBlocked = true;
13798
- }
13799
- else if (err instanceof SchemaHasUncheckableOpsError) {
13800
- schemaErrors.uncheckableOps.push({
13801
- type: "database-type-config",
13802
- key: dbType,
13803
- operations: err.operations,
13804
- message: err.message,
13805
- });
13806
- blockedDatabaseTypes.push({
13807
- databaseType: dbType,
13808
- reason: `schema change blocked: operation(s) have dynamic refs (re-run with --accept-warnings to commit)`,
13809
- });
13810
- typeGateBlocked = true;
13811
- }
13812
- else if (err instanceof OpsExistError) {
13813
- schemaErrors.opsExist.push({
13814
- type: "database-type-config",
13815
- key: dbType,
13816
- opCount: err.opCount,
13817
- message: err.message,
13818
- });
13819
- blockedDatabaseTypes.push({
13820
- databaseType: dbType,
13821
- reason: `schema deletion blocked: ${err.opCount} operation(s) still registered after this push's deletes`,
13822
- });
13823
- typeGateBlocked = true;
13824
- }
13825
- else if (err instanceof SchemaRequiredError) {
13826
- schemaErrors.schemaRequired.push({
13827
- type: "operation",
13828
- key: `${dbType}/_`,
13829
- message: err.message,
13830
- });
13831
- blockedDatabaseTypes.push({
13832
- databaseType: dbType,
13833
- reason: `operation push blocked: type has no schema set`,
13834
- });
13835
- typeGateBlocked = true;
13836
- }
13837
- else if (err instanceof OperationRefError) {
13838
- schemaErrors.opRefs.push({
13839
- type: "operation",
13840
- key: dbType,
13841
- refs: err.refs,
13842
- message: err.message,
13843
- });
13844
- blockedDatabaseTypes.push({
13845
- databaseType: dbType,
13846
- reason: `operation push blocked: unresolved references in the schema`,
13847
- });
13848
- typeGateBlocked = true;
13849
- }
13850
- 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) {
13851
13343
  // Surface the conflict and skip this type (don't blow away the
13852
13344
  // server with mutating calls). Mirrors the apply path.
13853
13345
  conflicts.push({
@@ -13868,14 +13360,6 @@ What a push guarantees (two stages):
13868
13360
  });
13869
13361
  typeGateBlocked = true;
13870
13362
  }
13871
- else if (opGateContext) {
13872
- // The op-edit gate dry-run threw a plain (non-gate) error —
13873
- // e.g. an invalid op `definition` the server rejects with a
13874
- // 400. The validate-first loop already knows which op failed,
13875
- // so name it (issue #684) instead of coarsening to the
13876
- // db-type level. Mirrors the apply path's per-op wraps.
13877
- throw wrapEntityError(err, opGateContext.action, "operation", opGateContext.key);
13878
- }
13879
13363
  else {
13880
13364
  throw wrapEntityError(err, "update", "database type", dbType);
13881
13365
  }
@@ -13895,17 +13379,6 @@ What a push guarantees (two stages):
13895
13379
  else {
13896
13380
  changes.push({ type: "database-type-config", action: "create", key: dbType });
13897
13381
  }
13898
- for (const op of operations) {
13899
- const existingOp = existingOpsForType[op.name];
13900
- changes.push({
13901
- type: "operation",
13902
- action: existingOp ? "update" : "create",
13903
- key: `${dbType}/${op.name}`,
13904
- });
13905
- }
13906
- for (const delName of pendingOpDeletes) {
13907
- changes.push({ type: "operation", action: "delete", key: `${dbType}/${delName}` });
13908
- }
13909
13382
  continue;
13910
13383
  }
13911
13384
  // A live type the manifest never recorded is about to be updated,
@@ -13931,9 +13404,8 @@ What a push guarantees (two stages):
13931
13404
  // never recorded is updated rather than re-created.
13932
13405
  if (typeExistsOnServer) {
13933
13406
  // Update existing type config — only when there is a genuine
13934
- // type-level change. Operations are handled separately below.
13935
- // Issue #369: skipping the PATCH on an operations-only edit avoids
13936
- // 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)
13937
13409
  // and a spurious "Updated database type". Issue #1567: the owned
13938
13410
  // scalars are always in `updateData` now (value-or-`null`), so the
13939
13411
  // skip decision moved from "payload non-empty" to the
@@ -13950,18 +13422,7 @@ What a push guarantees (two stages):
13950
13422
  // `schema: null` clears it server-side; codex review gap on
13951
13423
  // PR #766). Built by the shared helper so the validate pass
13952
13424
  // and apply pass agree (issue #813).
13953
- // The look-ahead goes on the REAL mutating PATCH exactly as it
13954
- // went on the dry-run above — the gates it feeds run on this
13955
- // call, and the operation creates/updates/deletes it
13956
- // describes still happen in step 3 below. It is sent whole
13957
- // and unconditionally: a push that only rewrites operations
13958
- // has no deletes to report, and gating on that was what kept
13959
- // the #2732 case (model removal + operation rewrites) from
13960
- // ever reaching the server as one push.
13961
- const updateData = {
13962
- ...computeTypeUpdateData(),
13963
- ...pendingOpClaims,
13964
- };
13425
+ const updateData = computeTypeUpdateData();
13965
13426
  if (typeLevelChange) {
13966
13427
  changes.push({ type: "database-type-config", action: "update", key: dbType });
13967
13428
  const updated = await client.updateDatabaseTypeConfig(resolvedAppId, dbType, updateData, expectedModifiedAt, {
@@ -13970,8 +13431,8 @@ What a push guarantees (two stages):
13970
13431
  });
13971
13432
  info(` Updated database type: ${dbType}`);
13972
13433
  // Issue #1567: name each owned scalar this push cleared, so
13973
- // removing an access gate (celContextAccess / defaultAccess)
13974
- // is never silent.
13434
+ // removing a rule (triggers, autoPopulatedFields) is never
13435
+ // silent.
13975
13436
  reportClearedFields("Cleared ");
13976
13437
  printStaleInstanceWarning(updated);
13977
13438
  if (syncState?.entities?.databaseTypes?.[dbType] && updated?.modifiedAt) {
@@ -13988,28 +13449,11 @@ What a push guarantees (two stages):
13988
13449
  if (err instanceof ConflictError) {
13989
13450
  // #2731 B5 — the server moved between the reconcile fetch
13990
13451
  // and this write, so recompute the field diff against the
13991
- // record that is there NOW (type plus its ops and
13992
- // subscriptions, which the comparison treats as one unit).
13452
+ // record that is there NOW.
13993
13453
  conflicts.push(await lateConflict("database-type-config", dbType, err, expectedModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData));
13994
13454
  // The PATCH was rejected: this file did not land.
13995
13455
  fileHadConflict = true;
13996
13456
  }
13997
- else if (err instanceof SchemaBreaksOpsError) {
13998
- schemaErrors.schemaBreaks.push({
13999
- type: "database-type-config",
14000
- key: dbType,
14001
- operations: err.operations,
14002
- message: err.message,
14003
- });
14004
- }
14005
- else if (err instanceof SchemaHasUncheckableOpsError) {
14006
- schemaErrors.uncheckableOps.push({
14007
- type: "database-type-config",
14008
- key: dbType,
14009
- operations: err.operations,
14010
- message: err.message,
14011
- });
14012
- }
14013
13457
  else if (err instanceof TomlParseError) {
14014
13458
  schemaErrors.tomlParse.push({
14015
13459
  type: "database-type-config",
@@ -14019,14 +13463,6 @@ What a push guarantees (two stages):
14019
13463
  message: err.message,
14020
13464
  });
14021
13465
  }
14022
- else if (err instanceof OpsExistError) {
14023
- schemaErrors.opsExist.push({
14024
- type: "database-type-config",
14025
- key: dbType,
14026
- opCount: err.opCount,
14027
- message: err.message,
14028
- });
14029
- }
14030
13466
  else {
14031
13467
  throw wrapEntityError(err, "update", "database type", dbType);
14032
13468
  }
@@ -14053,10 +13489,6 @@ What a push guarantees (two stages):
14053
13489
  createData.ruleSetId = typeConfig.ruleSetId;
14054
13490
  if (typeConfig.triggers)
14055
13491
  createData.triggers = typeConfig.triggers;
14056
- if (typeConfig.metadataAccess)
14057
- createData.metadataAccess = typeConfig.metadataAccess;
14058
- if (typeConfig.defaultAccess)
14059
- createData.defaultAccess = typeConfig.defaultAccess;
14060
13492
  if (typeConfig.autoPopulatedFields)
14061
13493
  createData.autoPopulatedFields = typeConfig.autoPopulatedFields;
14062
13494
  if (typeConfig.timestamps)
@@ -14124,11 +13556,7 @@ What a push guarantees (two stages):
14124
13556
  // remote config and refuse to mutate it without a baseline:
14125
13557
  // surface it as a SKIPPED type (reusing the #813 blocked-type
14126
13558
  // disclosure + non-zero exit) so the user runs `config pull`
14127
- // first. The op sub-loop is skipped via `continue`, so no
14128
- // operations are created against the un-reconciled type.
14129
- const existingOpsRemote = await client
14130
- .listDatabaseTypeOperations(resolvedAppId, dbType)
14131
- .catch(() => []);
13559
+ // first.
14132
13560
  const hasNonDefaultField = (v) => {
14133
13561
  if (v === null || v === undefined)
14134
13562
  return false;
@@ -14138,12 +13566,8 @@ What a push guarantees (two stages):
14138
13566
  return Object.keys(v).length > 0;
14139
13567
  return true;
14140
13568
  };
14141
- const isMinimalAutoCreated = (!Array.isArray(existingOpsRemote) ||
14142
- existingOpsRemote.length === 0) &&
14143
- !hasNonDefaultField(existing?.ruleSetId) &&
13569
+ const isMinimalAutoCreated = !hasNonDefaultField(existing?.ruleSetId) &&
14144
13570
  !hasNonDefaultField(existing?.triggers) &&
14145
- !hasNonDefaultField(existing?.metadataAccess) &&
14146
- !hasNonDefaultField(existing?.defaultAccess) &&
14147
13571
  !hasNonDefaultField(existing?.autoPopulatedFields) &&
14148
13572
  !hasNonDefaultField(existing?.timestamps) &&
14149
13573
  !hasNonDefaultField(existing?.schema);
@@ -14177,13 +13601,6 @@ What a push guarantees (two stages):
14177
13601
  action: "update",
14178
13602
  key: dbType,
14179
13603
  });
14180
- for (const op of operations) {
14181
- changes.push({
14182
- type: "operation",
14183
- action: "create",
14184
- key: `${dbType}/${op.name}`,
14185
- });
14186
- }
14187
13604
  continue;
14188
13605
  }
14189
13606
  info(` reconciled auto-created database type \`${dbType}\``);
@@ -14208,7 +13625,7 @@ What a push guarantees (two stages):
14208
13625
  // "modified since last sync" CONFLICT (Q4). Subsequent
14209
13626
  // pushes carry the baseline just seeded above. Skip the
14210
13627
  // PATCH entirely when the TOML declares no type-level
14211
- // fields (an ops-only file) so we don't send an empty
13628
+ // fields so we don't send an empty
14212
13629
  // body, which the server rejects with HTTP 400 (#369).
14213
13630
  const reconcileData = computeTypeUpdateData();
14214
13631
  if (Object.keys(reconcileData).length > 0) {
@@ -14227,11 +13644,6 @@ What a push guarantees (two stages):
14227
13644
  : syncState.entities.databaseTypes[dbType].hasSchema;
14228
13645
  }
14229
13646
  }
14230
- // Operations declared in the TOML flow through the existing
14231
- // op sub-loop below. `existingOps` is derived from
14232
- // `existingEntry` (the pre-push sync-state snapshot, which is
14233
- // empty here), so every op is CREATEd — matching the empty
14234
- // auto-created type on the server.
14235
13647
  }
14236
13648
  else {
14237
13649
  throw wrapEntityError(err, "create", "database type", dbType);
@@ -14239,315 +13651,22 @@ What a push guarantees (two stages):
14239
13651
  }
14240
13652
  }
14241
13653
  }
14242
- // Process operations for this type. The baseline is the same one
14243
- // the validate pass used, so a type the server no longer has
14244
- // creates its ops rather than PATCHing rows that went with it.
14245
- const existingOps = existingOpsForType;
14246
- const tomlOpNames = new Set(operations.map((op) => op.name));
14247
- for (const op of operations) {
14248
- const existingOp = existingOps[op.name];
14249
- if (existingOp) {
14250
- // Update existing operation
14251
- changes.push({ type: "operation", action: "update", key: `${dbType}/${op.name}` });
14252
- if (!options.dryRun) {
14253
- // #2731 B4 — the LIVE row's timestamp when the reconcile read
14254
- // it, the manifest's otherwise (degraded path).
14255
- const expectedOpModifiedAt = options.force
14256
- ? undefined
14257
- : liveOpsByName?.get(op.name)?.modifiedAt ??
14258
- existingOp.modifiedAt;
14259
- try {
14260
- const updated = await client.updateDatabaseTypeOperation(resolvedAppId, dbType, op.name, {
14261
- // Include `type` so the server can detect/apply
14262
- // type-in-place changes (issue #692). When the type
14263
- // matches the stored value, this is a no-op.
14264
- type: op.type,
14265
- modelName: op.modelName,
14266
- access: op.access,
14267
- definition: op.definition,
14268
- params: op.params,
14269
- }, expectedOpModifiedAt);
14270
- info(` Updated operation: ${dbType}/${op.name}`);
14271
- // Recorded even when the manifest had never seen this row:
14272
- // the update targeted a row live state named, and a state
14273
- // that stays silent about it would send this same push
14274
- // again on the next run.
14275
- const opsEntry = syncState?.entities?.databaseTypes?.[dbType];
14276
- if (opsEntry && updated?.modifiedAt) {
14277
- if (!opsEntry.operations)
14278
- opsEntry.operations = {};
14279
- opsEntry.operations[op.name] = { modifiedAt: updated.modifiedAt };
14280
- }
14281
- }
14282
- catch (err) {
14283
- if (err instanceof ConflictError) {
14284
- // #2731 B5 — an operation is part of this file's compared
14285
- // surface, so its 409 is reported like the type's own:
14286
- // against the record that is there NOW, with the field
14287
- // diff (`operations` as a unit) rather than two
14288
- // timestamps the operator cannot act on.
14289
- conflicts.push({
14290
- ...(await lateConflict("operation", `${dbType}/${op.name}`, err, expectedOpModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData)),
14291
- });
14292
- // The op was rejected, so this file did not fully land.
14293
- fileHadConflict = true;
14294
- }
14295
- else if (err instanceof SchemaRequiredError) {
14296
- schemaErrors.schemaRequired.push({
14297
- type: "operation",
14298
- key: `${dbType}/${op.name}`,
14299
- message: err.message,
14300
- });
14301
- // Issue #915 (defect b): a gate-rejected op means this
14302
- // file's state did NOT fully land — don't poison the
14303
- // content hash (see the contentHash write below).
14304
- fileHadGateRejectedOp = true;
14305
- }
14306
- else if (err instanceof OperationRefError) {
14307
- schemaErrors.opRefs.push({
14308
- type: "operation",
14309
- key: `${dbType}/${op.name}`,
14310
- refs: err.refs,
14311
- message: err.message,
14312
- });
14313
- fileHadGateRejectedOp = true;
14314
- }
14315
- else {
14316
- throw wrapEntityError(err, "update", "operation", `${dbType}/${op.name}`);
14317
- }
14318
- }
14319
- }
14320
- }
14321
- else {
14322
- // Create new operation
14323
- changes.push({ type: "operation", action: "create", key: `${dbType}/${op.name}` });
14324
- if (!options.dryRun) {
14325
- try {
14326
- const created = await client.createDatabaseTypeOperation(resolvedAppId, dbType, {
14327
- name: op.name,
14328
- type: op.type,
14329
- modelName: op.modelName,
14330
- access: op.access,
14331
- definition: op.definition,
14332
- params: op.params,
14333
- });
14334
- info(` Created operation: ${dbType}/${op.name}`);
14335
- if (syncState?.entities?.databaseTypes?.[dbType]) {
14336
- if (!syncState.entities.databaseTypes[dbType].operations) {
14337
- syncState.entities.databaseTypes[dbType].operations = {};
14338
- }
14339
- syncState.entities.databaseTypes[dbType].operations[op.name] = {
14340
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
14341
- };
14342
- }
14343
- }
14344
- catch (err) {
14345
- if (err instanceof SchemaRequiredError) {
14346
- schemaErrors.schemaRequired.push({
14347
- type: "operation",
14348
- key: `${dbType}/${op.name}`,
14349
- message: err.message,
14350
- });
14351
- // Issue #915 (defect b): a gate-rejected op means this
14352
- // file's state did NOT fully land — don't poison the
14353
- // content hash (see the contentHash write below).
14354
- fileHadGateRejectedOp = true;
14355
- }
14356
- else if (err instanceof OperationRefError) {
14357
- schemaErrors.opRefs.push({
14358
- type: "operation",
14359
- key: `${dbType}/${op.name}`,
14360
- refs: err.refs,
14361
- message: err.message,
14362
- });
14363
- fileHadGateRejectedOp = true;
14364
- }
14365
- else {
14366
- throw wrapEntityError(err, "create", "operation", `${dbType}/${op.name}`);
14367
- }
14368
- }
14369
- }
14370
- }
14371
- }
14372
- // Delete operations that were removed from TOML
14373
- for (const existingOpName of Object.keys(existingOps)) {
14374
- if (!tomlOpNames.has(existingOpName)) {
14375
- changes.push({ type: "operation", action: "delete", key: `${dbType}/${existingOpName}` });
14376
- if (!options.dryRun) {
14377
- try {
14378
- await client.deleteDatabaseTypeOperation(resolvedAppId, dbType, existingOpName);
14379
- info(` Deleted operation: ${dbType}/${existingOpName}`);
14380
- if (syncState?.entities?.databaseTypes?.[dbType]?.operations) {
14381
- delete syncState.entities.databaseTypes[dbType].operations[existingOpName];
14382
- }
14383
- }
14384
- catch (err) {
14385
- throw wrapEntityError(err, "delete", "operation", `${dbType}/${existingOpName}`);
14386
- }
14387
- }
14388
- }
14389
- }
14390
- // Process subscriptions for this type (issue #803). Mirrors the
14391
- // operations reconcile above, keyed on `subscriptionKey`:
14392
- // create new keys, PUT changed/existing keys, delete keys present
14393
- // in SyncState but absent from the TOML. Server errors (409 key
14394
- // collision, `#`-in-key 400, max-20 400, missing-required 400)
14395
- // are surfaced cleanly via `wrapEntityError` — named, not a crash.
14396
- const existingSubs = liveChildBaseline(liveSubsByName, syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) ??
14397
- ((typeExistsOnServer
14398
- ? syncState?.entities?.databaseTypes?.[dbType]?.subscriptions
14399
- : undefined) || {});
14400
- const tomlSubKeys = new Set(subscriptions.map((sub) => sub.subscriptionKey));
14401
- for (const sub of subscriptions) {
14402
- const subKey = sub.subscriptionKey;
14403
- const existingSub = existingSubs[subKey];
14404
- if (existingSub) {
14405
- // Update existing subscription (PUT — idempotent).
14406
- changes.push({ type: "subscription", action: "update", key: `${dbType}/${subKey}` });
14407
- if (!options.dryRun) {
14408
- // #2731 B4 — the LIVE row's timestamp when the reconcile read
14409
- // it, the manifest's otherwise (degraded path). Subscriptions
14410
- // are part of a converted type's push units, so a concurrent
14411
- // server edit has to be a 409 here too, not a silent
14412
- // overwrite.
14413
- const expectedSubModifiedAt = options.force
14414
- ? undefined
14415
- : liveSubsByName?.get(subKey)?.modifiedAt ??
14416
- existingSub.modifiedAt;
14417
- try {
14418
- const updated = await client.updateDatabaseTypeSubscription(resolvedAppId, dbType, subKey, {
14419
- displayName: sub.displayName,
14420
- modelName: sub.modelName,
14421
- filter: sub.filter,
14422
- access: sub.access,
14423
- description: sub.description,
14424
- select: sub.select,
14425
- emit: sub.emit,
14426
- params: sub.params,
14427
- status: sub.status,
14428
- }, expectedSubModifiedAt);
14429
- info(` Updated subscription: ${dbType}/${subKey}`);
14430
- // Gate the SyncState write on a server-returned
14431
- // `modifiedAt`, mirroring the operations precedent above
14432
- // (don't fall back to a fresh local timestamp) — and record
14433
- // a row the manifest had never seen, for the same reason.
14434
- const subsEntry = syncState?.entities?.databaseTypes?.[dbType];
14435
- if (subsEntry && updated?.modifiedAt) {
14436
- if (!subsEntry.subscriptions)
14437
- subsEntry.subscriptions = {};
14438
- subsEntry.subscriptions[subKey] = { modifiedAt: updated.modifiedAt };
14439
- }
14440
- }
14441
- catch (err) {
14442
- if (err instanceof ConflictError) {
14443
- conflicts.push({
14444
- ...(await lateConflict("subscription", `${dbType}/${subKey}`, err, expectedSubModifiedAt, refetchLiveDatabaseType, databaseTypeSpec, tomlData)),
14445
- });
14446
- // The subscription was rejected, so this file did not
14447
- // fully land — the baseline stamp below must not claim it
14448
- // did.
14449
- fileHadConflict = true;
14450
- }
14451
- else {
14452
- throw wrapEntityError(err, "update", "subscription", `${dbType}/${subKey}`);
14453
- }
14454
- }
14455
- }
14456
- }
14457
- else {
14458
- // Create new subscription (POST).
14459
- changes.push({ type: "subscription", action: "create", key: `${dbType}/${subKey}` });
14460
- if (!options.dryRun) {
14461
- try {
14462
- const created = await client.createDatabaseTypeSubscription(resolvedAppId, dbType, {
14463
- subscriptionKey: subKey,
14464
- displayName: sub.displayName,
14465
- modelName: sub.modelName,
14466
- filter: sub.filter,
14467
- access: sub.access,
14468
- description: sub.description,
14469
- select: sub.select,
14470
- emit: sub.emit,
14471
- params: sub.params,
14472
- status: sub.status,
14473
- });
14474
- info(` Created subscription: ${dbType}/${subKey}`);
14475
- if (syncState?.entities?.databaseTypes?.[dbType]) {
14476
- if (!syncState.entities.databaseTypes[dbType].subscriptions) {
14477
- syncState.entities.databaseTypes[dbType].subscriptions = {};
14478
- }
14479
- syncState.entities.databaseTypes[dbType].subscriptions[subKey] = {
14480
- modifiedAt: created?.modifiedAt || new Date().toISOString(),
14481
- };
14482
- }
14483
- }
14484
- catch (err) {
14485
- throw wrapEntityError(err, "create", "subscription", `${dbType}/${subKey}`);
14486
- }
14487
- }
14488
- }
14489
- }
14490
- // Delete subscriptions that were removed from TOML (hard-delete on
14491
- // current `main`, PR #787 — frees the key for reuse). A 409 on
14492
- // recreate is impossible here since we delete first; the pull-side
14493
- // "active only" filter (controller `list` excludes archived)
14494
- // agrees because hard-delete leaves no archived row behind.
14495
- for (const existingSubKey of Object.keys(existingSubs)) {
14496
- if (!tomlSubKeys.has(existingSubKey)) {
14497
- changes.push({ type: "subscription", action: "delete", key: `${dbType}/${existingSubKey}` });
14498
- if (!options.dryRun) {
14499
- try {
14500
- await client.deleteDatabaseTypeSubscription(resolvedAppId, dbType, existingSubKey);
14501
- info(` Deleted subscription: ${dbType}/${existingSubKey}`);
14502
- if (syncState?.entities?.databaseTypes?.[dbType]?.subscriptions) {
14503
- delete syncState.entities.databaseTypes[dbType].subscriptions[existingSubKey];
14504
- }
14505
- }
14506
- catch (err) {
14507
- throw wrapEntityError(err, "delete", "subscription", `${dbType}/${existingSubKey}`);
14508
- }
14509
- }
14510
- }
14511
- }
14512
- // Refresh the file's content hash after processing operations +
14513
- // subscriptions (issue #803, behavior g). The type-config update
14514
- // block only refreshes the hash when a type-level field changed
14515
- // (its `updateData` was non-empty); an operations- or
14516
- // subscriptions-only edit left a stale hash, so the next push
14517
- // would re-PUT every op/sub instead of skipping the unchanged
14518
- // file. Recompute here so a truly-unchanged subsequent push skips
14519
- // the whole file via `shouldPushFile`. `--force` bypasses that
14520
- // skip, so forced re-pushes are unaffected.
14521
- //
14522
- // Issue #915 (defect b): when the gate rejected an op for this
14523
- // file, do NOT record a matching content hash. The db-type itself
14524
- // may have been created (the push is non-transactional), but the
14525
- // blocked op never landed — a matching hash would make
14526
- // `shouldPushFile` short-circuit the next push and hide the missing
14527
- // op as "in sync". Clear the hash instead of writing it: an empty
14528
- // hash forces `shouldPushFile` to re-evaluate the file, so a
14529
- // subsequent `config push --dry-run` still surfaces the op as drift
14530
- // and a corrected re-push converges without manual file edits. We
14531
- // clear (rather than merely skip) so the fresh-type create path's
14532
- // earlier optimistic hash write (see `createDatabaseTypeConfig`
14533
- // above) is also invalidated.
14534
- //
14535
- // #2731 B4: the semantic baseline is refreshed here too, and
14536
- // cleared alongside the byte hash when an op was gate-rejected —
14537
- // a baseline claiming convergence would make the next run read the
14538
- // missing op as server drift rather than as work still to do. A
14539
- // 409 on the type or one of its ops means the same thing: the
14540
- // 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.
14541
13660
  if (!options.dryRun && syncState?.entities?.databaseTypes?.[dbType]) {
14542
- if (fileHadGateRejectedOp || fileHadConflict) {
13661
+ if (fileHadConflict) {
14543
13662
  syncState.entities.databaseTypes[dbType].contentHash = "";
14544
13663
  syncState.entities.databaseTypes[dbType].semanticHash = undefined;
14545
13664
  }
14546
13665
  else {
14547
13666
  syncState.entities.databaseTypes[dbType].contentHash =
14548
13667
  computeFileHash(filePath);
14549
- // B4 — the baseline reads back what the server now holds, type
14550
- // 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
14551
13670
  // stamp a value the handlers normalized as the state both sides
14552
13671
  // agreed on, and the next local edit would report a conflict
14553
13672
  // against a difference this push itself created. A read that
@@ -14579,6 +13698,9 @@ What a push guarantees (two stages):
14579
13698
  // import that escapes the config tree aborts this function before
14580
13699
  // anything is sent for it (behavior 12).
14581
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();
14582
13704
  if (existsSync(functionsPushDir)) {
14583
13705
  // #3182 — the typed handle. Written BEFORE the bundles are built, so
14584
13706
  // an author who runs `config push` after editing a schema gets the
@@ -14673,6 +13795,15 @@ What a push guarantees (two stages):
14673
13795
  withDerivedIdentity(tomlData, "function", { key });
14674
13796
  const existingEntry = syncState?.entities?.functions?.[key];
14675
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
+ };
14676
13807
  // Build first: nothing is sent for a function whose code does not
14677
13808
  // compile or whose imports leave the tree.
14678
13809
  let push;
@@ -14692,6 +13823,7 @@ What a push guarantees (two stages):
14692
13823
  key,
14693
13824
  message: err.message,
14694
13825
  });
13826
+ markUnapplied();
14695
13827
  continue;
14696
13828
  }
14697
13829
  // #3455 (D3455-004) — the project declares a document schema this
@@ -14718,6 +13850,7 @@ What a push guarantees (two stages):
14718
13850
  key,
14719
13851
  message: failure.message,
14720
13852
  });
13853
+ markUnapplied();
14721
13854
  continue;
14722
13855
  }
14723
13856
  }
@@ -14835,6 +13968,7 @@ What a push guarantees (two stages):
14835
13968
  // the same contract `applyFailures` gives every other type; a throw
14836
13969
  // here would let one refusal block a whole repository.
14837
13970
  let functionId = liveFunction?.functionId ?? existingEntry?.id;
13971
+ let createdThisPush = false;
14838
13972
  let pushedVersion;
14839
13973
  try {
14840
13974
  if (!functionId) {
@@ -14859,6 +13993,7 @@ What a push guarantees (two stages):
14859
13993
  return existing;
14860
13994
  });
14861
13995
  functionId = created.functionId;
13996
+ createdThisPush = true;
14862
13997
  info(` Created function: ${key}`);
14863
13998
  }
14864
13999
  else if (headerAction !== "skip") {
@@ -14881,6 +14016,11 @@ What a push guarantees (two stages):
14881
14016
  key,
14882
14017
  message: String(err?.message ?? err),
14883
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();
14884
14024
  continue;
14885
14025
  }
14886
14026
  // #3455 (D3455-010) — did the server keep the document schema it
@@ -14943,8 +14083,15 @@ What a push guarantees (two stages):
14943
14083
  }
14944
14084
  // #3798 — the agent prompts, now that every function they name that
14945
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.
14946
14089
  if (deferredAgentPromptFiles.length > 0) {
14947
- 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);
14948
14095
  }
14949
14096
  // Process group type configs
14950
14097
  const groupTypeConfigsDir = join(configDir, "group-type-configs");
@@ -17285,12 +16432,9 @@ What a push guarantees (two stages):
17285
16432
  // Blob buckets — #2880: the list row is the record. #4116: a bucket
17286
16433
  // names its rule set, so its rows take the type configs' gate.
17287
16434
  compareByDefinition("blob-bucket", "blobBuckets", localBlobBucketsParsed, new Map((blobBucketsDiffResult.items || []).map((b) => [b.bucketKey, b])), blobBucketsOutcomeDiff.ok && ruleSetsOk);
17288
- // database-type-configs: the list carries only the type header, so operations
17289
- // and subscriptions are fetched per type (mirroring pull) before the
17290
- // content comparison. A per-type fetch failure degrades that one row to
17291
- // "content not compared" rather than aborting the whole diff (#1485
17292
- // edge case). Both operations AND subscriptions are included so an
17293
- // 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.)
17294
16438
  for (const [key, parsed] of localDatabaseTypesParsed) {
17295
16439
  const remote = remoteDatabaseTypes.get(key);
17296
16440
  if (!remote) {
@@ -17303,23 +16447,10 @@ What a push guarantees (two stages):
17303
16447
  hint = "content not compared (rule sets unavailable)";
17304
16448
  }
17305
16449
  else {
17306
- const [opsOutcome, subsOutcome] = await Promise.all([
17307
- attemptFetch(() => client.listDatabaseTypeOperations(resolvedAppId, key)),
17308
- attemptFetch(() => client.listDatabaseTypeSubscriptions(resolvedAppId, key)),
17309
- ]);
17310
- if (!opsOutcome.ok || !subsOutcome.ok) {
17311
- hint = "content not compared (fetch failed)";
17312
- }
17313
- else {
17314
- const operations = Array.isArray(opsOutcome.value) ? opsOutcome.value : [];
17315
- const subscriptions = Array.isArray(subsOutcome.value) ? subsOutcome.value : [];
17316
- // The shared verdict (#3267): a Modified row names the
17317
- // operation, subscription, model field or `[type]` scalar it
17318
- // differs in, and a comparison that throws degrades this one
17319
- // row to "not compared". The ops/subscriptions are the remote
17320
- // side's extra only — the file already carries its own.
17321
- ({ status, hint } = compareLocalToRemote(configDiffSpec("database-type-config"), parsed, remote, diffMaps, { operations, subscriptions }));
17322
- }
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, {}));
17323
16454
  }
17324
16455
  differences.push({ type: "database-type-config", key, status, hint });
17325
16456
  }
@@ -17767,8 +16898,13 @@ What a push guarantees (two stages):
17767
16898
  // next, so an in-loop abort would leave earlier files rewritten. Every
17768
16899
  // offending file is reported, so the author fixes the tree once.
17769
16900
  const retiredKeyErrors = [];
16901
+ const databaseTypeSurface = CONFIG_SURFACES.find((surface) => surface.label === "database-type-config");
17770
16902
  for (const file of dbFiles) {
17771
- 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);
17772
16908
  if (message)
17773
16909
  retiredKeyErrors.push(message);
17774
16910
  }
@@ -17786,30 +16922,9 @@ What a push guarantees (two stages):
17786
16922
  const filePath = join(dbTypesDir, file);
17787
16923
  const rawBefore = readFileSync(filePath, "utf-8");
17788
16924
  const tomlData = parseTomlFile(filePath);
17789
- // `accessRule`/`access` conflicts surface as a clear error here too,
17790
- // rather than silently dropping the field during a migrate rewrite.
17791
- let typeConfig;
17792
- let operations;
17793
- let subscriptions;
17794
- try {
17795
- ({ typeConfig, operations, subscriptions } =
17796
- parseDatabaseTypeToml(tomlData));
17797
- }
17798
- catch (err) {
17799
- if (err instanceof SubscriptionAccessKeyConflictError) {
17800
- error(` ${file}: ${err.message}`);
17801
- error(`Aborting migrate-toml: subscription \`access\`/\`accessRule\` conflict in ${file}.`);
17802
- process.exit(1);
17803
- }
17804
- throw err;
17805
- }
17806
- // Force-native: ignore existing hints so every TOMLable field
17807
- // ends up in nested-table form. Subscriptions are preserved on the
17808
- // rewrite (issue #803) so migrate-toml never drops them.
17809
- const rewritten = serializeDatabaseType(typeConfig, operations, ruleSetIdToName, {
17810
- defaultForm: "native",
16925
+ const { typeConfig } = parseDatabaseTypeToml(tomlData);
16926
+ const rewritten = serializeDatabaseType(typeConfig, ruleSetIdToName, {
17811
16927
  logger: (msg) => info(` ${msg}`),
17812
- subscriptions,
17813
16928
  });
17814
16929
  if (rewritten === rawBefore) {
17815
16930
  unchangedCount++;