spooder 4.3.1 → 4.4.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.
Files changed (4) hide show
  1. package/README.md +47 -26
  2. package/package.json +4 -1
  3. package/src/api.ts +137 -53
  4. package/src/cli.ts +6 -3
package/README.md CHANGED
@@ -71,8 +71,6 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
71
71
  - [`set_cookie(res: Response, name: string, value: string, options?: CookieOptions)`](#api-state-management-set-cookie)
72
72
  - [`get_cookies(source: Request | Response): Record<string, string>`](#api-state-management-get-cookies)
73
73
  - [API > Database Schema](#api-database-schema)
74
- - [`db_update_schema_sqlite(db: Database, schema: string): Promise<void>`](#api-database-schema-db-update-schema-sqlite)
75
- - [`db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`](#api-database-schema-db-init-schema-sqlite)
76
74
 
77
75
  # Installation
78
76
 
@@ -225,6 +223,10 @@ server.webhook(process.env.WEBHOOK_SECRET, '/webhook', payload => {
225
223
  });
226
224
  ```
227
225
 
226
+ ### Skip Updates
227
+
228
+ In addition to being skipped in [dev mode](#cli-dev-mode), updates can also be skipped in production mode by passing the `--no-update` flag.
229
+
228
230
  <a id="cli-canary"></a>
229
231
  ## CLI > Canary
230
232
 
@@ -1325,13 +1327,52 @@ const cookies = get_cookies(req, true);
1325
1327
 
1326
1328
  `spooder` provides a straightforward API to manage database schema in revisions through source control.
1327
1329
 
1330
+ Database schema is updated with `db_update_schema_DRIVER` where `DRIVER` corresponds to the database driver being used.
1331
+
1328
1332
  > [!NOTE]
1329
- > Currently, only SQLite is supported. This may be expanded once Bun supports more database drivers.
1333
+ > Currently, only SQLite and MySQL are supported. This may be expanded once Bun supports more database drivers.
1330
1334
 
1331
- <a id="api-database-schema-db-update-schema-sqlite"></a>
1332
- ### 🔧 `db_update_schema_sqlite(db: Database, schema: string): Promise<void>`
1335
+ ```ts
1336
+ // sqlite example
1337
+ import { db_update_schema_sqlite } from 'spooder';
1338
+ import { Database } from 'bun:sqlite';
1333
1339
 
1334
- `db_update_schema_sqlite` takes a [`Database`](https://bun.sh/docs/api/sqlite) instance and a schema directory.
1340
+ const db = new Database('./database.sqlite');
1341
+ await db_update_schema_sqlite(db, './schema');
1342
+ ```
1343
+
1344
+ ```ts
1345
+ // mysql example
1346
+ import { db_update_schema_mysql } from 'spooder';
1347
+ import mysql from 'mysql2';
1348
+
1349
+ const db = await mysql.createConnection({
1350
+ // connection options
1351
+ // see https://github.com/mysqljs/mysql#connection-options
1352
+ })
1353
+ ```
1354
+
1355
+ > [!IMPORTANT]
1356
+ > MySQL requires the optional dependency `mysql2` to be installed - this is not automatically installed with spooder. This will be replaced when bun:sql supports MySQL natively.
1357
+
1358
+ Database initiation and schema updating can be streamlined with the `db_init_schema_DRIVER` functions. The following examples are equivalent to the above ones.
1359
+
1360
+ ```ts
1361
+ // sqlite example
1362
+ import { db_init_schema_sqlite } from 'spooder';
1363
+ const db = await db_init_schema_sqlite('./database.sqlite', './schema');
1364
+ ```
1365
+
1366
+ ```ts
1367
+ // mysql example
1368
+ import { db_init_schema_mysql } from 'spooder';
1369
+ const db = await db_init_schema_mysql({
1370
+ // connection options
1371
+ // see https://github.com/mysqljs/mysql#connection-options
1372
+ }, './schema');
1373
+ ```
1374
+
1375
+ ### Schema Files
1335
1376
 
1336
1377
  The schema directory is expected to contain an SQL file for each table in the database with the file name matching the name of the table.
1337
1378
 
@@ -1404,26 +1445,6 @@ try {
1404
1445
  }
1405
1446
  ```
1406
1447
 
1407
- <a id="api-database-schema-db-init-schema-sqlite"></a>
1408
- ### 🔧 `db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`
1409
-
1410
- `db_init_schema_sqlite` exists as a convenience function to create a new database and apply the schema in one step.
1411
-
1412
- ```ts
1413
- import { db_init_schema_sqlite } from 'spooder';
1414
- const db = await db_init_schema_sqlite('./database.sqlite', './schema');
1415
- ```
1416
-
1417
- The above is equivalent to the following.
1418
-
1419
- ```ts
1420
- import { db_update_schema_sqlite } from 'spooder';
1421
- import { Database } from 'bun:sqlite';
1422
-
1423
- const db = new Database('./database.sqlite', { create: true });
1424
- await db_update_schema_sqlite(db, './schema');
1425
- ```
1426
-
1427
1448
  ## Legal
1428
1449
  This software is provided as-is with no warranty or guarantee. The authors of this project are not responsible or liable for any problems caused by using this software or any part thereof. Use of this software does not entitle you to any support or assistance from the authors of this project.
1429
1450
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "4.3.1",
4
+ "version": "4.4.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "bun": "./src/api.ts",
@@ -12,6 +12,9 @@
12
12
  "devDependencies": {
13
13
  "@types/bun": "^1.0.5"
14
14
  },
15
+ "optionalDependencies": {
16
+ "mysql2": "^3.11.0"
17
+ },
15
18
  "bin": {
16
19
  "spooder": "./src/cli.ts"
17
20
  }
package/src/api.ts CHANGED
@@ -6,6 +6,16 @@ import { log } from './utils';
6
6
  import crypto from 'crypto';
7
7
  import { Blob } from 'node:buffer';
8
8
  import { Database } from 'bun:sqlite';
9
+ import type * as mysql_types from 'mysql2/promise';
10
+
11
+ let mysql: typeof mysql_types | undefined;
12
+ try {
13
+ mysql = await import('mysql2/promise') as typeof mysql_types;
14
+ } catch (e) {
15
+ // mysql2 optional dependency not installed.
16
+ // this dependency will be replaced once bun:sql supports mysql.
17
+ // db_update_schema_mysql and db_init_schema_mysql will throw.
18
+ }
9
19
 
10
20
  export const HTTP_STATUS_CODE = http.STATUS_CODES;
11
21
 
@@ -207,6 +217,66 @@ export async function generate_hash_subs(length = 7, prefix = 'hash=', hashes?:
207
217
  }
208
218
 
209
219
  type Row_DBSchema = { db_schema_table_name: string, db_schema_version: number };
220
+ type SchemaVersionMap = Map<string, number>;
221
+
222
+ async function db_load_schema(schema_dir: string, schema_versions: SchemaVersionMap) {
223
+ const schema_out = [];
224
+ const schema_files = await fs.readdir(schema_dir);
225
+
226
+ for (const schema_file of schema_files) {
227
+ const schema_file_lower = schema_file.toLowerCase();
228
+ if (!schema_file_lower.endsWith('.sql'))
229
+ continue;
230
+
231
+ log('[{db}] parsing schema file {%s}', schema_file_lower);
232
+
233
+ const schema_name = path.basename(schema_file_lower, '.sql');
234
+ const schema_path = path.join(schema_dir, schema_file);
235
+ const schema = await fs.readFile(schema_path, 'utf8');
236
+
237
+ const revisions = new Map();
238
+ let current_rev_id = 0;
239
+ let current_rev = '';
240
+
241
+ for (const line of schema.split(/\r?\n/)) {
242
+ const rev_start = line.match(/^--\s*\[(\d+)\]/);
243
+ if (rev_start !== null) {
244
+ // New chunk definition detected, store the current chunk and start a new one.
245
+ if (current_rev_id > 0) {
246
+ revisions.set(current_rev_id, current_rev);
247
+ current_rev = '';
248
+ }
249
+
250
+ const rev_number = parseInt(rev_start[1]);
251
+ if (isNaN(rev_number) || rev_number < 1)
252
+ throw new Error(rev_number + ' is not a valid revision number in ' + schema_file_lower);
253
+ current_rev_id = rev_number;
254
+ } else {
255
+ // Append to existing revision.
256
+ current_rev += line + '\n';
257
+ }
258
+ }
259
+
260
+ // There may be something left in current_chunk once we reach end of the file.
261
+ if (current_rev_id > 0)
262
+ revisions.set(current_rev_id, current_rev);
263
+
264
+ if (revisions.size === 0) {
265
+ log('[{db}] {%s} contains no valid revisions', schema_file);
266
+ continue;
267
+ }
268
+
269
+ const current_schema_version = schema_versions.get(schema_name) ?? 0;
270
+ schema_out.push({
271
+ revisions,
272
+ name: schema_name,
273
+ current_version: current_schema_version,
274
+ chunk_keys: Array.from(revisions.keys()).filter(chunk_id => chunk_id > current_schema_version).sort((a, b) => a - b)
275
+ });
276
+ }
277
+
278
+ return schema_out;
279
+ }
210
280
 
211
281
  export async function db_update_schema_sqlite(db: Database, schema_dir: string) {
212
282
  log('[{db}] updating database schema for {%s}', db.filename);
@@ -228,67 +298,68 @@ export async function db_update_schema_sqlite(db: Database, schema_dir: string)
228
298
  ON CONFLICT(db_schema_table_name) DO UPDATE SET db_schema_version = EXCLUDED.db_schema_version
229
299
  `);
230
300
 
231
- const schema_files = await fs.readdir(schema_dir);
232
- for (const schema_file of schema_files) {
233
- const schema_file_lower = schema_file.toLowerCase();
234
- if (!schema_file_lower.endsWith('.sql'))
235
- continue;
236
-
237
- log('[{db}] parsing schema file {%s}', schema_file_lower);
238
-
239
- const schema_name = path.basename(schema_file_lower, '.sql');
240
- const schema_path = path.join(schema_dir, schema_file);
241
- const schema = await fs.readFile(schema_path, 'utf8');
242
-
243
- const revisions = new Map();
244
- let current_rev_id = 0;
245
- let current_rev = '';
246
-
247
- for (const line of schema.split(/\r?\n/)) {
248
- const rev_start = line.match(/^--\s*\[(\d+)\]/);
249
- if (rev_start !== null) {
250
- // New chunk definition detected, store the current chunk and start a new one.
251
- if (current_rev_id > 0) {
252
- revisions.set(current_rev_id, current_rev);
253
- current_rev = '';
254
- }
301
+ const schemas = await db_load_schema(schema_dir, schema_versions);
255
302
 
256
- const rev_number = parseInt(rev_start[1]);
257
- if (isNaN(rev_number) || rev_number < 1)
258
- throw new Error(rev_number + ' is not a valid revision number in ' + schema_file_lower);
259
- current_rev_id = rev_number;
260
- } else {
261
- // Append to existing revision.
262
- current_rev += line + '\n';
263
- }
303
+ for (const schema of schemas) {
304
+ let newest_schema_version = schema.current_version;
305
+ for (const rev_id of schema.chunk_keys) {
306
+ const revision = schema.revisions.get(rev_id);
307
+ log('[{db}] applying revision {%d} to {%s}', rev_id, schema.name);
308
+ db.transaction(() => db.run(revision))();
309
+ newest_schema_version = rev_id;
264
310
  }
311
+
312
+ if (newest_schema_version > schema.current_version) {
313
+ log('[{db}] updated table {%s} to revision {%d}', schema.name, newest_schema_version);
314
+ update_schema_query.run(newest_schema_version, schema.name);
315
+ }
316
+ }
317
+ })();
318
+ }
265
319
 
266
- // There may be something left in current_chunk once we reach end of the file.
267
- if (current_rev_id > 0)
268
- revisions.set(current_rev_id, current_rev);
320
+ export async function db_update_schema_mysql(db: mysql_types.Connection, schema_dir: string) {
321
+ if (mysql === undefined)
322
+ throw new Error('{db_update_schema_mysql} cannot be called without optional dependency {mysql2} installed');
269
323
 
270
- if (revisions.size === 0) {
271
- log('[{db}] {%s} contains no valid revisions', schema_file);
272
- continue;
273
- }
324
+ log('[{db}] updating database schema for {%s}', db.config.database);
274
325
 
275
- const current_schema_version = schema_versions.get(schema_name) ?? 0;
276
- const chunk_keys = Array.from(revisions.keys()).filter(chunk_id => chunk_id > current_schema_version).sort((a, b) => a - b);
326
+ const schema_versions = new Map();
277
327
 
278
- let newest_schema_version = current_schema_version;
279
- for (const rev_id of chunk_keys) {
280
- const revision = revisions.get(rev_id);
281
- log('[{db}] applying revision {%d} to {%s}', rev_id, schema_name);
282
- db.transaction(() => db.run(revision))();
283
- newest_schema_version = rev_id;
284
- }
328
+ try {
329
+ const [rows] = await db.query('SELECT db_schema_table_name, db_schema_version FROM db_schema');
330
+ for (const row of rows as Array<Row_DBSchema>)
331
+ schema_versions.set(row.db_schema_table_name, row.db_schema_version);
332
+ } catch (e) {
333
+ log('[{db}] creating {db_schema} table');
334
+ await db.query('CREATE TABLE db_schema (db_schema_table_name VARCHAR(255) PRIMARY KEY, db_schema_version INT)');
335
+ }
285
336
 
286
- if (newest_schema_version > current_schema_version) {
287
- log('[{db}] updated table {%s} to revision {%d}', schema_name, newest_schema_version);
288
- update_schema_query.run(newest_schema_version, schema_name);
289
- }
337
+ await db.beginTransaction();
338
+
339
+ const update_schema_query = await db.prepare(`
340
+ INSERT INTO db_schema (db_schema_version, db_schema_table_name) VALUES (?, ?)
341
+ ON DUPLICATE KEY UPDATE db_schema_version = VALUES(db_schema_version);
342
+ `);
343
+
344
+ const schemas = await db_load_schema(schema_dir, schema_versions);
345
+ for (const schema of schemas) {
346
+ let newest_schema_version = schema.current_version;
347
+ for (const rev_id of schema.chunk_keys) {
348
+ const revision = schema.revisions.get(rev_id);
349
+ log('[{db}] applying revision {%d} to {%s}', rev_id, schema.name);
350
+
351
+ await db.query(revision);
352
+ newest_schema_version = rev_id;
290
353
  }
291
- })();
354
+
355
+ if (newest_schema_version > schema.current_version) {
356
+ log('[{db}] updated table {%s} to revision {%d}', schema.name, newest_schema_version);
357
+
358
+ await update_schema_query.execute([newest_schema_version, schema.name]);
359
+ }
360
+ }
361
+
362
+ await db.commit();
292
363
  }
293
364
 
294
365
  export async function db_init_schema_sqlite(db_path: string, schema_dir: string): Promise<Database> {
@@ -297,6 +368,19 @@ export async function db_init_schema_sqlite(db_path: string, schema_dir: string)
297
368
  return db;
298
369
  }
299
370
 
371
+ export async function db_init_schema_mysql(db_info: mysql_types.ConnectionConfig, schema_dir: string): Promise<mysql_types.Connection> {
372
+ if (mysql === undefined)
373
+ throw new Error('{db_init_schema_mysql} cannot be called without optional dependency {mysql2} installed');
374
+
375
+ // required for parsing multiple statements from schema files
376
+ db_info.multipleStatements = true;
377
+
378
+ const db = await mysql.createConnection(db_info);
379
+ await db_update_schema_mysql(db, schema_dir);
380
+
381
+ return db;
382
+ }
383
+
300
384
  type CookieOptions = {
301
385
  same_site?: 'Strict' | 'Lax' | 'None',
302
386
  secure?: boolean,
package/src/cli.ts CHANGED
@@ -8,13 +8,18 @@ async function start_server() {
8
8
 
9
9
  const argv = process.argv.slice(2);
10
10
  const is_dev_mode = argv.includes('--dev');
11
+ const skip_updates = argv.includes('--no-update');
11
12
 
12
13
  if (is_dev_mode)
13
14
  log('[{dev}] spooder has been started in {dev mode}');
14
15
 
15
16
  const config = await get_config();
16
17
 
17
- if (!is_dev_mode) {
18
+ if (is_dev_mode) {
19
+ log('[{update}] skipping update commands in {dev mode}');
20
+ } else if (skip_updates) {
21
+ log('[{update}] skipping update commands due to {--no-update} flag');
22
+ } else {
18
23
  const update_commands = config.update;
19
24
  const n_update_commands = update_commands.length;
20
25
 
@@ -42,8 +47,6 @@ async function start_server() {
42
47
  }
43
48
  }
44
49
  }
45
- } else {
46
- log('[{dev}] skipping update commands in {dev mode}');
47
50
  }
48
51
 
49
52
  const crash_console_history = config.canary.crash_console_history;