primitive-admin 1.1.0-alpha.65 → 1.1.0-alpha.67

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 (106) hide show
  1. package/README.md +6 -7
  2. package/assets/skill/skills/primitive-platform/SKILL.md +28 -9
  3. package/dist/bin/primitive.js +4 -3
  4. package/dist/bin/primitive.js.map +1 -1
  5. package/dist/src/commands/apps.js +14 -5
  6. package/dist/src/commands/apps.js.map +1 -1
  7. package/dist/src/commands/config.js +11 -11
  8. package/dist/src/commands/config.js.map +1 -1
  9. package/dist/src/commands/databases.js +15 -3
  10. package/dist/src/commands/databases.js.map +1 -1
  11. package/dist/src/commands/email-templates.js +5 -5
  12. package/dist/src/commands/env.d.ts +12 -1
  13. package/dist/src/commands/env.js +98 -25
  14. package/dist/src/commands/env.js.map +1 -1
  15. package/dist/src/commands/init.d.ts +9 -1
  16. package/dist/src/commands/init.js +810 -218
  17. package/dist/src/commands/init.js.map +1 -1
  18. package/dist/src/commands/prompts.js +5 -2
  19. package/dist/src/commands/prompts.js.map +1 -1
  20. package/dist/src/commands/sync-app-settings.d.ts +83 -55
  21. package/dist/src/commands/sync-app-settings.js +154 -200
  22. package/dist/src/commands/sync-app-settings.js.map +1 -1
  23. package/dist/src/commands/sync.d.ts +362 -29
  24. package/dist/src/commands/sync.js +2734 -351
  25. package/dist/src/commands/sync.js.map +1 -1
  26. package/dist/src/commands/workflows.js +19 -5
  27. package/dist/src/commands/workflows.js.map +1 -1
  28. package/dist/src/lib/api-client.d.ts +21 -1
  29. package/dist/src/lib/api-client.js +23 -2
  30. package/dist/src/lib/api-client.js.map +1 -1
  31. package/dist/src/lib/app-settings-descriptor.d.ts +150 -17
  32. package/dist/src/lib/app-settings-descriptor.js +344 -31
  33. package/dist/src/lib/app-settings-descriptor.js.map +1 -1
  34. package/dist/src/lib/config-object-descriptor.js +4 -1
  35. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  36. package/dist/src/lib/config-payload.js +14 -3
  37. package/dist/src/lib/config-payload.js.map +1 -1
  38. package/dist/src/lib/config-surface.d.ts +37 -1
  39. package/dist/src/lib/config-surface.js +118 -0
  40. package/dist/src/lib/config-surface.js.map +1 -1
  41. package/dist/src/lib/env-resolver-core.d.ts +147 -0
  42. package/dist/src/lib/env-resolver-core.js +265 -0
  43. package/dist/src/lib/env-resolver-core.js.map +1 -0
  44. package/dist/src/lib/env-resolver.d.ts +28 -6
  45. package/dist/src/lib/env-resolver.js +44 -32
  46. package/dist/src/lib/env-resolver.js.map +1 -1
  47. package/dist/src/lib/generated-config-surfaces.d.ts +71 -0
  48. package/dist/src/lib/generated-config-surfaces.js +264 -69
  49. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  50. package/dist/src/lib/init-adopt.d.ts +16 -0
  51. package/dist/src/lib/init-adopt.js +34 -0
  52. package/dist/src/lib/init-adopt.js.map +1 -0
  53. package/dist/src/lib/init-assets.d.ts +39 -0
  54. package/dist/src/lib/init-assets.js +97 -0
  55. package/dist/src/lib/init-assets.js.map +1 -0
  56. package/dist/src/lib/init-config.d.ts +21 -4
  57. package/dist/src/lib/init-config.js +45 -8
  58. package/dist/src/lib/init-config.js.map +1 -1
  59. package/dist/src/lib/init-plan.d.ts +80 -0
  60. package/dist/src/lib/init-plan.js +95 -0
  61. package/dist/src/lib/init-plan.js.map +1 -0
  62. package/dist/src/lib/init-production-env.d.ts +48 -0
  63. package/dist/src/lib/init-production-env.js +59 -0
  64. package/dist/src/lib/init-production-env.js.map +1 -0
  65. package/dist/src/lib/init-schema.d.ts +74 -0
  66. package/dist/src/lib/init-schema.js +358 -0
  67. package/dist/src/lib/init-schema.js.map +1 -0
  68. package/dist/src/lib/init-xcode.d.ts +33 -0
  69. package/dist/src/lib/init-xcode.js +114 -0
  70. package/dist/src/lib/init-xcode.js.map +1 -0
  71. package/dist/src/lib/integration-request-config.d.ts +30 -0
  72. package/dist/src/lib/integration-request-config.js +145 -0
  73. package/dist/src/lib/integration-request-config.js.map +1 -0
  74. package/dist/src/lib/local-state.d.ts +55 -0
  75. package/dist/src/lib/local-state.js +167 -0
  76. package/dist/src/lib/local-state.js.map +1 -0
  77. package/dist/src/lib/platform-owned.d.ts +12 -0
  78. package/dist/src/lib/platform-owned.js +21 -0
  79. package/dist/src/lib/platform-owned.js.map +1 -1
  80. package/dist/src/lib/project-config.js +2 -4
  81. package/dist/src/lib/project-config.js.map +1 -1
  82. package/dist/src/lib/resolve-init-server.d.ts +0 -7
  83. package/dist/src/lib/resolve-init-server.js +2 -19
  84. package/dist/src/lib/resolve-init-server.js.map +1 -1
  85. package/dist/src/lib/resolve-platform.d.ts +43 -14
  86. package/dist/src/lib/resolve-platform.js +74 -12
  87. package/dist/src/lib/resolve-platform.js.map +1 -1
  88. package/dist/src/lib/server-text-normalization.d.ts +51 -0
  89. package/dist/src/lib/server-text-normalization.js +90 -0
  90. package/dist/src/lib/server-text-normalization.js.map +1 -0
  91. package/dist/src/lib/server-url.d.ts +22 -0
  92. package/dist/src/lib/server-url.js +33 -0
  93. package/dist/src/lib/server-url.js.map +1 -0
  94. package/dist/src/lib/sync-resource-types.d.ts +8 -0
  95. package/dist/src/lib/sync-resource-types.js +19 -2
  96. package/dist/src/lib/sync-resource-types.js.map +1 -1
  97. package/dist/src/lib/sync-selectors.js +4 -4
  98. package/dist/src/lib/sync-selectors.js.map +1 -1
  99. package/dist/src/lib/template.d.ts +50 -1
  100. package/dist/src/lib/template.js +59 -23
  101. package/dist/src/lib/template.js.map +1 -1
  102. package/dist/src/lib/test-case-keys.d.ts +29 -0
  103. package/dist/src/lib/test-case-keys.js +55 -0
  104. package/dist/src/lib/test-case-keys.js.map +1 -0
  105. package/dist/src/types/index.d.ts +40 -8
  106. package/package.json +2 -2
