@syncular/migrations 0.0.4-26 → 0.0.4-33

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.
@@ -1 +1 @@
1
- {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AASH,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAIzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,aAAa,CAAC,EAAE,EACpC,OAAO,EAAE,oBAAoB,CAAC,EAAE,CAAC,GAChC,OAAO,CAAC,mBAAmB,CAAC,CAsE9B;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,EAAE,EACvC,EAAE,EAAE,OAAO,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC,EAC/B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,MAAM,CAAC,CAKjB"}
1
+ {"version":3,"file":"runner.d.ts","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"AAAA;;GAEG;AASH,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAwCzE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,aAAa,CAAC,EAAE,EACpC,OAAO,EAAE,oBAAoB,CAAC,EAAE,CAAC,GAChC,OAAO,CAAC,mBAAmB,CAAC,CAqG9B;AAED;;GAEG;AACH,wBAAsB,gBAAgB,CAAC,EAAE,EACvC,EAAE,EAAE,OAAO,QAAQ,EAAE,MAAM,CAAC,EAAE,CAAC,EAC/B,aAAa,CAAC,EAAE,MAAM,GACrB,OAAO,CAAC,MAAM,CAAC,CAKjB"}
package/dist/runner.js CHANGED
@@ -4,6 +4,34 @@
4
4
  import { getMigrationChecksum } from './define.js';
5
5
  import { clearAppliedMigrations, ensureTrackingTable, getAppliedMigrations, recordAppliedMigration, } from './tracking.js';
6
6
  const DEFAULT_TRACKING_TABLE = 'sync_migration_state';
7
+ const migrationRunQueues = new Map();
8
+ function toErrorMessage(error) {
9
+ return error instanceof Error ? error.message : String(error);
10
+ }
11
+ function isAlreadyExistsSchemaError(error) {
12
+ const message = toErrorMessage(error).toLowerCase();
13
+ return (message.includes('already exists') ||
14
+ (message.includes('relation') && message.includes('exists')));
15
+ }
16
+ async function runWithMigrationQueue(queueKey, task) {
17
+ const previous = migrationRunQueues.get(queueKey) ?? Promise.resolve();
18
+ let release;
19
+ const current = new Promise((resolve) => {
20
+ release = resolve;
21
+ });
22
+ const tail = previous.then(() => current);
23
+ migrationRunQueues.set(queueKey, tail);
24
+ await previous;
25
+ try {
26
+ return await task();
27
+ }
28
+ finally {
29
+ release();
30
+ if (migrationRunQueues.get(queueKey) === tail) {
31
+ migrationRunQueues.delete(queueKey);
32
+ }
33
+ }
34
+ }
7
35
  /**
8
36
  * Run pending migrations and track their state.
9
37
  *
@@ -30,59 +58,88 @@ export async function runMigrations(options) {
30
58
  const { db, migrations } = options;
31
59
  const trackingTable = options.trackingTable ?? DEFAULT_TRACKING_TABLE;
32
60
  const onChecksumMismatch = options.onChecksumMismatch ?? 'error';
33
- // Ensure tracking table exists
34
- await ensureTrackingTable(db, trackingTable);
35
- // Get already applied migrations
36
- let applied = await getAppliedMigrations(db, trackingTable);
37
- let appliedByVersion = new Map(applied.map((m) => [m.version, m]));
38
- const appliedVersions = [];
39
- let wasReset = false;
40
- // Check for checksum mismatches up-front when reset mode is enabled
41
- if (onChecksumMismatch === 'reset' && applied.length > 0) {
42
- const hasMismatch = migrations.migrations.some((migration) => {
43
- const existing = appliedByVersion.get(migration.version);
44
- if (!existing)
45
- return false;
46
- return existing.checksum !== getMigrationChecksum(migration);
47
- });
48
- if (hasMismatch) {
49
- // Let caller drop application tables first
50
- await options.beforeReset?.(db);
51
- // Clear tracking state so all migrations re-run
52
- await clearAppliedMigrations(db, trackingTable);
53
- wasReset = true;
54
- // Refresh applied list (now empty)
55
- applied = await getAppliedMigrations(db, trackingTable);
56
- appliedByVersion = new Map(applied.map((m) => [m.version, m]));
61
+ const beforeReset = options.beforeReset;
62
+ // Serialize migration runs per tracking table to avoid duplicate CREATE TABLE
63
+ // races when startup paths invoke migrations concurrently (e.g. React StrictMode).
64
+ return runWithMigrationQueue(`tracking:${trackingTable}`, async () => {
65
+ // Ensure tracking table exists
66
+ await ensureTrackingTable(db, trackingTable);
67
+ // Get already applied migrations
68
+ let applied = await getAppliedMigrations(db, trackingTable);
69
+ let appliedByVersion = new Map(applied.map((m) => [m.version, m]));
70
+ const appliedVersions = [];
71
+ let wasReset = false;
72
+ let recoveredFromSchemaConflict = false;
73
+ // Check for checksum mismatches up-front when reset mode is enabled
74
+ if (onChecksumMismatch === 'reset' && applied.length > 0) {
75
+ const hasMismatch = migrations.migrations.some((migration) => {
76
+ const existing = appliedByVersion.get(migration.version);
77
+ if (!existing)
78
+ return false;
79
+ return existing.checksum !== getMigrationChecksum(migration);
80
+ });
81
+ if (hasMismatch) {
82
+ // Let caller drop application tables first
83
+ await options.beforeReset?.(db);
84
+ // Clear tracking state so all migrations re-run
85
+ await clearAppliedMigrations(db, trackingTable);
86
+ wasReset = true;
87
+ // Refresh applied list (now empty)
88
+ applied = await getAppliedMigrations(db, trackingTable);
89
+ appliedByVersion = new Map(applied.map((m) => [m.version, m]));
90
+ }
57
91
  }
58
- }
59
- for (const migration of migrations.migrations) {
60
- const existing = appliedByVersion.get(migration.version);
61
- if (existing) {
62
- // Migration already applied - verify checksum hasn't changed
63
- const currentChecksum = getMigrationChecksum(migration);
64
- if (existing.checksum !== currentChecksum) {
65
- throw new Error(`Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
66
- `Expected checksum ${existing.checksum}, got ${currentChecksum}. ` +
67
- 'Migrations must not be modified after being applied.');
92
+ for (let index = 0; index < migrations.migrations.length; index += 1) {
93
+ const migration = migrations.migrations[index];
94
+ const existing = appliedByVersion.get(migration.version);
95
+ if (existing) {
96
+ // Migration already applied - verify checksum hasn't changed
97
+ const currentChecksum = getMigrationChecksum(migration);
98
+ if (existing.checksum !== currentChecksum) {
99
+ throw new Error(`Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
100
+ `Expected checksum ${existing.checksum}, got ${currentChecksum}. ` +
101
+ 'Migrations must not be modified after being applied.');
102
+ }
103
+ continue;
68
104
  }
69
- continue;
105
+ // Run the migration
106
+ try {
107
+ await migration.fn(db);
108
+ }
109
+ catch (error) {
110
+ const canRecoverFromConflict = onChecksumMismatch === 'reset' &&
111
+ typeof beforeReset === 'function' &&
112
+ !recoveredFromSchemaConflict &&
113
+ isAlreadyExistsSchemaError(error);
114
+ if (!canRecoverFromConflict) {
115
+ throw error;
116
+ }
117
+ // Recover once from partially-applied state where app tables exist but
118
+ // migration tracking rows were not committed.
119
+ await beforeReset(db);
120
+ await clearAppliedMigrations(db, trackingTable);
121
+ wasReset = true;
122
+ recoveredFromSchemaConflict = true;
123
+ applied = await getAppliedMigrations(db, trackingTable);
124
+ appliedByVersion = new Map(applied.map((m) => [m.version, m]));
125
+ appliedVersions.length = 0;
126
+ index = -1;
127
+ continue;
128
+ }
129
+ // Record it as applied
130
+ await recordAppliedMigration(db, trackingTable, {
131
+ version: migration.version,
132
+ name: migration.name,
133
+ checksum: getMigrationChecksum(migration),
134
+ });
135
+ appliedVersions.push(migration.version);
70
136
  }
71
- // Run the migration
72
- await migration.fn(db);
73
- // Record it as applied
74
- await recordAppliedMigration(db, trackingTable, {
75
- version: migration.version,
76
- name: migration.name,
77
- checksum: getMigrationChecksum(migration),
78
- });
79
- appliedVersions.push(migration.version);
80
- }
81
- return {
82
- applied: appliedVersions,
83
- currentVersion: migrations.currentVersion,
84
- wasReset,
85
- };
137
+ return {
138
+ applied: appliedVersions,
139
+ currentVersion: migrations.currentVersion,
140
+ wasReset,
141
+ };
142
+ });
86
143
  }
87
144
  /**
88
145
  * Get the current schema version without running any migrations.
@@ -1 +1 @@
1
- {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runner.js","sourceRoot":"","sources":["../src/runner.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/migrations",
3
- "version": "0.0.4-26",
3
+ "version": "0.0.4-33",
4
4
  "description": "Database migration utilities for Syncular",
5
5
  "license": "MIT",
6
6
  "author": "Benjamin Kniffler",
@@ -30,6 +30,7 @@
30
30
  "exports": {
31
31
  ".": {
32
32
  "bun": "./src/index.ts",
33
+ "browser": "./src/index.ts",
33
34
  "import": {
34
35
  "types": "./dist/index.d.ts",
35
36
  "default": "./dist/index.js"
package/src/runner.ts CHANGED
@@ -12,6 +12,42 @@ import {
12
12
  import type { RunMigrationsOptions, RunMigrationsResult } from './types';
13
13
 
14
14
  const DEFAULT_TRACKING_TABLE = 'sync_migration_state';
15
+ const migrationRunQueues = new Map<string, Promise<void>>();
16
+
17
+ function toErrorMessage(error: unknown): string {
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+
21
+ function isAlreadyExistsSchemaError(error: unknown): boolean {
22
+ const message = toErrorMessage(error).toLowerCase();
23
+ return (
24
+ message.includes('already exists') ||
25
+ (message.includes('relation') && message.includes('exists'))
26
+ );
27
+ }
28
+
29
+ async function runWithMigrationQueue<T>(
30
+ queueKey: string,
31
+ task: () => Promise<T>
32
+ ): Promise<T> {
33
+ const previous = migrationRunQueues.get(queueKey) ?? Promise.resolve();
34
+ let release!: () => void;
35
+ const current = new Promise<void>((resolve) => {
36
+ release = resolve;
37
+ });
38
+ const tail = previous.then(() => current);
39
+ migrationRunQueues.set(queueKey, tail);
40
+
41
+ await previous;
42
+ try {
43
+ return await task();
44
+ } finally {
45
+ release();
46
+ if (migrationRunQueues.get(queueKey) === tail) {
47
+ migrationRunQueues.delete(queueKey);
48
+ }
49
+ }
50
+ }
15
51
 
16
52
  /**
17
53
  * Run pending migrations and track their state.
@@ -41,72 +77,103 @@ export async function runMigrations<DB>(
41
77
  const { db, migrations } = options;
42
78
  const trackingTable = options.trackingTable ?? DEFAULT_TRACKING_TABLE;
43
79
  const onChecksumMismatch = options.onChecksumMismatch ?? 'error';
80
+ const beforeReset = options.beforeReset;
81
+
82
+ // Serialize migration runs per tracking table to avoid duplicate CREATE TABLE
83
+ // races when startup paths invoke migrations concurrently (e.g. React StrictMode).
84
+ return runWithMigrationQueue(`tracking:${trackingTable}`, async () => {
85
+ // Ensure tracking table exists
86
+ await ensureTrackingTable(db, trackingTable);
87
+
88
+ // Get already applied migrations
89
+ let applied = await getAppliedMigrations(db, trackingTable);
90
+ let appliedByVersion = new Map(applied.map((m) => [m.version, m]));
91
+
92
+ const appliedVersions: number[] = [];
93
+ let wasReset = false;
94
+ let recoveredFromSchemaConflict = false;
95
+
96
+ // Check for checksum mismatches up-front when reset mode is enabled
97
+ if (onChecksumMismatch === 'reset' && applied.length > 0) {
98
+ const hasMismatch = migrations.migrations.some((migration) => {
99
+ const existing = appliedByVersion.get(migration.version);
100
+ if (!existing) return false;
101
+ return existing.checksum !== getMigrationChecksum(migration);
102
+ });
103
+
104
+ if (hasMismatch) {
105
+ // Let caller drop application tables first
106
+ await options.beforeReset?.(db);
107
+ // Clear tracking state so all migrations re-run
108
+ await clearAppliedMigrations(db, trackingTable);
109
+ wasReset = true;
110
+
111
+ // Refresh applied list (now empty)
112
+ applied = await getAppliedMigrations(db, trackingTable);
113
+ appliedByVersion = new Map(applied.map((m) => [m.version, m]));
114
+ }
115
+ }
44
116
 
45
- // Ensure tracking table exists
46
- await ensureTrackingTable(db, trackingTable);
47
-
48
- // Get already applied migrations
49
- let applied = await getAppliedMigrations(db, trackingTable);
50
- let appliedByVersion = new Map(applied.map((m) => [m.version, m]));
51
-
52
- const appliedVersions: number[] = [];
53
- let wasReset = false;
54
-
55
- // Check for checksum mismatches up-front when reset mode is enabled
56
- if (onChecksumMismatch === 'reset' && applied.length > 0) {
57
- const hasMismatch = migrations.migrations.some((migration) => {
117
+ for (let index = 0; index < migrations.migrations.length; index += 1) {
118
+ const migration = migrations.migrations[index]!;
58
119
  const existing = appliedByVersion.get(migration.version);
59
- if (!existing) return false;
60
- return existing.checksum !== getMigrationChecksum(migration);
61
- });
62
-
63
- if (hasMismatch) {
64
- // Let caller drop application tables first
65
- await options.beforeReset?.(db);
66
- // Clear tracking state so all migrations re-run
67
- await clearAppliedMigrations(db, trackingTable);
68
- wasReset = true;
69
-
70
- // Refresh applied list (now empty)
71
- applied = await getAppliedMigrations(db, trackingTable);
72
- appliedByVersion = new Map(applied.map((m) => [m.version, m]));
73
- }
74
- }
75
120
 
76
- for (const migration of migrations.migrations) {
77
- const existing = appliedByVersion.get(migration.version);
78
-
79
- if (existing) {
80
- // Migration already applied - verify checksum hasn't changed
81
- const currentChecksum = getMigrationChecksum(migration);
82
- if (existing.checksum !== currentChecksum) {
83
- throw new Error(
84
- `Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
85
- `Expected checksum ${existing.checksum}, got ${currentChecksum}. ` +
86
- 'Migrations must not be modified after being applied.'
87
- );
121
+ if (existing) {
122
+ // Migration already applied - verify checksum hasn't changed
123
+ const currentChecksum = getMigrationChecksum(migration);
124
+ if (existing.checksum !== currentChecksum) {
125
+ throw new Error(
126
+ `Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
127
+ `Expected checksum ${existing.checksum}, got ${currentChecksum}. ` +
128
+ 'Migrations must not be modified after being applied.'
129
+ );
130
+ }
131
+ continue;
88
132
  }
89
- continue;
90
- }
91
133
 
92
- // Run the migration
93
- await migration.fn(db);
134
+ // Run the migration
135
+ try {
136
+ await migration.fn(db);
137
+ } catch (error) {
138
+ const canRecoverFromConflict =
139
+ onChecksumMismatch === 'reset' &&
140
+ typeof beforeReset === 'function' &&
141
+ !recoveredFromSchemaConflict &&
142
+ isAlreadyExistsSchemaError(error);
143
+
144
+ if (!canRecoverFromConflict) {
145
+ throw error;
146
+ }
147
+
148
+ // Recover once from partially-applied state where app tables exist but
149
+ // migration tracking rows were not committed.
150
+ await beforeReset(db);
151
+ await clearAppliedMigrations(db, trackingTable);
152
+ wasReset = true;
153
+ recoveredFromSchemaConflict = true;
154
+ applied = await getAppliedMigrations(db, trackingTable);
155
+ appliedByVersion = new Map(applied.map((m) => [m.version, m]));
156
+ appliedVersions.length = 0;
157
+ index = -1;
158
+ continue;
159
+ }
94
160
 
95
- // Record it as applied
96
- await recordAppliedMigration(db, trackingTable, {
97
- version: migration.version,
98
- name: migration.name,
99
- checksum: getMigrationChecksum(migration),
100
- });
161
+ // Record it as applied
162
+ await recordAppliedMigration(db, trackingTable, {
163
+ version: migration.version,
164
+ name: migration.name,
165
+ checksum: getMigrationChecksum(migration),
166
+ });
101
167
 
102
- appliedVersions.push(migration.version);
103
- }
168
+ appliedVersions.push(migration.version);
169
+ }
104
170
 
105
- return {
106
- applied: appliedVersions,
107
- currentVersion: migrations.currentVersion,
108
- wasReset,
109
- };
171
+ return {
172
+ applied: appliedVersions,
173
+ currentVersion: migrations.currentVersion,
174
+ wasReset,
175
+ };
176
+ });
110
177
  }
111
178
 
112
179
  /**