feat(db,contracts): full v0.1 data model migrations and shared zod contracts (W1-1)
- migrations 0002-0012 split by domain (foundation/instances/content/identity/ sources/assets/policy/execution/agent/ops/organization): 41 tables, uuid pk, created_at/updated_at + touch trigger, text+CHECK status sets, pgcrypto/pg_trgm - key constraints: source_release(site_id, site_release_id), observation(feed_id, guid), external_identity accepted partial unique, download_task(instance_id, torrent_hash), one active acquisition_plan per slot, one active path_binding per entity/season, asset_location root+path uniqueness, organization_record change_seq/change_xid cursor - secret table (ciphertext/nonce/kdf) + secret_ref columns; outbox dispatch fields (ADR-002) - src/types.ts generated from the migrated schema (scripts/gen-types.ts), drift-checked in tests; int8 -> number, date -> string parsers; migrator accepts migrationTableSchema - contracts: Row/Create/Update schemas per domain, public organization-record API (changeSeq cursor), GlobalSettings, CapabilityDescriptor + 14 capability IO schemas, typed SSE events, Api endpoint table - tests: migration up/down round trip on real PG in an isolated schema, constraint checks, contracts<->db column/enum sync, positive/negative parse for every exported schema Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
7ba712ea55
commit
cc8b3a7df4
42 files changed
+7026
-25
No files matched your search
@@ -2,6 +2,15 @@ import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import type { DB } from "./types";
|
||||
|
||||
// int8 → number(大小/序号都在 2^53 内;超出即报错而不是静默丢精度)
|
||||
pg.types.setTypeParser(pg.types.builtins.INT8, (v: string) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isSafeInteger(n)) throw new RangeError(`int8 超出安全整数范围: ${v}`);
|
||||
return n;
|
||||
});
|
||||
// date → 原样 'YYYY-MM-DD' 字符串,避免按本地时区转成 Date 造成日期漂移
|
||||
pg.types.setTypeParser(pg.types.builtins.DATE, (v: string) => v);
|
||||
|
||||
export interface CreateDbOptions {
|
||||
/** 默认读 process.env.DATABASE_URL */
|
||||
connectionString?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 统一从本包取 Kysely 与 sql,避免各包各装一份 kysely
|
||||
export { type Kysely, sql, type Transaction } from "kysely";
|
||||
export { type CreateDbOptions, createDb } from "./client";
|
||||
export { createMigrator, MIGRATIONS_DIR, migrateToLatest, resetSchema } from "./migrator";
|
||||
export { createMigrator, MIGRATIONS_DIR, type MigratorOptions, migrateToLatest, resetSchema } from "./migrator";
|
||||
export type * from "./types";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
// 迁移辅助:迁移以原生 DDL 书写,便于评审与对照 REQ §12。
|
||||
// 注意:已发布迁移引用的辅助函数行为不得再改变,只能新增函数。
|
||||
|
||||
/** 执行一段不带参数的 DDL(可含多条语句,pg 简单查询协议) */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function execDdl(db: Kysely<any>, ddl: string): Promise<void> {
|
||||
await sql.raw(ddl).execute(db);
|
||||
}
|
||||
|
||||
/** text 列的枚举 CHECK:`check (col in ('a','b'))` */
|
||||
export function oneOf(column: string, values: readonly string[]): string {
|
||||
return `check (${column} in (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")}))`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准实体表:uuid 主键 + created_at/updated_at + updated_at 自动刷新触发器。
|
||||
* body 为其余列与表级约束(逗号分隔,不含首尾逗号)。
|
||||
*/
|
||||
export function entityTable(name: string, body: string): string {
|
||||
return `
|
||||
create table ${name} (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
${body.trim()},
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create trigger ${name}_touch_updated_at before update on ${name}
|
||||
for each row execute function umt_touch_updated_at();
|
||||
`;
|
||||
}
|
||||
|
||||
/** 按顺序 drop 表(调用方按依赖逆序传入) */
|
||||
export function dropTables(names: readonly string[]): string {
|
||||
return names.map((n) => `drop table if exists ${n} cascade;`).join("\n");
|
||||
}
|
||||
@@ -7,18 +7,28 @@ import { FileMigrationProvider, type MigrationResultSet, Migrator } from "kysely
|
||||
/** 迁移目录:packages/db/migrations,文件名 NNNN_name.ts,导出 up/down */
|
||||
export const MIGRATIONS_DIR = fileURLToPath(new URL("../migrations", import.meta.url));
|
||||
|
||||
export interface MigratorOptions {
|
||||
/**
|
||||
* kysely_migration 表所在 schema。默认不指定(走 search_path)。
|
||||
* 在非 public schema 里迁移(如测试隔离 schema)时必须显式指定,
|
||||
* 否则 Kysely 会把 public 下已存在的迁移表当成当前库的迁移记录。
|
||||
*/
|
||||
migrationTableSchema?: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移器不关心具体表类型
|
||||
export function createMigrator(db: Kysely<any>): Migrator {
|
||||
export function createMigrator(db: Kysely<any>, opts: MigratorOptions = {}): Migrator {
|
||||
return new Migrator({
|
||||
db,
|
||||
provider: new FileMigrationProvider({ fs, path, migrationFolder: MIGRATIONS_DIR }),
|
||||
...(opts.migrationTableSchema ? { migrationTableSchema: opts.migrationTableSchema } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 迁移到最新;失败时抛出 */
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function migrateToLatest(db: Kysely<any>): Promise<MigrationResultSet> {
|
||||
const result = await createMigrator(db).migrateToLatest();
|
||||
export async function migrateToLatest(db: Kysely<any>, opts: MigratorOptions = {}): Promise<MigrationResultSet> {
|
||||
const result = await createMigrator(db, opts).migrateToLatest();
|
||||
if (result.error) throw result.error;
|
||||
return result;
|
||||
}
|
||||
|
||||
+1094
-17
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user