@syncular/migrations 0.0.6-244 → 0.0.6-246

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/src/define.ts CHANGED
@@ -4,8 +4,8 @@
4
4
 
5
5
  import type {
6
6
  DefinedMigrations,
7
+ MigrationChecksumMode,
7
8
  MigrationDefinition,
8
- MigrationFn,
9
9
  MigrationRecord,
10
10
  ParsedMigration,
11
11
  } from './types';
@@ -21,149 +21,26 @@ function parseVersionKey(key: string): number | null {
21
21
  return Number.isNaN(version) ? null : version;
22
22
  }
23
23
 
24
- /**
25
- * Normalize a function source string for checksum comparison.
26
- * Strips comments and collapses whitespace so that formatting-only
27
- * changes don't break checksums.
28
- */
29
- function stripCommentsPreservingStrings(source: string): string {
30
- let out = '';
31
- let i = 0;
32
- let mode:
33
- | 'code'
34
- | 'singleQuote'
35
- | 'doubleQuote'
36
- | 'template'
37
- | 'lineComment'
38
- | 'blockComment' = 'code';
39
-
40
- while (i < source.length) {
41
- const char = source[i]!;
42
- const next = source[i + 1];
43
-
44
- if (mode === 'lineComment') {
45
- if (char === '\n') {
46
- out += '\n';
47
- mode = 'code';
48
- }
49
- i += 1;
50
- continue;
51
- }
52
-
53
- if (mode === 'blockComment') {
54
- if (char === '*' && next === '/') {
55
- i += 2;
56
- mode = 'code';
57
- continue;
58
- }
59
- if (char === '\n') {
60
- out += '\n';
61
- }
62
- i += 1;
63
- continue;
64
- }
65
-
66
- if (mode === 'singleQuote') {
67
- out += char;
68
- if (char === '\\' && next !== undefined) {
69
- out += next;
70
- i += 2;
71
- continue;
72
- }
73
- if (char === "'") {
74
- mode = 'code';
75
- }
76
- i += 1;
77
- continue;
78
- }
79
-
80
- if (mode === 'doubleQuote') {
81
- out += char;
82
- if (char === '\\' && next !== undefined) {
83
- out += next;
84
- i += 2;
85
- continue;
86
- }
87
- if (char === '"') {
88
- mode = 'code';
89
- }
90
- i += 1;
91
- continue;
92
- }
93
-
94
- if (mode === 'template') {
95
- out += char;
96
- if (char === '\\' && next !== undefined) {
97
- out += next;
98
- i += 2;
99
- continue;
100
- }
101
- if (char === '`') {
102
- mode = 'code';
103
- }
104
- i += 1;
105
- continue;
106
- }
107
-
108
- if (char === '/' && next === '/') {
109
- mode = 'lineComment';
110
- i += 2;
111
- continue;
112
- }
113
- if (char === '/' && next === '*') {
114
- mode = 'blockComment';
115
- i += 2;
116
- continue;
117
- }
118
- if (char === "'") {
119
- mode = 'singleQuote';
120
- out += char;
121
- i += 1;
122
- continue;
123
- }
124
- if (char === '"') {
125
- mode = 'doubleQuote';
126
- out += char;
127
- i += 1;
128
- continue;
129
- }
130
- if (char === '`') {
131
- mode = 'template';
132
- out += char;
133
- i += 1;
134
- continue;
135
- }
136
-
137
- out += char;
138
- i += 1;
139
- }
140
-
141
- return out;
142
- }
143
-
144
- function normalizeSource(source: string): string {
145
- return stripCommentsPreservingStrings(source)
146
- .replace(/\s+/g, ' ') // collapse whitespace
147
- .trim();
24
+ function isMigrationDefinitionObject<DB>(
25
+ value: MigrationDefinition<DB>
26
+ ): value is MigrationDefinition<DB> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
148
28
  }
149
29
 
