spooder 4.3.2 → 4.4.1

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 (3) hide show
  1. package/README.md +43 -26
  2. package/package.json +4 -1
  3. package/src/api.ts +137 -53
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
 
@@ -1329,13 +1327,52 @@ const cookies = get_cookies(req, true);
1329
1327
 
1330
1328
  `spooder` provides a straightforward API to manage database schema in revisions through source control.
1331
1329
 
1330
+ Database schema is updated with `db_update_schema_DRIVER` where `DRIVER` corresponds to the database driver being used.
1331
+
1332
1332
  > [!NOTE]
1333
- > 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.
1334
+
1335
+ ```ts
1336
+ // sqlite example
1337
+ import { db_update_schema_sqlite } from 'spooder';
1338
+ import { Database } from 'bun:sqlite';
1339
+
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.
1334
1357
 
1335
- <a id="api-database-schema-db-update-schema-sqlite"></a>
1336
- ### 🔧 `db_update_schema_sqlite(db: Database, schema: string): Promise<void>`
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.
1337
1359
 
1338
- `db_update_schema_sqlite` takes a [`Database`](https://bun.sh/docs/api/sqlite) instance and a schema directory.
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
1339
1376
 
1340
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.
1341
1378
 
@@ -1408,26 +1445,6 @@ try {
1408
1445
  }
1409
1446
  ```
1410
1447
 
1411
- <a id="api-database-schema-db-init-schema-sqlite"></a>
1412
- ### 🔧 `db_init_schema_sqlite(db_path: string, schema: string): Promise<Database>`
1413
-
1414
- `db_init_schema_sqlite` exists as a convenience function to create a new database and apply the schema in one step.
1415
-
1416
- ```ts
1417
- import { db_init_schema_sqlite } from 'spooder';
1418
- const db = await db_init_schema_sqlite('./database.sqlite', './schema');
1419
- ```
1420
-
1421
- The above is equivalent to the following.
1422
-
1423
- ```ts
1424
- import { db_update_schema_sqlite } from 'spooder';
1425
- import { Database } from 'bun:sqlite';
1426
-
1427
- const db = new Database('./database.sqlite', { create: true });
1428
- await db_update_schema_sqlite(db, './schema');
1429
- ```
1430
-
1431
1448
  ## Legal
1432
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.
1433
1450
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "4.3.2",
4
+ "version": "4.4.1",
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.ConnectionOptions, 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,