@@ -6,6 +6,7 @@ import { type PushMode } from "../lib/config-payload.js";
6
6
  import { type ConfigObjectSurface, type ConfigTable } from "../lib/generated-config-surfaces.js";
7
7
  import { type PresenceOutcome, type TestBlockType } from "../lib/sync-resource-types.js";
8
8
  import type { SyncState } from "../types/index.js";
9
+ import { type SyncSelection } from "../lib/sync-selectors.js";
9
10
  /**
10
11
  * Wrap a server-side error so the printed message identifies which entity
11
12
  * was in flight. Used by every entity create/update/delete call site in the
@@ -526,6 +527,36 @@ export declare function archivedTombstoneEntries(items: any[], select: {
526
527
  id: string;
527
528
  modifiedAt: string;
528
529
  }>;
530
+ /**
531
+ * Fold one type's tombstone entries into the sync state this pull is building
532
+ * (#2803, corrected by #2887).
533
+ *
534
+ * Two rules, and the second one is the fix. A tombstone is recorded only for a
535
+ * key this pull did NOT export (an exported key is a live row and its own
536
+ * record) and only when the selection covers it. And a recorded key is marked
537
+ * as MATCHED: archived rows are dropped before `selectPull` runs, so the
538
+ * selector that named one never reached the set that decides whether the pull
539
+ * found what it was asked for. `config pull --only workflow/<key>` against an
540
+ * archived managed row therefore removed the file, saved the tombstone, and
541
+ * then exited 1 saying "the server has no workflow/<key>" — a failure report
542
+ * about a mutation that had already succeeded, which is the worst thing an
543
+ * automation can be told. The fix is generic, so integrations and webhooks
544
+ * (which had the bug first) get it too.
545
+ *
546
+ * Mutates `built` and `matchedSelectors` and returns the keys it recorded, so
547
+ * the caller can report each one. Pure otherwise, and unit-testable.
548
+ */
549
+ export declare function recordArchivedTombstones(built: Record<string, {
550
+ id: string;
551
+ modifiedAt: string;
552
+ }>, tombstones: Record<string, {
553
+ id: string;
554
+ modifiedAt: string;
555
+ }>, context: {
556
+ label: string;
557
+ selection: SyncSelection | null;
558
+ matchedSelectors: Set<string>;
559
+ }): string[];
529
560
  /**
530
561
  * The `config diff` annotation for an object an operator took out of service
531
562
  * (issue #2645, criterion 9; #2803).
@@ -784,6 +815,32 @@ export declare function unknownKeyPreflightTargets(): Array<{
784
815
  surface: ConfigObjectSurface;
785
816
  tables: ConfigTable[];
786
817
  }>;
818
+ /** One config file the TOML preflight rejects, and the row it belongs to. */
819
+ export interface ConfigFileValidationError {
820
+ /** The `config diff` row type, e.g. `prompt`. */
821
+ type: string;
822
+ /** The row key the local file pairs on. */
823
+ key: string;
824
+ filePath: string;
825
+ /** The messages, verbatim — the same text `config push` aborts with. */
826
+ messages: string[];
827
+ }
828
+ /**
829
+ * Every per-entity config file whose TOML `config push` would refuse (#2880
830
+ * criterion 1, symmetry).
831
+ *
832
+ * One collector, two commands. Push aborts on the messages before issuing a
833
+ * request; `config diff` renders them as their own rows, because the failure
834
+ * this closes is a file diff calls Synced and push then rejects — the state the
835
+ * issue's reporter hit after #2803 retired `[prompt].status`, where a local key
836
+ * with no server counterpart is invisible to a comparison and fatal to a push.
837
+ *
838
+ * Three checks, all definition-driven: the document's table shape, keys no
839
+ * definition declares (retired keys among them), and — since #2880 — values
840
+ * whose spelling is not the declared type. A file that does not parse is left
841
+ * to the per-type loop that reads it, which names the parse error itself.
842
+ */
843
+ export declare function collectConfigFileValidationErrors(configDir: string): ConfigFileValidationError[];
787
844
  /**
788
845
  * Every `<key>.tests/*.toml` sidecar under a config directory.
789
846
  *
@@ -1108,12 +1165,47 @@ export interface ConfigDiffMaps {
1108
1165
  export interface ConfigDiffSpec {
1109
1166
  label: string;
1110
1167
  table: ConfigTable;
1111
- /** TOML doc -> the wire-shaped entity (the same parse `config push` uses). */
1112
- parse(doc: any): any;
1168
+ /**
1169
+ * TOML doc -> the wire-shaped entity (the same parse `config push` uses).
1170
+ *
1171
+ * `extra` is the per-type context the projection needs and the document does
1172
+ * not carry (#2880): a test case's id→name lookups, through which the two
1173
+ * spellings of a reference meet. Mirrors `serialize`, which has taken one
1174
+ * since the database types joined.
1175
+ */
1176
+ parse(doc: any, extra?: any): any;
1113
1177
  /** Server entity -> the TOML `config pull` would write. */
