@southwind-ai/database 0.1.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/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./src/dialects.js";
2
+ export * from "./src/runner.js";
3
+ export * from "./src/schema/index.js";
4
+ export * from "./src/sql-store.js";
5
+ export * from "./src/store.js";
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@southwind-ai/database",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "engines": {
7
+ "bun": ">=1.3.0"
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "src/**/*.ts",
12
+ "!src/**/*.test.ts"
13
+ ],
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit --project tsconfig.json"
16
+ },
17
+ "dependencies": {
18
+ "@southwind-ai/shared": "workspace:*",
19
+ "drizzle-orm": "^0.45.2"
20
+ },
21
+ "exports": {
22
+ ".": "./index.ts"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ }
27
+ }
@@ -0,0 +1,218 @@
1
+ import {
2
+ MIGRATION_BUNDLE_FORMAT_VERSION,
3
+ type BundleMigrationDefinition,
4
+ type MigrationBundle,
5
+ type MigrationDefinition,
6
+ type MigrationManifestSource,
7
+ } from "@southwind-ai/shared";
8
+
9
+ export function normalizeManifestBundles(
10
+ modules: readonly MigrationManifestSource[],
11
+ ): MigrationBundle[] {
12
+ return modules.map((manifest) => {
13
+ const bundle =
14
+ manifest.migrationBundle ||
15
+ legacyManifestBundle(manifest, manifest.migrations);
16
+ if (bundle.module !== manifest.name) {
17
+ throw new Error(
18
+ `Migration Bundle ${bundle.module} 与 Manifest ${manifest.name} 不一致`,
19
+ );
20
+ }
21
+ if (bundle.version !== manifest.version) {
22
+ throw new Error(
23
+ `Migration Bundle ${bundle.module} 版本 ${bundle.version} 与 Manifest ${manifest.version} 不一致`,
24
+ );
25
+ }
26
+ assertSameDependencies(manifest, bundle);
27
+ assertSameMigrations(manifest, bundle);
28
+ assertSchemaMigrationReferences(manifest, bundle);
29
+ return bundle;
30
+ });
31
+ }
32
+
33
+ function assertSameDependencies(
34
+ manifest: MigrationManifestSource,
35
+ bundle: MigrationBundle,
36
+ ): void {
37
+ const declared = manifest.dependencies || [];
38
+ const bundled = bundle.dependencies || [];
39
+ if (
40
+ declared.length !== bundled.length ||
41
+ declared.some((dependency) => !bundled.includes(dependency))
42
+ ) {
43
+ throw new Error(
44
+ `Manifest ${manifest.name} 的 dependencies 必须与 Migration Bundle 一致`,
45
+ );
46
+ }
47
+ }
48
+
49
+ function legacyManifestBundle(
50
+ manifest: MigrationManifestSource,
51
+ migrations: readonly MigrationDefinition[],
52
+ ): MigrationBundle {
53
+ return {
54
+ formatVersion: MIGRATION_BUNDLE_FORMAT_VERSION,
55
+ module: manifest.name,
56
+ version: manifest.version,
57
+ dependencies: manifest.dependencies,
58
+ migrations: migrations.map((migration) => ({
59
+ ...migration,
60
+ checksum:
61
+ migration.checksum ||
62
+ `legacy:${manifest.name}:${manifest.version}:${migration.id}`,
63
+ })),
64
+ };
65
+ }
66
+
67
+ function assertSameMigrations(
68
+ manifest: MigrationManifestSource,
69
+ bundle: MigrationBundle,
70
+ ): void {
71
+ const declared = manifest.migrations.map(({ id }) => id);
72
+ const bundled = bundle.migrations.map(({ id }) => id);
73
+ if (
74
+ declared.length !== bundled.length ||
75
+ declared.some((id, index) => id !== bundled[index])
76
+ ) {
77
+ throw new Error(
78
+ `Manifest ${manifest.name} 的 migrations 必须与 Migration Bundle 完全一致`,
79
+ );
80
+ }
81
+ }
82
+
83
+ function assertSchemaMigrationReferences(
84
+ manifest: MigrationManifestSource,
85
+ bundle: MigrationBundle,
86
+ ): void {
87
+ const ids = new Set(bundle.migrations.map(({ id }) => id));
88
+ for (const schema of manifest.schemaExports || []) {
89
+ for (const migrationId of schema.migrationIds) {
90
+ if (!ids.has(migrationId)) {
91
+ throw new Error(
92
+ `Schema Export ${schema.key} 引用了 Bundle 中不存在的 Migration:${migrationId}`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ export function validateAndOrderBundles(
100
+ bundles: readonly MigrationBundle[],
101
+ ): MigrationBundle[] {
102
+ const byModule = new Map<string, MigrationBundle>();
103
+ const migrationOwner = new Map<string, string>();
104
+ for (const bundle of bundles) {
105
+ validateBundle(bundle);
106
+ if (byModule.has(bundle.module)) {
107
+ throw new Error(`Migration Bundle 模块重复:${bundle.module}`);
108
+ }
109
+ byModule.set(bundle.module, bundle);
110
+ for (const migration of bundle.migrations) {
111
+ const owner = migrationOwner.get(migration.id);
112
+ if (owner) {
113
+ throw new Error(
114
+ `Migration ID 重复:${migration.id}(${owner} / ${bundle.module})`,
115
+ );
116
+ }
117
+ migrationOwner.set(migration.id, bundle.module);
118
+ }
119
+ }
120
+ for (const bundle of bundles) {
121
+ for (const dependency of bundle.dependencies || []) {
122
+ if (!byModule.has(dependency)) {
123
+ throw new Error(
124
+ `Migration Bundle ${bundle.module} 依赖未安装模块:${dependency}`,
125
+ );
126
+ }
127
+ }
128
+ validateMigrationDependencies(bundle, migrationOwner);
129
+ }
130
+ return topologicalBundles(byModule);
131
+ }
132
+
133
+ function validateBundle(bundle: MigrationBundle): void {
134
+ if (bundle.formatVersion !== MIGRATION_BUNDLE_FORMAT_VERSION) {
135
+ throw new Error(
136
+ `Migration Bundle ${bundle.module} 格式版本不受支持:${bundle.formatVersion}`,
137
+ );
138
+ }
139
+ if (!bundle.module.trim()) throw new Error("Migration Bundle 缺少 module");
140
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(bundle.version)) {
141
+ throw new Error(
142
+ `Migration Bundle ${bundle.module} 版本无效:${bundle.version}`,
143
+ );
144
+ }
145
+ for (const migration of bundle.migrations) {
146
+ if (migration.module !== bundle.module) {
147
+ throw new Error(
148
+ `Migration ${migration.id} 的模块归属应为 ${bundle.module}`,
149
+ );
150
+ }
151
+ if (!migration.id.trim()) throw new Error("Migration ID 不能为空");
152
+ if (!migration.checksum.trim()) {
153
+ throw new Error(`Migration ${migration.id} 缺少 checksum`);
154
+ }
155
+ }
156
+ }
157
+
158
+ function validateMigrationDependencies(
159
+ bundle: MigrationBundle,
160
+ owners: ReadonlyMap<string, string>,
161
+ ): void {
162
+ for (const migration of bundle.migrations) {
163
+ for (const dependency of migration.dependsOn || []) {
164
+ const owner = owners.get(dependency);
165
+ if (!owner) {
166
+ throw new Error(`Migration ${migration.id} 依赖不存在:${dependency}`);
167
+ }
168
+ if (
169
+ owner !== bundle.module &&
170
+ !(bundle.dependencies || []).includes(owner)
171
+ ) {
172
+ throw new Error(
173
+ `Migration ${migration.id} 跨模块依赖 ${owner},但 Bundle 未声明该模块依赖`,
174
+ );
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ function topologicalBundles(
181
+ pendingSource: ReadonlyMap<string, MigrationBundle>,
182
+ ): MigrationBundle[] {
183
+ const pending = new Map(pendingSource);
184
+ const sorted: MigrationBundle[] = [];
185
+ while (pending.size > 0) {
186
+ const ready = Array.from(pending.values()).find((bundle) =>
187
+ (bundle.dependencies || []).every(
188
+ (dependency) => !pending.has(dependency),
189
+ ),
190
+ );
191
+ if (!ready) throw new Error("Migration Bundle 模块依赖存在循环");
192
+ sorted.push(ready);
193
+ pending.delete(ready.module);
194
+ }
195
+ return sorted;
196
+ }
197
+
198
+ export function orderBundleMigrations(
199
+ bundle: MigrationBundle,
200
+ ): BundleMigrationDefinition[] {
201
+ const pending = new Map(
202
+ bundle.migrations.map((migration) => [migration.id, migration]),
203
+ );
204
+ const sorted: BundleMigrationDefinition[] = [];
205
+ while (pending.size > 0) {
206
+ const ready = Array.from(pending.values()).find((migration) =>
207
+ (migration.dependsOn || []).every(
208
+ (dependency) => !pending.has(dependency),
209
+ ),
210
+ );
211
+ if (!ready) {
212
+ throw new Error(`Migration Bundle ${bundle.module} 的迁移依赖存在循环`);
213
+ }
214
+ sorted.push(ready);
215
+ pending.delete(ready.id);
216
+ }
217
+ return sorted;
218
+ }
@@ -0,0 +1,139 @@
1
+ import type {
2
+ ColumnTypeMapping,
3
+ DatabaseCompatibilityAdapter,
4
+ DatabaseDialect,
5
+ DialectCapabilities,
6
+ PageRequest,
7
+ PaginationSqlParts,
8
+ } from "@southwind-ai/shared";
9
+
10
+ const reservedWords: Record<DatabaseDialect, string[]> = {
11
+ postgresql: ["user", "role", "order"],
12
+ mysql: ["user", "role", "order"],
13
+ dm: ["user", "role", "order", "limit"],
14
+ kingbase: ["user", "role", "order", "limit"],
15
+ };
16
+
17
+ const capabilities: Record<DatabaseDialect, DialectCapabilities> = {
18
+ postgresql: {
19
+ dialect: "postgresql",
20
+ identifierStrategy: "identity",
21
+ jsonColumnStrategy: "jsonb",
22
+ supportsReturning: true,
23
+ supportsRecursiveCte: true,
24
+ supportsFullTextSearch: true,
25
+ reservedWords: reservedWords.postgresql,
26
+ },
27
+ mysql: {
28
+ dialect: "mysql",
29
+ identifierStrategy: "auto-increment",
30
+ jsonColumnStrategy: "json",
31
+ supportsReturning: false,
32
+ supportsRecursiveCte: true,
33
+ supportsFullTextSearch: true,
34
+ reservedWords: reservedWords.mysql,
35
+ },
36
+ dm: {
37
+ dialect: "dm",
38
+ identifierStrategy: "sequence",
39
+ jsonColumnStrategy: "text",
40
+ supportsReturning: false,
41
+ supportsRecursiveCte: true,
42
+ supportsFullTextSearch: false,
43
+ reservedWords: reservedWords.dm,
44
+ },
45
+ kingbase: {
46
+ dialect: "kingbase",
47
+ identifierStrategy: "identity",
48
+ jsonColumnStrategy: "json",
49
+ supportsReturning: true,
50
+ supportsRecursiveCte: true,
51
+ supportsFullTextSearch: false,
52
+ reservedWords: reservedWords.kingbase,
53
+ },
54
+ };
55
+
56
+ const typeMappings: Record<
57
+ DatabaseDialect,
58
+ Record<ColumnTypeMapping["logicalType"], string>
59
+ > = {
60
+ postgresql: {
61
+ id: "uuid",
62
+ string: "varchar(255)",
63
+ text: "text",
64
+ integer: "integer",
65
+ boolean: "boolean",
66
+ datetime: "timestamptz",
67
+ json: "jsonb",
68
+ },
69
+ mysql: {
70
+ id: "varchar(36)",
71
+ string: "varchar(255)",
72
+ text: "text",
73
+ integer: "int",
74
+ boolean: "tinyint(1)",
75
+ datetime: "datetime",
76
+ json: "json",
77
+ },
78
+ dm: {
79
+ id: "varchar(36)",
80
+ string: "varchar(255)",
81
+ text: "clob",
82
+ integer: "int",
83
+ boolean: "tinyint",
84
+ datetime: "timestamp",
85
+ json: "clob",
86
+ },
87
+ kingbase: {
88
+ id: "varchar(36)",
89
+ string: "varchar(255)",
90
+ text: "text",
91
+ integer: "integer",
92
+ boolean: "boolean",
93
+ datetime: "timestamp",
94
+ json: "json",
95
+ },
96
+ };
97
+
98
+ export function createDialectAdapter(
99
+ dialect: DatabaseDialect,
100
+ ): DatabaseCompatibilityAdapter {
101
+ return {
102
+ dialect,
103
+ capabilities: capabilities[dialect],
104
+ mapColumnType(logicalType) {
105
+ return {
106
+ logicalType,
107
+ physicalType: typeMappings[dialect][logicalType],
108
+ };
109
+ },
110
+ mapIdentifier(name) {
111
+ const needsQuote = capabilities[dialect].reservedWords.includes(
112
+ name.toLowerCase(),
113
+ );
114
+ if (!needsQuote) {
115
+ return name;
116
+ }
117
+
118
+ return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
119
+ },
120
+ mapJsonPath(column, path) {
121
+ const jsonPath = path.join(".");
122
+ if (dialect === "postgresql") {
123
+ return `${column} #>> '{${path.join(",")}}'`;
124
+ }
125
+
126
+ if (dialect === "mysql" || dialect === "kingbase") {
127
+ return `json_extract(${column}, '$.${jsonPath}')`;
128
+ }
129
+
130
+ return `${column}`;
131
+ },
132
+ mapPagination(request: PageRequest): PaginationSqlParts {
133
+ return {
134
+ limit: request.pageSize,
135
+ offset: (request.page - 1) * request.pageSize,
136
+ };
137
+ },
138
+ };
139
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type {
2
+ BundleMigrationDefinition,
3
+ DatabaseCompatibilityAdapter,
4
+ DatabaseDialect,
5
+ MigrationBundle,
6
+ MigrationManifestSource,
7
+ MigrationStatement,
8
+ } from "@southwind-ai/shared";
9
+ import {
10
+ normalizeManifestBundles,
11
+ orderBundleMigrations,
12
+ validateAndOrderBundles,
13
+ } from "./bundle-validation.js";
14
+ import { createDialectAdapter } from "./dialects.js";
15
+ import type { MigrationHistoryEntry, MigrationHistoryStore } from "./store.js";
16
+
17
+ export interface MigrationRunnerOptions {
18
+ dialect: DatabaseDialect;
19
+ store: MigrationHistoryStore;
20
+ execute?: (statement: MigrationStatement) => Promise<void>;
21
+ now?: () => Date;
22
+ }
23
+
24
+ export interface MigrationPlanItem {
25
+ id: string;
26
+ module: string;
27
+ description: string;
28
+ direction: "up" | "down";
29
+ checksum: string;
30
+ skipped: boolean;
31
+ skipReason?: "already-applied";
32
+ }
33
+
34
+ export interface MigrationRunReport {
35
+ dialect: DatabaseDialect;
36
+ dryRun: boolean;
37
+ startedAt: string;
38
+ finishedAt: string;
39
+ planned: MigrationPlanItem[];
40
+ applied: string[];
41
+ failed: Array<{ id: string; error: string }>;
42
+ statements: MigrationStatement[];
43
+ }
44
+
45
+ export class PlatformMigrationRunner {
46
+ private readonly adapter: DatabaseCompatibilityAdapter;
47
+ private readonly executeStatement: (
48
+ statement: MigrationStatement,
49
+ ) => Promise<void>;
50
+ private readonly now: () => Date;
51
+
52
+ constructor(private readonly options: MigrationRunnerOptions) {
53
+ this.adapter = createDialectAdapter(options.dialect);
54
+ this.executeStatement = options.execute || (async () => undefined);
55
+ this.now = options.now || (() => new Date());
56
+ }
57
+
58
+ async plan(modules: readonly MigrationManifestSource[]) {
59
+ return this.planBundles(normalizeManifestBundles(modules));
60
+ }
61
+
62
+ async planBundles(
63
+ bundles: readonly MigrationBundle[],
64
+ ): Promise<MigrationPlanItem[]> {
65
+ const ordered = validateAndOrderBundles(bundles);
66
+ const applied = appliedById(await this.options.store.listApplied());
67
+ return ordered.flatMap((bundle) =>
68
+ orderBundleMigrations(bundle).map((migration) =>
69
+ planItem(migration, applied.get(migration.id)),
70
+ ),
71
+ );
72
+ }
73
+
74
+ async run(
75
+ modules: readonly MigrationManifestSource[],
76
+ dryRun = false,
77
+ ): Promise<MigrationRunReport> {
78
+ return this.runBundles(normalizeManifestBundles(modules), dryRun);
79
+ }
80
+
81
+ async runBundles(
82
+ bundles: readonly MigrationBundle[],
83
+ dryRun = false,
84
+ ): Promise<MigrationRunReport> {
85
+ const startedAt = this.now().toISOString();
86
+ const ordered = validateAndOrderBundles(bundles);
87
+ const planned = await this.planBundles(ordered);
88
+ const migrations = new Map(
89
+ ordered.flatMap((bundle) =>
90
+ bundle.migrations.map(
91
+ (migration) => [migration.id, migration] as const,
92
+ ),
93
+ ),
94
+ );
95
+ const applied: string[] = [];
96
+ const failed: Array<{ id: string; error: string }> = [];
97
+ const statements: MigrationStatement[] = [];
98
+
99
+ for (const item of planned) {
100
+ if (item.skipped) continue;
101
+ const migration = migrations.get(item.id);
102
+ if (!migration) throw new Error(`Migration ${item.id} 不存在`);
103
+
104
+ const stepStartedAt = this.now().toISOString();
105
+ const stepStatements: MigrationStatement[] = [];
106
+ try {
107
+ await migration.run({
108
+ dialect: this.options.dialect,
109
+ dryRun,
110
+ adapter: this.adapter,
111
+ execute: async (statement) => {
112
+ statements.push(statement);
113
+ stepStatements.push(statement);
114
+ if (!dryRun) await this.executeStatement(statement);
115
+ },
116
+ });
117
+ applied.push(item.id);
118
+ if (!dryRun) {
119
+ await this.recordResult(item, stepStartedAt, stepStatements);
120
+ }
121
+ } catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ failed.push({ id: item.id, error: message });
124
+ if (!dryRun) {
125
+ await this.recordResult(item, stepStartedAt, stepStatements, message);
126
+ }
127
+ break;
128
+ }
129
+ }
130
+
131
+ return {
132
+ dialect: this.options.dialect,
133
+ dryRun,
134
+ startedAt,
135
+ finishedAt: this.now().toISOString(),
136
+ planned,
137
+ applied,
138
+ failed,
139
+ statements,
140
+ };
141
+ }
142
+
143
+ private async recordResult(
144
+ item: MigrationPlanItem,
145
+ startedAt: string,
146
+ statements: readonly MigrationStatement[],
147
+ error?: string,
148
+ ): Promise<void> {
149
+ await this.options.store.record({
150
+ id: item.id,
151
+ module: item.module,
152
+ direction: item.direction,
153
+ status: error ? "failed" : "applied",
154
+ checksum: item.checksum,
155
+ startedAt,
156
+ finishedAt: this.now().toISOString(),
157
+ statements: statements.map(({ sql }) => sql),
158
+ error,
159
+ });
160
+ }
161
+ }
162
+
163
+ function appliedById(entries: readonly MigrationHistoryEntry[]) {
164
+ return new Map(entries.map((entry) => [entry.id, entry]));
165
+ }
166
+
167
+ function planItem(
168
+ migration: BundleMigrationDefinition,
169
+ applied: MigrationHistoryEntry | undefined,
170
+ ): MigrationPlanItem {
171
+ if (applied?.module !== undefined && applied.module !== migration.module) {
172
+ throw new Error(
173
+ `已应用 Migration ${migration.id} 归属 ${applied.module},当前归属 ${migration.module}`,
174
+ );
175
+ }
176
+ if (
177
+ applied?.checksum !== undefined &&
178
+ applied.checksum !== migration.checksum
179
+ ) {
180
+ throw new Error(
181
+ `已应用 Migration ${migration.id} checksum 与当前 Bundle 不一致`,
182
+ );
183
+ }
184
+ return {
185
+ id: migration.id,
186
+ module: migration.module,
187
+ description: migration.description,
188
+ direction: migration.direction,
189
+ checksum: migration.checksum,
190
+ skipped: Boolean(applied),
191
+ skipReason: applied ? "already-applied" : undefined,
192
+ };
193
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ index,
3
+ integer,
4
+ pgTable,
5
+ text,
6
+ timestamp,
7
+ uniqueIndex,
8
+ varchar,
9
+ } from "drizzle-orm/pg-core";
10
+ import { durableJobs } from "./durable-jobs.js";
11
+
12
+ export const bulkDataJobs = pgTable(
13
+ "bulk_data_jobs",
14
+ {
15
+ id: varchar("id", { length: 64 }).primaryKey(),
16
+ kind: varchar("kind", { length: 24 }).notNull(),
17
+ resource: varchar("resource", { length: 80 }).notNull(),
18
+ mode: varchar("mode", { length: 24 }),
19
+ status: varchar("status", { length: 32 }).notNull(),
20
+ idempotencyKey: varchar("idempotency_key", { length: 160 }).notNull(),
21
+ requestDigest: varchar("request_digest", { length: 64 }).notNull(),
22
+ requestedBy: varchar("requested_by", { length: 64 }).notNull(),
23
+ totalRows: integer("total_rows").notNull().default(0),
24
+ processedRows: integer("processed_rows").notNull().default(0),
25
+ succeededRows: integer("succeeded_rows").notNull().default(0),
26
+ failedRows: integer("failed_rows").notNull().default(0),
27
+ attempt: integer("attempt").notNull().default(0),
28
+ maxAttempts: integer("max_attempts").notNull().default(3),
29
+ nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
30
+ error: text("error"),
31
+ durableJobId: varchar("durable_job_id", { length: 64 }).references(
32
+ () => durableJobs.id,
33
+ { onDelete: "restrict" },
34
+ ),
35
+ createdAt: timestamp("created_at", { withTimezone: true })
36
+ .notNull()
37
+ .defaultNow(),
38
+ startedAt: timestamp("started_at", { withTimezone: true }),
39
+ finishedAt: timestamp("finished_at", { withTimezone: true }),
40
+ updatedAt: timestamp("updated_at", { withTimezone: true })
41
+ .notNull()
42
+ .defaultNow(),
43
+ },
44
+ (table) => [
45
+ uniqueIndex("uk_bulk_data_jobs_idempotency").on(
46
+ table.requestedBy,
47
+ table.kind,
48
+ table.resource,
49
+ table.idempotencyKey,
50
+ ),
51
+ index("idx_bulk_data_jobs_claim").on(
52
+ table.status,
53
+ table.nextAttemptAt,
54
+ table.createdAt,
55
+ ),
56
+ index("idx_bulk_data_jobs_actor_created").on(
57
+ table.requestedBy,
58
+ table.createdAt,
59
+ ),
60
+ index("idx_bulk_data_jobs_durable_job").on(table.durableJobId),
61
+ ],
62
+ );
63
+
64
+ export const bulkDataArtifacts = pgTable(
65
+ "bulk_data_artifacts",
66
+ {
67
+ id: varchar("id", { length: 64 }).primaryKey(),
68
+ jobId: varchar("job_id", { length: 64 })
69
+ .notNull()
70
+ .references(() => bulkDataJobs.id, { onDelete: "restrict" }),
71
+ kind: varchar("kind", { length: 24 }).notNull(),
72
+ filename: varchar("filename", { length: 240 }).notNull(),
73
+ contentType: varchar("content_type", { length: 120 }).notNull(),
74
+ content: text("content"),
75
+ artifactRef: varchar("artifact_ref", { length: 37 }),
76
+ sha256: varchar("sha256", { length: 64 }).notNull(),
77
+ byteSize: integer("byte_size").notNull(),
78
+ createdAt: timestamp("created_at", { withTimezone: true })
79
+ .notNull()
80
+ .defaultNow(),
81
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
82
+ },
83
+ (table) => [
84
+ uniqueIndex("uk_bulk_data_artifacts_job_kind").on(table.jobId, table.kind),
85
+ index("idx_bulk_data_artifacts_expiry").on(table.expiresAt),
86
+ ],
87
+ );
@@ -0,0 +1,33 @@
1
+ import { timestamp, varchar } from "drizzle-orm/pg-core";
2
+
3
+ export const idColumn = (name = "id") => varchar(name, { length: 64 });
4
+
5
+ export const statusColumn = (name = "status") => varchar(name, { length: 32 });
6
+
7
+ export const auditColumns = {
8
+ createdAt: timestamp("created_at", { withTimezone: true })
9
+ .notNull()
10
+ .defaultNow(),
11
+ createdBy: varchar("created_by", { length: 64 }).notNull(),
12
+ updatedAt: timestamp("updated_at", { withTimezone: true })
13
+ .notNull()
14
+ .defaultNow(),
15
+ updatedBy: varchar("updated_by", { length: 64 }).notNull(),
16
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
17
+ deletedBy: varchar("deleted_by", { length: 64 }),
18
+ };
19
+
20
+ export interface GovernedExportScopeSnapshot {
21
+ tenantId: "single-tenant";
22
+ actorId: string;
23
+ departmentId?: string;
24
+ grants: Array<
25
+ | { scope: "all" | "tenant" | "self" }
26
+ | {
27
+ scope: "department" | "department-and-children";
28
+ departmentId?: string;
29
+ }
30
+ | { scope: "custom"; dimension: string; values: string[] }
31
+ >;
32
+ roleIds: string[];
33
+ }