@syncular/migrations 0.0.6-96 → 0.1.2

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/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * @syncular/migrations - Versioned migration system
3
3
  *
4
- * Provides migration definition, tracking, and execution.
4
+ * Provides migration definition, tracking, naming, and execution.
5
5
  */
6
6
 
7
+ export * from './checksum';
7
8
  export * from './define';
9
+ export * from './naming';
8
10
  export * from './runner';
9
11
  export * from './tracking';
10
12
  export * from './types';
package/src/naming.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @syncular/migrations - Migration tracking table naming helpers
3
+ */
4
+
5
+ export type MigrationTrackingTableNamePart =
6
+ | string
7
+ | number
8
+ | null
9
+ | undefined
10
+ | false;
11
+
12
+ export interface CreateMigrationTrackingTableNameOptions {
13
+ /**
14
+ * Optional namespace prefix placed before the scope segments.
15
+ * Defaults to `sync`.
16
+ */
17
+ namespace?:
18
+ | MigrationTrackingTableNamePart
19
+ | readonly MigrationTrackingTableNamePart[];
20
+ /**
21
+ * Optional suffix segments appended after `migration_state`.
22
+ */
23
+ suffix?:
24
+ | MigrationTrackingTableNamePart
25
+ | readonly MigrationTrackingTableNamePart[];
26
+ }
27
+
28
+ export const DEFAULT_MIGRATION_TRACKING_TABLE = 'sync_migration_state';
29
+
30
+ function normalizeTrackingTableNamePart(
31
+ part: MigrationTrackingTableNamePart
32
+ ): string | null {
33
+ if (part === null || part === undefined || part === false) {
34
+ return null;
35
+ }
36
+
37
+ const normalized = String(part)
38
+ .trim()
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9]+/g, '_')
41
+ .replace(/^_+|_+$/g, '')
42
+ .replace(/_+/g, '_');
43
+
44
+ return normalized.length > 0 ? normalized : null;
45
+ }
46
+
47
+ function normalizeTrackingTableNameParts(
48
+ input:
49
+ | MigrationTrackingTableNamePart
50
+ | readonly MigrationTrackingTableNamePart[]
51
+ | undefined
52
+ ): string[] {
53
+ if (Array.isArray(input)) {
54
+ return input
55
+ .map((part) => normalizeTrackingTableNamePart(part))
56
+ .filter((part): part is string => part !== null);
57
+ }
58
+
59
+ const normalized = normalizeTrackingTableNamePart(
60
+ input as MigrationTrackingTableNamePart
61
+ );
62
+ return normalized ? [normalized] : [];
63
+ }
64
+
65
+ /**
66
+ * Create a stable migration tracking table name.
67
+ *
68
+ * The generated name always includes the `migration_state` suffix and
69
+ * normalizes segments to lowercase snake_case.
70
+ *
71
+ * @example
72
+ * createMigrationTrackingTableName()
73
+ * // => 'sync_migration_state'
74
+ *
75
+ * @example
76
+ * createMigrationTrackingTableName('server')
77
+ * // => 'sync_server_migration_state'
78
+ *
79
+ * @example
80
+ * createMigrationTrackingTableName(['spaces', 'billing'], {
81
+ * suffix: ['prod', 'v2'],
82
+ * })
83
+ * // => 'sync_spaces_billing_migration_state_prod_v2'
84
+ */
85
+ export function createMigrationTrackingTableName(
86
+ scope?:
87
+ | MigrationTrackingTableNamePart
88
+ | readonly MigrationTrackingTableNamePart[],
89
+ options: CreateMigrationTrackingTableNameOptions = {}
90
+ ): string {
91
+ const namespace =
92
+ options.namespace === undefined ? ['sync'] : options.namespace;
93
+ const segments = [
94
+ ...normalizeTrackingTableNameParts(namespace),
95
+ ...normalizeTrackingTableNameParts(scope),
96
+ 'migration',
97
+ 'state',
98
+ ...normalizeTrackingTableNameParts(options.suffix),
99
+ ];
100
+
101
+ if (segments.length === 0) {
102
+ return DEFAULT_MIGRATION_TRACKING_TABLE;
103
+ }
104
+
105
+ return segments.join('_');
106
+ }
package/src/runner.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  * @syncular/migrations - Migration runner
3
3
  */
