primitive-admin 1.2.0-alpha.11 → 1.2.0-alpha.13

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 (55) hide show
  1. package/dist/src/commands/agent-sessions.d.ts +26 -10
  2. package/dist/src/commands/agent-sessions.js +80 -83
  3. package/dist/src/commands/agent-sessions.js.map +1 -1
  4. package/dist/src/commands/init.js +108 -34
  5. package/dist/src/commands/init.js.map +1 -1
  6. package/dist/src/commands/locks.d.ts +5 -3
  7. package/dist/src/commands/locks.js +17 -61
  8. package/dist/src/commands/locks.js.map +1 -1
  9. package/dist/src/commands/rule-sets.d.ts +1 -1
  10. package/dist/src/commands/rule-sets.js +0 -1
  11. package/dist/src/commands/rule-sets.js.map +1 -1
  12. package/dist/src/commands/sync.d.ts +28 -0
  13. package/dist/src/commands/sync.js +255 -70
  14. package/dist/src/commands/sync.js.map +1 -1
  15. package/dist/src/lib/api-client.d.ts +6 -4
  16. package/dist/src/lib/api-client.js +4 -14
  17. package/dist/src/lib/api-client.js.map +1 -1
  18. package/dist/src/lib/config-object-descriptor.js +1 -0
  19. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  20. package/dist/src/lib/db-codegen/dbTypeIR.d.ts +13 -0
  21. package/dist/src/lib/db-codegen/dbTypeIR.js +13 -0
  22. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  23. package/dist/src/lib/db3-type-models.d.ts +92 -0
  24. package/dist/src/lib/db3-type-models.js +321 -0
  25. package/dist/src/lib/db3-type-models.js.map +1 -0
  26. package/dist/src/lib/function-db-types.d.ts +3 -1
  27. package/dist/src/lib/function-db-types.js +42 -8
  28. package/dist/src/lib/function-db-types.js.map +1 -1
  29. package/dist/src/lib/function-typecheck.js +3 -0
  30. package/dist/src/lib/function-typecheck.js.map +1 -1
  31. package/dist/src/lib/generated-config-surfaces.d.ts +19 -2
  32. package/dist/src/lib/generated-config-surfaces.js +38 -3
  33. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  34. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  35. package/dist/src/lib/generated-sdk-types.js +1 -1
  36. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  37. package/dist/src/lib/init-assets.d.ts +2 -6
  38. package/dist/src/lib/init-assets.js +11 -27
  39. package/dist/src/lib/init-assets.js.map +1 -1
  40. package/dist/src/lib/init-ios-links.d.ts +11 -7
  41. package/dist/src/lib/init-ios-links.js +28 -22
  42. package/dist/src/lib/init-ios-links.js.map +1 -1
  43. package/dist/src/lib/init-schema.d.ts +15 -0
  44. package/dist/src/lib/init-schema.js +54 -0
  45. package/dist/src/lib/init-schema.js.map +1 -1
  46. package/dist/src/lib/session-scope.d.ts +26 -0
  47. package/dist/src/lib/session-scope.js +58 -0
  48. package/dist/src/lib/session-scope.js.map +1 -0
  49. package/dist/src/lib/sync-resource-types.d.ts +54 -0
  50. package/dist/src/lib/sync-resource-types.js +92 -1
  51. package/dist/src/lib/sync-resource-types.js.map +1 -1
  52. package/dist/src/lib/template.d.ts +39 -3
  53. package/dist/src/lib/template.js +103 -14
  54. package/dist/src/lib/template.js.map +1 -1
  55. package/package.json +2 -2
@@ -9,7 +9,8 @@ import { buildDatabaseTypeTomlData } from "../lib/toml-database-config.js";
9
9
  // #3317 — see `config-json-field.ts`.
10
10
  import { serializeJsonField } from "../lib/config-json-field.js";
11
11
  import { parseMetadataCategoryToml, validateMetadataCategoryConfig, serializeMetadataCategoryConfig, parseDeclaredAccessManifestToml, validateDeclaredAccessManifest, } from "../lib/toml-metadata-config.js";
12
- import { getCurrentAppId, getServerUrl } from "../lib/config.js";
12
+ import { getCurrentAppId, getServerUrl, loadCredentials } from "../lib/config.js";
13
+ import { sessionHoldsScope, tokenReachesScope, } from "../lib/session-scope.js";
13
14
  import { requireCurrentEnvironment } from "../lib/env-resolver.js";
14
15
  import { ensureFileIgnored } from "../lib/local-state.js";
15
16
  import { PROJECT_TREE_DIR } from "../lib/env-resolver-core.js";
