@fonderie/admin 0.8.3 → 0.9.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/brain/outcomes.md +2 -0
- package/brain/signatures.md +30 -0
- package/dist/index.cjs +159 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -2
- package/dist/index.d.ts +86 -2
- package/dist/index.js +156 -3
- package/dist/index.js.map +1 -1
- package/dist/ui/app.global.js +6 -6
- package/package.json +1 -1
package/dist/index.d.cts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { IAdminCheck, IAdminCheckReport, IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem, Middleware } from '@fonderie/core';
|
|
2
|
-
import { IStoreAdapter } from '@fonderie/store';
|
|
2
|
+
import { IStoreAdapter, MigrationImpact } from '@fonderie/store';
|
|
3
|
+
import { IRequestSchema } from '@fonderie/core/middlewares';
|
|
3
4
|
|
|
4
5
|
interface IAdminOptions {
|
|
5
6
|
adminToken?: string;
|
|
6
7
|
path?: string;
|
|
7
8
|
checks?: IAdminCheck[];
|
|
8
9
|
checkTimeoutMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* The ordered migration sets this deployment applies, exactly as its own
|
|
12
|
+
* applier runs them — brick directories and the app's own, interleaved.
|
|
13
|
+
* Unset ⇒ the migrations routes are not registered at all.
|
|
14
|
+
*
|
|
15
|
+
* Pass the SAME constant the applier reads. The order is the app's to
|
|
16
|
+
* declare and this surface must never infer it: bricks and app migrations
|
|
17
|
+
* depend on each other across the list, and a panel guessing that order
|
|
18
|
+
* would eventually guess wrong in a way that only shows on a fresh database.
|
|
19
|
+
*/
|
|
20
|
+
migrations?: ReadonlyArray<IMigrationSet>;
|
|
9
21
|
ui?: boolean;
|
|
10
22
|
/**
|
|
11
23
|
* Answer only for requests addressed to this hostname (or one of these);
|
|
@@ -131,6 +143,22 @@ interface IAdminTokensReport {
|
|
|
131
143
|
legacy: IAdminTokenEntry[];
|
|
132
144
|
issued: IAdminTokenRecord[] | null;
|
|
133
145
|
}
|
|
146
|
+
type IMigrationSet = readonly [name: string, dir: string];
|
|
147
|
+
interface IAdminPendingMigration {
|
|
148
|
+
file: string;
|
|
149
|
+
impact: MigrationImpact;
|
|
150
|
+
destructive: string[];
|
|
151
|
+
}
|
|
152
|
+
interface IAdminMigrationModule {
|
|
153
|
+
name: string;
|
|
154
|
+
pending: IAdminPendingMigration[];
|
|
155
|
+
blockedBy: string | null;
|
|
156
|
+
appliable: boolean;
|
|
157
|
+
}
|
|
158
|
+
interface IAdminMigrationsReport {
|
|
159
|
+
everApplied: boolean;
|
|
160
|
+
modules: IAdminMigrationModule[];
|
|
161
|
+
}
|
|
134
162
|
|
|
135
163
|
declare const DEFAULT_ADMIN_PATH = "/_admin";
|
|
136
164
|
declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
|
|
@@ -171,6 +199,62 @@ declare function configReport(app: IFonderieApp, env: string[]): IAdminConfigRep
|
|
|
171
199
|
declare function routesReport(app: IFonderieApp, adminModule: string): IAdminRoutesReport;
|
|
172
200
|
declare function tokensReport(app: IFonderieApp, adminModule: string, issued: IAdminTokenRecord[] | null): IAdminTokensReport;
|
|
173
201
|
|
|
202
|
+
/**
|
|
203
|
+
* `expect` is the exact pending list the operator was shown, in order.
|
|
204
|
+
*
|
|
205
|
+
* It is not ceremony. Between rendering the page and clicking the button a
|
|
206
|
+
* deploy can change what is pending — and the thing that changed is precisely
|
|
207
|
+
* the thing nobody reviewed. Sending it back makes the server refuse rather
|
|
208
|
+
* than apply a set the operator never saw. Same shape as versionedWrite's
|
|
209
|
+
* if_version, and it also gives this POST the body schema audit:validation
|
|
210
|
+
* requires.
|
|
211
|
+
*/
|
|
212
|
+
declare const applyMigrationsSchema: IRequestSchema;
|
|
213
|
+
/**
|
|
214
|
+
* What every module is waiting on, with each pending file's impact.
|
|
215
|
+
*
|
|
216
|
+
* `blockedBy` names the first EARLIER module that is behind. Order is
|
|
217
|
+
* load-bearing and declared by the app, so a module cannot be applied while
|
|
218
|
+
* something it may depend on is unapplied — the panel offers one button at a
|
|
219
|
+
* time and the operator walks the list in the declared order.
|
|
220
|
+
*/
|
|
221
|
+
declare function migrationsReport(store: IStoreAdapter, sets: ReadonlyArray<IMigrationSet>): Promise<IAdminMigrationsReport>;
|
|
222
|
+
type ApplyOutcome = {
|
|
223
|
+
ok: true;
|
|
224
|
+
reason: 'MIGRATIONS_APPLIED' | 'MIGRATIONS_UP_TO_DATE';
|
|
225
|
+
module: IAdminMigrationModule;
|
|
226
|
+
} | {
|
|
227
|
+
ok: false;
|
|
228
|
+
reason: 'NOT_FOUND';
|
|
229
|
+
} | {
|
|
230
|
+
ok: false;
|
|
231
|
+
reason: 'MIGRATIONS_OUT_OF_ORDER';
|
|
232
|
+
blockedBy: string;
|
|
233
|
+
} | {
|
|
234
|
+
ok: false;
|
|
235
|
+
reason: 'MIGRATIONS_CHANGED';
|
|
236
|
+
expected: string[];
|
|
237
|
+
actual: string[];
|
|
238
|
+
} | {
|
|
239
|
+
ok: false;
|
|
240
|
+
reason: 'MIGRATION_DESTRUCTIVE';
|
|
241
|
+
files: IAdminMigrationModule['pending'];
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Apply one module's pending migrations — all of them, or none.
|
|
245
|
+
*
|
|
246
|
+
* There is no partial apply, deliberately: files run in lexicographic order, so
|
|
247
|
+
* an additive migration sorting AFTER a destructive one may depend on it.
|
|
248
|
+
* Cherry-picking around a DROP would apply 901 against a schema that never saw
|
|
249
|
+
* 900. Refusing the whole module at the first destructive file is the only safe
|
|
250
|
+
* reading, and happens to be the only one MigrationRunner.run() supports.
|
|
251
|
+
*
|
|
252
|
+
* `expect` is the file list the operator was looking at. Between the render and
|
|
253
|
+
* the click a deploy can change what is pending; without this the button would
|
|
254
|
+
* apply something nobody reviewed.
|
|
255
|
+
*/
|
|
256
|
+
declare function applyModuleMigrations(store: IStoreAdapter, sets: ReadonlyArray<IMigrationSet>, module: string, expect: readonly string[]): Promise<ApplyOutcome>;
|
|
257
|
+
|
|
174
258
|
declare const SCOPES: readonly AdminScope[];
|
|
175
259
|
declare function scopeFor(method: string, path: string): AdminScope;
|
|
176
260
|
declare function grants(held: readonly AdminScope[], needed: AdminScope): boolean;
|
|
@@ -188,4 +272,4 @@ declare function issueToken(store: IStoreAdapter, input: {
|
|
|
188
272
|
declare function revokeToken(store: IStoreAdapter, id: string): Promise<boolean>;
|
|
189
273
|
declare function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | 'root'): Middleware;
|
|
190
274
|
|
|
191
|
-
export { ADMIN_VERSION, AdminModule, type AdminRouteGuard, type AdminScope, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminConfigReport, type IAdminDoctorReport, type IAdminEnvEntry, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, type IAdminRouteEntry, type IAdminRoutesReport, type IAdminTokenEntry, type IAdminTokenRecord, type IAdminTokensReport, MAX_PAGE, SCOPES, adminLog, attention, buildManifest, collectChecks, configReport, grants, hashToken, issueToken, listTokens, readAdminLog, requireAdminScope, revokeToken, routesReport, runDoctor, scopeFor, tokensReport };
|
|
275
|
+
export { ADMIN_VERSION, AdminModule, type AdminRouteGuard, type AdminScope, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminConfigReport, type IAdminDoctorReport, type IAdminEnvEntry, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminMigrationModule, type IAdminMigrationsReport, type IAdminModuleEntry, type IAdminOptions, type IAdminPendingMigration, type IAdminRouteEntry, type IAdminRoutesReport, type IAdminTokenEntry, type IAdminTokenRecord, type IAdminTokensReport, type IMigrationSet, MAX_PAGE, SCOPES, adminLog, applyMigrationsSchema, applyModuleMigrations, attention, buildManifest, collectChecks, configReport, grants, hashToken, issueToken, listTokens, migrationsReport, readAdminLog, requireAdminScope, revokeToken, routesReport, runDoctor, scopeFor, tokensReport };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { IAdminCheck, IAdminCheckReport, IReadinessReport, IRouteEntry, IFonderieModule, IFonderieApp, IReadinessProblem, Middleware } from '@fonderie/core';
|
|
2
|
-
import { IStoreAdapter } from '@fonderie/store';
|
|
2
|
+
import { IStoreAdapter, MigrationImpact } from '@fonderie/store';
|
|
3
|
+
import { IRequestSchema } from '@fonderie/core/middlewares';
|
|
3
4
|
|
|
4
5
|
interface IAdminOptions {
|
|
5
6
|
adminToken?: string;
|
|
6
7
|
path?: string;
|
|
7
8
|
checks?: IAdminCheck[];
|
|
8
9
|
checkTimeoutMs?: number;
|
|
10
|
+
/**
|
|
11
|
+
* The ordered migration sets this deployment applies, exactly as its own
|
|
12
|
+
* applier runs them — brick directories and the app's own, interleaved.
|
|
13
|
+
* Unset ⇒ the migrations routes are not registered at all.
|
|
14
|
+
*
|
|
15
|
+
* Pass the SAME constant the applier reads. The order is the app's to
|
|
16
|
+
* declare and this surface must never infer it: bricks and app migrations
|
|
17
|
+
* depend on each other across the list, and a panel guessing that order
|
|
18
|
+
* would eventually guess wrong in a way that only shows on a fresh database.
|
|
19
|
+
*/
|
|
20
|
+
migrations?: ReadonlyArray<IMigrationSet>;
|
|
9
21
|
ui?: boolean;
|
|
10
22
|
/**
|
|
11
23
|
* Answer only for requests addressed to this hostname (or one of these);
|
|
@@ -131,6 +143,22 @@ interface IAdminTokensReport {
|
|
|
131
143
|
legacy: IAdminTokenEntry[];
|
|
132
144
|
issued: IAdminTokenRecord[] | null;
|
|
133
145
|
}
|
|
146
|
+
type IMigrationSet = readonly [name: string, dir: string];
|
|
147
|
+
interface IAdminPendingMigration {
|
|
148
|
+
file: string;
|
|
149
|
+
impact: MigrationImpact;
|
|
150
|
+
destructive: string[];
|
|
151
|
+
}
|
|
152
|
+
interface IAdminMigrationModule {
|
|
153
|
+
name: string;
|
|
154
|
+
pending: IAdminPendingMigration[];
|
|
155
|
+
blockedBy: string | null;
|
|
156
|
+
appliable: boolean;
|
|
157
|
+
}
|
|
158
|
+
interface IAdminMigrationsReport {
|
|
159
|
+
everApplied: boolean;
|
|
160
|
+
modules: IAdminMigrationModule[];
|
|
161
|
+
}
|
|
134
162
|
|
|
135
163
|
declare const DEFAULT_ADMIN_PATH = "/_admin";
|
|
136
164
|
declare const DEFAULT_CHECK_TIMEOUT_MS = 10000;
|
|
@@ -171,6 +199,62 @@ declare function configReport(app: IFonderieApp, env: string[]): IAdminConfigRep
|
|
|
171
199
|
declare function routesReport(app: IFonderieApp, adminModule: string): IAdminRoutesReport;
|
|
172
200
|
declare function tokensReport(app: IFonderieApp, adminModule: string, issued: IAdminTokenRecord[] | null): IAdminTokensReport;
|
|
173
201
|
|
|
202
|
+
/**
|
|
203
|
+
* `expect` is the exact pending list the operator was shown, in order.
|
|
204
|
+
*
|
|
205
|
+
* It is not ceremony. Between rendering the page and clicking the button a
|
|
206
|
+
* deploy can change what is pending — and the thing that changed is precisely
|
|
207
|
+
* the thing nobody reviewed. Sending it back makes the server refuse rather
|
|
208
|
+
* than apply a set the operator never saw. Same shape as versionedWrite's
|
|
209
|
+
* if_version, and it also gives this POST the body schema audit:validation
|
|
210
|
+
* requires.
|
|
211
|
+
*/
|
|
212
|
+
declare const applyMigrationsSchema: IRequestSchema;
|
|
213
|
+
/**
|
|
214
|
+
* What every module is waiting on, with each pending file's impact.
|
|
215
|
+
*
|
|
216
|
+
* `blockedBy` names the first EARLIER module that is behind. Order is
|
|
217
|
+
* load-bearing and declared by the app, so a module cannot be applied while
|
|
218
|
+
* something it may depend on is unapplied — the panel offers one button at a
|
|
219
|
+
* time and the operator walks the list in the declared order.
|
|
220
|
+
*/
|
|
221
|
+
declare function migrationsReport(store: IStoreAdapter, sets: ReadonlyArray<IMigrationSet>): Promise<IAdminMigrationsReport>;
|
|
222
|
+
type ApplyOutcome = {
|
|
223
|
+
ok: true;
|
|
224
|
+
reason: 'MIGRATIONS_APPLIED' | 'MIGRATIONS_UP_TO_DATE';
|
|
225
|
+
module: IAdminMigrationModule;
|
|
226
|
+
} | {
|
|
227
|
+
ok: false;
|
|
228
|
+
reason: 'NOT_FOUND';
|
|
229
|
+
} | {
|
|
230
|
+
ok: false;
|
|
231
|
+
reason: 'MIGRATIONS_OUT_OF_ORDER';
|
|
232
|
+
blockedBy: string;
|
|
233
|
+
} | {
|
|
234
|
+
ok: false;
|
|
235
|
+
reason: 'MIGRATIONS_CHANGED';
|
|
236
|
+
expected: string[];
|
|
237
|
+
actual: string[];
|
|
238
|
+
} | {
|
|
239
|
+
ok: false;
|
|
240
|
+
reason: 'MIGRATION_DESTRUCTIVE';
|
|
241
|
+
files: IAdminMigrationModule['pending'];
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Apply one module's pending migrations — all of them, or none.
|
|
245
|
+
*
|
|
246
|
+
* There is no partial apply, deliberately: files run in lexicographic order, so
|
|
247
|
+
* an additive migration sorting AFTER a destructive one may depend on it.
|
|
248
|
+
* Cherry-picking around a DROP would apply 901 against a schema that never saw
|
|
249
|
+
* 900. Refusing the whole module at the first destructive file is the only safe
|
|
250
|
+
* reading, and happens to be the only one MigrationRunner.run() supports.
|
|
251
|
+
*
|
|
252
|
+
* `expect` is the file list the operator was looking at. Between the render and
|
|
253
|
+
* the click a deploy can change what is pending; without this the button would
|
|
254
|
+
* apply something nobody reviewed.
|
|
255
|
+
*/
|
|
256
|
+
declare function applyModuleMigrations(store: IStoreAdapter, sets: ReadonlyArray<IMigrationSet>, module: string, expect: readonly string[]): Promise<ApplyOutcome>;
|
|
257
|
+
|
|
174
258
|
declare const SCOPES: readonly AdminScope[];
|
|
175
259
|
declare function scopeFor(method: string, path: string): AdminScope;
|
|
176
260
|
declare function grants(held: readonly AdminScope[], needed: AdminScope): boolean;
|
|
@@ -188,4 +272,4 @@ declare function issueToken(store: IStoreAdapter, input: {
|
|
|
188
272
|
declare function revokeToken(store: IStoreAdapter, id: string): Promise<boolean>;
|
|
189
273
|
declare function requireAdminScope(bootstrap: string, store: IStoreAdapter | undefined, needed: AdminScope | 'root'): Middleware;
|
|
190
274
|
|
|
191
|
-
export { ADMIN_VERSION, AdminModule, type AdminRouteGuard, type AdminScope, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminConfigReport, type IAdminDoctorReport, type IAdminEnvEntry, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminModuleEntry, type IAdminOptions, type IAdminRouteEntry, type IAdminRoutesReport, type IAdminTokenEntry, type IAdminTokenRecord, type IAdminTokensReport, MAX_PAGE, SCOPES, adminLog, attention, buildManifest, collectChecks, configReport, grants, hashToken, issueToken, listTokens, readAdminLog, requireAdminScope, revokeToken, routesReport, runDoctor, scopeFor, tokensReport };
|
|
275
|
+
export { ADMIN_VERSION, AdminModule, type AdminRouteGuard, type AdminScope, DEFAULT_ACTOR, DEFAULT_ADMIN_PATH, DEFAULT_CHECK_TIMEOUT_MS, type IAdminAttention, type IAdminAttentionItem, type IAdminCheckResult, type IAdminConfigReport, type IAdminDoctorReport, type IAdminEnvEntry, type IAdminLogEntry, type IAdminLogPage, type IAdminManifest, type IAdminMigrationModule, type IAdminMigrationsReport, type IAdminModuleEntry, type IAdminOptions, type IAdminPendingMigration, type IAdminRouteEntry, type IAdminRoutesReport, type IAdminTokenEntry, type IAdminTokenRecord, type IAdminTokensReport, type IMigrationSet, MAX_PAGE, SCOPES, adminLog, applyMigrationsSchema, applyModuleMigrations, attention, buildManifest, collectChecks, configReport, grants, hashToken, issueToken, listTokens, migrationsReport, readAdminLog, requireAdminScope, revokeToken, routesReport, runDoctor, scopeFor, tokensReport };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/module.ts
|
|
2
|
-
import { readFileSync } from "fs";
|
|
2
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
3
3
|
import { HTTP as HTTP2, normalizeMountPath, normalizeRequestPath as normalizeRequestPath2, setApiResponse as setApiResponse2 } from "@fonderie/core";
|
|
4
4
|
import { validate, validateAdminToken } from "@fonderie/core/middlewares";
|
|
5
5
|
|
|
@@ -142,6 +142,91 @@ function decodeCursor(c) {
|
|
|
142
142
|
return at && id ? { at, id } : null;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// src/migrate.ts
|
|
146
|
+
import { readFileSync } from "fs";
|
|
147
|
+
import { join } from "path";
|
|
148
|
+
import { classifyMigration, InternalMigrationRunner } from "@fonderie/store";
|
|
149
|
+
var applyMigrationsSchema = {
|
|
150
|
+
safeParse(input) {
|
|
151
|
+
const fail = (path, message) => ({
|
|
152
|
+
success: false,
|
|
153
|
+
error: { issues: [{ path: [path], message }] }
|
|
154
|
+
});
|
|
155
|
+
const b = input ?? {};
|
|
156
|
+
const expect = b["expect"];
|
|
157
|
+
if (!Array.isArray(expect) || !expect.every((f) => typeof f === "string"))
|
|
158
|
+
return fail("expect", "expect must be the list of pending filenames you were shown");
|
|
159
|
+
return { success: true, data: { expect } };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
async function hasMigrationHistory(store) {
|
|
163
|
+
try {
|
|
164
|
+
const rows = await store.query(
|
|
165
|
+
"SELECT count(*)::int AS n FROM fonderie_migrations"
|
|
166
|
+
);
|
|
167
|
+
return (rows[0]?.n ?? 0) > 0;
|
|
168
|
+
} catch {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function classifyPending(dir, files) {
|
|
173
|
+
return files.map((file) => {
|
|
174
|
+
const { impact, destructive } = classifyMigration(readFileSync(join(dir, file), "utf8"));
|
|
175
|
+
return { file, impact, destructive };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
async function migrationsReport(store, sets) {
|
|
179
|
+
const everApplied = await hasMigrationHistory(store);
|
|
180
|
+
const modules = [];
|
|
181
|
+
let firstBehind = null;
|
|
182
|
+
for (const [name, dir] of sets) {
|
|
183
|
+
const pending = await new InternalMigrationRunner(store, dir).pending();
|
|
184
|
+
const files = classifyPending(dir, pending);
|
|
185
|
+
modules.push({
|
|
186
|
+
name,
|
|
187
|
+
pending: files,
|
|
188
|
+
// Only a module BEFORE this one blocks it. The first module with
|
|
189
|
+
// pending work blocks every module after it, and nothing blocks it.
|
|
190
|
+
blockedBy: firstBehind,
|
|
191
|
+
// A file that deletes data on a database with nothing in it yet is not
|
|
192
|
+
// destroying anything. Same rule the CLI's --check applies.
|
|
193
|
+
appliable: files.length > 0 && firstBehind === null && (!everApplied || files.every((f) => f.impact === "additive"))
|
|
194
|
+
});
|
|
195
|
+
if (files.length > 0 && firstBehind === null) firstBehind = name;
|
|
196
|
+
}
|
|
197
|
+
return { everApplied, modules };
|
|
198
|
+
}
|
|
199
|
+
async function applyModuleMigrations(store, sets, module, expect) {
|
|
200
|
+
const set = sets.find(([name]) => name === module);
|
|
201
|
+
if (!set) return { ok: false, reason: "NOT_FOUND" };
|
|
202
|
+
const before = await migrationsReport(store, sets);
|
|
203
|
+
const target = before.modules.find((m) => m.name === module);
|
|
204
|
+
if (!target) return { ok: false, reason: "NOT_FOUND" };
|
|
205
|
+
if (target.blockedBy) {
|
|
206
|
+
return { ok: false, reason: "MIGRATIONS_OUT_OF_ORDER", blockedBy: target.blockedBy };
|
|
207
|
+
}
|
|
208
|
+
const actual = target.pending.map((p) => p.file);
|
|
209
|
+
const same = actual.length === expect.length && actual.every((f, i) => f === expect[i]);
|
|
210
|
+
if (!same) {
|
|
211
|
+
return { ok: false, reason: "MIGRATIONS_CHANGED", expected: [...expect], actual };
|
|
212
|
+
}
|
|
213
|
+
if (actual.length === 0) {
|
|
214
|
+
return { ok: true, reason: "MIGRATIONS_UP_TO_DATE", module: target };
|
|
215
|
+
}
|
|
216
|
+
const destructive = target.pending.filter((p) => p.impact === "destructive");
|
|
217
|
+
if (destructive.length > 0 && before.everApplied) {
|
|
218
|
+
return { ok: false, reason: "MIGRATION_DESTRUCTIVE", files: destructive };
|
|
219
|
+
}
|
|
220
|
+
await new InternalMigrationRunner(store, set[1]).run();
|
|
221
|
+
const after = await migrationsReport(store, sets);
|
|
222
|
+
const now = after.modules.find((m) => m.name === module);
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
reason: "MIGRATIONS_APPLIED",
|
|
226
|
+
module: now ?? { name: module, pending: [], blockedBy: null, appliable: false }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
145
230
|
// src/manifest.ts
|
|
146
231
|
function buildManifest(app, admin) {
|
|
147
232
|
const report = app.securityReport();
|
|
@@ -332,7 +417,7 @@ var uiHtml = (scriptPath) => `<!doctype html>
|
|
|
332
417
|
// src/module.ts
|
|
333
418
|
var DEFAULT_ADMIN_PATH = "/_admin";
|
|
334
419
|
var DEFAULT_CHECK_TIMEOUT_MS = 1e4;
|
|
335
|
-
var ADMIN_VERSION = "0.
|
|
420
|
+
var ADMIN_VERSION = "0.9.0";
|
|
336
421
|
function normalizeHostPattern(host) {
|
|
337
422
|
return host.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/\/.*$/, "").toLowerCase();
|
|
338
423
|
}
|
|
@@ -465,6 +550,70 @@ var AdminModule = class {
|
|
|
465
550
|
]
|
|
466
551
|
]
|
|
467
552
|
] : [];
|
|
553
|
+
const migrationSets = this.options.migrations;
|
|
554
|
+
const scoped = store && migrationSets ? [
|
|
555
|
+
[
|
|
556
|
+
"GET",
|
|
557
|
+
"/_admin/migrations",
|
|
558
|
+
[
|
|
559
|
+
async () => setApiResponse2(
|
|
560
|
+
HTTP2.OK,
|
|
561
|
+
"MIGRATIONS",
|
|
562
|
+
"Pending migrations by module",
|
|
563
|
+
await migrationsReport(store, migrationSets)
|
|
564
|
+
)
|
|
565
|
+
]
|
|
566
|
+
],
|
|
567
|
+
[
|
|
568
|
+
"POST",
|
|
569
|
+
"/_admin/migrations/:module/apply",
|
|
570
|
+
[
|
|
571
|
+
validate(applyMigrationsSchema),
|
|
572
|
+
async (ctx) => {
|
|
573
|
+
const { expect } = ctx.meta["body"];
|
|
574
|
+
const name = ctx.meta.params?.["module"] ?? "";
|
|
575
|
+
const out = await applyModuleMigrations(store, migrationSets, name, expect);
|
|
576
|
+
if (out.ok) {
|
|
577
|
+
return setApiResponse2(
|
|
578
|
+
HTTP2.OK,
|
|
579
|
+
out.reason,
|
|
580
|
+
out.reason === "MIGRATIONS_APPLIED" ? `Applied. ${out.module.pending.length} still pending in ${name}.` : `${name} is already up to date`,
|
|
581
|
+
out.module
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
switch (out.reason) {
|
|
585
|
+
case "NOT_FOUND":
|
|
586
|
+
return setApiResponse2(
|
|
587
|
+
HTTP2.NOT_FOUND,
|
|
588
|
+
"NOT_FOUND",
|
|
589
|
+
`No migration set named "${name}"`
|
|
590
|
+
);
|
|
591
|
+
case "MIGRATIONS_OUT_OF_ORDER":
|
|
592
|
+
return setApiResponse2(
|
|
593
|
+
HTTP2.CONFLICT,
|
|
594
|
+
out.reason,
|
|
595
|
+
`Apply "${out.blockedBy}" first \u2014 it runs before "${name}" and is behind.`,
|
|
596
|
+
{ blockedBy: out.blockedBy }
|
|
597
|
+
);
|
|
598
|
+
case "MIGRATIONS_CHANGED":
|
|
599
|
+
return setApiResponse2(
|
|
600
|
+
HTTP2.CONFLICT,
|
|
601
|
+
out.reason,
|
|
602
|
+
"What is pending changed since you looked. Refresh and read it again.",
|
|
603
|
+
{ expected: out.expected, actual: out.actual }
|
|
604
|
+
);
|
|
605
|
+
default:
|
|
606
|
+
return setApiResponse2(
|
|
607
|
+
HTTP2.UNPROCESSABLE,
|
|
608
|
+
out.reason,
|
|
609
|
+
"A pending migration deletes data. No down-migration brings it back \u2014 apply it through CI or `npm run migrate`, not from here.",
|
|
610
|
+
{ files: out.files }
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
]
|
|
615
|
+
]
|
|
616
|
+
] : [];
|
|
468
617
|
const mounted = /* @__PURE__ */ new Map();
|
|
469
618
|
const mount = (module, method, path, handlers, needed) => {
|
|
470
619
|
const key = `${method.toUpperCase()} ${path}`;
|
|
@@ -492,7 +641,7 @@ var AdminModule = class {
|
|
|
492
641
|
const loadJs = () => {
|
|
493
642
|
if (js === null) {
|
|
494
643
|
try {
|
|
495
|
-
js =
|
|
644
|
+
js = readFileSync2(new URL("./ui/app.global.js", import.meta.url), "utf8");
|
|
496
645
|
} catch {
|
|
497
646
|
js = "";
|
|
498
647
|
}
|
|
@@ -531,6 +680,7 @@ var AdminModule = class {
|
|
|
531
680
|
for (const [method, path, handler] of own) mount(this.name, method, at(path), [handler]);
|
|
532
681
|
for (const [method, path, handlers] of rootOnly)
|
|
533
682
|
mount(this.name, method, at(path), handlers, "root");
|
|
683
|
+
for (const [method, path, handlers] of scoped) mount(this.name, method, at(path), handlers);
|
|
534
684
|
for (const { module, description } of app.adminDescriptions()) {
|
|
535
685
|
for (const r of description.routes ?? [])
|
|
536
686
|
mount(module, r.method, this.path + r.path, r.handlers);
|
|
@@ -549,6 +699,8 @@ export {
|
|
|
549
699
|
MAX_PAGE,
|
|
550
700
|
SCOPES,
|
|
551
701
|
adminLog,
|
|
702
|
+
applyMigrationsSchema,
|
|
703
|
+
applyModuleMigrations,
|
|
552
704
|
attention,
|
|
553
705
|
buildManifest,
|
|
554
706
|
collectChecks,
|
|
@@ -557,6 +709,7 @@ export {
|
|
|
557
709
|
hashToken,
|
|
558
710
|
issueToken,
|
|
559
711
|
listTokens,
|
|
712
|
+
migrationsReport,
|
|
560
713
|
readAdminLog,
|
|
561
714
|
requireAdminScope,
|
|
562
715
|
revokeToken,
|