@sebspark/spanner-migrate 0.1.1 → 1.0.0

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/README.md CHANGED
@@ -14,90 +14,196 @@ yarn add -D @sebspark/spanner-migrate
14
14
 
15
15
  ---
16
16
 
17
- ## CLI Usage
17
+ ## CLI Commands
18
18
 
19
- Run `spanner-migrate` from your project root. If no command is provided, the help message is displayed.
19
+ `spanner-migrate` provides several commands for managing database migrations in Google Spanner.
20
20
 
21
- ```zsh
22
- spanner-migrate [command] [options]
21
+ ### Initialize Configuration
22
+
23
+ ```sh
24
+ spanner-migrate init
23
25
  ```
24
26
 
25
- ### Commands
27
+ Initializes a `.spanner-migrate.config.json` file by prompting for:
28
+ - Spanner instance name
29
+ - One or more database configurations
30
+ - Optional Google Cloud project name
26
31
 
27
- #### `init`
28
- Initialize a Spanner migration configuration file (`.spanner-migrate.config.json`).
32
+ ### Create a Migration
29
33
 
30
- **Usage:**
34
+ ```sh
35
+ spanner-migrate create <description ...> [--database <name>]
36
+ spanner-migrate create add users table
37
+ spanner-migrate create --database=mydb add users table
38
+ ```
31
39
 
32
- ```zsh
33
- spanner-migrate init
40
+ Creates a new migration file with the specified description.
41
+
42
+ - If `--database` (`-d`) is provided, it uses the specified database.
43
+ - If multiple databases exist and none is specified, the user is prompted to select one.
44
+ - The filename is generated from the description (`<timestamp>_add_users_table.sql`).
45
+
46
+ ### Apply Migrations
47
+
48
+ ```sh
49
+ spanner-migrate up
50
+ spanner-migrate up --database <name>
51
+ spanner-migrate up --database <name> --max <n>
52
+ ```
53
+
54
+ Applies pending migrations.
55
+
56
+ - If **no** `--database` and `--max` are provided, applies all migrations to all databases.
57
+ - If `--database` (`-d`) is provided, applies migrations only to that database.
58
+ - If `--max` (`-m`) is provided, limits the number of migrations applied (requires `--database`).
59
+ - `--max` must be an integer greater than 0.
60
+
61
+ ### Roll Back Last Migration
62
+
63
+ ```sh
64
+ spanner-migrate down
65
+ spanner-migrate down --database <name>
66
+ ```
67
+
68
+ Rolls back the last applied migration.
69
+
70
+ - If a **single** database exists, it is automatically selected.
71
+ - If multiple databases exist, `--database` is **required**.
72
+ - The specified `--database` must exist.
73
+
74
+ ### Show Migration Status
75
+
76
+ ```sh
77
+ spanner-migrate status
78
+ spanner-migrate status --database <name>
79
+ ```
80
+
81
+ Displays migration status.
82
+
83
+ - If `--database` is specified, shows status for that database.
84
+ - If no `--database` is provided, shows status for all configured databases.
85
+
86
+ ### Help
87
+
88
+ ```sh
89
+ spanner-migrate --help
90
+ spanner-migrate <command> --help
34
91
  ```
35
92
 
36
- **Prompts:**
37
- - `Enter the path for your migrations`: Directory for migration files (default: `./migrations`).
38
- - `Enter Spanner instance name`: The name of the Spanner instance.
39
- - `Enter Spanner database name`: The name of the Spanner database.
40
- - `Enter Google Cloud project name`: (Optional) The Google Cloud project name.
93
+ Displays help for the CLI or a specific command.
41
94
 
42
95
  ---
43
96
 
44
- #### `create <description>`
45
- Create a new migration file.
97
+ ## Programmatic Usage
46
98
 
47
- **Usage:**
99
+ In addition to the CLI, `spanner-migrate` can be used as a Node.js module to manage migrations programmatically.
48
100
 