@@ -17,6 +18,7 @@ import { cachedVarsNote, layerVars, planVarsOverridePull, varsOverridePath as re
17
18
  import { resolveSyncDir, resolveSnapshotsRoot, resolveSyncStateReadPath, resolveSyncStateWritePath, SYNC_STATE_FILENAME, checkLegacyDatabaseTypesDir, } from "../lib/sync-paths.js";
18
19
  import { createSnapshot, listSnapshots, resolveSnapshot, restoreSnapshot, pruneSnapshots, } from "../lib/snapshots.js";
19
20
  import { parseConfigToml, stringifyConfigToml } from "../lib/config-toml.js";
21
+ import { cloneTomlValue, planDb3TypePull, readDb3TypesDir, resolveDb3TypeModels } from "../lib/db3-type-models.js";
20
22
  import { canonicalizeIntegrationRequestConfig } from "../lib/integration-request-config.js";
21
23
  import { canonicalizeServerTextFields } from "../lib/server-text-normalization.js";
22
24
  import { canonicalJsonStringify } from "../lib/canonical-json.js";
@@ -27,6 +29,9 @@ import { collectAppSettingsPushErrors, writePulledAppSettings, diffAppSettings,
27
29
  import { BLOB_BUCKET_TABLE, DATABASE_TYPE_TABLE, DB3_TYPE_TABLE, EMAIL_TEMPLATE_TABLE, INTEGRATION_TABLE, METADATA_CATEGORY_CONFIG_TABLE, PROMPT_CONFIG_TABLE, PROMPT_TABLE, RULE_SET_TABLE, SERVER_FUNCTION_TABLE, TEST_CASE_SURFACE, TEST_CASE_TABLE, configDeclaredTypeErrors, deprecatedConfigKey, fieldServerDefault, findMisshapenTables, findUnknownDocumentKeys, findUnknownGroupedTomlKeys, findUnknownTomlKeys, misshapenTableMessage, retiredTomlKey, retiredDocumentKey, spellsDecimalNumber, unrecognizedPushKeysMessage, unrecognizedPushTablesMessage, warnUnrecognizedServerKeys, } from "../lib/config-surface.js";
28
30
  import { buildPayloadFromToml, projectEntityToPayload, projectRecordToToml, tomlOwned, } from "../lib/config-payload.js";
29
31
  import { CONFIG_SURFACES, isEmptyForEmit, tomlGroupsOf, RETIRED_CONFIG_DIRECTORIES, RETIRED_TYPE_CONFIG_DIRECTORIES, } from "../lib/generated-config-surfaces.js";
32
+ // #4959 — the retired rule-set resource types, vendored with the one sentence
33
+ // the rule-set routes answer, so the preflight refuses exactly what they do.
34
+ import { retiredRuleSetResourceType } from "../lib/generated-config-surfaces.js";
30
35
  // #3626 — the prompt KIND rule, vendored with the definitions so the CLI's
31
36
  // preflight refuses exactly what the admin routes refuse.
32
37
  import { promptKindOf, resolvePromptKind, } from "../lib/generated-config-surfaces.js";
@@ -66,7 +71,7 @@ import { isFunctionDeclarationFile, resolveDocumentSchema, } from "../lib/functi
66
71
  import { createFunctionTypechecker, } from "../lib/function-typecheck.js";
67
72
  import { resolveFunctionSourceRoots } from "../lib/function-source-roots.js";
68
73
  import { detectLayout, migrateLegacyToV2 } from "../lib/block-layout.js";
69
- import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, collectPushPruneCandidates, decidePushPruneCandidate, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, planPrune, preserveUnwrittenEntries, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
74
+ import { ABSENT_FROM_EXPORT, LOCAL_ONLY_ABSENT, LOCAL_ONLY_NEW, PUSH_PRUNE_SPECS, REMOTE_ONLY_UNMANAGED, REMOTE_ONLY_WILL_DELETE, SYNC_RESOURCE_TYPES, VALIDATION_ERROR, applyPrune, attachmentWritePath, attemptFetch, classifyLocalOnly, classifyPruneDeleteFailure, classifyRemoteOnly, formatPrunePlan, hasUncommittedChanges, isSafeAttachmentFilename, planPrune, planPushPrune, preserveUnwrittenEntries, pruneAdminScopeRefusal, pruneScopeUnknownRefusal, removePrunedSidecar, ruleSetFileKey, } from "../lib/sync-resource-types.js";
70
75
  import { normalizeTestCaseKey, testCaseKeyError, } from "../lib/test-case-keys.js";
71
76
  import { slugifyTestCaseName, testCaseFileBasenames } from "../lib/test-case-file-names.js";
72
77
  // The sidecar naming rules live in `lib/` (#3061); re-exported here because
@@ -1459,6 +1464,37 @@ export function configUnknownKeyError(filePath, tomlData, table) {
1459
1464
  * spelling with no scheduled removal (a function's frozen `access`, #4738) is
1460
1465
  * not one of them. A repeated table's entries are read one by one.
1461
1466
  */
1467
+ /**
1468
+ * #4989 — whether this session can make the prune's hard deletes in `appId`:
1469
+ * read from the stored token and step-up where they say, with the app's
1470
+ * parent looked up only when a pin on it could be what reaches the app, and
1471
+ * from the server for an opaque CI token. Rejects when a read it needs fails;
1472
+ * the caller treats that as unknown, never as `admin`.
1473
+ */
1474
+ async function sessionHoldsAdminFor(client, appId) {
1475
+ const credentials = loadCredentials();
1476
+ // No stored session to read: the server judges each delete.
1477
+ if (!credentials)
1478
+ return true;
1479
+ const target = { appId };
1480
+ let holds = sessionHoldsScope(credentials, "admin", target);
1481
+ if (holds === true)
1482
+ return true;
1483
+ const app = (await client.getApp(appId));
1484
+ target.parentAppId = app?.parentAppId ?? null;
1485
+ holds = sessionHoldsScope(credentials, "admin", target);
1486
+ if (holds !== null)
1487
+ return holds;
1488
+ // An opaque CI token: the server says what it holds. "full" is unscoped.
1489
+ const me = (await client.getMe());
1490
+ const session = me?.session;
1491
+ if (!session)
1492
+ return true;
1493
+ return tokenReachesScope({
1494
+ scope: Array.isArray(session.scope) ? session.scope : undefined,
1495
+ apps: session.apps,
1496
+ }, "admin", target);
1497
+ }
1462
1498
  function deprecatedKeysPresent(tomlData, table) {
1463
1499
  const prefix = table.tomlPath.join(".");
1464
1500
  const at = table.tomlPath.reduce((node, segment) => (node == null ? node : node[segment]), tomlData);
@@ -1680,6 +1716,31 @@ export function retiredTypeConfigFilesIn(configDir) {
1680
1716
  }
1681
1717
  return refusals;
1682
1718
  }
1719
+ /**
1720
+ * Every `rule-sets/*.toml` whose `[ruleSet] resourceType` is retired (#4959:
1721
+ * `lock`), as `<file>: ruleSet.resourceType = "<type>" — <sentence>`. A file
1722
+ * that does not parse is left to the rule-set validators.
1723
+ */
1724
+ export function retiredRuleSetFilesIn(configDir) {
1725
+ const dir = join(configDir, "rule-sets");
1726
+ if (!existsSync(dir))
1727
+ return [];
1728
+ const refusals = [];
1729
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".toml")).sort()) {
1730
+ let resourceType;
1731
+ try {
1732
+ resourceType = parseTomlFile(join(dir, file))?.ruleSet?.resourceType;
1733
+ }
1734
+ catch {
1735
+ continue;
1736
+ }
1737
+ const retired = retiredRuleSetResourceType(resourceType);
1738
+ if (!retired)
1739
+ continue;
1740
+ refusals.push(`${file}: ruleSet.resourceType = ${JSON.stringify(resourceType)} — ${retired.message}`);
1741
+ }
1742
+ return refusals;
1743
+ }
1683
1744
  /**
1684
1745
  * `config push`'s preflight — every check the authored files decide on their
1685
1746
  * own, run before the FIRST mutating call (#976 fix A, #3320, #3375).
@@ -1755,6 +1816,13 @@ export async function runConfigPushPreflight(input) {
1755
1816
  }
1756
1817
  if (retiredTypeConfigFiles.length > 0)
1757
1818
  abortOnPreflightErrors();
1819
+ // #4959 — a rule set of a retired resource type (`lock`) is refused here,
1820
+ // with the sentence the rule-set routes give, before the rule-set loop could
1821
+ // skip it as unchanged. Deleting the file and pushing with `--prune` removes
1822
+ // the stored row.
1823
+ for (const refusal of retiredRuleSetFilesIn(configDir)) {
1824
+ preflightValidationErrors.push(` ${refusal}`);
1825
+ }
1758
1826
  // Validate all database-type TOMLs: the parse itself refuses an invalid
1759
1827
  // declared-access manifest. (#3189 — the `[[operations]]` `$params` and
1760
1828
  // `[[subscriptions]]` checks retired with the two tables, which the
@@ -1776,9 +1844,12 @@ export async function runConfigPushPreflight(input) {
1776
1844
  // refused here, naming both files, before anything is applied.
1777
1845
  const preflightDb3TypesDir = join(configDir, "db3-types");
1778
1846
  if (existsSync(preflightDb3TypesDir)) {
1847
+ // #5025 — each file as push sends it: with the models it names.
1848
+ const preflightDb3Resolution = resolveDb3TypeModels(preflightDb3TypesDir);
1779
1849
  for (const file of selectFiles("db3-type", preflightDb3TypesDir, readdirSync(preflightDb3TypesDir).filter((f) => f.endsWith(".toml")))) {
1780
1850
  try {
1781
- parseDb3TypeToml(parseTomlFile(join(preflightDb3TypesDir, file)));
1851
+ const parsed = parseTomlFile(join(preflightDb3TypesDir, file));
1852
+ parseDb3TypeToml(preflightDb3Resolution.documents.get(file) ?? parsed);
1782
1853
  }
1783
1854
  catch (err) {
1784
1855
  preflightValidationErrors.push(` ${file}: ${err?.message || String(err)}`);
@@ -1983,7 +2054,10 @@ export async function runConfigPushPreflight(input) {
1983
2054
  // cannot disagree about which files are pushable.
1984
2055
  for (const rejected of collectConfigFileValidationErrors(configDir)) {
1985
2056
  for (const message of rejected.messages) {
1986
- preflightValidationErrors.push(` ${message}`);
2057
+ // #5025 — a refusal about two db3 type files is collected for each.
2058
+ if (!preflightValidationErrors.includes(` ${message}`)) {
2059
+ preflightValidationErrors.push(` ${message}`);
2060
+ }
1987
2061
  }
1988
2062
  }
1989
2063
  // #4872 — a deprecated key is accepted and never sent; the file is told
@@ -2901,6 +2975,29 @@ export function collectConfigFileValidationErrors(configDir) {
2901
2975
  messages,
2902
2976
  });
2903
2977
  }
2978
+ // #5025 — a db3 type naming a model declared in another file of the
2979
+ // directory: an undeclared or self reference, or two files declaring one
2980
+ // model differently, refuses every file it is about.
2981
+ if (dir === "db3-types") {
2982
+ for (const refusal of resolveDb3TypeModels(typeDir).refusals) {
2983
+ for (const file of refusal.files) {
2984
+ const filePath = join(typeDir, file);
2985
+ let tomlData;
2986
+ try {
2987
+ tomlData = parseTomlFile(filePath);
2988
+ }
2989
+ catch {
2990
+ continue;
2991
+ }
2992
+ found.push({
2993
+ type: surface.label,
2994
+ key: localConfigRowKey(surface.label, tomlData, file),
2995
+ filePath,
2996
+ messages: [refusal.message],
2997
+ });
2998
+ }
2999
+ }
3000
+ }
2904
3001
  }
2905
3002
  return found;
2906
3003
  }
@@ -4134,6 +4231,42 @@ export const DB3_TYPE_DIFF_SPEC = {
4134
4231
  serialize: (record, maps) => serializeDb3Type(record, maps.ruleSetIdToName),
4135
4232
  describe: (entity) => `db3 type "${entity?.databaseType}"`,
4136
4233
  };
4234
+ /**
4235
+ * #5025 — what a `config push` conflict on `db3-types/<name>.toml` says when
4236
+ * the server holds a type of that name this tree never pulled: a database
4237
+ * type (`POST databases/types`), or a db3 type pushed from another tree.
4238
+ */
4239
+ export function db3TypeConflictHint(name) {
4240
+ return (`The server already holds a type named "${name}" (a database type, or a db3 type this ` +
4241
+ `tree never pulled). Run \`primitive config pull\` to adopt it as db3-types/${name}.toml, ` +
4242
+ `or \`primitive config push --force\` to overwrite it with this file.`);
4243
+ }
4244
+ /**
4245
+ * #5025 — the baseline a successful `PUT db3-types/:name` stamps: the semantic
4246
+ * hash of the row its answer carries, read as the live listing spells it. A
4247
+ * file naming shared models reads differently whenever the declaring file
4248
+ * moves, while its own bytes stand still, so the byte hash alone would read
4249
+ * the next such edit as both sides having changed.
4250
+ */
4251
+ export function db3TypeWriteBaseline(answerType, name, maps) {
4252
+ if (!answerType || typeof answerType !== "object")
4253
+ return undefined;
4254
+ const { name: typeName, revision: _revision, ...row } = answerType;
4255
+ return remoteSemanticBaseline({
4256
+ spec: DB3_TYPE_DIFF_SPEC,
4257
+ maps,
4258
+ returned: { ...row, databaseType: row.databaseType ?? typeName ?? name },
4259
+ });
4260
+ }
4261
+ /**
4262
+ * #5025 — the hint a declined `db3-types/` push carries: the conflict hint
4263
+ * when the row is live but this tree holds no baseline for it, none otherwise.
4264
+ */
4265
+ export function db3TypeDeclineHint(name, outcome) {
4266
+ return outcome.action === "conflict" && outcome.direction === "unknown-direction"
4267
+ ? db3TypeConflictHint(name)
4268
+ : undefined;
4269
+ }
4137
4270
  /**
4138
4271
  * #4802 — the names a config tree holds in BOTH `db3-types/` and
4139
4272
  * `database-type-configs/`, each one resource. Twins whose files read the same
@@ -4150,6 +4283,8 @@ export function classifyDb3TypeTwins(configDir, maps = { ruleSetIdToName: new Ma
4150
4283
  if (!existsSync(db3Dir) || !existsSync(legacyDir))
4151
4284
  return { agreeing, refusals, refusedNames };
4152
4285
  const legacy = new Map();
4286
+ // #5025 — the db3 side as push sends it, with the models it names.
4287
+ const resolved = resolveDb3TypeModels(db3Dir).documents;
4153
4288
  for (const file of readdirSync(legacyDir).filter((f) => f.endsWith(".toml"))) {
4154
4289
  try {
4155
4290
  const parsed = parseTomlFile(join(legacyDir, file));
@@ -4167,6 +4302,7 @@ export function classifyDb3TypeTwins(configDir, maps = { ruleSetIdToName: new Ma
4167
4302
  catch {
4168
4303
  continue;
4169
4304
  }
4305
+ parsed = resolved.get(file) ?? parsed;
4170
4306
  const name = parsed?.type?.name || basename(file, ".toml");
4171
4307
  const twin = legacy.get(name);
4172
4308
  if (!twin)
@@ -7794,11 +7930,42 @@ export function registerConfigSyncCommands(sync) {
7794
7930
  }
7795
7931
  // #4802 — db3 types: the same rows, named by `name`.
7796
7932
  const db3TypeEntities = {};
7933
+ const db3TypeContents = new Map();
7934
+ for (const typeConfig of db3TypesToWrite) {
7935
+ db3TypeContents.set(`${typeConfig.databaseType}.toml`, serializeDb3Type(typeConfig, ruleSetIdToName, { logger: (msg) => info(` ${msg}`) }));
7936
+ }
7937
+ // #5025 — a local file naming models declared in another file is kept
7938
+ // when, resolved against the tree this pull leaves, it reads as its
7939
+ // server row; decided for every file before any is written. The tree
7940
+ // it leaves has no file the prune below removes, so a declaring type
7941
+ // deleted on the server is planned out here (a file the prune then
7942
+ // keeps for its local edits only costs a rewrite in full).
7943
+ const db3TypeResource = SYNC_RESOURCE_TYPES.find((type) => type.stateSlot === "db3Types");
7944
+ const db3PrunePlan = planPrune({
7945
+ resourceType: db3TypeResource,
7946
+ priorKeys: Object.keys(priorEntities.db3Types ?? {}).filter((key) => selectionIncludesKey(pullSelection, db3TypeResource.label, key)),
7947
+ presence: databaseTypeConfigsOutcome.ok
7948
+ ? { ok: true, serverKeys: db3TypesToWrite.map((t) => t.databaseType) }
7949
+ : { ok: false },
7950
+ enabled: options.prune !== false,
7951
+ });
7952
+ const db3PullPlan = planDb3TypePull({
7953
+ local: readDb3TypesDir(join(configDir, "db3-types")),
7954
+ server: db3TypeContents,
7955
+ same: (serverContent, document) => sameDb3TypeAs(serverContent, pullDiffMaps)(stringifyConfigToml(document)),
7956
+ removed: db3PrunePlan.keys.map((key) => db3TypeResource.fileFromKey(key)),
7957
+ });
7797
7958
  for (const typeConfig of db3TypesToWrite) {
7798
7959
  const filename = `${typeConfig.databaseType}.toml`;
7799
7960
  const filePath = join(configDir, "db3-types", filename);
7800
- const content = serializeDb3Type(typeConfig, ruleSetIdToName, { logger: (msg) => info(` ${msg}`) });
7801
- const written = writePulledFile(filePath, content, pullWrites, sameDb3TypeAs(content, pullDiffMaps));
7961
+ const content = db3TypeContents.get(filename);
7962
+ const kept = db3PullPlan.keep.has(filename);
7963
+ if (kept)
7964
+ pullWrites.unchanged += 1;
7965
+ // A reference-form file the plan rewrites is compared by bytes only:
7966
+ // unresolved, it can read as the server row while what it pushes does not.
7967
+ const matches = db3PullPlan.rewrite.has(filename) ? undefined : sameDb3TypeAs(content, pullDiffMaps);
7968
+ const written = !kept && writePulledFile(filePath, content, pullWrites, matches);
7802
7969
  let semanticHash;
7803
7970
  try {
7804
7971
  semanticHash = hashLocalConfigForDiff(DB3_TYPE_DIFF_SPEC, parseConfigToml(content), pullDiffMaps);
@@ -7816,6 +7983,8 @@ export function registerConfigSyncCommands(sync) {
7816
7983
  if (written)
7817
7984
  info(` Wrote db3-types/${filename}`);
7818
7985
  }
7986
+ for (const note of db3PullPlan.notes)
7987
+ warn(` Note: ${note}`);
7819
7988
  // #3182 — the typed-handle artifacts are REGENERATED, never pulled:
7820
7989
  // they are not in the envelope, and a fresh clone must end up with the
7821
7990
  // same three files a push would have written. Written here, after the
@@ -8399,6 +8568,8 @@ What a push guarantees (two stages):
8399
8568
  else if (conflict.direction === "late-409") {
8400
8569
  console.log(` ${chalk.dim("the server changed between this push's read and its write")}`);
8401
8570
  }
8571
+ if (conflict.hint)
8572
+ console.log(` ${conflict.hint}`);
8402
8573
  for (const field of conflict.fields || []) {
8403
8574
  console.log(` ${field.field}: local ${summarizeFieldValue(field.local)} | server ${summarizeFieldValue(field.server)}`);
8404
8575
  }
@@ -8546,7 +8717,7 @@ What a push guarantees (two stages):
8546
8717
  warn(` ${label}: live state unavailable (${message}) — falling back to last-sync content hashes`);
8547
8718
  };
8548
8719
  /** Record a decision push refused to apply, with its per-field diff. */
8549
- const recordDeclined = (type, key, outcome, storedModifiedAt) => {
8720
+ const recordDeclined = (type, key, outcome, storedModifiedAt, hint) => {
8550
8721
  if (outcome.action === "drift") {
8551
8722
  drifted.push({ type, key });
8552
8723
  return;
@@ -8595,6 +8766,7 @@ What a push guarantees (two stages):
8595
8766
  direction: outcome.direction,
8596
8767
  fields: outcome.fields,
8597
8768
  planned: false,
8769
+ ...(hint ? { hint } : {}),
8598
8770
  });
8599
8771
  };
8600
8772
  /**
@@ -8747,6 +8919,39 @@ What a push guarantees (two stages):
8747
8919
  if (varsOverrideKeys && varsOverrideFile) {
8748
8920
  info(varsOverrideSummaryLine(varsOverrideFile, varsOverrideKeys));
8749
8921
  }
8922
+ // #4989 — a prune whose plan hard-deletes needs `admin`. A session
8923
+ // without it is refused here, before anything below mutates, rather
8924
+ // than having each delete refused after the rest of the push landed.
8925
+ // A session whose scope cannot be read is refused too: assuming
8926
+ // `admin` would let the push land halfway all the same.
8927
+ if (options.prune) {
8928
+ const refusal = pruneAdminScopeRefusal(await planPushPrune({
8929
+ specs: PUSH_PRUNE_SPECS.filter((spec) => spec.deleteNeedsAdmin),
8930
+ configDir,
8931
+ entities: syncState?.entities,
8932
+ includes: (label, key) => selectionIncludesKey(pushSelection, label, key),
8933
+ client,
8934
+ appId: resolvedAppId,
8935
+ force: !!options.force,
8936
+ }));
8937
+ let stop = null;
8938
+ if (refusal) {
8939
+ try {
8940
+ if (!(await sessionHoldsAdminFor(client, resolvedAppId)))
8941
+ stop = refusal;
8942
+ }
8943
+ catch (err) {
8944
+ stop = pruneScopeUnknownRefusal(err?.message ?? String(err));
8945
+ }
8946
+ }
8947
+ if (stop && options.dryRun) {
8948
+ warn(stop);
8949
+ }
8950
+ else if (stop) {
8951
+ error(stop);
8952
+ process.exit(1);
8953
+ }
8954
+ }
8750
8955
  // Re-derived, not returned: both are one-line functions of inputs the
8751
8956
  // entry point already took, and widening its return type to carry them
8752
8957
  // would make the vars apply leg depend on the preflight's internals.
@@ -10731,9 +10936,11 @@ What a push guarantees (two stages):
10731
10936
  else if (files.length > 0) {
10732
10937
  warnLiveUnavailable("db3-types", "rule sets unavailable");
10733
10938
  }
10939
+ // #5025 — each file with the models it names in another file.
10940
+ const db3Resolution = resolveDb3TypeModels(db3TypesDir);
10734
10941
  for (const file of files) {
10735
10942
  const filePath = join(db3TypesDir, file);
10736
- const tomlData = parseTomlFile(filePath);
10943
+ const tomlData = db3Resolution.documents.get(file) ?? parseTomlFile(filePath);
10737
10944
  const { typeConfig } = parseDb3TypeToml(tomlData);
10738
10945
  const name = typeConfig.databaseType || basename(file, ".toml");
10739
10946
  if (agreeingTwins.has(name))
@@ -10780,7 +10987,7 @@ What a push guarantees (two stages):
10780
10987
  continue;
10781
10988
  }
10782
10989
  if (gate.action === "drift" || gate.action === "conflict" || gate.action === "immutable") {
10783
- recordDeclined("db3-type", name, gate, entry?.modifiedAt);
10990
+ recordDeclined("db3-type", name, gate, entry?.modifiedAt, db3TypeDeclineHint(name, gate));
10784
10991
  continue;
10785
10992
  }
10786
10993
  }
@@ -10828,11 +11035,18 @@ What a push guarantees (two stages):
10828
11035
  else if (answer.push?.status === "not_started") {
10829
11036
  warn(` The push to the db3s of ${name} did not start: ${answer.push.error}`);
10830
11037
  }
10831
- record(answer.type?.modifiedAt || new Date().toISOString(), undefined, typeof answer.type?.schema === "string" && answer.type.schema.length > 0);
11038
+ record(answer.type?.modifiedAt || new Date().toISOString(), db3TypeWriteBaseline(answer.type, name, pushDiffMaps), typeof answer.type?.schema === "string" && answer.type.schema.length > 0);
10832
11039
  }
10833
11040
  catch (err) {
10834
11041
  if (err instanceof ConflictError) {
10835
- conflicts.push({ type: "db3-type", key: name, serverModifiedAt: err.serverModifiedAt, localModifiedAt: expectedModifiedAt || "unknown" });
11042
+ conflicts.push({
11043
+ type: "db3-type",
11044
+ key: name,
11045
+ serverModifiedAt: err.serverModifiedAt,
11046
+ localModifiedAt: expectedModifiedAt || "unknown",
11047
+ // #5025 — a create the server refused: it holds the name.
11048
+ ...(exists ? {} : { hint: db3TypeConflictHint(name) }),
11049
+ });
10836
11050
  continue;
10837
11051
  }
10838
11052
  throw wrapEntityError(err, exists ? "update" : "create", "db3 type", name);
@@ -12426,65 +12640,25 @@ What a push guarantees (two stages):
12426
12640
  const toDelete = [];
12427
12641
  const alreadyAbsent = [];
12428
12642
  const skipped = [];
12429
- for (const spec of PUSH_PRUNE_SPECS) {
12430
- const priorEntities = syncState?.entities?.[spec.resourceType.stateSlot];
12431
- const candidates = collectPushPruneCandidates({
12432
- spec,
12433
- configDir,
12434
- priorEntities,
12435
- }).filter(({ key }) =>
12643
+ for (const item of await planPushPrune({
12644
+ specs: PUSH_PRUNE_SPECS,
12645
+ configDir,
12646
+ entities: syncState?.entities,
12436
12647
  // `--only` bounds prune to the selection. Without this a
12437
12648
  // single-object push with `--prune` would delete every managed
12438
12649
  // entity whose file happens to be missing — objects the operator
12439
12650
  // did not name and is not looking at.
12440
- selectionIncludesKey(pushSelection, spec.resourceType.label, key));
12441
- for (const { key, entry } of candidates) {
12442
- const address = spec.addressOf(key, entry);
12443
- // Behavior 4: skip the point read for a candidate we could not
12444
- // delete anyway — no usable address (for metadata-category-configs
12445
- // that includes a state key that is not a `resourceType#category`
12446
- // pair, #2762), or a spec with no delete surface.
12447
- if (!address || !spec.deleteEntity) {
12448
- const decision = decidePushPruneCandidate({
12449
- spec,
12450
- address,
12451
- priorModifiedAt: entry?.modifiedAt,
12452
- fetch: { ok: false },
12453
- force: !!options.force,
12454
- });
12455
- skipped.push({ spec, key, entry, address, decision });
12456
- continue;
12457
- }
12458
- // Point read: confirm live presence + drift. Fail closed — any
12459
- // non-404 error leaves the entity and its state slot untouched.
12460
- let fetchOutcome;
12461
- try {
12462
- const detail = await spec.fetchDetail(client, resolvedAppId, address);
12463
- fetchOutcome = { ok: true, detail };
12464
- }
12465
- catch (err) {
12466
- if (err instanceof ApiError && err.statusCode === 404) {
12467
- fetchOutcome = { ok: false, status: 404 };
12468
- }
12469
- else {
12470
- fetchOutcome = { ok: false };
12471
- }
12472
- }
12473
- const decision = decidePushPruneCandidate({
12474
- spec,
12475
- address,
12476
- priorModifiedAt: entry?.modifiedAt,
12477
- fetch: fetchOutcome,
12478
- force: !!options.force,
12479
- });
12480
- const item = { spec, key, entry, address, decision };
12481
- if (decision.action === "delete")
12482
- toDelete.push(item);
12483
- else if (decision.action === "drop-state")
12484
- alreadyAbsent.push(item);
12485
- else
12486
- skipped.push(item);
12487
- }
12651
+ includes: (label, key) => selectionIncludesKey(pushSelection, label, key),
12652
+ client,
12653
+ appId: resolvedAppId,
12654
+ force: !!options.force,
12655
+ })) {
12656
+ if (item.decision.action === "delete")
12657
+ toDelete.push(item);
12658
+ else if (item.decision.action === "drop-state")
12659
+ alreadyAbsent.push(item);
12660
+ else
12661
+ skipped.push(item);
12488
12662
  }
12489
12663
  const totalPlanned = toDelete.length +
12490
12664
  alreadyAbsent.length +
@@ -12570,6 +12744,7 @@ What a push guarantees (two stages):
12570
12744
  else {
12571
12745
  let prunedCount = 0;
12572
12746
  let blockedCount = 0;
12747
+ let failedCount = 0;
12573
12748
  // Entities already gone server-side: drop their stale state (2).
12574
12749
  for (const item of alreadyAbsent) {
12575
12750
  dropPruneState(item.spec, item.key);
@@ -12603,8 +12778,16 @@ What a push guarantees (two stages):
12603
12778
  warn(` Blocked — ${item.spec.resourceType.label} ${item.key} is still referenced: ${message}`);
12604
12779
  break;
12605
12780
  default:
12606
- blockedCount++;
12607
- warn(` Skipped — could not delete ${item.spec.resourceType.label} ${item.key}: ${message}`);
12781
+ // #4989 — any other refusal fails the push with the
12782
+ // server's error, as every other apply failure does
12783
+ // (#2731 B2), and keeps the state so a re-run retries.
12784
+ failedCount++;
12785
+ applyFailures.push({
12786
+ type: item.spec.resourceType.label,
12787
+ key: item.key,
12788
+ message,
12789
+ });
12790
+ error(` Failed — could not delete ${item.spec.resourceType.label} ${item.key}: ${message}`);
12608
12791
  }
12609
12792
  }
12610
12793
  }
@@ -12621,7 +12804,7 @@ What a push guarantees (two stages):
12621
12804
  });
12622
12805
  prunedCount += testCasePrune.deleted + testCasePrune.deletedAttachments;
12623
12806
  if (totalPlanned > 0) {
12624
- info(`Prune: ${prunedCount} deleted, ${alreadyAbsent.length} already absent, ${skipped.length + blockedCount} skipped.`);
12807
+ info(`Prune: ${prunedCount} deleted, ${alreadyAbsent.length} already absent, ${skipped.length + blockedCount} skipped${failedCount > 0 ? `, ${failedCount} failed` : ""}.`);
12625
12808
  }
12626
12809
  }
12627
12810
  }
@@ -12969,8 +13152,10 @@ What a push guarantees (two stages):
12969
13152
  }
12970
13153
  const db3TypesDiffDir = join(configDir, "db3-types");
12971
13154
  if (databaseTypeConfigsOutcomeDiff.ok && existsSync(db3TypesDiffDir)) {
13155
+ // #5025 — compared as push sends it, with the models it names.
13156
+ const db3DiffResolution = resolveDb3TypeModels(db3TypesDiffDir);
12972
13157
  for (const file of readdirSync(db3TypesDiffDir).filter((f) => f.endsWith(".toml"))) {
12973
- const tomlData = parseTomlFile(join(db3TypesDiffDir, file));
13158
+ const tomlData = cloneTomlValue(db3DiffResolution.documents.get(file) ?? parseTomlFile(join(db3TypesDiffDir, file)));
12974
13159
  const key = tomlData.type?.name || basename(file, ".toml");
12975
13160
  if (!tomlData.type)
12976
13161
  tomlData.type = {};