@syncular/migrations 0.0.6-243 → 0.0.6-245

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,15 @@
3
3
  */
4
4
 
5
5
  import {
6
- getCompatibleMigrationChecksums,
6
+ DISABLED_MIGRATION_CHECKSUM,
7
+ DISABLED_MIGRATION_CHECKSUM_ALGORITHM,
8
+ getLegacyMigrationChecksum,
7
9
  getMigrationChecksum,
8
- } from './define';
10
+ getMigrationChecksumAlgorithm,
11
+ inferMigrationChecksumDialect,
12
+ LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM,
13
+ SQL_TRACE_MIGRATION_CHECKSUM_ALGORITHM,
14
+ } from './checksum';
9
15
  import { DEFAULT_MIGRATION_TRACKING_TABLE } from './naming';
10
16
  import {
11
17
  clearAppliedMigrations,
@@ -15,6 +21,10 @@ import {
15
21
  removeAppliedMigration,
16
22
  } from './tracking';
17
23
  import type {
24
+ DefinedMigrations,
25
+ MigrationChecksumAlgorithm,
26
+ MigrationChecksumDialect,
27
+ ParsedMigration,
18
28
  RunMigrationsOptions,
19
29
  RunMigrationsResult,
20
30
  RunMigrationsToVersionOptions,
@@ -35,6 +45,77 @@ function isAlreadyExistsSchemaError(error: unknown): boolean {
35
45
  );
36
46
  }
37
47
 
48
+ function isDeterministicMigration<DB>(migration: ParsedMigration<DB>): boolean {
49
+ return migration.checksum === 'deterministic';
50
+ }
51
+
52
+ function getDeterministicMigrations<DB>(
53
+ migrations: DefinedMigrations<DB>
54
+ ): ParsedMigration<DB>[] {
55
+ return migrations.migrations.filter(isDeterministicMigration);
56
+ }
57
+
58
+ function requireChecksumDialect<DB>(
59
+ options: RunMigrationsOptions<DB>
60
+ ): MigrationChecksumDialect {
61
+ const dialect = inferMigrationChecksumDialect(options.db);
62
+
63
+ if (dialect) {
64
+ return dialect;
65
+ }
66
+
67
+ throw new Error(
68
+ 'Deterministic migration checksums are not supported for this runtime or dialect. ' +
69
+ 'Set `checksum: "disabled"` on these migrations if they must run without checksum validation.'
70
+ );
71
+ }
72
+
73
+ async function getStoredChecksumForMigration<DB>(
74
+ options: RunMigrationsOptions<DB>,
75
+ migration: ParsedMigration<DB>,
76
+ dialect: MigrationChecksumDialect | null
77
+ ): Promise<string> {
78
+ if (migration.checksum === 'disabled') {
79
+ return DISABLED_MIGRATION_CHECKSUM;
80
+ }
81
+
82
+ const resolvedDialect = dialect ?? requireChecksumDialect(options);
83
+ const checksum = await getMigrationChecksum(
84
+ options.migrations,
85
+ migration,
86
+ resolvedDialect
87
+ );
88
+
89
+ if (!checksum) {
90
+ throw new Error(
91
+ `Migration v${migration.version} (${migration.name}) is configured for deterministic checksums but did not produce one.`
92
+ );
93
+ }
94
+
95
+ return checksum;
96
+ }
97
+
98
+ async function getChecksumForAlgorithm<DB>(
99
+ options: RunMigrationsOptions<DB>,
100
+ migration: ParsedMigration<DB>,
101
+ algorithm: MigrationChecksumAlgorithm,
102
+ dialect: MigrationChecksumDialect | null
103
+ ): Promise<string> {
104
+ if (algorithm === DISABLED_MIGRATION_CHECKSUM_ALGORITHM) {
105
+ return DISABLED_MIGRATION_CHECKSUM;
106
+ }
107
+
108
+ if (algorithm === LEGACY_SOURCE_MIGRATION_CHECKSUM_ALGORITHM) {
109
+ return getLegacyMigrationChecksum(migration);
110
+ }
111
+
112
+ if (algorithm === SQL_TRACE_MIGRATION_CHECKSUM_ALGORITHM) {
113
+ return await getStoredChecksumForMigration(options, migration, dialect);
114
+ }
115
+
116
+ throw new Error(`Unsupported migration checksum algorithm: ${algorithm}`);
117
+ }
118
+
38
119
  async function runWithMigrationQueue<T>(
39
120
  queueKey: string,
40
121
  task: () => Promise<T>
@@ -66,8 +147,14 @@ async function runWithMigrationQueue<T>(
66
147
  * import { defineMigrations, runMigrations } from '@syncular/migrations';
67
148
  *
68
149
  * const migrations = defineMigrations({
69
- * v1: async (db) => { ... },
70
- * v2: async (db) => { ... },
150
+ * v1: {
151
+ * up: async (db) => { ... },
152
+ * down: async (db) => { ... },
153
+ * },
154
+ * v2: {
155
+ * up: async (db) => { ... },
156
+ * down: async (db) => { ... },
157
+ * },
71
158
  * });
72
159
  *
73
160
  * const result = await runMigrations({
@@ -130,16 +217,33 @@ export async function runMigrationsToVersion<DB>(
130
217
  const revertedVersions: number[] = [];
131
218
  let wasReset = false;
132
219
  let recoveredFromSchemaConflict = false;
220
+ const deterministicMigrations = getDeterministicMigrations(migrations);
221
+ const checksumDialect =
222
+ deterministicMigrations.length > 0
223
+ ? requireChecksumDialect(options)
224
+ : null;
133
225
 
134
226
  // Check for checksum mismatches up-front when reset mode is enabled
135
227
  if (onChecksumMismatch === 'reset' && applied.length > 0) {
136
- const hasMismatch = migrations.migrations.some((migration) => {
228
+ let hasMismatch = false;
229
+
230
+ for (const migration of deterministicMigrations) {
137
231
  const existing = appliedByVersion.get(migration.version);
138
- if (!existing) return false;
139
- return !getCompatibleMigrationChecksums(migration).includes(
140
- existing.checksum
232
+ if (!existing) {
233
+ continue;
234
+ }
235
+
236
+ const currentChecksum = await getChecksumForAlgorithm(
237
+ options,
238
+ migration,
239
+ existing.checksum_algorithm,
240
+ checksumDialect
141
241
  );
142
- });
242
+ if (existing.checksum !== currentChecksum) {
243
+ hasMismatch = true;
244
+ break;
245
+ }
246
+ }
143
247
 
144
248
  if (hasMismatch) {
145
249
  // Let caller drop application tables first
@@ -159,9 +263,19 @@ export async function runMigrationsToVersion<DB>(
159
263
  if (!existing) {
160
264
  continue;
161
265
  }
162
- const currentChecksum = getMigrationChecksum(migration);
163
- const compatibleChecksums = getCompatibleMigrationChecksums(migration);
164
- if (!compatibleChecksums.includes(existing.checksum)) {
266
+
267
+ if (migration.checksum === 'disabled') {
268
+ continue;
269
+ }
270
+
271
+ const currentChecksum = await getChecksumForAlgorithm(
272
+ options,
273
+ migration,
274
+ existing.checksum_algorithm,
275
+ checksumDialect
276
+ );
277
+
278
+ if (existing.checksum !== currentChecksum) {
165
279
  throw new Error(
166
280
  `Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
167
281
  `Stored checksum ${existing.checksum} is not compatible with current checksum ${currentChecksum}. ` +
@@ -209,10 +323,17 @@ export async function runMigrationsToVersion<DB>(
209
323
  continue;
210
324
  }
211
325
 
326
+ const checksum = await getStoredChecksumForMigration(
327
+ options,
328
+ migration,
329
+ checksumDialect
330
+ );
331
+
212
332
  await recordAppliedMigration(db, trackingTable, {
213
333
  version: migration.version,
214
334
  name: migration.name,
215
- checksum: getMigrationChecksum(migration),
335
+ checksum,
336
+ checksum_algorithm: getMigrationChecksumAlgorithm(migration),
216
337
  });
217
338
  appliedVersions.push(migration.version);
218
339
  }
@@ -228,12 +349,6 @@ export async function runMigrationsToVersion<DB>(
228
349
  `Cannot revert migration v${version}: migration is not defined in current migration set.`
229
350
  );
230
351
  }
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
352
  await migration.down(db);
238
353
  await removeAppliedMigration(db, trackingTable, version);
239
354
  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 MigrationChecksumDialect = 'sqlite' | 'postgres';
15
+
16
+ export type MigrationChecksumAlgorithm =
17
+ | 'legacy_source_v1'
18
+ | 'sql_trace_v1'
19
+ | 'disabled';
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
  /**