1114
1178
  serialize(record: any, maps: ConfigDiffMaps, extra?: any): string;
1115
1179
  /** Structural values to hash beside the definition's fields. */
1116
- extras?(entity: any, doc: any): Record<string, any>;
1180
+ extras?(entity: any, doc: any, extra?: any): Record<string, any>;
1181
+ /**
1182
+ * Rewrite the parsed entity the way the SERVER rewrites an accepted one
1183
+ * (#2880 DSO-003).
1184
+ *
1185
+ * Some handlers canonicalize on the way in — an integration's base URL gains
1186
+ * a trailing slash, its methods are upper-cased — so the state the server
1187
+ * returns is not the text the file holds. Comparing them raw makes an
1188
+ * authored-but-noncanonical value `local-edited` on every run and push
1189
+ * re-apply the same update forever; restamping the baseline cannot fix it,
1190
+ * because the local side never moves. Applied to both sides (the remote
1191
+ * reaches it through the same projection), so it must be idempotent.
1192
+ */
1193
+ canonicalize?(entity: any): void;
1194
+ /**
1195
+ * The projected keys THIS FILE does not manage (#2880 criterion 3).
1196
+ *
1197
+ * A handful of fields are present-only by design: push sends them when the
1198
+ * file spells them and leaves the server's value alone when it does not,
1199
+ * because "absent" here means "not authored here" rather than "cleared" — a
1200
+ * webhook with no `[verification]` section at all, whose signing material a
1201
+ * push must not revoke as a side effect. Comparing such a field against a
1202
+ * server that holds one reports a difference push will never act on: the
1203
+ * update omits the key, the server keeps its value, and the row comes back
1204
+ * Modified on every run. So the local side inherits the remote value for
1205
+ * exactly the keys the file leaves unmanaged, and the two commands agree
1206
+ * that there is nothing to do.
1207
+ */
1208
+ unmanaged?(doc: any): string[];
1117
1209
  /** Whether the entity carries a rule-set reference needing resolution. */
