@wtfalch/foundry 0.61.0 → 0.63.0

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 (97) hide show
  1. package/dist/bootstrap/declare.js +4 -1
  2. package/dist/bootstrap/versions.d.ts +2 -0
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.js +3 -0
  5. package/dist/migrations/0065_native_build_runs.sql +32 -0
  6. package/dist/migrations/0066_org_kosmos.sql +10 -0
  7. package/dist/migrations/0067_requested_changes_kosmos.sql +6 -0
  8. package/dist/migrations/0068_platform_registry.sql +17 -0
  9. package/dist/modules/sign-in.js +1 -1
  10. package/dist/native-build.d.ts +50 -0
  11. package/dist/native-build.js +72 -0
  12. package/dist/org.d.ts +22 -3
  13. package/dist/org.js +25 -2
  14. package/dist/plan/agora.d.ts +1 -1
  15. package/dist/plan/agora.js +4 -0
  16. package/dist/plan/archon-blue-green.d.ts +5 -0
  17. package/dist/plan/archon-blue-green.js +2 -0
  18. package/dist/plan/blue-green-apply.d.ts +5 -0
  19. package/dist/plan/blue-green-apply.js +197 -22
  20. package/dist/plan/blue-green.d.ts +80 -9
  21. package/dist/plan/blue-green.js +115 -14
  22. package/dist/plan/boule.d.ts +1 -1
  23. package/dist/plan/boule.js +2 -0
  24. package/dist/plan/broker-entries.d.ts +9 -0
  25. package/dist/plan/broker-entries.js +29 -0
  26. package/dist/plan/deploy-health.js +9 -2
  27. package/dist/plan/desktop-callback.js +4 -1
  28. package/dist/plan/diff.d.ts +5 -0
  29. package/dist/plan/diff.js +184 -63
  30. package/dist/plan/edge-access.d.ts +2 -3
  31. package/dist/plan/edge-access.js +8 -0
  32. package/dist/plan/executor.d.ts +111 -19
  33. package/dist/plan/executor.js +578 -72
  34. package/dist/plan/index.d.ts +2 -0
  35. package/dist/plan/index.js +1 -0
  36. package/dist/plan/jobs-scheduler-http.d.ts +2 -0
  37. package/dist/plan/jobs-scheduler-http.js +11 -0
  38. package/dist/plan/kosmos.d.ts +56 -0
  39. package/dist/plan/kosmos.js +84 -0
  40. package/dist/plan/locked-hostnames.js +5 -2
  41. package/dist/plan/org-names.d.ts +3 -3
  42. package/dist/plan/org-names.js +3 -1
  43. package/dist/plan/org-postgres-migrate.js +1 -0
  44. package/dist/plan/own-limits.d.ts +12 -0
  45. package/dist/plan/own-limits.js +2 -0
  46. package/dist/plan/plan-apply.d.ts +51 -1
  47. package/dist/plan/plan-apply.js +298 -25
  48. package/dist/plan/platform-diff.d.ts +6 -0
  49. package/dist/plan/platform-diff.js +74 -0
  50. package/dist/plan/platform-org.d.ts +2 -0
  51. package/dist/plan/platform-providers.d.ts +46 -1
  52. package/dist/plan/platform-providers.js +162 -1
  53. package/dist/plan/platform-registry-keys.d.ts +58 -0
  54. package/dist/plan/platform-registry-keys.js +69 -0
  55. package/dist/plan/platform-registry.d.ts +67 -0
  56. package/dist/plan/platform-registry.js +298 -0
  57. package/dist/plan/post-logout-uris.d.ts +1 -1
  58. package/dist/plan/post-logout-uris.js +5 -2
  59. package/dist/plan/providers-readers.js +20 -1
  60. package/dist/plan/release-host.d.ts +31 -2
  61. package/dist/plan/release-host.js +114 -6
  62. package/dist/plan/stage-copy-host.d.ts +104 -0
  63. package/dist/plan/stage-copy-host.js +259 -0
  64. package/dist/plan/stage-copy.js +44 -0
  65. package/dist/plan/stage-env.d.ts +9 -0
  66. package/dist/plan/stage-env.js +38 -0
  67. package/dist/plan/stage-guards.d.ts +57 -1
  68. package/dist/plan/stage-guards.js +106 -0
  69. package/dist/plan/stage-isolation.js +56 -0
  70. package/dist/plan/stage-services.d.ts +93 -0
  71. package/dist/plan/stage-services.js +153 -0
  72. package/dist/plan/stage-signin.d.ts +152 -0
  73. package/dist/plan/stage-signin.js +133 -0
  74. package/dist/plan/stage-sink.d.ts +2 -0
  75. package/dist/plan/stage-sink.js +6 -1
  76. package/dist/platform.d.ts +7 -1
  77. package/dist/platform.js +1 -0
  78. package/dist/release/decide.d.ts +12 -0
  79. package/dist/release/decide.js +37 -2
  80. package/dist/release/migration-compatibility.d.ts +66 -0
  81. package/dist/release/migration-compatibility.js +340 -0
  82. package/dist/release/run.d.ts +71 -1
  83. package/dist/release/run.js +92 -8
  84. package/dist/release/state.d.ts +14 -1
  85. package/dist/release/state.js +43 -0
  86. package/dist/release/types.d.ts +20 -0
  87. package/dist/release/types.js +2 -0
  88. package/dist/requested-changes.d.ts +11 -2
  89. package/dist/requested-changes.js +13 -1
  90. package/dist/slug.d.ts +1 -1
  91. package/dist/slug.js +11 -2
  92. package/dist/store/postgres.js +21 -0
  93. package/dist/store/schema.d.ts +87 -0
  94. package/dist/store/schema.js +10 -0
  95. package/package.json +3 -2
  96. package/versions.digests.json +1 -0
  97. package/versions.json +1 -0
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { bgKey } from './blue-green.js';
3
+ /** Tries the broker write this many times (CLAUDE.md, Platform steps, rule 1). */
4
+ export const STAGE_SIGNIN_DELIVERY_TRIES = 3;
5
+ /** The guard value for a subject that does not sign in. */
6
+ export const STAGE_SIGNIN_NONE = 'none';
7
+ /** Fixed text and ids only: safe to log, record and return. */
8
+ export class StageSignInError extends Error {
9
+ name = 'StageSignInError';
10
+ }
11
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
12
+ /**
13
+ * Makes stage's client, mints its app key once and writes its broker entry. Order: the client first (its ids
14
+ * recorded and persisted at once, a lost answer is adopted by name), then the key (held, its ref recorded under
15
+ * {@link bgKey.stageSignInKey} and persisted before anything is delivered), then the broker entry (retried).
16
+ * A run that failed halfway finds both and does the rest; the key is never minted twice. When the ref is
17
+ * recorded but the held store no longer has the key, this stops: it never mints another.
18
+ */
19
+ export async function ensureStageSignIn(input) {
20
+ const { id, port } = input;
21
+ const at = `stage sign-in for ${id}`;
22
+ let appId = input.recorded()[bgKey.stageClient(id)];
23
+ let clientId = input.recorded()[bgKey.stageClientId(id)];
24
+ if (!appId || !clientId) {
25
+ const found = await port.findClient(port.spec.clientName);
26
+ if (found) {
27
+ if (!found.clientId) {
28
+ throw new StageSignInError(`${at}: client ${found.appId} exists but its id cannot be read`);
29
+ }
30
+ appId = found.appId;
31
+ clientId = found.clientId;
32
+ }
33
+ else {
34
+ const created = await port.createClient(port.spec);
35
+ appId = created.appId;
36
+ clientId = created.clientId;
37
+ }
38
+ input.record(bgKey.stageClient(id), appId);
39
+ input.record(bgKey.stageClientId(id), clientId);
40
+ await input.persist();
41
+ }
42
+ // One mint: the key goes to the held store, its ref is recorded and persisted, and only then is it used.
43
+ let appKey = await port.held.resolve();
44
+ if (appKey) {
45
+ input.secret(appKey);
46
+ }
47
+ else {
48
+ if (input.recorded()[bgKey.stageSignInKey(id)]) {
49
+ throw new StageSignInError(`${at}: key ${port.held.ref} was minted but is no longer held; restore the held value`);
50
+ }
51
+ appKey = input.generate();
52
+ input.secret(appKey);
53
+ await port.held.put(appKey);
54
+ input.record(bgKey.stageSignInKey(id), port.held.ref);
55
+ await input.persist();
56
+ }
57
+ const entry = {
58
+ name: port.spec.brokerName,
59
+ organizationId: port.spec.organizationId,
60
+ clientId,
61
+ origins: port.spec.origins,
62
+ from: port.spec.from,
63
+ registration: port.spec.registration,
64
+ ...(port.spec.emailLink === undefined ? {} : { emailLink: port.spec.emailLink }),
65
+ };
66
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
67
+ for (let attempt = 1;; attempt++) {
68
+ try {
69
+ await port.writeBrokerEntry(entry, appKey);
70
+ break;
71
+ }
72
+ catch {
73
+ // The cause can echo the key; the ref is all a reader needs.
74
+ if (attempt >= STAGE_SIGNIN_DELIVERY_TRIES) {
75
+ throw new StageSignInError(`${at}: delivering key ${port.held.ref} to the broker entry ${port.spec.brokerName} failed after ${STAGE_SIGNIN_DELIVERY_TRIES} tries`);
76
+ }
77
+ await sleep(1000 * attempt);
78
+ }
79
+ }
80
+ input.record(bgKey.stageBroker(id), JSON.stringify({ name: entry.name, organizationId: entry.organizationId }));
81
+ await input.persist();
82
+ return {
83
+ env: [
84
+ { key: 'AUTH_CLIENT_ID', value: clientId },
85
+ { key: 'AUTH_APP_KEY', value: appKey },
86
+ ],
87
+ guard: stageSignInGuard({ appId, clientId, brokerName: entry.name, origins: entry.origins }),
88
+ };
89
+ }
90
+ /** `ok:<sha256 of the ids>`: the client, the app, the broker entry's name and its origins. No value goes in. */
91
+ export function stageSignInGuard(input) {
92
+ return `ok:${sha256(JSON.stringify([input.appId, input.clientId, input.brokerName, [...input.origins].sort()]))}`;
93
+ }
94
+ /** Whether the recorded `bg.<id>.stagesignin` lets the copy go on. */
95
+ export const stageSignInReady = (value) => value === STAGE_SIGNIN_NONE || value?.startsWith('ok:') === true;
96
+ /**
97
+ * Makes the public client of a static SPA's stage (Agora): found by name or created, its ids recorded and
98
+ * persisted at once. There is no key and no broker entry, so nothing secret exists. The guard hashes the app id,
99
+ * the client id, the client name and the origins.
100
+ */
101
+ export async function ensureStagePublicClient(input) {
102
+ const { id, port } = input;
103
+ const at = `stage sign-in for ${id}`;
104
+ let appId = input.recorded()[bgKey.stageClient(id)];
105
+ let clientId = input.recorded()[bgKey.stageClientId(id)];
106
+ if (!appId || !clientId) {
107
+ const found = await port.findClient(port.spec.clientName);
108
+ if (found) {
109
+ if (!found.clientId) {
110
+ throw new StageSignInError(`${at}: client ${found.appId} exists but its id cannot be read`);
111
+ }
112
+ appId = found.appId;
113
+ clientId = found.clientId;
114
+ }
115
+ else {
116
+ const created = await port.createClient(port.spec);
117
+ appId = created.appId;
118
+ clientId = created.clientId;
119
+ }
120
+ input.record(bgKey.stageClient(id), appId);
121
+ input.record(bgKey.stageClientId(id), clientId);
122
+ await input.persist();
123
+ }
124
+ return {
125
+ clientId,
126
+ guard: stageSignInGuard({
127
+ appId,
128
+ clientId,
129
+ brokerName: port.spec.clientName,
130
+ origins: port.spec.origins,
131
+ }),
132
+ };
133
+ }
@@ -26,6 +26,8 @@ type SinkOrg = Pick<DeclaredOrg, 'slug'>;
26
26
  export declare const stageSinkAppName: (org: SinkOrg) => string;