4
4
 
5
- import { getMigrationChecksum } from './define';
5
+ import {
6
+ DISABLED_MIGRATION_CHECKSUM,
7
+ DISABLED_MIGRATION_CHECKSUM_ALGORITHM,
8
+ getMigrationChecksumAlgorithm,
9
+ getStoredDeterministicChecksum,
10
+ } from './checksum';
11
+ import { DEFAULT_MIGRATION_TRACKING_TABLE } from './naming';
6
12
  import {
7
13
  clearAppliedMigrations,
8
14
  ensureTrackingTable,
@@ -11,13 +17,15 @@ import {
11
17
  removeAppliedMigration,
12
18
  } from './tracking';
13
19
  import type {
20
+ DefinedMigrations,
21
+ MigrationChecksumAlgorithm,
22
+ ParsedMigration,
14
23
  RunMigrationsOptions,
15
24
  RunMigrationsResult,
16
25
  RunMigrationsToVersionOptions,
17
26
  RunMigrationsToVersionResult,
18
27
  } from './types';
19
28
 
20
- const DEFAULT_TRACKING_TABLE = 'sync_migration_state';
21
29
  const migrationRunQueues = new Map<string, Promise<void>>();
22
30
 
23
31
  function toErrorMessage(error: unknown): string {
@@ -32,6 +40,39 @@ function isAlreadyExistsSchemaError(error: unknown): boolean {
32
40
  );
33
41
  }
34
42
 
43
+ function isDeterministicMigration<DB>(migration: ParsedMigration<DB>): boolean {
44
+ return migration.checksum === 'deterministic';
45
+ }
46
+
47
+ function getDeterministicMigrations<DB>(
48
+ migrations: DefinedMigrations<DB>
49
+ ): ParsedMigration<DB>[] {
50
+ return migrations.migrations.filter(isDeterministicMigration);
51
+ }
52
+
53
+ async function getStoredChecksumForMigration<DB>(
54
+ options: RunMigrationsOptions<DB>,
55
+ migration: ParsedMigration<DB>
56
+ ): Promise<string> {
57
+ return getStoredDeterministicChecksum(migration, options.checksums);
58
+ }
59
+
60
+ async function getChecksumForAlgorithm<DB>(
61
+ options: RunMigrationsOptions<DB>,
62
+ migration: ParsedMigration<DB>,
63
+ algorithm: MigrationChecksumAlgorithm
64
+ ): Promise<string> {
65
+ if (algorithm === DISABLED_MIGRATION_CHECKSUM_ALGORITHM) {
66
+ return DISABLED_MIGRATION_CHECKSUM;
67
+ }
68
+
69
+ if (algorithm === 'sql_trace_v1') {
70
+ return await getStoredChecksumForMigration(options, migration);
71
+ }
72
+
73
+ throw new Error(`Unsupported migration checksum algorithm: ${algorithm}`);
74
+ }
75
+
35
76
  async function runWithMigrationQueue<T>(
36
77
  queueKey: string,
37
78
  task: () => Promise<T>
@@ -63,8 +104,14 @@ async function runWithMigrationQueue<T>(
63
104
  * import { defineMigrations, runMigrations } from '@syncular/migrations';
64
105
  *
65
106
  * const migrations = defineMigrations({
66
- * v1: async (db) => { ... },
67
- * v2: async (db) => { ... },
107
+ * v1: {
108
+ * up: async (db) => { ... },
109
+ * down: async (db) => { ... },
110
+ * },
111
+ * v2: {
112
+ * up: async (db) => { ... },
113
+ * down: async (db) => { ... },
114
+ * },
68
115
  * });
69
116
  *
70
117
  * const result = await runMigrations({
@@ -98,7 +145,8 @@ export async function runMigrationsToVersion<DB>(
98
145
  options: RunMigrationsToVersionOptions<DB>
99
146
  ): Promise<RunMigrationsToVersionResult> {
100
147
  const { db, migrations, targetVersion } = options;
101
- const trackingTable = options.trackingTable ?? DEFAULT_TRACKING_TABLE;
148
+ const trackingTable =
149
+ options.trackingTable ?? DEFAULT_MIGRATION_TRACKING_TABLE;
102
150
  const onChecksumMismatch = options.onChecksumMismatch ?? 'error';
103
151
  const beforeReset = options.beforeReset;
104
152
  if (!Number.isInteger(targetVersion) || targetVersion < 0) {
@@ -126,14 +174,28 @@ export async function runMigrationsToVersion<DB>(
126
174
  const revertedVersions: number[] = [];
127
175
  let wasReset = false;
128
176
  let recoveredFromSchemaConflict = false;
177
+ const deterministicMigrations = getDeterministicMigrations(migrations);
129
178
 
130
179
  // Check for checksum mismatches up-front when reset mode is enabled
131
180
  if (onChecksumMismatch === 'reset' && applied.length > 0) {
132
- const hasMismatch = migrations.migrations.some((migration) => {
181
+ let hasMismatch = false;
182
+
183
+ for (const migration of deterministicMigrations) {
133
184
  const existing = appliedByVersion.get(migration.version);
134
- if (!existing) return false;
135
- return getMigrationChecksum(migration) !== existing.checksum;
136
- });
185
+ if (!existing) {
186
+ continue;
187
+ }
188
+
189
+ const currentChecksum = await getChecksumForAlgorithm(
190
+ options,
191
+ migration,
192
+ existing.checksum_algorithm
193
+ );
194
+ if (existing.checksum !== currentChecksum) {
195
+ hasMismatch = true;
196
+ break;
197
+ }
198
+ }
137
199
 
138
200
  if (hasMismatch) {
139
201
  // Let caller drop application tables first
@@ -153,8 +215,18 @@ export async function runMigrationsToVersion<DB>(
153
215
  if (!existing) {
154
216
  continue;
155
217
  }
156
- const currentChecksum = getMigrationChecksum(migration);
157
- if (currentChecksum !== existing.checksum) {
218
+
219
+ if (migration.checksum === 'disabled') {
220
+ continue;
221
+ }
222
+
223
+ const currentChecksum = await getChecksumForAlgorithm(
224
+ options,
225
+ migration,
226
+ existing.checksum_algorithm
227
+ );
228
+
229
+ if (existing.checksum !== currentChecksum) {
158
230
  throw new Error(
159
231
  `Migration v${migration.version} (${migration.name}) has changed since it was applied. ` +
160
232
  `Stored checksum ${existing.checksum} is not compatible with current checksum ${currentChecksum}. ` +
@@ -202,10 +274,19 @@ export async function runMigrationsToVersion<DB>(
202
274
  continue;
203
275
  }
204
276
 
277
+ const checksum = await getStoredChecksumForMigration(
278
+ options,
279
+ migration
280
+ );
281
+
205
282
  await recordAppliedMigration(db, trackingTable, {
206
283
  version: migration.version,
207
284
  name: migration.name,
208
- checksum: getMigrationChecksum(migration),
285
+ checksum,
286
+ checksum_algorithm: getMigrationChecksumAlgorithm(
287
+ migration,
288
+ options.checksums
289
+ ),
209
290
  });
210
291
  appliedVersions.push(migration.version);
211
292
  }
@@ -221,12 +302,6 @@ export async function runMigrationsToVersion<DB>(
221
302
  `Cannot revert migration v${version}: migration is not defined in current migration set.`
222
303
  );
223
304
  }
224
- if (typeof migration.down !== 'function') {
225
- throw new Error(
226
- `Cannot revert migration v${version} (${migration.name}): down migration is not defined.`
227
- );
228
- }
229
-
230
305
  await migration.down(db);
231
306
  await removeAppliedMigration(db, trackingTable, version);
232
307
  revertedVersions.push(version);
@@ -249,7 +324,7 @@ export async function getSchemaVersion<DB>(
249
324
  db: import('kysely').Kysely<DB>,
250
325
  trackingTable?: string
251
326
  ): Promise<number> {
252
- const tableName = trackingTable ?? DEFAULT_TRACKING_TABLE;
327
+ const tableName = trackingTable ?? DEFAULT_MIGRATION_TRACKING_TABLE;
253
328
  const applied = await getAppliedMigrations(db, tableName);
254
329
  if (applied.length === 0) return 0;
255
330
  return applied[applied.length - 1]!.version;
package/src/tracking.ts CHANGED
@@ -19,6 +19,7 @@ export async function ensureTrackingTable<DB>(
19
19
  .addColumn('name', 'text', (col) => col.notNull())
20
20
  .addColumn('applied_at', 'text', (col) => col.notNull())
21
21
  .addColumn('checksum', 'text', (col) => col.notNull())
22
+ .addColumn('checksum_algorithm', 'text', (col) => col.notNull())
22
23
  .execute();
23
24
  }
24
25
 
@@ -32,7 +33,7 @@ export async function getAppliedMigrations<DB, TTableName extends string>(
32
33
  await ensureTrackingTable(db, tableName);
33
34
 
34
35
  const result = await sql<MigrationStateRow>`
35
- select version, name, applied_at, checksum
36
+ select version, name, applied_at, checksum, checksum_algorithm
36
37
  from ${sql.table(tableName)}
37
38
  order by version asc
38
39
  `.execute(db);
@@ -51,12 +52,19 @@ export async function recordAppliedMigration<DB, TTableName extends string>(
51
52
  await ensureTrackingTable(db, tableName);
52
53
 
53
54
  await sql`
54
- insert into ${sql.table(tableName)} (version, name, applied_at, checksum)
55
+ insert into ${sql.table(tableName)} (
56
+ version,
57
+ name,
58
+ applied_at,
59
+ checksum,
60
+ checksum_algorithm
61
+ )
55
62
  values (
56
63
  ${migration.version},
57
64
  ${migration.name},
58
65
  ${new Date().toISOString()},
59
- ${migration.checksum}
66
+ ${migration.checksum},
67
+ ${migration.checksum_algorithm}
60
68
  )
61
69
  `.execute(db);
62
70
  }
package/src/types.ts CHANGED
@@ -9,6 +9,12 @@ 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 = 'sql_trace_v1' | 'disabled';
15
+
16
+ export type MigrationChecksums = Record<string, string>;
17
+
12
18
  /**
13
19
  * A reversible migration definition.
14
20
  */
@@ -16,16 +22,14 @@ export interface ReversibleMigrationDefinition<DB = unknown> {
16
22
  /** Apply schema/data changes for this version. */
17
23
  up: MigrationFn<DB>;
18
24
  /** Revert schema/data changes for this version. */
19
- down?: MigrationFn<DB>;
25
+ down: MigrationFn<DB>;
26
+ /** Controls whether this migration participates in checksum validation. */
27
+ checksum?: MigrationChecksumMode;
20
28
  }
21
29
 
22
- /**
23
- * A migration definition can be a single "up" function or
24
- * an object with explicit up/down handlers.
25
- */
30
+ /** A migration definition must explicitly provide both up/down handlers. */
26
31
  export type MigrationDefinition<DB = unknown> =
27
- | MigrationFn<DB>
28
- | ReversibleMigrationDefinition<DB>;
32
+ ReversibleMigrationDefinition<DB>;
29
33
 
30
34
  /**
31
35
  * Record of versioned migrations keyed by version string (e.g., 'v1', 'v2').
@@ -43,8 +47,10 @@ export interface ParsedMigration<DB = unknown> {
43
47
  name: string;
44
48
  /** Up migration function. */
45
49
  up: MigrationFn<DB>;
46
- /** Optional down migration function. */
47
- down?: MigrationFn<DB>;
50
+ /** Down migration function. */
51
+ down: MigrationFn<DB>;
52
+ /** Controls whether this migration participates in checksum validation. */
53
+ checksum: MigrationChecksumMode;
48
54
  }
49
55
 
50
56
  /**
@@ -67,6 +73,7 @@ export interface MigrationStateRow {
67
73
  name: string;
68
74
  applied_at: string;
69
75
  checksum: string;
76
+ checksum_algorithm: MigrationChecksumAlgorithm;
70
77
  }
71
78
 
72
79
  /**
@@ -77,6 +84,8 @@ export interface RunMigrationsOptions<DB = unknown> {
77
84
  db: Kysely<DB>;
78
85
  /** Defined migrations from defineMigrations() */
79
86
  migrations: DefinedMigrations<DB>;
87
+ /** Generated deterministic checksums for this migration set. */
88
+ checksums?: MigrationChecksums;
80
89
  /** Name of the tracking table (default: 'sync_migration_state') */
81
90
  trackingTable?: string;
82
91
  /** What to do when a migration's checksum doesn't match. Default: 'error' */