@southwind-ai/database 0.1.1 → 0.1.3

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 CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./src/dialects.js";
2
+ export * from "./src/postgres-module-migrations.js";
2
3
  export * from "./src/runner.js";
3
4
  export * from "./src/schema/index.js";
4
5
  export * from "./src/sql-store.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@southwind-ai/database",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "engines": {
@@ -15,7 +15,7 @@
15
15
  "typecheck": "tsc --noEmit --project tsconfig.json"
16
16
  },
17
17
  "dependencies": {
18
- "@southwind-ai/shared": "0.1.1",
18
+ "@southwind-ai/shared": "0.1.2",
19
19
  "drizzle-orm": "^0.45.2"
20
20
  },
21
21
  "exports": {
@@ -23,5 +23,8 @@
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
26
+ },
27
+ "devDependencies": {
28
+ "pg": "^8.22.0"
26
29
  }
27
30
  }
@@ -0,0 +1,69 @@
1
+ import type { MigrationBundle } from "@southwind-ai/shared";
2
+ import { PlatformMigrationRunner, type MigrationRunReport } from "./runner.js";
3
+ import {
4
+ createPostgresStatementExecutor,
5
+ PostgresMigrationHistoryStore,
6
+ type QueryableClient,
7
+ } from "./sql-store.js";
8
+
9
+ const MODULE_MIGRATION_LOCK = "windy.module-migrations.v1";
10
+
11
+ export interface PostgresTransactionClient extends QueryableClient {
12
+ release(): void;
13
+ }
14
+
15
+ export interface PostgresTransactionPool {
16
+ connect(): Promise<PostgresTransactionClient>;
17
+ }
18
+
19
+ export interface PostgresMigrationBundleOptions {
20
+ pool: PostgresTransactionPool;
21
+ bundles: readonly MigrationBundle[];
22
+ now?: () => Date;
23
+ }
24
+
25
+ export async function runPostgresMigrationBundles(
26
+ options: PostgresMigrationBundleOptions,
27
+ ): Promise<MigrationRunReport> {
28
+ const client = await options.pool.connect();
29
+ let transactionOpen = false;
30
+ try {
31
+ await client.query("begin");
32
+ transactionOpen = true;
33
+ await client.query("select pg_advisory_xact_lock(hashtext($1))", [
34
+ MODULE_MIGRATION_LOCK,
35
+ ]);
36
+ const runner = new PlatformMigrationRunner({
37
+ dialect: "postgresql",
38
+ store: new PostgresMigrationHistoryStore(client),
39
+ execute: createPostgresStatementExecutor(client),
40
+ now: options.now,
41
+ recordFailures: false,
42
+ });
43
+ const report = await runner.runBundles(options.bundles);
44
+ const failure = report.failed[0];
45
+ if (failure) {
46
+ throw new Error(
47
+ `Module Migration ${failure.id} 执行失败:${failure.error}`,
48
+ );
49
+ }
50
+ await client.query("commit");
51
+ transactionOpen = false;
52
+ return report;
53
+ } catch (error) {
54
+ if (transactionOpen) await rollbackWithoutMasking(client);
55
+ throw error;
56
+ } finally {
57
+ client.release();
58
+ }
59
+ }
60
+
61
+ async function rollbackWithoutMasking(
62
+ client: PostgresTransactionClient,
63
+ ): Promise<void> {
64
+ try {
65
+ await client.query("rollback");
66
+ } catch {
67
+ // 原始 migration 错误决定启动失败原因,连接释放后由 Pool 负责处置。
68
+ }
69
+ }
package/src/runner.ts CHANGED
@@ -1,16 +1,14 @@
1
- import type {
2
- BundleMigrationDefinition,
3
- DatabaseCompatibilityAdapter,
4
- DatabaseDialect,
5
- MigrationBundle,
6
- MigrationManifestSource,
7
- MigrationStatement,
8
- } from "@southwind-ai/shared";
9
1
  import {
10
2
  normalizeManifestBundles,
11
3
  orderBundleMigrations,
12
4
  validateAndOrderBundles,
13
- } from "./bundle-validation.js";
5
+ type BundleMigrationDefinition,
6
+ type DatabaseCompatibilityAdapter,
7
+ type DatabaseDialect,
8
+ type MigrationBundle,
9
+ type MigrationManifestSource,
10
+ type MigrationStatement,
11
+ } from "@southwind-ai/shared";
14
12
  import { createDialectAdapter } from "./dialects.js";