27
27
  /** The sink's HTTP address on the company's Docker network. */
28
28
  export declare const stageSinkInternalUrl: (org: SinkOrg) => string;
29
+ /** The sender stage mails carry. Apps send only when `MAIL_FROM` is set; the address cannot receive mail. */
30
+ export declare const STAGE_MAIL_FROM = "Stage <noreply@stage.invalid>";
29
31
  /**
30
32
  * The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
31
33
  * so stage holds no mail token and no Lettermint setting.
@@ -24,10 +24,15 @@ export const STAGE_SINK_VERSION = 1;
24
24
  export const stageSinkAppName = (org) => `${org.slug}-stage-sink`;
25
25
  /** The sink's HTTP address on the company's Docker network. */
26
26
  export const stageSinkInternalUrl = (org) => `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`;
27
+ /** The sender stage mails carry. Apps send only when `MAIL_FROM` is set; the address cannot receive mail. */
28
+ export const STAGE_MAIL_FROM = 'Stage <noreply@stage.invalid>';
27
29
  /**
28
30
  * The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
29
31
  * so stage holds no mail token and no Lettermint setting.
30
32
  */
31
33
  export function stageMailEnv(org) {
32
- return [{ key: 'MAIL_SINK_URL', value: stageSinkInternalUrl(org) }];
34
+ return [
35
+ { key: 'MAIL_SINK_URL', value: stageSinkInternalUrl(org) },
36
+ { key: 'MAIL_FROM', value: STAGE_MAIL_FROM },
37
+ ];
33
38
  }
