feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms
- Rule configs with typed parsers and registry; Berlin day helpers - Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as ColonyCondition with legacy text mapping), split queen source, varroa sample size; migration harness for data migrations - SuggestionsService: propose/refresh/resolve/obsolete/expire, accept/dismiss/reopen incl. bulk, lock order, concurrency e2e - SeasonService: phenology-based segments, inspection/varroa due dates, queen age; TimeEventsStep with JobPartialFailure - Frontend: per-rule settings forms with visible value sources Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
7097fcd97c
commit
d99a697739
116 files changed
+11239
-801
No files matched your search
@@ -0,0 +1,187 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { Client } from 'pg';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const BACKEND_ROOT = path.resolve(__dirname, '../..');
|
||||
const MIGRATIONS_DIR = path.join(BACKEND_ROOT, 'prisma', 'migrations');
|
||||
const SCHEMA_FILE = path.join(BACKEND_ROOT, 'prisma', 'schema.prisma');
|
||||
const SCRATCH_ROOT = path.join(BACKEND_ROOT, '.tmp-migrations');
|
||||
const PRISMA_CLI = path.join(
|
||||
BACKEND_ROOT,
|
||||
'node_modules',
|
||||
'prisma',
|
||||
'build',
|
||||
'index.js',
|
||||
);
|
||||
const DEPLOY_TIMEOUT_MS = 100_000;
|
||||
const SCHEMA_NAME_PATTERN = /^mig_[0-9a-f]{12}$/;
|
||||
|
||||
export interface ScratchDatabase {
|
||||
readonly schema: string;
|
||||
query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params?: unknown[],
|
||||
): Promise<T[]>;
|
||||
/** Applies every migration up to and including the named one. */
|
||||
migrateThrough(migrationName: string): Promise<void>;
|
||||
drop(): Promise<void>;
|
||||
}
|
||||
|
||||
function baseConnectionString(): string {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
throw new Error('DATABASE_URL is not set');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function withSchema(connectionString: string, schema: string): string {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.set('schema', schema);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function withoutSchemaParam(connectionString: string): string {
|
||||
const url = new URL(connectionString);
|
||||
url.searchParams.delete('schema');
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function listMigrationNames(): Promise<string[]> {
|
||||
const entries = await fs.readdir(MIGRATIONS_DIR, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fresh Postgres schema and migrates it up to, but not including,
|
||||
* `beforeMigration`. The migration folders are copied below
|
||||
* `.tmp-migrations/` inside the backend so that the prisma config resolves
|
||||
* the installed `prisma` package.
|
||||
*/
|
||||
export async function createScratchDatabase(
|
||||
beforeMigration: string,
|
||||
): Promise<ScratchDatabase> {
|
||||
const names = await listMigrationNames();
|
||||
const target = names.indexOf(beforeMigration);
|
||||
if (target < 0) {
|
||||
throw new Error(`Unknown migration: ${beforeMigration}`);
|
||||
}
|
||||
|
||||
const id = randomBytes(6).toString('hex');
|
||||
const schema = `mig_${id}`;
|
||||
if (!SCHEMA_NAME_PATTERN.test(schema)) {
|
||||
throw new Error('Unexpected scratch schema name');
|
||||
}
|
||||
const workDir = path.join(SCRATCH_ROOT, id);
|
||||
const scratchUrl = withSchema(baseConnectionString(), schema);
|
||||
|
||||
const admin = new Client({
|
||||
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||
});
|
||||
await admin.connect();
|
||||
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(MIGRATIONS_DIR, 'migration_lock.toml'),
|
||||
path.join(workDir, 'migration_lock.toml'),
|
||||
);
|
||||
// The URL reaches the config through the environment, never through disk.
|
||||
await fs.writeFile(
|
||||
path.join(workDir, 'prisma.config.ts'),
|
||||
[
|
||||
"import { defineConfig } from 'prisma/config';",
|
||||
'',
|
||||
'export default defineConfig({',
|
||||
` schema: ${JSON.stringify(SCHEMA_FILE)},`,
|
||||
` migrations: { path: ${JSON.stringify(workDir)} },`,
|
||||
" datasource: { url: process.env['MIGRATION_HARNESS_URL'] },",
|
||||
'});',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const copied = new Set<string>();
|
||||
const deploy = async (upToExclusive: number): Promise<void> => {
|
||||
for (const name of names.slice(0, upToExclusive)) {
|
||||
if (!copied.has(name)) {
|
||||
await fs.cp(path.join(MIGRATIONS_DIR, name), path.join(workDir, name), {
|
||||
recursive: true,
|
||||
});
|
||||
copied.add(name);
|
||||
}
|
||||
}
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
PRISMA_CLI,
|
||||
'migrate',
|
||||
'deploy',
|
||||
'--config',
|
||||
path.join(workDir, 'prisma.config.ts'),
|
||||
],
|
||||
{
|
||||
cwd: BACKEND_ROOT,
|
||||
env: { ...process.env, MIGRATION_HARNESS_URL: scratchUrl },
|
||||
timeout: DEPLOY_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let client: Client | null = null;
|
||||
const connected = async (): Promise<Client> => {
|
||||
if (!client) {
|
||||
client = new Client({
|
||||
connectionString: withoutSchemaParam(baseConnectionString()),
|
||||
});
|
||||
await client.connect();
|
||||
await client.query(`SET search_path TO "${schema}"`);
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
try {
|
||||
await deploy(target);
|
||||
} catch (error) {
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||
await admin.end();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
async query<T = Record<string, unknown>>(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
const active = await connected();
|
||||
const result = await active.query(sql, params);
|
||||
return result.rows as T[];
|
||||
},
|
||||
async migrateThrough(migrationName: string): Promise<void> {
|
||||
const index = names.indexOf(migrationName);
|
||||
if (index < 0) {
|
||||
throw new Error(`Unknown migration: ${migrationName}`);
|
||||
}
|
||||
await deploy(index + 1);
|
||||
},
|
||||
async drop(): Promise<void> {
|
||||
if (client) {
|
||||
await client.end();
|
||||
client = null;
|
||||
}
|
||||
await admin.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
||||
await admin.end();
|
||||
await fs.rm(workDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user