15
13
  import type { MigrationHistoryEntry, MigrationHistoryStore } from "./store.js";
16
14
 
@@ -19,11 +17,14 @@ export interface MigrationRunnerOptions {
19
17
  store: MigrationHistoryStore;
20
18
  execute?: (statement: MigrationStatement) => Promise<void>;
21
19
  now?: () => Date;
20
+ recordFailures?: boolean;
22
21
  }
23
22
 
24
23
  export interface MigrationPlanItem {
25
24
  id: string;
26
25
  module: string;
26
+ moduleVersion: string;
27
+ migrationOrder: number;
27
28
  description: string;
28
29
  direction: "up" | "down";
29
30
  checksum: string;
@@ -64,9 +65,15 @@ export class PlatformMigrationRunner {
64
65
  ): Promise<MigrationPlanItem[]> {
65
66
  const ordered = validateAndOrderBundles(bundles);
66
67
  const applied = appliedById(await this.options.store.listApplied());
68
+ let migrationOrder = 0;
67
69
  return ordered.flatMap((bundle) =>
68
70
  orderBundleMigrations(bundle).map((migration) =>
69
- planItem(migration, applied.get(migration.id)),
71
+ planItem(
72
+ migration,
73
+ bundle.version,
74
+ migrationOrder++,
75
+ applied.get(migration.id),
76
+ ),
70
77
  ),
71
78
  );
72
79
  }
@@ -121,7 +128,7 @@ export class PlatformMigrationRunner {
121
128
  } catch (error) {
122
129
  const message = error instanceof Error ? error.message : String(error);
123
130
  failed.push({ id: item.id, error: message });
124
- if (!dryRun) {
131
+ if (!dryRun && this.options.recordFailures !== false) {
125
132
  await this.recordResult(item, stepStartedAt, stepStatements, message);
126
133
  }
127
134
  break;
@@ -149,6 +156,8 @@ export class PlatformMigrationRunner {
149
156
  await this.options.store.record({
150
157
  id: item.id,
151
158
  module: item.module,
159
+ moduleVersion: item.moduleVersion,
160
+ migrationOrder: item.migrationOrder,
152
161
  direction: item.direction,
153
162
  status: error ? "failed" : "applied",
154
163
  checksum: item.checksum,
@@ -166,6 +175,8 @@ function appliedById(entries: readonly MigrationHistoryEntry[]) {
166
175
 
167
176
  function planItem(
168
177
  migration: BundleMigrationDefinition,
178
+ moduleVersion: string,
179
+ migrationOrder: number,
169
180
  applied: MigrationHistoryEntry | undefined,
170
181
  ): MigrationPlanItem {
171
182
  if (applied?.module !== undefined && applied.module !== migration.module) {
@@ -184,6 +195,8 @@ function planItem(
184
195
  return {
185
196
  id: migration.id,
186
197
  module: migration.module,
198
+ moduleVersion,
199
+ migrationOrder,
187
200
  description: migration.description,
188
201
  direction: migration.direction,
189
202
  checksum: migration.checksum,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  index,
3
+ integer,
3
4
  jsonb,
4
5
  pgTable,
5
6
  text,
@@ -12,6 +13,8 @@ export const windyMigrationHistory = pgTable(
12
13
  {
13
14
  id: varchar("id", { length: 160 }).primaryKey(),
14
15
  module: varchar("module", { length: 96 }).notNull(),
16
+ moduleVersion: varchar("module_version", { length: 64 }),
17
+ migrationOrder: integer("migration_order"),
15
18
  direction: varchar("direction", { length: 16 }).notNull(),
16
19
  status: varchar("status", { length: 16 }).notNull(),
17
20
  checksum: varchar("checksum", { length: 128 }),
@@ -2,7 +2,10 @@
2
2
  export {};
3
3
 
4
4
  // @windy-module system.configuration begin
5
- import type { ModuleConfigDiffEntry, ModuleConfigValues } from "@southwind-ai/shared";
5
+ import type {
6
+ ModuleConfigDiffEntry,
7
+ ModuleConfigValues,
8
+ } from "@southwind-ai/shared";
6
9
  import {
7
10
  index,
8
11
  integer,
@@ -15,6 +18,7 @@ import {
15
18
  // @windy-module system.configuration end
16
19
  // @windy-module system.settings begin
17
20
  import {
21
+ boolean,
18
22
  pgTable as settingsTable,
19
23
  text,
20
24
  varchar as settingsVarchar,
@@ -33,6 +37,17 @@ export const platformBrandingSettings = settingsTable(
33
37
  ...auditColumns,
34
38
  },
35
39
  );
40
+
41
+ export const platformWatermarkPolicies = settingsTable(
42
+ "platform_watermark_policies",
43
+ {
44
+ id: settingsVarchar("id", { length: 64 }).primaryKey(),
45
+ enabled: boolean("enabled").notNull(),
46
+ businessPagesEnabled: boolean("business_pages_enabled").notNull(),
47
+ customContent: settingsVarchar("custom_content", { length: 80 }).notNull(),
48
+ ...auditColumns,
49
+ },
50
+ );
36
51
  // @windy-module system.settings end
37
52
 
38
53
  // @windy-module system.configuration begin
package/src/sql-store.ts CHANGED
@@ -15,6 +15,8 @@ export interface QueryableClient {
15
15
  interface MigrationHistoryRow {
16
16
  id: string;
17
17
  module: string;
18
+ module_version: string | null;
19
+ migration_order: number | null;
18
20
  direction: "up" | "down";
19
21
  status: "applied" | "failed";
20
22
  checksum: string | null;
@@ -29,7 +31,8 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
29
31
 
30
32
  async listApplied(): Promise<MigrationHistoryEntry[]> {
31
33
  const result = await this.client.query<MigrationHistoryRow>(
32
- `select id, module, direction, status, checksum, statements, error, started_at, finished_at
34
+ `select id, module, module_version, migration_order, direction, status,
35
+ checksum, statements, error, started_at, finished_at
33
36
  from windy_migration_history
34
37
  where status = $1
35
38
  order by finished_at asc`,
@@ -42,10 +45,13 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
42
45
  async record(entry: MigrationHistoryEntry): Promise<void> {
43
46
  await this.client.query(
44
47
  `insert into windy_migration_history
45
- (id, module, direction, status, checksum, statements, error, started_at, finished_at)
46
- values ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)
48
+ (id, module, module_version, migration_order, direction, status,
49
+ checksum, statements, error, started_at, finished_at)
50
+ values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)
47
51
  on conflict (id) do update set
48
52
  module = excluded.module,
53
+ module_version = excluded.module_version,
54
+ migration_order = excluded.migration_order,
49
55
  direction = excluded.direction,
50
56
  status = excluded.status,
51
57
  checksum = excluded.checksum,
@@ -56,6 +62,8 @@ export class PostgresMigrationHistoryStore implements MigrationHistoryStore {
56
62
  [
57
63
  entry.id,
58
64
  entry.module,
65
+ entry.moduleVersion || null,
66
+ entry.migrationOrder ?? null,
59
67
  entry.direction,
60
68
  entry.status,
61
69
  entry.checksum || null,
@@ -86,6 +94,8 @@ function mapHistoryRow(row: MigrationHistoryRow): MigrationHistoryEntry {
86
94
  return {
87
95
  id: row.id,
88
96
  module: row.module,
97
+ moduleVersion: row.module_version || undefined,
98
+ migrationOrder: row.migration_order ?? undefined,
89
99
  direction: row.direction,
90
100
  status: row.status,
91
101
  startedAt: toIsoString(row.started_at),
package/src/store.ts CHANGED
@@ -5,6 +5,8 @@ export type MigrationStatus = "applied" | "failed";
5
5
  export interface MigrationHistoryEntry {
6
6
  id: string;
7
7
  module: string;
8
+ moduleVersion?: string;
9
+ migrationOrder?: number;
8
10
  direction: "up" | "down";
9
11
  status: MigrationStatus;
10
12
  startedAt: ISODateTime;
@@ -1,218 +0,0 @@
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
- }