@@ -72,7 +72,7 @@ export declare function uptimeArtifactUrl(tag: string): string;
72
72
  * holds the Cloudflare token `<username>.<org domain>` claims use, so no
73
73
  * hosting entry ever holds that credential itself.
74
74
  */
75
- export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge' | 'hc' | 'coolify';
75
+ export type PlatformServiceKey = 'auth' | 'mail' | 'sfu' | 'files' | 'ai' | 'jobs' | 'integrations' | 'archon' | 'edge' | 'hc' | 'registry' | 'coolify';
76
76
  export declare const PLATFORM_SERVICE_KEYS: readonly PlatformServiceKey[];
77
77
  /**
78
78
  * The one shared platform every org's services run on -- decided in #165 as
@@ -123,6 +123,12 @@ export interface DeclaredPlatform {
123
123
  * `plan/platform-diff.ts`'s `planHeartbeats`.
124
124
  */
125
125
  hc: PlatformService;
126
+ /**
127
+ * The platform's image registry (`registry:3`, htpasswd sign-in): the images the build workflow
128
+ * pushes and the company servers pull. `gitRef` is the image tag, pinned by digest in
129
+ * `versions.json` like `hc`'s. See `plan/platform-registry.ts`.
130
+ */
131
+ registry: PlatformService;
126
132
  /**
127
133
  * The Coolify panel. Its address is declared so the platform's hostnames
128
134
  * include it; foundry never deploys it, so `enabled` stays false and
package/dist/platform.js CHANGED
@@ -15,6 +15,7 @@ export const PLATFORM_SERVICE_KEYS = [
15
15
  'archon',
16
16
  'edge',
17
17
  'hc',
18
+ 'registry',
18
19
  'coolify',
19
20
  ];
20
21
  /** The only values {@link DeclaredPlatform.heartbeatsProvider} may hold. */