49
- ```zsh
50
- spanner-migrate create add users table
101
+ ### Importing
102
+
103
+ ```typescript
104
+ import { init, create, up, down, status } from '@sebspark/spanner-migrate'
51
105
  ```
52
106
 
53
- #### `up`
54
- Apply pending migrations
107
+ ### Initializing Configuration
55
108
 
56
- **Usage:**
109
+ ```typescript
110
+ import { init, type Config } from '@sebspark/spanner-migrate'
57
111
 
58
- ```zsh
59
- spanner-migrate up
112
+ const config: Config = {
113
+ instance: {
114
+ name: 'my-instance',
115
+ databases: [
116
+ { name: 'mydb', migrationsPath: './migrations' },
117
+ ],
118
+ },
119
+ projectId: 'my-gcp-project',
120
+ }
121
+
122
+ await init(config, '.spanner-migrate.config.json')
60
123
  ```
61
124
 
62
- If you don't want to apply all pending migrations, use the `--max` or `-m` flag
125
+ Writes the given configuration to a `.spanner-migrate.config.json` file.
63
126
 
64
- ```zsh
65
- spanner migrate up --max 1
127
+ ### Creating a Migration
128
+
129
+ ```typescript
130
+ import { create, type DatabaseConfig } from '@sebspark/spanner-migrate'
131
+
132
+ const databaseConfig: DatabaseConfig = {
133
+ name: 'mydb',
134
+ migrationsPath: './migrations',
135
+ }
136
+
137
+ await create(databaseConfig, 'add users table')
66
138
  ```
67
139
 
68
- #### `down`
69
- Rollback one migration
140
+ Creates a new migration file for the specified database.
70
141
 
71
- **Usage:**
142
+ ### Applying Migrations
72
143
 
73
- ```zsh
74
- spanner-migrate down
144
+ ```typescript
145
+ import { up, type Config, type DatabaseConfig } from '@sebspark/spanner-migrate'
146
+
147
+ // Load configuration
148
+ const config: Config = /* Load from file or define inline */
149
+
150
+ // Apply all migrations to all databases
151
+ await up(config)
152
+
153
+ // Apply all migrations to a specific database
154
+ const databaseConfig: DatabaseConfig = config.instance.databases[0]
155
+ await up(config, databaseConfig)
156
+
157
+ // Apply up to 5 migrations to a specific database
158
+ await up(config, databaseConfig, 5)
75
159
  ```
76
160
 
77
- #### `status`
78
- Check migration status
161
+ - Applies pending migrations.
162
+ - If a database is specified, only applies migrations to that database.
163
+ - If `max` is specified, applies at most `max` migrations.
79
164
 
80
- **Usage:**
165
+ ### Rolling Back Migrations
81
166
 
82
- ```zsh
83
- spanner-migrate status
167
+ ```typescript
168
+ import { up, type Config, type DatabaseConfig } from '@sebspark/spanner-migrate'
169
+
170
+ const config: Config = /* Load from file */
171
+ const databaseConfig: DatabaseConfig = config.instance.databases[0]
172
+
173
+ // Roll back the last applied migration
174
+ await down(config, databaseConfig)
84
175
  ```
85
- Displays an overview of applied and peding migrations
86
176
 
87
- ```text
88
- Migrations
177
+ - Rolls back the last applied migration for the specified database.
178
+ - Requires a database to be specified.
179
+
180
+ ### Checking Migration Status
89
181
 
90
- Applied
91
- --------------------------------------------------------------------------------
92
- 20250122080434866_add_users_table
93
- 20250122080444982_add_index_on_users
182
+ ```typescript
183
+ import { up, type Config, type DatabaseConfig } from '@sebspark/spanner-migrate'
94
184
 
95
- New
96
- --------------------------------------------------------------------------------
97
- 20250122080444982_add_index_on_users
185
+ const config: Config = /* Load from file */
186
+
187
+ // Check status for all databases
188
+ const migrationStatus = await status(config)
189
+ console.log(migrationStatus)
190
+
191
+ // Check status for a specific database
192
+ const databaseConfig = config.instance.databases[0]
193
+ const migrationStatusSingle = await status(config, [databaseConfig])
194
+ console.log(migrationStatusSingle)
98
195
  ```