1118
1210
  ruleSetRef?: boolean;
1119
1211
  /** How the type names itself in the rule-set resolution message. */
@@ -1121,9 +1213,10 @@ export interface ConfigDiffSpec {
1121
1213
  }
1122
1214
  /**
1123
1215
  * Every type the shared comparator serves — the types `config diff` compares
1124
- * field-for-field, and that `config push` gates on the same projection wherever
1125
- * it has adopted the shared gate (#2731 B1/B2; webhooks joined the comparator
1126
- * in #2757 while push keeps its own byte-hash gate).
1216
+ * field-for-field, and that `config push` gates on the same projection
1217
+ * (#2731 B1/B2; webhooks joined the comparator in #2757 and their push joined
1218
+ * the same gate in #2880, so no registered type answers "changed?" from the
1219
+ * file's bytes any more).
1127
1220
  *
1128
1221
  * Published so the round-trip acceptance bar (#2731 B9) can require a fixture
1129
1222
  * per type instead of listing them a second time by hand: converting a type is
@@ -1138,11 +1231,41 @@ export declare function configDiffSpec(label: string): ConfigDiffSpec;
1138
1231
  * a push that needs to SAY what differs and a diff that only needs to know THAT
1139
1232
  * something differs read the same projection.
1140
1233
  */
1141
- export declare function projectLocalConfig(spec: ConfigDiffSpec, parsedToml: any, maps: ConfigDiffMaps): Record<string, any>;
1234
+ export declare function projectLocalConfig(spec: ConfigDiffSpec, parsedToml: any, maps: ConfigDiffMaps, extra?: any): Record<string, any>;
1142
1235
  /** The same projection for a SERVER entity — see `hashRemoteConfigForDiff`. */
1143
1236
  export declare function projectRemoteConfig(spec: ConfigDiffSpec, record: any, maps: ConfigDiffMaps, extra?: any): Record<string, any>;
1237
+ /**
1238
+ * BOTH sides of one comparison, with the file's unmanaged keys reconciled
1239
+ * (#2880) — the one place `spec.unmanaged` is honored, so `config diff` and
1240
+ * `config push` cannot read the same file differently.
1241
+ */
1242
+ export declare function projectConfigPair(spec: ConfigDiffSpec, localParsed: any, remoteRecord: any, maps: ConfigDiffMaps, extra?: any, localExtra?: any): {
1243
+ local: Record<string, any>;
1244
+ remote: Record<string, any>;
1245
+ };
1246
+ /**
1247
+ * One `config diff` row's content verdict for a registered type (#2880).
1248
+ *
1249
+ * The three outcomes the per-type blocks were each spelling out by hand:
1250
+ * equal (`exists`), different (`modified`, framed as a preview of
1251
+ * `config pull`), and "could not tell" — a missing record or a comparison that
1252
+ * threw, which degrades THIS row and never the whole diff. Written once so a
1253
+ * type joining the comparator cannot accidentally report a fourth thing.
1254
+ *
1255
+ * The two `extra` arguments are the same split `decidePushForConfig` makes:
1256
+ * `extra` carries what only the SERVER side has (a database type's operation
1257
+ * rows), while `localExtra` is context BOTH sides read a value through — a test
1258
+ * case's id→name lookups. Giving the local side nothing was a silent
1259
+ * mistranslation: a sidecar pinned by a resolvable `configId` projected the id
1260
+ * while the server's projection resolved it to the name, so an untouched file
1261
+ * reported Modified in diff and drifted in push.
1262
+ */
1263
+ export declare function compareLocalToRemote(spec: ConfigDiffSpec, localParsed: any, remoteRecord: any, maps?: ConfigDiffMaps, extra?: any, localExtra?: any): {
1264
+ status: string;
1265
+ hint?: string;
1266
+ };
1144
1267
  /** Hash a LOCAL config file's definition-projected field set (#2644). */