@@ -51,6 +51,8 @@ export interface Recorded {
51
51
  /** Set while window.kind is step, inconclusive, window or cap. */
52
52
  release: ActiveRelease | null;
53
53
  held: string[];
54
+ /** Stage holds the sink env. Absent reads as no. */
55
+ stageSink?: boolean;
54
56
  }
55
57
  export interface ColorCounts {
56
58
  requests: number;
@@ -85,6 +87,14 @@ export type ReleaseEffect = {
85
87
  | {
86
88
  kind: 'deploy_stage';
87
89
  ref: string;
90
+ }
91
+ /**
92
+ * Run the stage copy (or undo it) for the first open request of that kind. The runner does not execute it: it
93
+ * hands it to its caller, who runs it after this run's other effects, writes and weights are done.
94
+ */
95
+ | {
96
+ kind: 'stage_copy';
97
+ mode: 'copy' | 'undo';
88
98
  };
89
99
  export interface Decision {
90
100
  recorded: Recorded;
@@ -94,6 +104,8 @@ export interface Decision {
94
104
  blocked: string[];
95
105
  /** Actions that changed nothing, with a fixed reason. */
96
106
  refused: string[];
107
+ /** The reason a `copy_prod_to_stage` request was refused here, for `bg.<id>.stagecopy`; else null. */
108
+ stageRefusal: string | null;
97
109
  }
98
110
  export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
99
111
  /** The time a health read should cover: from the step or window start to now. */
@@ -81,6 +81,7 @@ function applyActions(r, actions, now, out, kind) {
81
81
  let ended = false;
82
82
  let approved = false;
83
83
  let opened = false;
84
+ const stageRequests = [];
84
85
  for (const a of actions) {
85
86
  if (a.kind === 'deploy_stage') {
86
87
  if (typeof a.ref === 'string' && STAGE_REF_PATTERN.test(a.ref))
@@ -135,6 +136,9 @@ function applyActions(r, actions, now, out, kind) {
135
136
  promoted = true;
136
137
  }
137
138
  }
139
+ else if (a.kind === 'copy_prod_to_stage' || a.kind === 'undo_stage_copy') {
140
+ stageRequests.push(a.kind);
141
+ }
138
142
  else if (a.kind === 'rollback') {
139
143
  if (!cur.release)
140
144
  out.refused.push('rollback refused: no release is running');
@@ -157,6 +161,26 @@ function applyActions(r, actions, now, out, kind) {
157
161
  }
158
162
  }
159
163
  }
164
+ // The stage requests are judged last, against the release the other actions of this run left.
165
+ for (const kind of stageRequests) {
166
+ const copy = kind === 'copy_prod_to_stage';
167
+ const name = copy ? 'copy' : 'undo';
168
+ if (out.effects.some((e) => e.kind === 'stage_copy')) {
169
+ out.refused.push(`${name} refused: one stage copy request runs at a time`);
170
+ }
171
+ else if (cur.release) {
172
+ // A copy takes up to ~45 minutes; a release that is stepping needs the runner free to roll back.
173
+ out.refused.push(`${name} refused: a release is running`);
174
+ out.stageRefusal = 'release';
175
+ }
176
+ else if (copy && !cur.stageSink) {
177
+ out.refused.push('copy refused: stage sink is not applied');
178
+ out.stageRefusal = 'sink';
179
+ }
180
+ else {
181
+ out.effects.push({ kind: 'stage_copy', mode: copy ? 'copy' : 'undo' });
182
+ }
183
+ }
160
184
  return { recorded: cur, promoted, ended, approved, opened };
161
185
  }
162
186
  /**
@@ -168,7 +192,11 @@ export function decide(input) {
168
192
  const { now, observation } = input;
169
193
  const steps = normalizeSteps(input.steps);
170
194
  const holdMs = (input.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES) * MINUTE;
171
- const out = { effects: [], refused: [] };
195
+ const out = {
196
+ effects: [],
197
+ refused: [],
198
+ stageRefusal: null,
199
+ };
172
200
  const advance = input.advance ?? DEFAULT_ADVANCE;
173
201
  const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
174
202
  const errorMargin = input.errorMargin ?? ERROR_RATE_MARGIN;
@@ -285,7 +313,14 @@ export function decide(input) {
285
313
  }
286
314
  if (r.window.kind === 'cap')
287
315
  blocked.push('release window reached the cap without enough data: pass or roll back in Archon');
288
- return { recorded: r, signal, effects: out.effects, blocked, refused: out.refused };
316
+ return {
317
+ recorded: r,
318
+ signal,
319
+ effects: out.effects,
320
+ blocked,
321
+ refused: out.refused,
322
+ stageRefusal: out.stageRefusal,
323
+ };
289
324
  }
290
325
  function stepTo(r, rel, percent, now) {
291
326
  const at = now.toISOString();
@@ -0,0 +1,66 @@
1
+ import { type WorkerAppConfig } from '../worker-app-config.js';
2
+ /**
3
+ * Whether a release's database changes may go to idle (foundry#934, estate DECISIONS.md,
4
+ * 2026-10-21 "Releases and versions"). Idle shares the live database with whatever release is
5
+ * live, so a release may only add -- never drop, narrow, rename or retype something the live
6
+ * release still reads or writes, and never delete a Durable Object class the live release still
7
+ * uses. A removal needs two releases: one that stops using the object, a later one -- once that
8
+ * first release is no longer on live, idle or draining anywhere -- that removes it.
9
+ *
10
+ * Pure: no database connection, no network call, no data read. The caller supplies everything it
11
+ * already has: what the previous live release applied (`liveMigrationIds`, `liveDoTag`), what
12
+ * release tags are deployed anywhere right now (`deployedReleases`), and which removals this
13
+ * release declares as the drained second step of an earlier one (`declaredRemovals`). There is no
14
+ * override beyond that declaration: a destructive step with no declaration, or whose declared
15
+ * release has not drained, is refused every time.
16
+ *
17
+ * A SQL statement declares its own removal with a `-- removal-since: <release>` comment directly
18
+ * above it (the same convention `@wtfalch/db`'s migration safety check uses for
19
+ * `-- destructive: <reason>`); a Durable Object class removal is declared in `declaredRemovals`,
20
+ * since `deleted_classes` carries no comment of its own.
21
+ *
22
+ * The SQL classifier is pattern-based, not a real SQL parser: it is a safety rail against an
23
+ * accidental incompatible release, not a boundary against someone deliberately hiding a drop
24
+ * inside a string. A statement it cannot classify is refused rather than guessed as safe.
25
+ */
26
+ /** A Durable Object class removal this release declares as the drained second step of an earlier one. */
27
+ export interface DeclaredClassRemoval {
28
+ /** The class name a `deleted_classes` step of this release removes. */
29
+ className: string;
30
+ /** The earlier release that stopped using the class. Must have fully drained before this passes. */
31
+ sinceRelease: string;
32
+ }
33
+ export interface MigrationCompatibilityInput {
34
+ /** This release's own `migrations.json` (SQL), or `null` for a release with none. */
35
+ migrationsJson: string | null;
36
+ /** The SQL migration ids the previous live release already applied, in order. Default: none. */
37
+ liveMigrationIds?: readonly string[];
38
+ /** This release's full, declared Durable Object migrations, in order. Default: none. */
39
+ doMigrations?: WorkerAppConfig['durableObjects']['migrations'];
40
+ /** The Durable Object migration tag the previous live release holds, if any. */
41
+ liveDoTag?: string;
42
+ /** Durable Object class removals this release declares. Default: none. */
43
+ declaredRemovals?: readonly DeclaredClassRemoval[];
44
+ /** Release tags that are, right now, on live, idle or draining anywhere for this app. Default: none. */
45
+ deployedReleases?: readonly string[];
46
+ }
47
+ export interface MigrationCompatibilityProblem {
48
+ /** The SQL migration id or Durable Object migration tag the statement is in. */
49
+ migration: string;
50
+ /** The statement or step that is refused, trimmed to one line. */
51
+ statement: string;
52
+ /** Why it is refused, and what to do about it, in plain words. */
53
+ reason: string;
54
+ }
55
+ export type MigrationCompatibilityResult = {
56
+ ok: true;
57
+ } | {
58
+ ok: false;
59
+ problems: MigrationCompatibilityProblem[];
60
+ };
61
+ /**
62
+ * Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
63
+ * previous live release already applied and which releases are deployed anywhere right now. See
64
+ * the module docstring for the rule and the removal convention.
65
+ */
66
+ export declare function checkMigrationCompatibility(input: MigrationCompatibilityInput): MigrationCompatibilityResult;