@softure-ai/db 0.0.0-stage → 0.1.6
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +383 -2
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +8 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +38 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +27 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +205 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +90 -0
- package/dist/client.js.map +1 -0
- package/dist/configured.d.ts +31 -0
- package/dist/configured.d.ts.map +1 -0
- package/dist/configured.js +74 -0
- package/dist/configured.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/adopt.d.ts +33 -0
- package/dist/migrations/adopt.d.ts.map +1 -0
- package/dist/migrations/adopt.js +97 -0
- package/dist/migrations/adopt.js.map +1 -0
- package/dist/migrations/apply.d.ts +35 -0
- package/dist/migrations/apply.d.ts.map +1 -0
- package/dist/migrations/apply.js +115 -0
- package/dist/migrations/apply.js.map +1 -0
- package/dist/migrations/export.d.ts +10 -0
- package/dist/migrations/export.d.ts.map +1 -0
- package/dist/migrations/export.js +58 -0
- package/dist/migrations/export.js.map +1 -0
- package/dist/migrations/exported.d.ts +20 -0
- package/dist/migrations/exported.d.ts.map +1 -0
- package/dist/migrations/exported.js +61 -0
- package/dist/migrations/exported.js.map +1 -0
- package/dist/migrations/files.d.ts +21 -0
- package/dist/migrations/files.d.ts.map +1 -0
- package/dist/migrations/files.js +112 -0
- package/dist/migrations/files.js.map +1 -0
- package/dist/migrations/introspect.d.ts +6 -0
- package/dist/migrations/introspect.d.ts.map +1 -0
- package/dist/migrations/introspect.js +117 -0
- package/dist/migrations/introspect.js.map +1 -0
- package/dist/migrations/ledger.d.ts +27 -0
- package/dist/migrations/ledger.d.ts.map +1 -0
- package/dist/migrations/ledger.js +45 -0
- package/dist/migrations/ledger.js.map +1 -0
- package/dist/migrations/migrator.d.ts +97 -0
- package/dist/migrations/migrator.d.ts.map +1 -0
- package/dist/migrations/migrator.js +301 -0
- package/dist/migrations/migrator.js.map +1 -0
- package/dist/migrations/problems.d.ts +93 -0
- package/dist/migrations/problems.d.ts.map +1 -0
- package/dist/migrations/problems.js +62 -0
- package/dist/migrations/problems.js.map +1 -0
- package/dist/migrations/reference.d.ts +23 -0
- package/dist/migrations/reference.d.ts.map +1 -0
- package/dist/migrations/reference.js +64 -0
- package/dist/migrations/reference.js.map +1 -0
- package/dist/migrations/session.d.ts +15 -0
- package/dist/migrations/session.d.ts.map +1 -0
- package/dist/migrations/session.js +56 -0
- package/dist/migrations/session.js.map +1 -0
- package/dist/migrations/stubs.d.ts +23 -0
- package/dist/migrations/stubs.d.ts.map +1 -0
- package/dist/migrations/stubs.js +99 -0
- package/dist/migrations/stubs.js.map +1 -0
- package/dist/shared.d.ts +9 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +32 -0
- package/dist/shared.js.map +1 -0
- package/dist/testing.d.ts +23 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +62 -0
- package/dist/testing.js.map +1 -0
- package/package.json +73 -4
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +47 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +250 -0
- package/src/client.ts +151 -0
- package/src/configured.ts +109 -0
- package/src/index.ts +46 -0
- package/src/migrations/adopt.ts +136 -0
- package/src/migrations/apply.ts +135 -0
- package/src/migrations/export.ts +66 -0
- package/src/migrations/exported.ts +82 -0
- package/src/migrations/files.ts +129 -0
- package/src/migrations/introspect.ts +165 -0
- package/src/migrations/ledger.ts +75 -0
- package/src/migrations/migrator.ts +403 -0
- package/src/migrations/problems.ts +99 -0
- package/src/migrations/reference.ts +78 -0
- package/src/migrations/session.ts +74 -0
- package/src/migrations/stubs.ts +139 -0
- package/src/shared.ts +36 -0
- package/src/testing.ts +80 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// The module migrator (docs/02-module-standard.md §4). Every enabled module's SQL files run in the
|
|
2
|
+
// module's own schema, dependencies first, one transaction per file together with its ledger row.
|
|
3
|
+
// Everything that can be checked (names, numbering, checksums, order) is checked for all modules
|
|
4
|
+
// before the first file runs, so a problem in one module never leaves another half-migrated.
|
|
5
|
+
// The app's own migrations (drizzle's, usually) plug in through `app.before` / `app.after`, and
|
|
6
|
+
// `app.baseline` names the module files the app's own history already creates (issue #152).
|
|
7
|
+
import { ok, sortModulesByDependencies, type AnySoftureModule } from "@softure-ai/core";
|
|
8
|
+
import type { DatabaseHandle } from "../client.js";
|
|
9
|
+
import { applyFile, describeError, recordAdoption, withMigrationLock, type MigrationUnit } from "./apply.js";
|
|
10
|
+
import { readMigrationFiles } from "./files.js";
|
|
11
|
+
import { describeSchema, diffSchemas } from "./introspect.js";
|
|
12
|
+
import { LEDGER_FILES, LEDGER_MODULE_ID, LEDGER_SCHEMA, LEDGER_VERSION, readJournal, type JournalRow } from "./ledger.js";
|
|
13
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
14
|
+
import { buildReferenceSchema, listOwnedSchemas } from "./reference.js";
|
|
15
|
+
import { withSession, type MigrationSession } from "./session.js";
|
|
16
|
+
import { readAppTables } from "./stubs.js";
|
|
17
|
+
|
|
18
|
+
export { applyFile, recordAdoption, rollBack, withMigrationLock, type MigrationUnit } from "./apply.js";
|
|
19
|
+
|
|
20
|
+
export interface MigrateOptions {
|
|
21
|
+
/** The enabled modules, in any order; dependencies are applied first. */
|
|
22
|
+
readonly modules: readonly AnySoftureModule[];
|
|
23
|
+
/**
|
|
24
|
+
* Read each module's files from `<migrationsDir>/<module id>/` instead of the module's own
|
|
25
|
+
* folder: the container path, where a bundle cannot locate package folders.
|
|
26
|
+
*/
|
|
27
|
+
readonly migrationsDir?: URL;
|
|
28
|
+
/** Called after each file is committed, e.g. to print progress. */
|
|
29
|
+
readonly onApplied?: (step: MigrationStep) => void;
|
|
30
|
+
/** Called after each file a baseline recorded as adopted, e.g. to print progress. */
|
|
31
|
+
readonly onAdopted?: (step: MigrationStep) => void;
|
|
32
|
+
/**
|
|
33
|
+
* The app's own migrations around the module files, and its baseline. `planMigrations` runs no
|
|
34
|
+
* hook; it reads only the baseline.
|
|
35
|
+
*/
|
|
36
|
+
readonly app?: AppMigrations;
|
|
37
|
+
/** Called after an app hook succeeded, e.g. to print progress. */
|
|
38
|
+
readonly onAppMigrated?: (phase: AppMigrationPhase) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The app's own migration runner, called by `migrate` under the migration lock on every run (it
|
|
43
|
+
* must skip what it already applied, as drizzle's migrator does). A throw or rejection is reported
|
|
44
|
+
* as `db.app_migration_failed`.
|
|
45
|
+
*/
|
|
46
|
+
export type AppMigrationHook = (handle: DatabaseHandle) => Promise<void>;
|
|
47
|
+
|
|
48
|
+
export interface AppMigrations {
|
|
49
|
+
/** Runs before the module files: app tables that module SQL references (normally the app's whole history). */
|
|
50
|
+
readonly before?: AppMigrationHook;
|
|
51
|
+
/** Runs after the module files: app migrations that reference tables a module creates. */
|
|
52
|
+
readonly after?: AppMigrationHook;
|
|
53
|
+
/**
|
|
54
|
+
* The module files the app's own history already creates: module id → the last such file (1..n).
|
|
55
|
+
* For a listed module the ledger has never seen, `migrate` compares the module's schema with
|
|
56
|
+
* files 1..n once `before` ran and records them as `adopted`, then applies the later files; an
|
|
57
|
+
* empty schema is migrated normally. Once the module is in the ledger the entry has no effect.
|
|
58
|
+
*/
|
|
59
|
+
readonly baseline?: Readonly<Record<string, number>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AppMigrationPhase = "before" | "after";
|
|
63
|
+
|
|
64
|
+
export interface MigrationStep {
|
|
65
|
+
readonly module: string;
|
|
66
|
+
readonly schema: string;
|
|
67
|
+
readonly version: number;
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly checksum: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MigrationPlan {
|
|
73
|
+
/** What `migrate` would apply, in order. */
|
|
74
|
+
readonly pending: readonly MigrationStep[];
|
|
75
|
+
/**
|
|
76
|
+
* The pending steps a baseline may adopt instead of applying: they are adopted when the module's
|
|
77
|
+
* schema already holds objects after `before` (which a plan does not run).
|
|
78
|
+
*/
|
|
79
|
+
readonly baseline: readonly MigrationStep[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface MigrationReport {
|
|
83
|
+
readonly applied: readonly MigrationStep[];
|
|
84
|
+
/** The files a baseline recorded as adopted, in order. */
|
|
85
|
+
readonly adopted: readonly MigrationStep[];
|
|
86
|
+
/** The app hooks that ran, in order. */
|
|
87
|
+
readonly app: readonly AppMigrationPhase[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `drizzle` holds drizzle's own ledger (`drizzle.__drizzle_migrations`) in every app on this stack.
|
|
91
|
+
const RESERVED_SCHEMAS = new Set([LEDGER_SCHEMA, "public", "information_schema", "drizzle"]);
|
|
92
|
+
const MAX_IDENTIFIER_BYTES = 63;
|
|
93
|
+
|
|
94
|
+
/** The dry run: what `migrate` would apply. Takes no lock and writes nothing. */
|
|
95
|
+
export async function planMigrations(handle: DatabaseHandle, options: MigrateOptions): Promise<MigrationResult<MigrationPlan>> {
|
|
96
|
+
const units = await prepareUnits(options.modules, options.migrationsDir);
|
|
97
|
+
if (!units.ok) {
|
|
98
|
+
return units;
|
|
99
|
+
}
|
|
100
|
+
const baseline = options.app?.baseline ?? {};
|
|
101
|
+
const baselineProblems = checkBaseline(options.modules, units.value, baseline);
|
|
102
|
+
if (baselineProblems.length > 0) {
|
|
103
|
+
return failWith(baselineProblems);
|
|
104
|
+
}
|
|
105
|
+
const journal = await withSession(handle, readJournal);
|
|
106
|
+
const comparison = compareJournal(units.value, journal);
|
|
107
|
+
if (comparison.problems.length > 0) {
|
|
108
|
+
return failWith(comparison.problems);
|
|
109
|
+
}
|
|
110
|
+
const adoptable = comparison.pending.filter((step) => isBaselineStep(step, baseline, journal));
|
|
111
|
+
return ok({ pending: comparison.pending, baseline: adoptable });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Applies every pending migration under the advisory lock: the ledger, `app.before`, the module
|
|
116
|
+
* files (adopting a baseline where it applies), then `app.after`. Nothing runs when a check fails.
|
|
117
|
+
*/
|
|
118
|
+
export async function migrate(handle: DatabaseHandle, options: MigrateOptions): Promise<MigrationResult<MigrationReport>> {
|
|
119
|
+
const units = await prepareUnits(options.modules, options.migrationsDir);
|
|
120
|
+
if (!units.ok) {
|
|
121
|
+
return units;
|
|
122
|
+
}
|
|
123
|
+
const app = options.app ?? {};
|
|
124
|
+
const baseline = app.baseline ?? {};
|
|
125
|
+
const baselineProblems = checkBaseline(options.modules, units.value, baseline);
|
|
126
|
+
if (baselineProblems.length > 0) {
|
|
127
|
+
return failWith(baselineProblems);
|
|
128
|
+
}
|
|
129
|
+
return withSession(handle, (session) =>
|
|
130
|
+
withMigrationLock(session, async () => {
|
|
131
|
+
// Read after the lock: a runner that waited sees what the first one applied.
|
|
132
|
+
const journal = await readJournal(session);
|
|
133
|
+
const comparison = compareJournal(units.value, journal);
|
|
134
|
+
if (comparison.problems.length > 0) {
|
|
135
|
+
return failWith(comparison.problems);
|
|
136
|
+
}
|
|
137
|
+
const applied: MigrationStep[] = [];
|
|
138
|
+
const adopted: MigrationStep[] = [];
|
|
139
|
+
const ranHooks: AppMigrationPhase[] = [];
|
|
140
|
+
const applySteps = async (steps: readonly MigrationStep[]): Promise<MigrationProblem | null> => {
|
|
141
|
+
for (const step of steps) {
|
|
142
|
+
const { unit, file } = findFile(units.value, step);
|
|
143
|
+
const failure = await applyFile(session, { unit, file, method: "applied" });
|
|
144
|
+
if (failure !== null) {
|
|
145
|
+
return failure;
|
|
146
|
+
}
|
|
147
|
+
applied.push(step);
|
|
148
|
+
options.onApplied?.(step);
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
};
|
|
152
|
+
const runHook = async (phase: AppMigrationPhase): Promise<MigrationProblem | null> => {
|
|
153
|
+
const failure = await runAppHook(handle, app, phase);
|
|
154
|
+
if (failure === null && app[phase] !== undefined) {
|
|
155
|
+
ranHooks.push(phase);
|
|
156
|
+
options.onAppMigrated?.(phase);
|
|
157
|
+
}
|
|
158
|
+
return failure;
|
|
159
|
+
};
|
|
160
|
+
// Unit by unit in dependency order, so a module's dependencies are migrated before its
|
|
161
|
+
// baseline is compared.
|
|
162
|
+
const migrateModules = async (): Promise<MigrationProblem | null> => {
|
|
163
|
+
for (const unit of units.value) {
|
|
164
|
+
if (unit.module === LEDGER_MODULE_ID) continue;
|
|
165
|
+
const steps = comparison.pending.filter((step) => step.module === unit.module);
|
|
166
|
+
const through = baseline[unit.module];
|
|
167
|
+
if (through === undefined || journal.some((row) => row.module === unit.module)) {
|
|
168
|
+
const failure = await applySteps(steps);
|
|
169
|
+
if (failure !== null) return failure;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const adoption = await adoptBaseline(session, { modules: options.modules, units: units.value, unit, through });
|
|
173
|
+
if (adoption.problem !== null) return adoption.problem;
|
|
174
|
+
for (const step of adoption.adopted) {
|
|
175
|
+
adopted.push(step);
|
|
176
|
+
options.onAdopted?.(step);
|
|
177
|
+
}
|
|
178
|
+
const failure = await applySteps(steps.filter((step) => !adoption.adopted.some((done) => done.version === step.version)));
|
|
179
|
+
if (failure !== null) return failure;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// The ledger first, so it exists whatever a hook does; then before, the modules, after.
|
|
185
|
+
const ledgerSteps = comparison.pending.filter((step) => step.module === LEDGER_MODULE_ID);
|
|
186
|
+
const failure = (await applySteps(ledgerSteps)) ?? (await runHook("before")) ?? (await migrateModules()) ?? (await runHook("after"));
|
|
187
|
+
if (failure !== null) {
|
|
188
|
+
return failWith([failure]);
|
|
189
|
+
}
|
|
190
|
+
return ok({ applied, adopted, app: ranHooks });
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Adopt-or-migrate for a baseline module the ledger has never seen: an empty schema adopts
|
|
197
|
+
* nothing (its files are then applied normally); otherwise the schema must equal files 1..through,
|
|
198
|
+
* which are then recorded as adopted in one transaction.
|
|
199
|
+
*/
|
|
200
|
+
async function adoptBaseline(
|
|
201
|
+
session: MigrationSession,
|
|
202
|
+
input: { modules: readonly AnySoftureModule[]; units: readonly MigrationUnit[]; unit: MigrationUnit; through: number },
|
|
203
|
+
): Promise<{ adopted: MigrationStep[]; problem: MigrationProblem | null }> {
|
|
204
|
+
const { unit, through } = input;
|
|
205
|
+
const live = await describeSchema(session, unit.schema);
|
|
206
|
+
if (live.length === 0) {
|
|
207
|
+
return { adopted: [], problem: null };
|
|
208
|
+
}
|
|
209
|
+
// Built only here, so a database whose modules are all in the ledger never needs PGlite. The app's
|
|
210
|
+
// tables exist by now (`before` ran), so module SQL that references them finds their stubs.
|
|
211
|
+
const appTables = await readAppTables(session, listOwnedSchemas(input.units));
|
|
212
|
+
const reference = await buildReferenceSchema({ modules: input.modules, units: input.units, target: unit, through, appTables });
|
|
213
|
+
if (!reference.ok) {
|
|
214
|
+
return { adopted: [], problem: reference.problems[0] ?? null };
|
|
215
|
+
}
|
|
216
|
+
const differences = diffSchemas(reference.value, live);
|
|
217
|
+
if (differences.length > 0) {
|
|
218
|
+
return { adopted: [], problem: { code: "db.schema_mismatch", module: unit.module, schema: unit.schema, differences } };
|
|
219
|
+
}
|
|
220
|
+
const files = unit.files.filter((file) => file.version <= through);
|
|
221
|
+
const failure = await recordAdoption(session, unit, files);
|
|
222
|
+
if (failure !== null) {
|
|
223
|
+
return { adopted: [], problem: failure };
|
|
224
|
+
}
|
|
225
|
+
return { adopted: files.map((file) => toStep(unit, file)), problem: null };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* A baseline may name only enabled modules with a schema and migrations, and a last file between
|
|
230
|
+
* 1 and the module's file count. Checked before anything runs.
|
|
231
|
+
*/
|
|
232
|
+
function checkBaseline(
|
|
233
|
+
modules: readonly AnySoftureModule[],
|
|
234
|
+
units: readonly MigrationUnit[],
|
|
235
|
+
baseline: Readonly<Record<string, number>>,
|
|
236
|
+
): MigrationProblem[] {
|
|
237
|
+
return Object.entries(baseline).flatMap(([module, through]): MigrationProblem[] => {
|
|
238
|
+
if (!modules.some((candidate) => candidate.id === module)) {
|
|
239
|
+
return [{ code: "db.adopt_unknown_module", module }];
|
|
240
|
+
}
|
|
241
|
+
const unit = units.find((candidate) => candidate.module === module);
|
|
242
|
+
if (unit === undefined) {
|
|
243
|
+
return [{ code: "db.adopt_no_schema", module }];
|
|
244
|
+
}
|
|
245
|
+
return checkThrough(unit, through);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `through` must be a whole number from 1 to the unit's file count. */
|
|
250
|
+
export function checkThrough(unit: MigrationUnit, through: number): MigrationProblem[] {
|
|
251
|
+
const files = unit.files.length;
|
|
252
|
+
return Number.isInteger(through) && through >= 1 && through <= files
|
|
253
|
+
? []
|
|
254
|
+
: [{ code: "db.adopt_through_out_of_range", module: unit.module, through, files }];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isBaselineStep(step: MigrationStep, baseline: Readonly<Record<string, number>>, journal: readonly JournalRow[]): boolean {
|
|
258
|
+
const through = baseline[step.module];
|
|
259
|
+
return through !== undefined && step.version <= through && !journal.some((row) => row.module === step.module);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function findFile(units: readonly MigrationUnit[], step: MigrationStep): { unit: MigrationUnit; file: MigrationUnit["files"][number] } {
|
|
263
|
+
const unit = units.find((candidate) => candidate.module === step.module);
|
|
264
|
+
const file = unit?.files.find((candidate) => candidate.version === step.version);
|
|
265
|
+
if (unit === undefined || file === undefined) {
|
|
266
|
+
throw new Error(`migrate: step ${step.module} ${step.version} has no file; compareJournal is broken`);
|
|
267
|
+
}
|
|
268
|
+
return { unit, file };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function toStep(unit: MigrationUnit, file: MigrationUnit["files"][number]): MigrationStep {
|
|
272
|
+
return { module: unit.module, schema: unit.schema, version: file.version, name: file.name, checksum: file.checksum };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Runs one app hook alone under the migration lock (the CLI's `--adopt` runs `before` first, so the
|
|
277
|
+
* app migration that moves a table and the adoption are one command). Ok when there is no hook.
|
|
278
|
+
*/
|
|
279
|
+
export async function runAppMigrations(
|
|
280
|
+
handle: DatabaseHandle,
|
|
281
|
+
app: AppMigrations,
|
|
282
|
+
phase: AppMigrationPhase,
|
|
283
|
+
): Promise<MigrationResult<{ ran: boolean }>> {
|
|
284
|
+
if (app[phase] === undefined) {
|
|
285
|
+
return ok({ ran: false });
|
|
286
|
+
}
|
|
287
|
+
const failure = await withSession(handle, (session) => withMigrationLock(session, () => runAppHook(handle, app, phase)));
|
|
288
|
+
return failure === null ? ok({ ran: true }) : failWith([failure]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function runAppHook(handle: DatabaseHandle, app: AppMigrations, phase: AppMigrationPhase): Promise<MigrationProblem | null> {
|
|
292
|
+
const hook = app[phase];
|
|
293
|
+
if (hook === undefined) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
try {
|
|
297
|
+
await hook(handle);
|
|
298
|
+
return null;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return { code: "db.app_migration_failed", phase, reason: describeErrorChain(error) };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// drizzle wraps the database error (`Failed query: <sql>`, the Postgres message in `cause`), so the
|
|
305
|
+
// whole chain is the reason.
|
|
306
|
+
function describeErrorChain(error: unknown): string {
|
|
307
|
+
const messages: string[] = [];
|
|
308
|
+
let current: unknown = error;
|
|
309
|
+
while (current !== undefined && current !== null && messages.length < 5) {
|
|
310
|
+
messages.push(describeError(current));
|
|
311
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
312
|
+
}
|
|
313
|
+
return messages.join(": ");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Validates the module list and reads every file: the ledger first, then the modules with a
|
|
318
|
+
* schema in dependency order. Fails with every problem found.
|
|
319
|
+
*/
|
|
320
|
+
export async function prepareUnits(
|
|
321
|
+
modules: readonly AnySoftureModule[],
|
|
322
|
+
migrationsDir: URL | undefined,
|
|
323
|
+
): Promise<MigrationResult<MigrationUnit[]>> {
|
|
324
|
+
const problems = modules.flatMap(checkModule);
|
|
325
|
+
const sorted = sortModulesByDependencies(modules);
|
|
326
|
+
if (!sorted.ok) {
|
|
327
|
+
problems.push({ code: "db.dependency_cycle", modules: modules.map((module) => module.id) });
|
|
328
|
+
}
|
|
329
|
+
if (problems.length > 0 || !sorted.ok) {
|
|
330
|
+
return failWith(problems);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const units: MigrationUnit[] = [
|
|
334
|
+
{ module: LEDGER_MODULE_ID, schema: LEDGER_SCHEMA, moduleVersion: LEDGER_VERSION, files: LEDGER_FILES },
|
|
335
|
+
];
|
|
336
|
+
for (const module of sorted.value) {
|
|
337
|
+
const schema = module.manifest.dbSchema;
|
|
338
|
+
if (schema === null || module.migrations === null) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const dir = migrationsDir === undefined ? module.migrations.dir : new URL(`${module.id}/`, ensureFolderUrl(migrationsDir));
|
|
342
|
+
const files = await readMigrationFiles(module.id, dir);
|
|
343
|
+
if (files.ok) {
|
|
344
|
+
units.push({ module: module.id, schema, moduleVersion: module.manifest.version, files: files.value });
|
|
345
|
+
} else {
|
|
346
|
+
problems.push(...files.problems);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return problems.length > 0 ? failWith(problems) : ok(units);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Checks applied files against the folder and lists the pending ones in run order. */
|
|
353
|
+
export function compareJournal(
|
|
354
|
+
units: readonly MigrationUnit[],
|
|
355
|
+
journal: readonly JournalRow[],
|
|
356
|
+
): { problems: MigrationProblem[]; pending: MigrationStep[] } {
|
|
357
|
+
const problems: MigrationProblem[] = [];
|
|
358
|
+
const pending: MigrationStep[] = [];
|
|
359
|
+
for (const unit of units) {
|
|
360
|
+
const rows = journal.filter((row) => row.module === unit.module);
|
|
361
|
+
const appliedVersions = new Set(rows.map((row) => row.version));
|
|
362
|
+
const lastApplied = Math.max(0, ...rows.map((row) => row.version));
|
|
363
|
+
|
|
364
|
+
for (const row of rows) {
|
|
365
|
+
const file = unit.files.find((candidate) => candidate.version === row.version);
|
|
366
|
+
if (file === undefined) {
|
|
367
|
+
problems.push({ code: "db.migration_missing", module: unit.module, version: row.version, name: row.name });
|
|
368
|
+
} else if (file.checksum !== row.checksum || file.name !== row.name) {
|
|
369
|
+
problems.push({ code: "db.migration_changed", module: unit.module, version: row.version, name: row.name });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (const file of unit.files) {
|
|
373
|
+
if (appliedVersions.has(file.version)) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (file.version < lastApplied) {
|
|
377
|
+
problems.push({ code: "db.migration_out_of_order", module: unit.module, version: file.version, appliedVersion: lastApplied });
|
|
378
|
+
} else {
|
|
379
|
+
pending.push({ module: unit.module, schema: unit.schema, version: file.version, name: file.name, checksum: file.checksum });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return { problems, pending };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function checkModule(module: AnySoftureModule): MigrationProblem[] {
|
|
387
|
+
const schema = module.manifest.dbSchema;
|
|
388
|
+
if (module.id === LEDGER_MODULE_ID || (schema !== null && isReservedSchema(schema))) {
|
|
389
|
+
return [{ code: "db.reserved_module", module: module.id }];
|
|
390
|
+
}
|
|
391
|
+
if (schema === null && module.migrations !== null) {
|
|
392
|
+
return [{ code: "db.migrations_without_schema", module: module.id }];
|
|
393
|
+
}
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function isReservedSchema(schema: string): boolean {
|
|
398
|
+
return RESERVED_SCHEMAS.has(schema) || schema.startsWith("pg_") || Buffer.byteLength(schema, "utf8") > MAX_IDENTIFIER_BYTES;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function ensureFolderUrl(url: URL): URL {
|
|
402
|
+
return url.href.endsWith("/") ? url : new URL(`${url.href}/`);
|
|
403
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Every expected migration failure, as a value (AGENTS.md: expected failures are results). Each
|
|
2
|
+
// problem names the module and file it is about (or the app's hook phase); `describeProblem` turns
|
|
3
|
+
// it into the English line the CLI prints and `createTestDatabase` throws.
|
|
4
|
+
import type { Err, Ok } from "@softure-ai/core";
|
|
5
|
+
|
|
6
|
+
export type MigrationProblem =
|
|
7
|
+
| { readonly code: "db.invalid_migration_file"; readonly module: string; readonly file: string; readonly reason: string }
|
|
8
|
+
| { readonly code: "db.migrations_unreadable"; readonly module: string; readonly dir: string; readonly reason: string }
|
|
9
|
+
| { readonly code: "db.migration_changed"; readonly module: string; readonly version: number; readonly name: string }
|
|
10
|
+
| { readonly code: "db.migration_missing"; readonly module: string; readonly version: number; readonly name: string }
|
|
11
|
+
| { readonly code: "db.migration_out_of_order"; readonly module: string; readonly version: number; readonly appliedVersion: number }
|
|
12
|
+
| { readonly code: "db.migration_failed"; readonly module: string; readonly version: number; readonly name: string; readonly reason: string }
|
|
13
|
+
| { readonly code: "db.app_migration_failed"; readonly phase: "before" | "after"; readonly reason: string }
|
|
14
|
+
| { readonly code: "db.reserved_module"; readonly module: string }
|
|
15
|
+
| { readonly code: "db.migrations_without_schema"; readonly module: string }
|
|
16
|
+
| { readonly code: "db.dependency_cycle"; readonly modules: readonly string[] }
|
|
17
|
+
| { readonly code: "db.adopt_unknown_module"; readonly module: string }
|
|
18
|
+
| { readonly code: "db.adopt_version_mismatch"; readonly module: string; readonly requested: string; readonly enabled: string }
|
|
19
|
+
| { readonly code: "db.adopt_already_applied"; readonly module: string }
|
|
20
|
+
| { readonly code: "db.adopt_dependency_pending"; readonly module: string; readonly dependency: string }
|
|
21
|
+
| { readonly code: "db.adopt_no_schema"; readonly module: string }
|
|
22
|
+
| { readonly code: "db.adopt_through_out_of_range"; readonly module: string; readonly through: number; readonly files: number }
|
|
23
|
+
| { readonly code: "db.adopt_reference_failed"; readonly module: string; readonly reason: string }
|
|
24
|
+
| { readonly code: "db.export_target_not_empty"; readonly module: string; readonly dir: string; readonly entries: readonly string[] }
|
|
25
|
+
| { readonly code: "db.schema_mismatch"; readonly module: string; readonly schema: string; readonly differences: readonly string[] };
|
|
26
|
+
|
|
27
|
+
export type MigrationErrorCode = MigrationProblem["code"];
|
|
28
|
+
|
|
29
|
+
/** A failed migration operation: the code of its first problem, and every problem found. */
|
|
30
|
+
export interface MigrationFailure extends Err<MigrationErrorCode> {
|
|
31
|
+
readonly problems: readonly MigrationProblem[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MigrationResult<T> = Ok<T> | MigrationFailure;
|
|
35
|
+
|
|
36
|
+
export function failWith(problems: readonly MigrationProblem[]): MigrationFailure {
|
|
37
|
+
const [first] = problems;
|
|
38
|
+
if (first === undefined) {
|
|
39
|
+
throw new Error("failWith: a migration failure needs at least one problem");
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: first.code, problems };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One English line per problem, naming the module and the file or object involved. */
|
|
45
|
+
export function describeProblem(problem: MigrationProblem): string {
|
|
46
|
+
switch (problem.code) {
|
|
47
|
+
case "db.invalid_migration_file":
|
|
48
|
+
return `${problem.module}: migration file ${problem.file} is invalid: ${problem.reason}`;
|
|
49
|
+
case "db.migrations_unreadable":
|
|
50
|
+
return `${problem.module}: cannot read the migrations folder ${problem.dir}: ${problem.reason}`;
|
|
51
|
+
case "db.migration_changed":
|
|
52
|
+
return `${problem.module}: applied migration ${formatFile(problem.version, problem.name)} was edited (checksum differs); add a new migration instead`;
|
|
53
|
+
case "db.migration_missing":
|
|
54
|
+
return `${problem.module}: applied migration ${formatFile(problem.version, problem.name)} is missing from the module`;
|
|
55
|
+
case "db.migration_out_of_order":
|
|
56
|
+
return `${problem.module}: migration ${formatVersion(problem.version)} is pending but ${formatVersion(problem.appliedVersion)} is already applied`;
|
|
57
|
+
case "db.migration_failed":
|
|
58
|
+
return `${problem.module}: migration ${formatFile(problem.version, problem.name)} failed and was rolled back: ${problem.reason}`;
|
|
59
|
+
case "db.app_migration_failed":
|
|
60
|
+
return problem.phase === "before"
|
|
61
|
+
? `app: the before migrations failed: ${problem.reason}; no module migration ran`
|
|
62
|
+
: `app: the after migrations failed: ${problem.reason}; the module migrations before it stay applied`;
|
|
63
|
+
case "db.reserved_module":
|
|
64
|
+
return `${problem.module}: uses a reserved id or schema (the id "softure"; the schemas softure, public, information_schema, drizzle, pg_* and names over 63 bytes)`;
|
|
65
|
+
case "db.migrations_without_schema":
|
|
66
|
+
return `${problem.module}: has migrations but no dbSchema in its manifest`;
|
|
67
|
+
case "db.dependency_cycle":
|
|
68
|
+
return `modules ${problem.modules.join(", ")} depend on each other in a cycle`;
|
|
69
|
+
case "db.adopt_unknown_module":
|
|
70
|
+
return `${problem.module}: cannot adopt a module that is not enabled in the config`;
|
|
71
|
+
case "db.adopt_version_mismatch":
|
|
72
|
+
return `${problem.module}: --adopt asks for ${problem.requested} but ${problem.enabled} is enabled`;
|
|
73
|
+
case "db.adopt_already_applied":
|
|
74
|
+
return `${problem.module}: already has migrations in the ledger; adopt only works on a module the ledger has never seen`;
|
|
75
|
+
case "db.adopt_dependency_pending":
|
|
76
|
+
return `${problem.module}: depends on ${problem.dependency}, whose pending migrations could not be applied (see above); adopt ${problem.dependency} first`;
|
|
77
|
+
case "db.adopt_no_schema":
|
|
78
|
+
return `${problem.module}: has no dbSchema or no migrations, so there is nothing to adopt`;
|
|
79
|
+
case "db.adopt_through_out_of_range":
|
|
80
|
+
return `${problem.module}: cannot adopt through ${String(problem.through)}; it is the number of the last file to adopt, a whole number from 1 to ${problem.files}`;
|
|
81
|
+
case "db.adopt_reference_failed":
|
|
82
|
+
return `${problem.module}: could not build the reference schema: ${problem.reason}`;
|
|
83
|
+
case "db.export_target_not_empty":
|
|
84
|
+
return `${problem.module}: ${problem.dir} holds other files (${problem.entries.join(", ")}); export into an empty folder`;
|
|
85
|
+
case "db.schema_mismatch":
|
|
86
|
+
return [
|
|
87
|
+
`${problem.module}: schema "${problem.schema}" differs from what its migrations create:`,
|
|
88
|
+
...problem.differences.map((difference) => ` ${difference}`),
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatVersion(version: number): string {
|
|
94
|
+
return String(version).padStart(4, "0");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatFile(version: number, name: string): string {
|
|
98
|
+
return `${formatVersion(version)}_${name}.sql`;
|
|
99
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The reference schema for adoption: what a module's files 1..N create, built on a scratch
|
|
2
|
+
// in-memory PGlite together with the modules it depends on (directly or not), because its SQL may
|
|
3
|
+
// reference them. `adoptModule` and a baseline in `migrate` compare a live schema with it. Module
|
|
4
|
+
// SQL may also reference the app's own tables; their stubs, copied from the live database
|
|
5
|
+
// (`stubs.ts`), are created first (issue #170).
|
|
6
|
+
import { ok, type AnySoftureModule } from "@softure-ai/core";
|
|
7
|
+
import { createDatabase } from "../client.js";
|
|
8
|
+
import { applyFile, type MigrationUnit } from "./apply.js";
|
|
9
|
+
import { describeSchema } from "./introspect.js";
|
|
10
|
+
import { LEDGER_MODULE_ID, LEDGER_SCHEMA } from "./ledger.js";
|
|
11
|
+
import { failWith, type MigrationProblem, type MigrationResult } from "./problems.js";
|
|
12
|
+
import { withSession } from "./session.js";
|
|
13
|
+
import { createAppStubs, type AppCatalog } from "./stubs.js";
|
|
14
|
+
|
|
15
|
+
// drizzle's ledger schema (`drizzle.__drizzle_migrations`): never an app table a module references.
|
|
16
|
+
const APP_MIGRATOR_SCHEMA = "drizzle";
|
|
17
|
+
|
|
18
|
+
export interface ReferenceInput {
|
|
19
|
+
/** The enabled modules, for the dependency graph. */
|
|
20
|
+
readonly modules: readonly AnySoftureModule[];
|
|
21
|
+
/** Their prepared units (`prepareUnits`), the ledger first, in dependency order. */
|
|
22
|
+
readonly units: readonly MigrationUnit[];
|
|
23
|
+
/** The module whose schema is described. */
|
|
24
|
+
readonly target: MigrationUnit;
|
|
25
|
+
/** The last file of the target to run; its dependencies run in full. */
|
|
26
|
+
readonly through: number;
|
|
27
|
+
/** Stubs of the app's tables (`readAppTables`), created before any file runs. */
|
|
28
|
+
readonly appTables?: AppCatalog;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every line `describeSchema` prints for the target's schema after its files 1..through. */
|
|
32
|
+
export async function buildReferenceSchema(input: ReferenceInput): Promise<MigrationResult<string[]>> {
|
|
33
|
+
const included = collectWithDependencies(input.modules, input.target.module);
|
|
34
|
+
const units = input.units
|
|
35
|
+
.filter((unit) => unit.module === LEDGER_MODULE_ID || included.has(unit.module))
|
|
36
|
+
.map((unit) => (unit.module === input.target.module ? { ...unit, files: unit.files.filter((file) => file.version <= input.through) } : unit));
|
|
37
|
+
const scratch = await createDatabase("pglite://");
|
|
38
|
+
try {
|
|
39
|
+
return await withSession(scratch, async (session) => {
|
|
40
|
+
const skipped = input.appTables === undefined ? [] : await createAppStubs(session, input.appTables);
|
|
41
|
+
for (const unit of units) {
|
|
42
|
+
for (const file of unit.files) {
|
|
43
|
+
const failure = await applyFile(session, { unit, file, method: "applied" });
|
|
44
|
+
if (failure !== null) {
|
|
45
|
+
const reason = describeFailure(failure) + (skipped.length > 0 ? ` (app table stubs skipped: ${skipped.join("; ")})` : "");
|
|
46
|
+
return failWith([{ code: "db.adopt_reference_failed", module: input.target.module, reason }]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return ok(await describeSchema(session, input.target.schema));
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
await scratch.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The schemas the migrator owns, which hold no app table: the ledger's, every unit's and drizzle's. */
|
|
58
|
+
export function listOwnedSchemas(units: readonly MigrationUnit[]): string[] {
|
|
59
|
+
return [...new Set([LEDGER_SCHEMA, APP_MIGRATOR_SCHEMA, ...units.map((unit) => unit.schema)])];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The ids of the target and every enabled module it depends on, directly or not. */
|
|
63
|
+
export function collectWithDependencies(modules: readonly AnySoftureModule[], target: string): Set<string> {
|
|
64
|
+
const byId = new Map(modules.map((module) => [module.id, module]));
|
|
65
|
+
const collected = new Set<string>();
|
|
66
|
+
const visit = (id: string): void => {
|
|
67
|
+
const module = byId.get(id);
|
|
68
|
+
if (module === undefined || collected.has(id)) return;
|
|
69
|
+
collected.add(id);
|
|
70
|
+
for (const dependency of Object.keys(module.manifest.dependsOn)) visit(dependency);
|
|
71
|
+
};
|
|
72
|
+
visit(target);
|
|
73
|
+
return collected;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function describeFailure(problem: MigrationProblem): string {
|
|
77
|
+
return problem.code === "db.migration_failed" ? `${problem.module} ${problem.name}: ${problem.reason}` : problem.code;
|
|
78
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// One raw connection for a migration run. The advisory lock is session-level, so the lock, every
|
|
2
|
+
// transaction and every statement of a run must share this connection (never `pool.query`).
|
|
3
|
+
import type { DatabaseHandle } from "../client.js";
|
|
4
|
+
|
|
5
|
+
type PgliteClient = Extract<DatabaseHandle, { kind: "pglite" }>["client"];
|
|
6
|
+
|
|
7
|
+
export interface MigrationSession {
|
|
8
|
+
/** A parameterised statement; returns its rows. */
|
|
9
|
+
readonly query: <T>(text: string, params?: readonly unknown[]) => Promise<T[]>;
|
|
10
|
+
/** One or more statements without parameters (a migration file). */
|
|
11
|
+
readonly exec: (sql: string) => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SessionSetting {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
readonly value: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SESSION_SETTINGS_SQL = "SELECT name, current_setting(name) AS value FROM pg_settings WHERE source = 'session'";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Runs `run` on one dedicated connection. A pg client is destroyed afterwards instead of going
|
|
23
|
+
* back to the pool, so a `SET` inside a migration file or a lock that failed to release cannot
|
|
24
|
+
* leak into the app's queries. PGlite has one connection, shared with the app, so the settings the
|
|
25
|
+
* run changed are put back afterwards; the app's own (`TimeZone`, `search_path`) stay as they were.
|
|
26
|
+
*/
|
|
27
|
+
export async function withSession<T>(handle: DatabaseHandle, run: (session: MigrationSession) => Promise<T>): Promise<T> {
|
|
28
|
+
if (handle.kind === "pglite") {
|
|
29
|
+
const { client } = handle;
|
|
30
|
+
const before = (await client.query<SessionSetting>(SESSION_SETTINGS_SQL)).rows;
|
|
31
|
+
try {
|
|
32
|
+
return await run({
|
|
33
|
+
query: async <R>(text: string, params?: readonly unknown[]) => (await client.query<R>(text, params ? [...params] : undefined)).rows,
|
|
34
|
+
exec: async (sql) => {
|
|
35
|
+
await client.exec(sql);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
await restoreSessionSettings(client, before);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const client = await handle.pool.connect();
|
|
44
|
+
// A dropped connection makes pg emit 'error' on the client; without a listener Node crashes the
|
|
45
|
+
// process. The pending query rejects on its own, so the listener only has to exist.
|
|
46
|
+
const ignoreClientError = (): void => undefined;
|
|
47
|
+
client.on("error", ignoreClientError);
|
|
48
|
+
try {
|
|
49
|
+
return await run({
|
|
50
|
+
query: async <R>(text: string, params?: readonly unknown[]) => (await client.query(text, params ? [...params] : undefined)).rows as R[],
|
|
51
|
+
exec: async (sql) => {
|
|
52
|
+
await client.query(sql);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} finally {
|
|
56
|
+
client.release(true);
|
|
57
|
+
client.off("error", ignoreClientError);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Puts back every value in `before` (also one the run reset) and resets every session setting the run added. */
|
|
62
|
+
async function restoreSessionSettings(client: PgliteClient, before: readonly SessionSetting[]): Promise<void> {
|
|
63
|
+
const after = new Map((await client.query<SessionSetting>(SESSION_SETTINGS_SQL)).rows.map((setting) => [setting.name, setting.value]));
|
|
64
|
+
for (const setting of before) {
|
|
65
|
+
if (after.get(setting.name) !== setting.value) {
|
|
66
|
+
await client.query("SELECT set_config($1, $2, false)", [setting.name, setting.value]);
|
|
67
|
+
}
|
|
68
|
+
after.delete(setting.name);
|
|
69
|
+
}
|
|
70
|
+
for (const name of after.keys()) {
|
|
71
|
+
// Names come from pg_settings, not from input; quoted as identifiers all the same.
|
|
72
|
+
await client.exec(`RESET "${name.replaceAll('"', '""')}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|