1145
- export declare function hashLocalConfigForDiff(spec: ConfigDiffSpec, parsedToml: any, maps: ConfigDiffMaps): string;
1268
+ export declare function hashLocalConfigForDiff(spec: ConfigDiffSpec, parsedToml: any, maps: ConfigDiffMaps, extra?: any): string;
1146
1269
  /**
1147
1270
  * Hash a SERVER entity the same way, by serializing it into the file `sync
1148
1271
  * pull` would write and hashing that — so the two sides are normalized
@@ -1162,10 +1285,13 @@ export interface PushFieldDiff {
1162
1285
  /**
1163
1286
  * What `config push` should do with one converted-type file, and why.
1164
1287
  *
1165
- * `create`/`skip`/`update` are the applying outcomes; `drift`, `conflict` and
1166
- * `immutable` are the three ways push declines to apply and says so.
1288
+ * `create`/`skip`/`update` are the applying outcomes; `drift`, `conflict`,
1289
+ * `immutable` and `live-unavailable` are the four ways push declines to apply
1290
+ * and says so.
1167
1291
  */
1168
1292
  export type PushGateOutcome = {
1293
+ action: "live-unavailable";
1294
+ } | {
1169
1295
  action: "create";
1170
1296
  } | {
1171
1297
  action: "skip";
@@ -1173,7 +1299,7 @@ export type PushGateOutcome = {
1173
1299
  remoteHash: string;
1174
1300
  } | {
1175
1301
  action: "update";
1176
- direction: "local-edited" | "forced";
1302
+ direction: "local-edited" | "forced" | "adopt-untracked";
1177
1303
  localHash: string;
1178
1304
  remoteHash?: string;
1179
1305
  expectedModifiedAt?: string;
@@ -1219,7 +1345,47 @@ export interface PushBaselineEntry {
1219
1345
  *
1220
1346
  * Declining is a first-class outcome here. `drift`, `conflict` and `immutable`
1221
1347
  * each carry the field diff their report prints, and nothing is applied.
1222
- */
1348
+ *
1349
+ * `adoptsUntrackedByKey` is the one exception a type can ask for, and it is
1350
+ * documented on the field: an object the manifest has never recorded is the
1351
+ * adopt its create path would have performed via a 409 (#1006/#2909), not a
1352
+ * difference to refuse.
1353
+ */
1354
+ /**
1355
+ * What the DEGRADED gate does with one file — the path a converted type takes
1356
+ * when its live read failed (#2880 criterion 7).
1357
+ *
1358
+ * Falling back to the manifest's byte hash is right while there is one to fall
1359
+ * back to. Without one, `shouldPushFile(file, undefined)` answers "push it",
1360
+ * so a fresh checkout or a lost manifest turned "we could not read the server"
1361
+ * into "overwrite the server", unconditionally and silently — the one shape of
1362
+ * blind write this issue's direction attribution exists to prevent. So a
1363
+ * baseline-less update DECLINES: the resource is not written and the operator
1364
+ * is told which of `config pull` / `--force` clears it.
1365
+ *
1366
+ * A file the manifest does not name is declined for the same reason, and this
1367
+ * is the part that is easy to get wrong: an unrecorded file looks like a
1368
+ * create, but only LIVE STATE can say the server has nothing under that key —
1369
+ * and live state is exactly what this path could not read. The create that
1370
+ * follows is adopted by key on the 409 and re-issued as an update, so
1371
+ * "there is no recorded entity to overwrite" would have been a blind overwrite
1372
+ * of a resource this run never compared. Every section's degraded gate is
1373
+ * reached only when the live read failed, so there is no case here where
1374
+ * absence was proved.
1375
+ */
1376
+ export type DegradedPushDecision = {
1377
+ action: "skip";
1378
+ } | {
1379
+ action: "send";
1380
+ } | {
1381
+ action: "decline";
1382
+ reason: "no-baseline";
1383
+ };
1384
+ export declare function decideDegradedPush(input: {
1385
+ force?: boolean;
1386
+ storedContentHash?: string;
1387
+ currentFileHash?: string;
1388
+ }): DegradedPushDecision;
1223
1389
  export declare function decidePushForConfig(input: {
1224
1390
  spec: ConfigDiffSpec;
1225
1391
  maps: ConfigDiffMaps;
@@ -1234,7 +1400,38 @@ export declare function decidePushForConfig(input: {
1234
1400
  entry?: PushBaselineEntry | null;
1235
1401
  /** Byte hash of the local file — the legacy direction signal. */
1236
1402
  localFileHash?: string;
1403
+ /**
1404
+ * Per-type context for the LOCAL projection (#2880) — a test case's id→name
1405
+ * lookups. Separate from `live.extra`, which carries remote-only rows (a
1406
+ * database type's operations) the local parse must never see.
1407
+ */
1408
+ localExtra?: any;
1237
1409
  force?: boolean;
1410
+ /**
1411
+ * Whether this type's push path ADOPTS an existing object by key (#2909).
1412
+ *
1413
+ * Opt-in, and it changes exactly one outcome: a live entity the manifest has
1414
+ * no row for at all. Prompts (#1006), and the other types whose create path
1415
+ * recovers a 409 through `adoptByKeyOnCreate409`, treat that as the adopt it
1416
+ * has always been — an object pushed from another slot, created out of band,
1417
+ * or orphaned by a push that aborted before recording it, which the local
1418
+ * file is the declared intent for. Without this the gate reads that same
1419
+ * shape as a difference it cannot attribute, refuses, and the create the
1420
+ * adopt guard recovers from is never even sent (the #2909 regression).
1421
+ *
1422
+ * #2934 — the criterion is the CREATE PATH, not which issue converted the
1423
+ * type: cron triggers, webhooks, integrations, blob buckets and the
1424
+ * group/collection type configs all recover their create's key conflict the
1425
+ * same way, so they pass it too. Passing it at one call site left the other
1426
+ * five reporting a conflict for the adopt their own create documents.
1427
+ *
1428
+ * Rule sets keep it OFF, and that is a decision rather than an omission:
1429
+ * #2731 made a never-synced rule set differing from a same-named live one the
1430
+ * conflict the operator resolves with `config pull` or `--force`, its own
1431
+ * tests pin that, and `resourceType` — the field a wrong adopt would silently
1432
+ * strand — is one an update cannot repair.
1433
+ */
1434
+ adoptsUntrackedByKey?: boolean;
1238
1435
  }): PushGateOutcome;
1239
1436
  /**
1240
1437
  * The identity a file may leave to its name, written back into the parsed
@@ -1585,7 +1782,16 @@ export declare function unselectedRuleSetIds(params: {
1585
1782
  nameForFile: (file: string) => string | undefined;
1586
1783
  }): Map<string, string>;
1587
1784
  export declare function slugifyTestCaseName(name: string): string;
1588
- export declare function resolveSlugCollisions(slug: string, usedSlugs: Set<string>): string;
1785
+ /**
1786
+ * The name to use for `slug`, given the names already taken.
1787
+ *
1788
+ * `occupy` decides what "already taken" means, and `usedSlugs` holds names in
1789
+ * that form (#2896): a file name is occupied CASE-INSENSITIVELY on the
1790
+ * filesystems most of these trees live on, so `Foo` and `foo` are one file even
1791
+ * though they are two strings. The returned name keeps its own casing — only
1792
+ * the occupancy check is normalized.
1793
+ */
1794
+ export declare function resolveSlugCollisions(slug: string, usedSlugs: Set<string>, occupy?: (name: string) => string): string;
1589
1795
  export declare function getTestsDir(configDir: string, blockType: string, blockKey: string): string;
1590
1796
  export interface TestCaseLookupMaps {
1591
1797
  configIdToName: Map<string, string>;
@@ -1795,6 +2001,24 @@ export declare function planAttachmentPush(params: {
1795
2001
  upload: string[];
1796
2002
  removedRemotely: string[];
1797
2003
  };
2004
+ /**
2005
+ * Create a test case carrying the identity its file name asserts (#2896).
2006
+ *
2007
+ * The one case protocol detection cannot answer is a create into an EMPTY
2008
+ * block: there is no listed record to read `key` off. Push is optimistic there,
2009
+ * and an older server's 400 is the answer — retried once without the key, and
2010
+ * named, because a case created without one will duplicate on the next clone.
2011
+ */
2012
+ export declare function createTestCaseWithIdentity(params: {
2013
+ client: ApiClient;
2014
+ appId: string;
2015
+ blockType: TestBlockType;
2016
+ blockId: string;
2017
+ payload: any;
2018
+ /** Omitted when the server has no keys; then this is exactly the old call. */
2019
+ key?: string;
2020
+ logger?: (message: string) => void;
2021
+ }): Promise<any>;
1798
2022
  /**
1799
2023
  * Push one block's authored test-case sidecar. Exported so the unit tests can
1800
2024
  * drive it against a stubbed client on a temp directory.
@@ -1818,6 +2042,23 @@ export declare function pushTestCasesForBlock(params: {
1818
2042
  }>;
1819
2043
  /** Where a rejected create/update/upload goes — it fails the push (#2731 B2). */
1820
2044
  failures: ApplyFailure[];
2045
+ /**
2046
+ * What push DECLINED to apply (#2880): server drift, a conflict, a live read
2047
+ * that failed with no baseline. Reported exactly as every other converted
2048
+ * type reports it — the caller passes its `recordDeclined`.
2049
+ */
2050
+ declined?: (type: string, key: string, outcome: Extract<PushGateOutcome, {
2051
+ action: "drift" | "conflict" | "immutable" | "live-unavailable";
2052
+ }>, storedModifiedAt?: string) => void;
2053
+ /** The id→name lookups the comparison reads a reference through (#2880). */
2054
+ lookupMaps?: TestCaseLookupMaps;
2055
+ /**
2056
+ * Re-derive `lookupMaps` from the caller's name→id maps (#2880). Called after
2057
+ * this case's references are resolved and before it is compared: resolution
2058
+ * is what LOADS a block's configs, so the comparison would otherwise read a
2059
+ * reference the same push just learned how to read.
2060
+ */
2061
+ refreshLookupMaps?: () => void;
1821
2062
  resolutionMaps?: PushResolutionMaps;
1822
2063
  options?: {
1823
2064
  force?: boolean;
@@ -1826,25 +2067,101 @@ export declare function pushTestCasesForBlock(params: {
1826
2067
  skipped: number;
1827
2068
  }>;
1828
2069
  /**
1829
- * Classify one block's sidecar against the server's test-case names (#2769).
2070
+ * The file name each live test case belongs in (#2896).
2071
+ *
2072
+ * A KEYED case is named by its key: that is what the key is — the basename the
2073
+ * committed tree carries. An unkeyed one (created by web-admin, an older CLI or
2074
+ * the raw API) keeps the slug-of-name naming, with the collision suffixes
2075
+ * resolved only AFTER the keys have claimed theirs, so a slug can never take a
2076
+ * name a key owns.
2077
+ *
2078
+ * One function for pull, for pairing's adoption pass and for the diff's
2079
+ * remote-only rows, because three commands that name a case's file differently
2080
+ * is the disagreement this issue reports.
2081
+ *
2082
+ * Two names that differ only by CASE are one file on a case-insensitive
2083
+ * filesystem, so occupancy is tracked the way the server's key constraint
2084
+ * normalizes: a keyed `Foo` and an unkeyed case named "foo" get `Foo.toml` and
2085
+ * `foo-2.toml`, rather than one pull silently overwriting the other's sidecar.
2086
+ */
2087
+ export declare function testCaseFileBasenames(liveCases: any[], onUnusableKey?: (live: any, reason: string) => void, isSafeName?: (name: string) => boolean): Map<any, string>;
2088
+ /**
2089
+ * Whether a sidecar and a live case say the same thing (#2896).
2090
+ *
2091
+ * The corroboration the adoption passes below need: identity nothing states can
2092
+ * only be inferred from content, and only when the inference is unique. It is
2093
+ * the SAME projection `config diff` compares with, memoized per file and per
2094
+ * record, so pairing and the change verdict can never disagree. A file that
2095
+ * cannot be read matches nothing — an unparseable sidecar fails its own push
2096
+ * with a message that names it.
2097
+ */
2098
+ export declare function testCaseContentMatcher(params: {
2099
+ testsDir: string;
2100
+ spec: ConfigDiffSpec;
2101
+ extra?: any;
2102
+ }): (localSlug: string, live: any) => boolean;
2103
+ /**
2104
+ * Which live test case each sidecar manages (#2880 behavior 16, #2896).
2105
+ *
2106
+ * The manifest id comes FIRST, then the identity the COMMITTED tree carries —
2107
+ * the file's basename, matched against the case's stored `key`. Everything
2108
+ * after that is adoption of a case whose identity nothing states: a rename the
2109
+ * manifest still remembers under the old name, a legacy case matched by
2110
+ * content, and finally the slug-of-name rule #2880 shipped.
2111
+ *
2112
+ * The order matters because every pass consumes its claims: identity that IS
2113
+ * recorded always wins over a guess, and a guess is only made when it is
2114
+ * unambiguous in BOTH directions. Where it is not, the file stays unpaired and
2115
+ * is barred from the create path — push refusing to guess is the whole point,
2116
+ * since the failure it replaces is a silently duplicated test case.
1830
2117
  *
1831
- * Remote slugs are derived exactly the way pull writes them — same slugify,
1832
- * same collision suffixes, same order — because a `<slug>-2.toml` on disk IS
1833
- * the second colliding name and must not read as an unpushed local file.
2118
+ * Against a server with no keys the passes that depend on them are skipped
2119
+ * entirely, so pairing is bit-for-bit what #2880 shipped.
2120
+ *
2121
+ * One function for both commands, because a diff row and a push decision that
2122
+ * pair differently are two answers to the same question.
1834
2123
  */
1835
- export declare function classifyTestCaseSidecarDiff(params: {
1836
- remoteNames: string[];
2124
+ export declare function pairTestCases(input: {
2125
+ blockType: string;
2126
+ blockKey: string;
1837
2127
  localSlugs: string[];
1838
- }): Array<{
1839
- slug: string;
1840
- status: string;
1841
- }>;
2128
+ liveCases: any[];
2129
+ managed: Record<string, {
2130
+ id?: string;
2131
+ slug?: string;
2132
+ blockKey?: string;
2133
+ }> | undefined;
2134
+ /** Whether the server carries identity keys at all (#2896). */
2135
+ serverSupportsKeys?: boolean;
2136
+ /** Whether a local sidecar's content equals a live case's projection. */
2137
+ contentMatches?: (localSlug: string, live: any) => boolean;
2138
+ }): {
2139
+ bySlug: Map<string, any>;
2140
+ localOnly: string[];
2141
+ remoteOnly: any[];
2142
+ /** Files adopted from a stale manifest entry: basename → that entry's key. */
2143
+ renamedFrom: Map<string, string>;
2144
+ /** Files barred from the create path this run, with the reason to report. */
2145
+ refused: Map<string, string>;
2146
+ };
1842
2147
  /**
1843
2148
  * `config diff` for one block's test-case sidecar (#2769). A failed listing is
1844
2149
  * an OUTCOME the caller reports as "not compared" — the old helper returned
1845
2150
  * silently, so a block whose tests could not be fetched simply disappeared from
1846
2151
  * a report that still read as exhaustive.
1847
2152
  */
2153
+ export interface TestCaseDiffRow {
2154
+ blockType: string;
2155
+ blockKey: string;
2156
+ slug: string;
2157
+ status: string;
2158
+ /** What a validation error or a degraded comparison has to say. */
2159
+ hint?: string;
2160
+ /** Attachments push would upload from this sidecar (#2880 behavior 19). */
2161
+ attachmentUploads?: string[];
2162
+ /** Managed attachments `push --prune` would delete (never a plain push). */
2163
+ attachmentDeletions?: string[];
2164
+ }
1848
2165
  export declare function compareTestCasesForBlock(params: {
1849
2166
  client: ApiClient;
1850
2167
  appId: string;
@@ -1852,14 +2169,12 @@ export declare function compareTestCasesForBlock(params: {
1852
2169
  blockId: string;
1853
2170
  blockKey: string;
1854
2171
  configDir: string;
2172
+ /** The manifest — identity, and the attachment hashes the plan reads. */
2173
+ syncState?: SyncState | null;
2174
+ lookupMaps?: TestCaseLookupMaps;
1855
2175
  }): Promise<{
1856
2176
  ok: true;
1857
- rows: Array<{
1858
- blockType: string;
1859
- blockKey: string;
1860
- slug: string;
1861
- status: string;
1862
- }>;
2177
+ rows: TestCaseDiffRow[];
1863
2178
  } | {
1864
2179
  ok: false;
1865
2180
  }>;
@@ -1871,6 +2186,17 @@ export declare function compareTestCasesForBlock(params: {
1871
2186
  * on the way to it, pushing a test case that tested something else.
1872
2187
  */
1873
2188
  export declare function testCaseSidecarErrors(filePath: string, tomlData: any): string[];
2189
+ /**
2190
+ * EVERY reason `config push` refuses one test-case sidecar (#2880 criterion 1).
2191
+ *
2192
+ * The four checks the push preflight ran inline: the document's shape, the keys
2193
+ * the definition recognizes, the declared type of each value, and the sidecar's
2194
+ * own authoring rules (#2769). Collected in one place because `config diff`
2195
+ * runs the identical list — it used to run only the last of the four, so a
2196
+ * sidecar with an unknown key or a mistyped ordinary field was reported Synced
2197
+ * by the command whose whole promise is that a Synced file pushes.
2198
+ */
2199
+ export declare function testCaseSidecarPreflightErrors(filePath: string, tomlData: any): string[];
1874
2200
  /** A managed test case whose authored file is gone (#2769). */
1875
2201
  export interface TestCaseDeletionCandidate {
1876
2202
  stateKey: string;
@@ -1880,6 +2206,13 @@ export interface TestCaseDeletionCandidate {
1880
2206
  slug: string;
1881
2207
  /** Absent when the case was never successfully created server-side. */
1882
2208
  id?: string;
2209
+ /**
2210
+ * The state entry — with a file still on disk — that holds this same id
2211
+ * (#2896). Set when the missing file was RENAMED rather than deleted: the
2212
+ * case is alive under another name, so the row is cleared and nothing is
2213
+ * deleted server-side.
2214
+ */
2215
+ supersededBy?: string;
1883
2216
  }
1884
2217
  /** A managed attachment whose local file is gone (#2769). */
1885
2218
  export interface AttachmentDeletionCandidate extends TestCaseDeletionCandidate {