@wtfalch/foundry 0.61.0 → 0.62.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.
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/migrations/0065_native_build_runs.sql +32 -0
- package/dist/migrations/0066_org_kosmos.sql +10 -0
- package/dist/modules/sign-in.js +1 -1
- package/dist/native-build.d.ts +70 -0
- package/dist/native-build.js +92 -0
- package/dist/org.d.ts +22 -3
- package/dist/org.js +25 -2
- package/dist/plan/agora.d.ts +1 -1
- package/dist/plan/agora.js +4 -0
- package/dist/plan/blue-green.d.ts +19 -7
- package/dist/plan/blue-green.js +35 -8
- package/dist/plan/deploy-health.js +9 -2
- package/dist/plan/desktop-callback.js +4 -1
- package/dist/plan/diff.js +180 -63
- package/dist/plan/edge-access.js +1 -0
- package/dist/plan/executor.d.ts +40 -18
- package/dist/plan/executor.js +153 -49
- package/dist/plan/kosmos.d.ts +56 -0
- package/dist/plan/kosmos.js +84 -0
- package/dist/plan/locked-hostnames.js +5 -2
- package/dist/plan/org-names.d.ts +3 -3
- package/dist/plan/org-names.js +3 -1
- package/dist/plan/org-postgres-migrate.js +1 -0
- package/dist/plan/own-limits.d.ts +6 -0
- package/dist/plan/own-limits.js +1 -0
- package/dist/plan/platform-providers.js +6 -0
- package/dist/plan/post-logout-uris.d.ts +1 -1
- package/dist/plan/post-logout-uris.js +5 -2
- package/dist/plan/providers-readers.js +20 -1
- package/dist/release/migration-compatibility.d.ts +66 -0
- package/dist/release/migration-compatibility.js +340 -0
- package/dist/requested-changes.js +2 -1
- package/dist/slug.d.ts +1 -1
- package/dist/slug.js +11 -2
- package/dist/store/postgres.js +13 -0
- package/dist/store/schema.d.ts +36 -0
- package/dist/store/schema.js +6 -0
- package/package.json +2 -2
|
@@ -2,6 +2,7 @@ import { ZitadelError } from '@wtfalch/providers/zitadel';
|
|
|
2
2
|
import { APP_ENTRY_KEY } from '../org.js';
|
|
3
3
|
import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
|
|
4
4
|
import { previewsOn } from './coolify-previews.js';
|
|
5
|
+
import { KOSMOS_MANAGED_ENV_KEYS } from './kosmos.js';
|
|
5
6
|
import { APP_GONE_KIND, } from './live.js';
|
|
6
7
|
import { SIGN_IN_UPSTREAM_ENV } from './sign-in.js';
|
|
7
8
|
/**
|
|
@@ -302,6 +303,23 @@ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
|
|
|
302
303
|
config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
|
|
303
304
|
};
|
|
304
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* The org's own recorded kosmos app's live sign-in env vars, as `config.env`, so `diff.ts`'s `planKosmos`
|
|
308
|
+
* can compare them with what `executor.ts` would write. Gated and failing exactly like
|
|
309
|
+
* {@link withAgoraEnvConfig}.
|
|
310
|
+
*/
|
|
311
|
+
async function withKosmosEnvConfig(client, resource, recorded) {
|
|
312
|
+
if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('kosmos:')) {
|
|
313
|
+
return resource;
|
|
314
|
+
}
|
|
315
|
+
const managed = KOSMOS_MANAGED_ENV_KEYS;
|
|
316
|
+
const env = {};
|
|
317
|
+
for (const e of await client.listEnvs(resource.key)) {
|
|
318
|
+
if (!e.is_preview && managed.includes(e.key))
|
|
319
|
+
env[e.key] = e.value;
|
|
320
|
+
}
|
|
321
|
+
return { ...resource, config: { ...resource.config, env } };
|
|
322
|
+
}
|
|
305
323
|
/** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
|
|
306
324
|
function toFoundryResources(system, resources) {
|
|
307
325
|
return resources.map((r) => ({ system, kind: r.kind, key: r.key, config: r.config }));
|
|
@@ -335,7 +353,8 @@ export class ProvidersCoolifyReader {
|
|
|
335
353
|
const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
|
|
336
354
|
const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
|
|
337
355
|
const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
|
|
338
|
-
|
|
356
|
+
const withKosmosConfig = await Promise.all(withBouleConfig.map((r) => withKosmosEnvConfig(this.client, r, recorded)));
|
|
357
|
+
return applyRecordedIds(withKosmosConfig, recorded);
|
|
339
358
|
}
|
|
340
359
|
async liveEnvEquals(orgId, appKey, name, value) {
|
|
341
360
|
const recorded = await this.resolveRecordedIds(orgId);
|
|
@@ -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;
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { foldMigrationClasses } from '../worker-app-config.js';
|
|
2
|
+
import { parseMigrations, unappliedDatabaseMigrations, } from '../worker-app-migrations.js';
|
|
3
|
+
const REMOVAL_MARKER = /^--\s*removal-since:\s*(\S.*)$/i;
|
|
4
|
+
const STATEMENT_MAX_CHARS = 160;
|
|
5
|
+
/** Collapses whitespace and cuts a statement down to a readable, one-line length for a message. */
|
|
6
|
+
function oneLine(text) {
|
|
7
|
+
const collapsed = text.replace(/\s+/g, ' ').trim();
|
|
8
|
+
return collapsed.length > STATEMENT_MAX_CHARS
|
|
9
|
+
? `${collapsed.slice(0, STATEMENT_MAX_CHARS - 1)}…`
|
|
10
|
+
: collapsed;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Splits `sql` into standalone statements at a semicolon that sits outside a line comment, a
|
|
14
|
+
* block comment, a single- or double-quoted literal, and a dollar-quoted body (a function's
|
|
15
|
+
* `$$ ... $$`, which may itself hold semicolons). Each statement keeps any comment lines directly
|
|
16
|
+
* above it, so a `-- removal-since:` marker travels with the statement it excuses.
|
|
17
|
+
*/
|
|
18
|
+
function splitStatements(sql) {
|
|
19
|
+
const statements = [];
|
|
20
|
+
let current = '';
|
|
21
|
+
let i = 0;
|
|
22
|
+
const n = sql.length;
|
|
23
|
+
while (i < n) {
|
|
24
|
+
const ch = sql[i];
|
|
25
|
+
if (ch === '-' && sql[i + 1] === '-') {
|
|
26
|
+
const end = sql.indexOf('\n', i);
|
|
27
|
+
const end2 = end === -1 ? n : end + 1;
|
|
28
|
+
current += sql.slice(i, end2);
|
|
29
|
+
i = end2;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '/' && sql[i + 1] === '*') {
|
|
33
|
+
const end = sql.indexOf('*/', i + 2);
|
|
34
|
+
const end2 = end === -1 ? n : end + 2;
|
|
35
|
+
current += sql.slice(i, end2);
|
|
36
|
+
i = end2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "'" || ch === '"') {
|
|
40
|
+
let j = i + 1;
|
|
41
|
+
while (j < n) {
|
|
42
|
+
if (sql[j] === ch) {
|
|
43
|
+
if (sql[j + 1] === ch) {
|
|
44
|
+
j += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
j += 1;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
j += 1;
|
|
51
|
+
}
|
|
52
|
+
current += sql.slice(i, j);
|
|
53
|
+
i = j;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (ch === '$') {
|
|
57
|
+
const tagMatch = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/.exec(sql.slice(i));
|
|
58
|
+
if (tagMatch) {
|
|
59
|
+
const tag = tagMatch[0];
|
|
60
|
+
const end = sql.indexOf(tag, i + tag.length);
|
|
61
|
+
const end2 = end === -1 ? n : end + tag.length;
|
|
62
|
+
current += sql.slice(i, end2);
|
|
63
|
+
i = end2;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (ch === ';') {
|
|
68
|
+
statements.push(current);
|
|
69
|
+
current = '';
|
|
70
|
+
i += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
current += ch;
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
if (current.trim() !== '')
|
|
77
|
+
statements.push(current);
|
|
78
|
+
return statements;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Splits `code` at a top-level comma (outside a quoted literal or parentheses), so each element
|
|
82
|
+
* of an `ALTER TABLE`'s comma-separated action list -- `ADD COLUMN a text DEFAULT 'x', ADD COLUMN
|
|
83
|
+
* b integer NOT NULL` -- can be judged on its own. Without this, a `DEFAULT` on one added column
|
|
84
|
+
* hid a `NOT NULL` with no default on a different one, because the two regexes ran against the
|
|
85
|
+
* whole statement (foundry#941 review).
|
|
86
|
+
*/
|
|
87
|
+
function splitTopLevelCommas(code) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let i = 0;
|
|
92
|
+
const n = code.length;
|
|
93
|
+
while (i < n) {
|
|
94
|
+
const ch = code[i];
|
|
95
|
+
if (ch === "'" || ch === '"') {
|
|
96
|
+
let j = i + 1;
|
|
97
|
+
while (j < n) {
|
|
98
|
+
if (code[j] === ch) {
|
|
99
|
+
if (code[j + 1] === ch) {
|
|
100
|
+
j += 2;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
j += 1;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
j += 1;
|
|
107
|
+
}
|
|
108
|
+
current += code.slice(i, j);
|
|
109
|
+
i = j;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (ch === '(') {
|
|
113
|
+
depth += 1;
|
|
114
|
+
current += ch;
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (ch === ')') {
|
|
119
|
+
depth = Math.max(0, depth - 1);
|
|
120
|
+
current += ch;
|
|
121
|
+
i += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === ',' && depth === 0) {
|
|
125
|
+
parts.push(current);
|
|
126
|
+
current = '';
|
|
127
|
+
i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
current += ch;
|
|
131
|
+
i += 1;
|
|
132
|
+
}
|
|
133
|
+
parts.push(current);
|
|
134
|
+
return parts;
|
|
135
|
+
}
|
|
136
|
+
/** The statement with its leading blank and comment lines removed, so classification reads code, not a marker. */
|
|
137
|
+
function withoutLeadingComments(statement) {
|
|
138
|
+
const lines = statement.split('\n');
|
|
139
|
+
let i = 0;
|
|
140
|
+
while (i < lines.length) {
|
|
141
|
+
const trimmed = lines[i].trim();
|
|
142
|
+
if (trimmed === '' || trimmed.startsWith('--')) {
|
|
143
|
+
i += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
return lines.slice(i).join('\n').trim();
|
|
149
|
+
}
|
|
150
|
+
/** The release named in a `-- removal-since: <release>` line directly above the statement, or `null`. */
|
|
151
|
+
function declaredSince(statement) {
|
|
152
|
+
for (const line of statement.split('\n')) {
|
|
153
|
+
const trimmed = line.trim();
|
|
154
|
+
if (trimmed === '')
|
|
155
|
+
continue;
|
|
156
|
+
const match = REMOVAL_MARKER.exec(trimmed);
|
|
157
|
+
if (match)
|
|
158
|
+
return match[1].trim();
|
|
159
|
+
if (!trimmed.startsWith('--'))
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Classifies one statement's code (comments already stripped) as additive or destructive, or
|
|
166
|
+
* `null` when it cannot tell: an unrecognised statement is never assumed to be additive.
|
|
167
|
+
*/
|
|
168
|
+
function classifyStatement(code) {
|
|
169
|
+
if (code === '')
|
|
170
|
+
return null;
|
|
171
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?(TABLE|SCHEMA|EXTENSION|SEQUENCE|VIEW)\b/i.test(code)) {
|
|
172
|
+
return { destructive: false, reason: 'creates something new' };
|
|
173
|
+
}
|
|
174
|
+
if (/^CREATE\s+(UNIQUE\s+)?INDEX(\s+CONCURRENTLY)?\b/i.test(code)) {
|
|
175
|
+
return { destructive: false, reason: 'adds an index' };
|
|
176
|
+
}
|
|
177
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?(FUNCTION|PROCEDURE)\b/i.test(code)) {
|
|
178
|
+
return { destructive: false, reason: 'adds or replaces a function' };
|
|
179
|
+
}
|
|
180
|
+
if (/^CREATE\s+(OR\s+REPLACE\s+)?TRIGGER\b/i.test(code)) {
|
|
181
|
+
return { destructive: false, reason: 'adds a trigger' };
|
|
182
|
+
}
|
|
183
|
+
if (/^(COMMENT\s+ON|GRANT)\b/i.test(code)) {
|
|
184
|
+
return {
|
|
185
|
+
destructive: false,
|
|
186
|
+
reason: 'changes neither data nor structure a running release reads',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (/^ALTER\s+TABLE\b/i.test(code)) {
|
|
190
|
+
if (/\bDROP\s+COLUMN\b/i.test(code))
|
|
191
|
+
return { destructive: true, reason: 'drops a column' };
|
|
192
|
+
if (/\bDROP\s+CONSTRAINT\b/i.test(code))
|
|
193
|
+
return { destructive: true, reason: 'drops a constraint' };
|
|
194
|
+
if (/\bRENAME\b/i.test(code))
|
|
195
|
+
return { destructive: true, reason: 'renames a table or column' };
|
|
196
|
+
if (/\bALTER\s+COLUMN\b[\s\S]*\b(SET\s+DATA\s+)?TYPE\b/i.test(code)) {
|
|
197
|
+
return { destructive: true, reason: "changes a column's type" };
|
|
198
|
+
}
|
|
199
|
+
if (/\bSET\s+NOT\s+NULL\b/i.test(code)) {
|
|
200
|
+
return { destructive: true, reason: 'adds a NOT NULL constraint to an existing column' };
|
|
201
|
+
}
|
|
202
|
+
if (/\bADD\s+COLUMN\b/i.test(code)) {
|
|
203
|
+
for (const clause of splitTopLevelCommas(code)) {
|
|
204
|
+
if (!/\bADD\s+COLUMN\b/i.test(clause))
|
|
205
|
+
continue;
|
|
206
|
+
if (/\bNOT\s+NULL\b/i.test(clause) && !/\bDEFAULT\b/i.test(clause)) {
|
|
207
|
+
return { destructive: true, reason: 'adds a NOT NULL column with no default' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { destructive: false, reason: 'adds a column' };
|
|
211
|
+
}
|
|
212
|
+
if (/\bADD\s+CONSTRAINT\b/i.test(code))
|
|
213
|
+
return { destructive: false, reason: 'adds a constraint' };
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (/^DROP\s+(TABLE|INDEX|TYPE|SCHEMA|FUNCTION|PROCEDURE|TRIGGER|VIEW|MATERIALIZED\s+VIEW|EXTENSION|SEQUENCE)\b/i.test(code)) {
|
|
217
|
+
return { destructive: true, reason: 'drops something the live release may still read' };
|
|
218
|
+
}
|
|
219
|
+
if (/^(TRUNCATE|DELETE\s+FROM|UPDATE\s+\S+\s+SET)\b/i.test(code)) {
|
|
220
|
+
return { destructive: true, reason: 'removes or rewrites existing rows' };
|
|
221
|
+
}
|
|
222
|
+
if (/^ALTER\s+(INDEX|SEQUENCE|VIEW|TYPE|SCHEMA)\b[\s\S]*\bRENAME\b/i.test(code)) {
|
|
223
|
+
return { destructive: true, reason: 'renames something' };
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
const splitHelp = (what) => `${what}; split this into two releases: stop using it in one, remove it in a later release once that one is no longer on live, idle or draining`;
|
|
228
|
+
/** A destructive statement or step named `since` is drained once that release deploys nowhere. */
|
|
229
|
+
function hasDrained(since, deployedReleases) {
|
|
230
|
+
return !deployedReleases.includes(since);
|
|
231
|
+
}
|
|
232
|
+
function sqlProblems(migrationsJson, liveMigrationIds, deployedReleases) {
|
|
233
|
+
let declared;
|
|
234
|
+
try {
|
|
235
|
+
declared = parseMigrations(migrationsJson);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
return [{ migration: '', statement: '', reason: error.message }];
|
|
239
|
+
}
|
|
240
|
+
let pending;
|
|
241
|
+
try {
|
|
242
|
+
pending = unappliedDatabaseMigrations(declared, liveMigrationIds);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
return [{ migration: '', statement: '', reason: error.message }];
|
|
246
|
+
}
|
|
247
|
+
const problems = [];
|
|
248
|
+
for (const migration of pending) {
|
|
249
|
+
for (const statement of splitStatements(migration.sql)) {
|
|
250
|
+
const code = withoutLeadingComments(statement);
|
|
251
|
+
const classified = classifyStatement(code);
|
|
252
|
+
if (classified === null) {
|
|
253
|
+
problems.push({
|
|
254
|
+
migration: migration.id,
|
|
255
|
+
statement: oneLine(code),
|
|
256
|
+
reason: 'this statement is not one foundry recognises as additive; split it into its own release and ask a person',
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!classified.destructive)
|
|
261
|
+
continue;
|
|
262
|
+
const since = declaredSince(statement);
|
|
263
|
+
if (since === null) {
|
|
264
|
+
problems.push({
|
|
265
|
+
migration: migration.id,
|
|
266
|
+
statement: oneLine(code),
|
|
267
|
+
reason: splitHelp(classified.reason),
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!hasDrained(since, deployedReleases)) {
|
|
272
|
+
problems.push({
|
|
273
|
+
migration: migration.id,
|
|
274
|
+
statement: oneLine(code),
|
|
275
|
+
reason: `${classified.reason}; declared as the second step after release ${since}, but ${since} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return problems;
|
|
281
|
+
}
|
|
282
|
+
function doProblems(doMigrations, liveDoTag, declaredRemovals, deployedReleases) {
|
|
283
|
+
if (doMigrations.length === 0)
|
|
284
|
+
return [];
|
|
285
|
+
let appliedCount = 0;
|
|
286
|
+
if (liveDoTag !== undefined) {
|
|
287
|
+
const index = doMigrations.findIndex((m) => m.tag === liveDoTag);
|
|
288
|
+
if (index < 0) {
|
|
289
|
+
return [
|
|
290
|
+
{
|
|
291
|
+
migration: liveDoTag,
|
|
292
|
+
statement: '',
|
|
293
|
+
reason: "the live release holds a Durable Object migration that is not in this release's declared list; migrations are append-only",
|
|
294
|
+
},
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
appliedCount = index + 1;
|
|
298
|
+
}
|
|
299
|
+
const folded = foldMigrationClasses(doMigrations.slice(0, appliedCount));
|
|
300
|
+
if ('error' in folded)
|
|
301
|
+
return [{ migration: '', statement: '', reason: folded.error }];
|
|
302
|
+
const problems = [];
|
|
303
|
+
for (const migration of doMigrations.slice(appliedCount)) {
|
|
304
|
+
for (const className of migration.deletedClasses ?? []) {
|
|
305
|
+
if (!folded.classes.has(className))
|
|
306
|
+
continue; // never reached live: nothing running still uses it
|
|
307
|
+
const removal = declaredRemovals.find((r) => r.className === className);
|
|
308
|
+
const statement = `deleted_classes: ${className}`;
|
|
309
|
+
if (!removal) {
|
|
310
|
+
problems.push({
|
|
311
|
+
migration: migration.tag,
|
|
312
|
+
statement,
|
|
313
|
+
reason: splitHelp(`deletes the Durable Object class '${className}', which the live release still uses`),
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (!hasDrained(removal.sinceRelease, deployedReleases)) {
|
|
318
|
+
problems.push({
|
|
319
|
+
migration: migration.tag,
|
|
320
|
+
statement,
|
|
321
|
+
reason: `deletes the Durable Object class '${className}', declared as the second step after release ${removal.sinceRelease}, but ${removal.sinceRelease} is still on live, idle or draining -- wait for it to fully drain, then release this one`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return problems;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether a release's migrations (SQL and Durable Object) may go to idle, given what the
|
|
330
|
+
* previous live release already applied and which releases are deployed anywhere right now. See
|
|
331
|
+
* the module docstring for the rule and the removal convention.
|
|
332
|
+
*/
|
|
333
|
+
export function checkMigrationCompatibility(input) {
|
|
334
|
+
const deployedReleases = input.deployedReleases ?? [];
|
|
335
|
+
const problems = [
|
|
336
|
+
...sqlProblems(input.migrationsJson, input.liveMigrationIds ?? [], deployedReleases),
|
|
337
|
+
...doProblems(input.doMigrations ?? [], input.liveDoTag, input.declaredRemovals ?? [], deployedReleases),
|
|
338
|
+
];
|
|
339
|
+
return problems.length === 0 ? { ok: true } : { ok: false, problems };
|
|
340
|
+
}
|
|
@@ -124,8 +124,9 @@ export function classifyChange(org, change, plan) {
|
|
|
124
124
|
throw new RequestedChangeError(`'${change.field}' is not a requestable field`);
|
|
125
125
|
}
|
|
126
126
|
// Archon validates the whole company for these; nothing waits for approval.
|
|
127
|
-
if (change.field === 'app' || change.field === 'build')
|
|
127
|
+
if (change.field === 'app' || change.field === 'build') {
|
|
128
128
|
return 'auto';
|
|
129
|
+
}
|
|
129
130
|
if (change.field === 'bouleApps') {
|
|
130
131
|
if (typeof change.enabled !== 'boolean') {
|
|
131
132
|
throw new RequestedChangeError('bouleApps needs enabled, true or false');
|
package/dist/slug.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export declare const RESERVED_SLUGS: ReadonlySet<string>;
|
|
3
3
|
/** Every platform app, tunnel, project and database name starts `platform-`, so no company slug may. */
|
|
4
4
|
export declare const RESERVED_SLUG_PREFIX = "platform-";
|
|
5
|
-
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
5
|
+
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
6
6
|
export declare function hasBarredSlugSuffix(label: string): boolean;
|
|
7
7
|
/**
|
|
8
8
|
* Why {@link validateSlug} refused a candidate slug, by name so a caller —
|
package/dist/slug.js
CHANGED
|
@@ -138,6 +138,14 @@ const AGORA_SUFFIX = /-agora$/;
|
|
|
138
138
|
* one that opts in.
|
|
139
139
|
*/
|
|
140
140
|
const BOULE_SUFFIX = /-boule$/;
|
|
141
|
+
/**
|
|
142
|
+
* `<something>-kosmos` — a level-1 org's own kosmos hostname
|
|
143
|
+
* (`<slug>-kosmos.oikos.host`, foundry#949). Kosmos is opt-in, the same way
|
|
144
|
+
* {@link CALLS_SUFFIX} guards a feature an org can decline: a slug ending
|
|
145
|
+
* `-kosmos` is only barred because it collides with that one org's own
|
|
146
|
+
* hostname once kosmos is on, not because every org gets one.
|
|
147
|
+
*/
|
|
148
|
+
const KOSMOS_SUFFIX = /-kosmos$/;
|
|
141
149
|
/** `<something>-auth|-mail|-files|-sfu|-cms` — an own company's service hostnames on oikos.host (`plan/own-hostnames.ts`). */
|
|
142
150
|
const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
143
151
|
/**
|
|
@@ -151,13 +159,14 @@ const OWN_SERVICE_SUFFIX = /-(auth|mail|files|sfu|cms)$/;
|
|
|
151
159
|
* `names-`.
|
|
152
160
|
*/
|
|
153
161
|
const NAMES_PREFIX = /^names(-|$)/;
|
|
154
|
-
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
162
|
+
/** Whether `label` ends in a suffix reserved for another org's own hostname (`-stage`, `-pr-<n>`, `-calls`, `-agora`, `-boule`, `-kosmos`, `-auth`, `-mail`, `-files`, `-sfu`, `-cms`). */
|
|
155
163
|
export function hasBarredSlugSuffix(label) {
|
|
156
164
|
return (STAGE_SUFFIX.test(label) ||
|
|
157
165
|
PR_SUFFIX.test(label) ||
|
|
158
166
|
CALLS_SUFFIX.test(label) ||
|
|
159
167
|
AGORA_SUFFIX.test(label) ||
|
|
160
168
|
BOULE_SUFFIX.test(label) ||
|
|
169
|
+
KOSMOS_SUFFIX.test(label) ||
|
|
161
170
|
OWN_SERVICE_SUFFIX.test(label));
|
|
162
171
|
}
|
|
163
172
|
/**
|
|
@@ -189,7 +198,7 @@ export function validateSlug(slug) {
|
|
|
189
198
|
if (hasBarredSlugSuffix(slug)) {
|
|
190
199
|
return {
|
|
191
200
|
reason: 'environment-suffix',
|
|
192
|
-
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, never as an org slug in its own right`,
|
|
201
|
+
message: `slug '${slug}' ends with an environment suffix ('-stage', '-pr-<digits>', '-calls', '-agora', '-boule', '-kosmos', '-auth', '-mail', '-files', '-sfu' or '-cms') reserved for another org's own hostname (#28, #145, #164, #161, #949) — 'acme-calls' is only legal as acme's calls hostname, 'acme-agora' only as acme's Agora hostname, 'acme-boule' only as acme's Boule hostname, 'acme-kosmos' only as acme's kosmos hostname, never as an org slug in its own right`,
|
|
193
202
|
};
|
|
194
203
|
}
|
|
195
204
|
return null;
|
package/dist/store/postgres.js
CHANGED
|
@@ -174,6 +174,17 @@ function rowToOrg(row) {
|
|
|
174
174
|
...(row.agoraBlueGreen
|
|
175
175
|
? { agora: { blueGreen: readBlueGreen(row.agoraBlueGreen, 'agora') } }
|
|
176
176
|
: {}),
|
|
177
|
+
// Omitted while unset, as `boule`/`agora` are. Validated on read, the same way.
|
|
178
|
+
...(row.kosmosBlueGreen || row.kosmosEnabled
|
|
179
|
+
? {
|
|
180
|
+
kosmos: {
|
|
181
|
+
...(row.kosmosBlueGreen
|
|
182
|
+
? { blueGreen: readBlueGreen(row.kosmosBlueGreen, 'kosmos') }
|
|
183
|
+
: {}),
|
|
184
|
+
...(row.kosmosEnabled ? { enabled: true } : {}),
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
: {}),
|
|
177
188
|
...(row.versionPin ? { versionPin: readVersionPin(row) } : {}),
|
|
178
189
|
site: { enabled: row.siteEnabled, usesDomain: row.siteUsesDomain },
|
|
179
190
|
hosting: {
|
|
@@ -240,6 +251,8 @@ function orgToRow(org) {
|
|
|
240
251
|
bouleBlueGreen: org.boule?.blueGreen ?? null,
|
|
241
252
|
bouleApps: org.boule?.apps ?? false,
|
|
242
253
|
agoraBlueGreen: org.agora?.blueGreen ?? null,
|
|
254
|
+
kosmosEnabled: org.kosmos?.enabled ?? false,
|
|
255
|
+
kosmosBlueGreen: org.kosmos?.blueGreen ?? null,
|
|
243
256
|
servicesOwn: org.services?.own ?? false,
|
|
244
257
|
servicesSfuSlot: org.services?.sfuSlot ?? null,
|
|
245
258
|
versionPin: org.versionPin && Object.keys(org.versionPin).length > 0 ? org.versionPin : null,
|
package/dist/store/schema.d.ts
CHANGED
|
@@ -461,6 +461,42 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
|
|
|
461
461
|
identity: undefined;
|
|
462
462
|
generated: undefined;
|
|
463
463
|
}, {}, {}>;
|
|
464
|
+
kosmosEnabled: import("drizzle-orm/pg-core").PgColumn<{
|
|
465
|
+
name: "kosmos_enabled";
|
|
466
|
+
tableName: "foundry_declared_orgs";
|
|
467
|
+
dataType: "boolean";
|
|
468
|
+
columnType: "PgBoolean";
|
|
469
|
+
data: boolean;
|
|
470
|
+
driverParam: boolean;
|
|
471
|
+
notNull: true;
|
|
472
|
+
hasDefault: true;
|
|
473
|
+
isPrimaryKey: false;
|
|
474
|
+
isAutoincrement: false;
|
|
475
|
+
hasRuntimeDefault: false;
|
|
476
|
+
enumValues: undefined;
|
|
477
|
+
baseColumn: never;
|
|
478
|
+
identity: undefined;
|
|
479
|
+
generated: undefined;
|
|
480
|
+
}, {}, {}>;
|
|
481
|
+
kosmosBlueGreen: import("drizzle-orm/pg-core").PgColumn<{
|
|
482
|
+
name: "kosmos_blue_green";
|
|
483
|
+
tableName: "foundry_declared_orgs";
|
|
484
|
+
dataType: "json";
|
|
485
|
+
columnType: "PgJsonb";
|
|
486
|
+
data: BlueGreenConfig | null;
|
|
487
|
+
driverParam: unknown;
|
|
488
|
+
notNull: false;
|
|
489
|
+
hasDefault: false;
|
|
490
|
+
isPrimaryKey: false;
|
|
491
|
+
isAutoincrement: false;
|
|
492
|
+
hasRuntimeDefault: false;
|
|
493
|
+
enumValues: undefined;
|
|
494
|
+
baseColumn: never;
|
|
495
|
+
identity: undefined;
|
|
496
|
+
generated: undefined;
|
|
497
|
+
}, {}, {
|
|
498
|
+
$type: BlueGreenConfig | null;
|
|
499
|
+
}>;
|
|
464
500
|
versionPin: import("drizzle-orm/pg-core").PgColumn<{
|
|
465
501
|
name: "version_pin";
|
|
466
502
|
tableName: "foundry_declared_orgs";
|
package/dist/store/schema.js
CHANGED
|
@@ -48,6 +48,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
48
48
|
agoraBlueGreen: jsonb('agora_blue_green').$type(),
|
|
49
49
|
// BouleApp.apps (org.ts): Boule runs apps for the company; `migrations/0063_boule_apps.sql`.
|
|
50
50
|
bouleApps: boolean('boule_apps').notNull().default(false),
|
|
51
|
+
// KosmosApp (org.ts, foundry#949): the company's own kosmos, set up only when asked; `migrations/0066_org_kosmos.sql`.
|
|
52
|
+
kosmosEnabled: boolean('kosmos_enabled').notNull().default(false),
|
|
53
|
+
kosmosBlueGreen: jsonb('kosmos_blue_green').$type(),
|
|
51
54
|
// VersionPin (org.ts): null follows the platform. `migrations/0053_org_version_pin.sql`.
|
|
52
55
|
versionPin: jsonb('version_pin').$type(),
|
|
53
56
|
// SiteConfig (org.ts, T10). A brand new org has no cms site.
|
|
@@ -80,6 +83,9 @@ export const declaredOrgs = pgTable('foundry_declared_orgs', {
|
|
|
80
83
|
hostingRepos: jsonb('hosting_repos').notNull().default([]).$type(),
|
|
81
84
|
// HostingConfig.github (org.ts); migrations/0062. Null: the company has no GitHub App installation.
|
|
82
85
|
hostingGithub: jsonb('hosting_github').$type(),
|
|
86
|
+
// migrations/0065 added `hosting_native_apps` for foundry#948; foundry#951 dropped the column from
|
|
87
|
+
// use (Boule owns native settings now, estate#262) but not from the database -- a migration only
|
|
88
|
+
// adds in this release, since idle and live share one database. The column stays, unread.
|
|
83
89
|
ssoEnabled: boolean('sso_enabled').notNull().default(false),
|
|
84
90
|
guestsEnabled: boolean('guests_enabled').notNull().default(false),
|
|
85
91
|
soloEnabled: boolean('solo_enabled').notNull().default(false),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/foundry",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@wtfalch/audit": "0.6.0",
|
|
62
62
|
"@wtfalch/audit-sealing": "npm:@wtfalch/audit@0.14.0",
|
|
63
63
|
"@wtfalch/auth": "^0.26.0",
|
|
64
|
-
"@wtfalch/edge": "0.
|
|
64
|
+
"@wtfalch/edge": "0.21.0",
|
|
65
65
|
"@wtfalch/email": "^0.20.0",
|
|
66
66
|
"@wtfalch/providers": "0.12.0",
|
|
67
67
|
"ssh2": "1.17.0",
|