99
196
 
100
- ---
197
+ - Displays applied and pending migrations for one or more databases.
198
+ - If a specific database is provided, only its status is shown.
199
+
200
+ ## Running on Spanner Emulator
201
+
202
+ If you want to test your migrations against a Spanner Emulator, you will need to set:
203
+
204
+ ```typescript
205
+ process.env.SPANNER_EMULATOR_HOST = 'localhost:<port>'
206
+ ```
101
207
 
102
208
  ## License
103
209
 
@@ -38,7 +38,7 @@ var applyDown = async (db) => {
38
38
  json: true
39
39
  };
40
40
  const [rows] = await db.run(req);
41
- const lastMigration = rows == null ? void 0 : rows[0];
41
+ const lastMigration = rows?.[0];
42
42
  if (!lastMigration) {
43
43
  throw new Error("No migrations found to roll back.");
44
44
  }
@@ -70,12 +70,11 @@ var runScript = async (db, script) => {
70
70
  }
71
71
  for (const statement of statements) {
72
72
  console.log(`Executing statement: ${statement}`);
73
- const sql = statement.replace(/--.*$/gm, "");
74
- if (isSchemaChange(sql)) {
75
- await db.updateSchema(sql);
73
+ if (isSchemaChange(statement)) {
74
+ await db.updateSchema(statement);
76
75
  } else {
77
76
  await db.runTransactionAsync(async (transaction) => {
78
- await transaction.runUpdate(sql);
77
+ await transaction.runUpdate(statement);
79
78
  await transaction.commit();
80
79
  });
81
80
  }
@@ -101,12 +100,12 @@ var SQL_CREATE_TABLE_MIGRATIONS = `
101
100
  down STRING(1024)
102
101
  ) PRIMARY KEY (id)
103
102
  `;
104
- var ensureMigrationTable = async (database) => {
105
- const [rows] = await database.run(SQL_SELECT_TABLE_MIGRATIONS);
103
+ var ensureMigrationTable = async (db) => {
104
+ const [rows] = await db.run(SQL_SELECT_TABLE_MIGRATIONS);
106
105
  if (rows.length) return;
107
106
  console.log("Creating migration table");
108
107
  try {
109
- await database.updateSchema(SQL_CREATE_TABLE_MIGRATIONS);
108
+ await db.updateSchema(SQL_CREATE_TABLE_MIGRATIONS);
110
109
  } catch (err) {
111
110
  console.error("Failed to create migrations table");
112
111
  throw err;
@@ -132,12 +131,12 @@ var getAppliedMigrations = async (db) => {
132
131
  };
133
132
 
134
133
  // src/files.ts
135
- import { access, mkdir, readdir, writeFile } from "node:fs/promises";
134
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
136
135
  import { join, resolve } from "node:path";
137
136
  var getMigrationFiles = async (path) => {
138
137
  try {
139
138
  const files = await readdir(path);
140
- const migrationFileIds = files.filter((file) => file.endsWith(".ts")).map((file) => file.replace(/\.ts$/, ""));
139
+ const migrationFileIds = files.filter((file) => file.endsWith(".sql")).map((file) => file.replace(/\.sql$/, ""));
141
140
  return migrationFileIds;
142
141
  } catch (error) {
143
142
  throw new Error(
@@ -147,35 +146,39 @@ var getMigrationFiles = async (path) => {
147
146
  };
148
147
  var getMigration = async (path, id) => {
149
148
  try {
150
- const filePath = resolve(process.cwd(), join(path, `${id}.ts`));
149
+ const filePath = resolve(process.cwd(), join(path, `${id}.sql`));
151
150
  try {
152
151
  await access(filePath);
153
152
  } catch (err) {
154
153
  throw new Error(`Migration file not found: ${filePath}`);
155
154
  }
156
- const migrationModule = await import(filePath);
157
- if (!migrationModule.up || !migrationModule.down) {
155
+ const migrationText = await readFile(filePath, "utf8");
156
+ const up2 = getSql(migrationText, "up");
157
+ const down2 = getSql(migrationText, "down");
158
+ const description = getDescription(migrationText);
159
+ if (!up2 || !down2) {
158
160
  throw new Error(
159
161
  `Migration file ${filePath} does not export required scripts (up, down).`
160
162
  );
161
163
  }
162
- return {
163
- id,
164
- description: id.split("_").slice(1).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "),
165
- // Generate a human-readable description
166
- up: migrationModule.up,
167
- down: migrationModule.down
168
- };
164
+ return { id, description, up: up2, down: down2 };
169
165
  } catch (error) {
170
166
  throw new Error(
171
167
  `Failed to get migration ${id}: ${error.message}`
172
168
  );
173
169
  }
174
170
  };
171
+ var getDescription = (text) => text?.match(/^--\s*Description:\s*(.+)$/m)?.[1]?.trim() || "";
172
+ var getSql = (text, direction) => {
173
+ const rx = {
174
+ up: /---- UP ----\n([\s\S]*?)\n---- DOWN ----/,
175
+ down: /---- DOWN ----\n([\s\S]*)$/
176
+ };
177
+ return text?.match(rx[direction])?.[1]?.replace(/--.*$/gm, "").trim();
178
+ };
175
179
  var getNewMigrations = (applied, files) => {
176
180
  const sortedFiles = files.sort();
177
181
  for (let ix = 0; ix < applied.length; ix++) {
178
- console.log(sortedFiles[ix], applied[ix].id);
179
182
  if (sortedFiles[ix] !== applied[ix].id) {
180
183
  throw new Error(
181
184
  `Mismatch between applied migrations and files. Found '${sortedFiles[ix]}' but expected '${applied[ix].id}' at position ${ix}.`
@@ -183,25 +186,24 @@ var getNewMigrations = (applied, files) => {
183
186
  }
184
187
  }
185
188
  const newMigrations = sortedFiles.slice(applied.length);
186
- console.log(`Found ${newMigrations.length} new migrations.`);
187
189
  return newMigrations;
188
190
  };
189
191
  var createMigration = async (path, description) => {
190
192
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
191
193
  const compactTimestamp = timestamp.replace(/[-:.TZ]/g, "");
192
194
  const parsedDescription = description.replace(/\s+/g, "_").toLowerCase();
193
- const filename = `${compactTimestamp}_${parsedDescription}.ts`;
195
+ const filename = `${compactTimestamp}_${parsedDescription}.sql`;
194
196
  const filePath = join(path, filename);
195
- const template = `// ${timestamp}
196
- // ${description}
197
+ const template = `-- Created: ${timestamp}
198
+ -- Description: ${description}
199
+
200
+ ---- UP ----
201
+
202
+
203
+
204
+ ---- DOWN ----
197
205
 
198
- export const up = \`
199
- -- SQL for migrate up
200
- \`
201
206
 
202
- export const down = \`
203
- -- SQL for migrate down
204
- \`
205
207
  `;
206
208
  try {
207
209
  await mkdir(path, { recursive: true });
@@ -234,42 +236,74 @@ var init = async (config, configPath) => {
234
236
  var create = async (config, description) => {
235
237
  await createMigration(config.migrationsPath, description);
236
238
  };
237
- var up = async (config, max = 1e3) => {
238
- const db = getDb(config);
239
- await ensureMigrationTable(db);
240
- const appliedMigrations = await getAppliedMigrations(db);
241
- const migrationFiles = await getMigrationFiles(config.migrationsPath);
242
- const newMigrations = getNewMigrations(appliedMigrations, migrationFiles);
243
- console.log(`Found ${newMigrations.length} new migrations.`);
244
- console.log(newMigrations.map((mig) => ` ${mig}`).join("\n"));
245
- for (const id of newMigrations.slice(0, max)) {
246
- const migration = await getMigration(config.migrationsPath, id);
247
- await applyUp(db, migration);
239
+ var up = async (config, database, max) => {
240
+ if (max && !database) {
241
+ throw new Error("Max number of migrations requires specifying a database");
242
+ }
243
+ const databases = database ? [database] : config.instance.databases;
244
+ for (const databaseConfig of databases) {
245
+ const path = {
246
+ projectId: config.projectId,
247
+ instanceName: config.instance.name,
248
+ databaseName: databaseConfig.name
249
+ };
250
+ const db = getDb(path);
251
+ await ensureMigrationTable(db);
252
+ const appliedMigrations = await getAppliedMigrations(db);
253
+ const migrationFiles = await getMigrationFiles(
254
+ databaseConfig.migrationsPath
255
+ );
256
+ const newMigrations = getNewMigrations(appliedMigrations, migrationFiles);
257
+ console.log(`Found ${newMigrations.length} new migrations.`);
258
+ console.log(newMigrations.map((mig) => ` ${mig}`).join("\n"));
259
+ const newMigrationsToApply = max ? newMigrations.slice(0, max) : newMigrations;
260
+ for (const id of newMigrationsToApply) {
261
+ const migration = await getMigration(databaseConfig.migrationsPath, id);
262
+ await applyUp(db, migration);
263
+ }
248
264
  }
249
265
  };
250
- var down = async (config) => {
251
- const db = getDb(config);
266
+ var down = async (config, database) => {
267
+ const path = {
268
+ projectId: config.projectId,
269
+ instanceName: config.instance.name,
270
+ databaseName: database.name
271
+ };
272
+ const db = getDb(path);
252
273
  await ensureMigrationTable(db);
253
274
  await applyDown(db);
254
275
  };
255
- var status = async (config) => {
256
- const db = getDb(config);
257
- await ensureMigrationTable(db);
258
- const appliedMigrations = await getAppliedMigrations(db);
259
- const migrationFiles = await getMigrationFiles(config.migrationsPath);
260
- const newMigrations = getNewMigrations(appliedMigrations, migrationFiles);
261
- return [
262
- "Migrations",
263
- "",
264
- "Applied",
265
- "--------------------------------------------------------------------------------",
266
- `${appliedMigrations.map((m) => m.id).join("\n")}
276
+ var status = async (config, databases) => {
277
+ const statuses = [];
278
+ for (const databaseConfig of databases || config.instance.databases) {
279
+ const path = {
280
+ projectId: config.projectId,
281
+ instanceName: config.instance.name,
282
+ databaseName: databaseConfig.name
283
+ };
284
+ const db = getDb(path);
285
+ await ensureMigrationTable(db);
286
+ const appliedMigrations = await getAppliedMigrations(db);
287
+ const migrationFiles = await getMigrationFiles(
288
+ databaseConfig.migrationsPath
289
+ );
290
+ const newMigrations = getNewMigrations(appliedMigrations, migrationFiles);
291
+ statuses.push(
292
+ [
293
+ `Migrations [${databaseConfig.name}]`,
294
+ "",
295
+ "Applied",
296
+ "--------------------------------------------------------------------------------",
297
+ `${appliedMigrations.map((m) => m.id).join("\n")}
267
298
  `,
268
- "New",
269
- "--------------------------------------------------------------------------------",
270
- `${newMigrations.join("\n")}
299
+ "New",
300
+ "--------------------------------------------------------------------------------",
301
+ `${newMigrations.join("\n")}
271
302
  `
272
- ].join("\n");
303
+ ].join("\n")
304
+ );
305
+ }
306
+ return statuses.join("\n\n");
273
307
  };
274
308
 
275
309
  export {