150
- /**
151
- * Compute a simple checksum for a migration function.
152
- * Used to detect if a migration has changed after being applied.
153
- */
154
- function computeChecksum<DB>(fn: MigrationFn<DB>): string {
155
- const fnStr = normalizeSource(fn.toString());
156
- let hash = 0;
157
- for (let i = 0; i < fnStr.length; i++) {
158
- hash = (hash * 31 + fnStr.charCodeAt(i)) >>> 0;
30
+ function normalizeChecksumMode(
31
+ key: string,
32
+ checksum: MigrationChecksumMode | undefined
33
+ ): MigrationChecksumMode {
34
+ if (checksum === undefined) {
35
+ return 'deterministic';
36
+ }
37
+ if (checksum === 'deterministic' || checksum === 'disabled') {
38
+ return checksum;
159
39
  }
160
- return hash.toString(16).padStart(8, '0');
161
- }
162
40
 
163
- function isMigrationDefinitionObject<DB>(
164
- value: MigrationDefinition<DB>
165
- ): value is { up: MigrationFn<DB>; down?: MigrationFn<DB> } {
166
- return typeof value === 'object' && value !== null && !Array.isArray(value);
41
+ throw new Error(
42
+ `Invalid migration "${key}": "checksum" must be "deterministic" or "disabled" when provided.`
43
+ );
167
44
  }
168
45
 
169
46
  /**
@@ -172,16 +49,28 @@ function isMigrationDefinitionObject<DB>(
172
49
  * @example
173
50
  * ```typescript
174
51
  * export const migrations = defineMigrations({
175
- * v1: async (db) => {
176
- * await db.schema.createTable('tasks')
177
- * .addColumn('id', 'text', col => col.primaryKey())
178
- * .addColumn('title', 'text', col => col.notNull())
179
- * .execute();
52
+ * v1: {
53
+ * up: async (db) => {
54
+ * await db.schema.createTable('tasks')
55
+ * .addColumn('id', 'text', col => col.primaryKey())
56
+ * .addColumn('title', 'text', col => col.notNull())
57
+ * .execute();
58
+ * },
59
+ * down: async (db) => {
60
+ * await db.schema.dropTable('tasks').ifExists().execute();
61
+ * },
180
62
  * },
181
- * v2: async (db) => {
182
- * await db.schema.alterTable('tasks')
183
- * .addColumn('priority', 'integer', col => col.defaultTo(0))
184
- * .execute();
63
+ * v2: {
64
+ * up: async (db) => {
65
+ * await db.schema.alterTable('tasks')
66
+ * .addColumn('priority', 'integer', col => col.defaultTo(0))
67
+ * .execute();
68
+ * },
69
+ * down: async (db) => {
70
+ * await db.schema.alterTable('tasks')
71
+ * .dropColumn('priority')
72
+ * .execute();
73
+ * },
185
74
  * },
186
75
  * });
187
76
  * ```
@@ -205,45 +94,27 @@ export function defineMigrations<
205
94
  );
206
95
  }
207
96
 
208
- const up = isMigrationDefinitionObject(definition)
209
- ? definition.up
210
- : definition;
211
- const down = isMigrationDefinitionObject(definition)
212
- ? definition.down
213
- : undefined;
214
- const compatibleChecksums = isMigrationDefinitionObject(definition)
215
- ? (definition.compatibleChecksums ?? [])
216
- : [];
217
- if (typeof up !== 'function') {
97
+ if (!isMigrationDefinitionObject(definition)) {
218
98
  throw new Error(
219
- `Invalid migration "${key}": expected an async function or { up, down? } object.`
99
+ `Invalid migration "${key}": expected a { up, down } object. Shorthand migration functions are not supported.`
220
100
  );
221
101
  }
222
- if (down !== undefined && typeof down !== 'function') {
223
- throw new Error(
224
- `Invalid migration "${key}": "down" must be a function when provided.`
225
- );
102
+
103
+ const { up, down } = definition;
104
+ const checksum = normalizeChecksumMode(key, definition.checksum);
105
+
106
+ if (typeof up !== 'function') {
107
+ throw new Error(`Invalid migration "${key}": "up" must be a function.`);
226
108
  }
227
- if (
228
- !Array.isArray(compatibleChecksums) ||
229
- compatibleChecksums.some(
230
- (checksum) =>
231
- typeof checksum !== 'string' || checksum.trim().length === 0
232
- )
233
- ) {
234
- throw new Error(
235
- `Invalid migration "${key}": "compatibleChecksums" must be an array of non-empty strings when provided.`
236
- );
109
+ if (typeof down !== 'function') {
110
+ throw new Error(`Invalid migration "${key}": "down" must be a function.`);
237
111
  }
238
-
239
112
  migrations.push({
240
113
  version,
241
114
  name: key,
242
115
  up,
243
116
  down,
244
- compatibleChecksums: [
245
- ...new Set(compatibleChecksums.map((v) => v.trim())),
246
- ],
117
+ checksum,
247
118
  });
248
119
  }
249
120
 
@@ -268,26 +139,3 @@ export function defineMigrations<
268
139
  },
269
140
  };
270
141
  }
271
-
272
- /**
273
- * Get the checksum for a migration.
274
- */
275
- export function getMigrationChecksum<DB>(
276
- migration: ParsedMigration<DB>
277
- ): string {
278
- return computeChecksum(migration.up);
279
- }
280
-
281
- /**
282
- * Get the accepted checksums for a migration, including the current checksum.
283
- */
284
- export function getCompatibleMigrationChecksums<DB>(
285
- migration: ParsedMigration<DB>
286
- ): string[] {
287
- return [
288
- ...new Set([
289
- getMigrationChecksum(migration),
290
- ...migration.compatibleChecksums,
291
- ]),
292
- ];
293
- }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Provides migration definition, tracking, naming, and execution.
5
5
  */
6
6
 
7
+ export * from './checksum';
7
8
  export * from './define';
8
9
  export * from './naming';
9
10
  export * from './runner';
package/src/runner.ts CHANGED
@@ -3,9 +3,13 @@
3
3
  */
4
4
 
5
5
  import {
6
- getCompatibleMigrationChecksums,
7
- getMigrationChecksum,
8
- } from './define';
6
+ DISABLED_MIGRATION_CHECKSUM,
7
+ DISABLED_MIGRATION_CHECKSUM_ALGORITHM,
8
+ getLegacyMigrationChecksum,
9
+ getMigrationChecksumAlgorithm,
10
+ getStoredDeterministicChecksum,
11
+ LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM,
12
+ } from './checksum';
9
13
  import { DEFAULT_MIGRATION_TRACKING_TABLE } from './naming';
10
14
  import {
11
15
  clearAppliedMigrations,
@@ -15,6 +19,9 @@ import {
15
19
  removeAppliedMigration,
16
20
  } from './tracking';
17
21
  import type {
22
+ DefinedMigrations,
23
+ MigrationChecksumAlgorithm,
24
+ ParsedMigration,
18
25
  RunMigrationsOptions,
19
26
  RunMigrationsResult,
20
27
  RunMigrationsToVersionOptions,
@@ -35,6 +42,43 @@ function isAlreadyExistsSchemaError(error: unknown): boolean {
35
42
  );
36
43
  }
37
44
 
45
+ function isDeterministicMigration<DB>(migration: ParsedMigration<DB>): boolean {
46
+ return migration.checksum === 'deterministic';
47
+ }
48
+
49
+ function getDeterministicMigrations<DB>(
50
+ migrations: DefinedMigrations<DB>
51
+ ): ParsedMigration<DB>[] {
52
+ return migrations.migrations.filter(isDeterministicMigration);
53
+ }
54
+
55
+ async function getStoredChecksumForMigration<DB>(
56
+ options: RunMigrationsOptions<DB>,
57
+ migration: ParsedMigration<DB>
58
+ ): Promise<string> {
59
+ return getStoredDeterministicChecksum(migration, options.checksums);
60
+ }
61
+
62
+ async function getChecksumForAlgorithm<DB>(
63
+ options: RunMigrationsOptions<DB>,
64
+ migration: ParsedMigration<DB>,
65
+ algorithm: MigrationChecksumAlgorithm
66
+ ): Promise<string> {
67
+ if (algorithm === DISABLED_MIGRATION_CHECKSUM_ALGORITHM) {
68
+ return DISABLED_MIGRATION_CHECKSUM;
69
+ }
70
+
71
+ if (algorithm === LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM) {
72
+ return getLegacyMigrationChecksum(migration);
73
+ }
74
+
75
+ if (algorithm === 'sql_trace_v1') {
76
+ return await getStoredChecksumForMigration(options, migration);
77
+ }
78
+
79
+ throw new Error(`Unsupported migration checksum algorithm: ${algorithm}`);
80
+ }
81
+
38
82
  async function runWithMigrationQueue<T>(
39
83
  queueKey: string,
40
84
  task: () => Promise<T>
@@ -66,8 +110,14 @@ async function runWithMigrationQueue<T>(
66
110
  * import { defineMigrations, runMigrations } from '@syncular/migrations';
67
111
  *
68
112
  * const migrations = defineMigrations({
69
- * v1: async (db) => { ... },
70
- * v2: async (db) => { ... },
113
+ * v1: {
114
+ * up: async (db) => { ... },
115
+ * down: async (db) => { ... },
116
+ * },
117
+ * v2: {
118
+ * up: async (db) => { ... },
119
+ * down: async (db) => { ... },
120
+ * },
71
121
  * });
72
122
  *
73
123
  * const result = await runMigrations({
@@ -130,16 +180,28 @@ export async function runMigrationsToVersion<DB>(
130
180
  const revertedVersions: number[] = [];
131
181
  let wasReset = false;
132
182
  let recoveredFromSchemaConflict = false;
183
+ const deterministicMigrations = getDeterministicMigrations(migrations);
133
184
 
134
185
  // Check for checksum mismatches up-front when reset mode is enabled
135
186
  if (onChecksumMismatch === 'reset' && applied.length > 0) {
136
- const hasMismatch = migrations.migrations.some((migration) => {
187
+ let hasMismatch = false;
188
+
189
+ for (const migration of deterministicMigrations) {
137
190
  const existing = appliedByVersion.get(migration.version);
138
- if (!existing) return false;
139
- return !getCompatibleMigrationChecksums(migration).includes(
140
- existing.checksum
191
+ if (!existing) {
192
+ continue;
193
+ }
194
+
195
+ const currentChecksum = await getChecksumForAlgorithm(
196
+ options,
197
+ migration,
198
+ existing.checksum_algorithm
141
199
  );
142
- });
200
+ if (existing.checksum !== currentChecksum) {
201
+ hasMismatch = true;
202
+ break;
203
+ }
204
+ }
143
205
 
144
206
  if (hasMismatch) {
145
207
  // Let caller drop application tables first
@@ -159,9 +221,18 @@ export async function runMigrationsToVersion<DB>(
159
221
  if (!existing) {
160
222
  continue;
161
223
  }
162
- const currentChecksum = getMigrationChecksum(migration);
163
- const compatibleChecksums = getCompatibleMigrationChecksums(migration);
164
- if (!compatibleChecksums.includes(existing.checksum)) {
224
+
225
+ if (migration.checksum === 'disabled') {
226
+ continue;
227
+ }
228
+
229
+ const currentChecksum = await getChecksumForAlgorithm(
230
+ options,
231
+ migration,
232
+ existing.checksum_algorithm
233
+ );
234
+
235
+ if (existing.checksum !== currentChecksum) {
165
236
  throw new Error(
166
237
  `Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
167
238
  `Stored checksum ${existing.checksum} is not compatible with current checksum ${currentChecksum}. ` +
@@ -209,10 +280,19 @@ export async function runMigrationsToVersion<DB>(
209
280
  continue;
210
281
  }
211
282
 
283
+ const checksum = await getStoredChecksumForMigration(
284
+ options,
285
+ migration
286
+ );
287
+
212
288
  await recordAppliedMigration(db, trackingTable, {
213
289
  version: migration.version,
214
290
  name: migration.name,
215
- checksum: getMigrationChecksum(migration),
291
+ checksum,
292
+ checksum_algorithm: getMigrationChecksumAlgorithm(
293
+ migration,
294
+ options.checksums
295
+ ),
216
296
  });
217
297
  appliedVersions.push(migration.version);
218
298
  }
@@ -228,12 +308,6 @@ export async function runMigrationsToVersion<DB>(
228
308
  `Cannot revert migration v${version}: migration is not defined in current migration set.`
229
309
  );
230
310
  }
231
- if (typeof migration.down !== 'function') {
232
- throw new Error(
233
- `Cannot revert migration v${version} (${migration.name}): down migration is not defined.`
234
- );
235
- }
236
-
237
311
  await migration.down(db);
238
312
  await removeAppliedMigration(db, trackingTable, version);
239
313
  revertedVersions.push(version);
package/src/tracking.ts CHANGED
@@ -3,8 +3,22 @@
3
3
  */
4
4
 
5
5
  import { type Kysely, sql } from 'kysely';
6
+ import { LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM } from './checksum';
6
7
  import type { MigrationStateRow } from './types';
7
8
 
9
+ function isDuplicateColumnError(error: unknown): boolean {
10
+ if (!(error instanceof Error)) {
11
+ return false;
12
+ }
13
+
14
+ const message = error.message.toLowerCase();
15
+ return (
16
+ message.includes('duplicate column') ||
17
+ message.includes('already exists') ||
18
+ (message.includes('column') && message.includes('exists'))
19
+ );
20
+ }
21
+
8
22
  /**
9
23
  * Ensure the migration tracking table exists.
10
24
  */
@@ -19,7 +33,19 @@ export async function ensureTrackingTable<DB>(
19
33
  .addColumn('name', 'text', (col) => col.notNull())
20
34
  .addColumn('applied_at', 'text', (col) => col.notNull())
21
35
  .addColumn('checksum', 'text', (col) => col.notNull())
36
+ .addColumn('checksum_algorithm', 'text', (col) => col.notNull())
22
37
  .execute();
38
+
39
+ try {
40
+ await sql`
41
+ alter table ${sql.table(tableName)}
42
+ add column checksum_algorithm text not null default ${sql.raw(`'${LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM}'`)}
43
+ `.execute(db);
44
+ } catch (error) {
45
+ if (!isDuplicateColumnError(error)) {
46
+ throw error;
47
+ }
48
+ }
23
49
  }
24
50
 
25
51
  /**
@@ -32,7 +58,7 @@ export async function getAppliedMigrations<DB, TTableName extends string>(
32
58
  await ensureTrackingTable(db, tableName);
33
59
 
34
60
  const result = await sql<MigrationStateRow>`
35
- select version, name, applied_at, checksum
61
+ select version, name, applied_at, checksum, checksum_algorithm
36
62
  from ${sql.table(tableName)}
37
63
  order by version asc
38
64
  `.execute(db);
@@ -51,12 +77,19 @@ export async function recordAppliedMigration<DB, TTableName extends string>(
51
77
  await ensureTrackingTable(db, tableName);
52
78
 
53
79
  await sql`
54
- insert into ${sql.table(tableName)} (version, name, applied_at, checksum)
80
+ insert into ${sql.table(tableName)} (
81
+ version,
82
+ name,
83
+ applied_at,
84
+ checksum,
85
+ checksum_algorithm
86
+ )
55
87
  values (
56
88
  ${migration.version},
57
89
  ${migration.name},
58
90
  ${new Date().toISOString()},
59
- ${migration.checksum}
91
+ ${migration.checksum},
92
+ ${migration.checksum_algorithm}
60
93
  )
61
94
  `.execute(db);
62
95
  }
package/src/types.ts CHANGED
@@ -9,6 +9,15 @@ import type { Kysely } from 'kysely';
9
9
  */
10
10
  export type MigrationFn<DB = unknown> = (db: Kysely<DB>) => Promise<void>;
11
11
 
12
+ export type MigrationChecksumMode = 'deterministic' | 'disabled';
13
+
14
+ export type MigrationChecksumAlgorithm =
15
+ | 'legacy_source_v1'
16
+ | 'sql_trace_v1'
17
+ | 'disabled';
18
+
19
+ export type MigrationChecksums = Record<string, string>;
20
+
12
21
  /**
13
22
  * A reversible migration definition.
14
23
  */
@@ -16,21 +25,14 @@ export interface ReversibleMigrationDefinition<DB = unknown> {
16
25
  /** Apply schema/data changes for this version. */
17
26
  up: MigrationFn<DB>;
18
27
  /** Revert schema/data changes for this version. */
19
- down?: MigrationFn<DB>;
20
- /**
21
- * Historical checksums that should still be accepted for previously-applied
22
- * copies of this migration.
23
- */
24
- compatibleChecksums?: string[];
28
+ down: MigrationFn<DB>;
29
+ /** Controls whether this migration participates in checksum validation. */
30
+ checksum?: MigrationChecksumMode;
25
31
  }
26
32
 
27
- /**
28
- * A migration definition can be a single "up" function or
29
- * an object with explicit up/down handlers.
30
- */
33
+ /** A migration definition must explicitly provide both up/down handlers. */
31
34
  export type MigrationDefinition<DB = unknown> =
32
- | MigrationFn<DB>
33
- | ReversibleMigrationDefinition<DB>;
35
+ ReversibleMigrationDefinition<DB>;
34
36
 
35
37
  /**
36
38
  * Record of versioned migrations keyed by version string (e.g., 'v1', 'v2').
@@ -48,10 +50,10 @@ export interface ParsedMigration<DB = unknown> {
48
50
  name: string;
49
51
  /** Up migration function. */
50
52
  up: MigrationFn<DB>;
51
- /** Optional down migration function. */
52
- down?: MigrationFn<DB>;
53
- /** Historical checksums that remain valid for already-applied copies. */
54
- compatibleChecksums: string[];
53
+ /** Down migration function. */
54
+ down: MigrationFn<DB>;
55
+ /** Controls whether this migration participates in checksum validation. */
56
+ checksum: MigrationChecksumMode;
55
57
  }
56
58
 
57
59
  /**
@@ -74,6 +76,7 @@ export interface MigrationStateRow {
74
76
  name: string;
75
77
  applied_at: string;
76
78
  checksum: string;
79
+ checksum_algorithm: MigrationChecksumAlgorithm;
77
80
  }
78
81
 
79
82
  /**
@@ -84,6 +87,8 @@ export interface RunMigrationsOptions<DB = unknown> {
84
87
  db: Kysely<DB>;
85
88
  /** Defined migrations from defineMigrations() */
86
89
  migrations: DefinedMigrations<DB>;
90
+ /** Generated deterministic checksums for this migration set. */
91
+ checksums?: MigrationChecksums;
87
92
  /** Name of the tracking table (default: 'sync_migration_state') */
88
93
  trackingTable?: string;
89
94
  /** What to do when a migration's checksum doesn't match. Default: 'error' */