@axium/server 0.27.0 → 0.28.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.
package/dist/database.js CHANGED
@@ -55,10 +55,14 @@ import { plugins } from '@axium/core/plugins';
55
55
  import { Kysely, PostgresDialect, sql } from 'kysely';
56
56
  import { jsonObjectFrom } from 'kysely/helpers/postgres';
57
57
  import { randomBytes } from 'node:crypto';
58
- import { readFileSync, writeFileSync } from 'node:fs';
58
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
59
+ import { join } from 'node:path/posix';
60
+ import { styleText } from 'node:util';
59
61
  import pg from 'pg';
62
+ import * as z from 'zod';
60
63
  import config from './config.js';
61
- import { styleText } from 'node:util';
64
+ import rawSchema from './db.json' with { type: 'json' };
65
+ import { dirs, systemDir } from './io.js';
62
66
  const sym = Symbol.for('Axium:database');
63
67
  export let database;
64
68
  export function connect() {
@@ -157,26 +161,16 @@ export async function getHBA(opt) {
157
161
  };
158
162
  return [content, writeBack];
159
163
  }
160
- const pgHba = `
164
+ /** @internal @hidden */
165
+ export const _pgHba = `
161
166
  local axium axium md5
162
167
  host axium axium 127.0.0.1/32 md5
163
168
  host axium axium ::1/128 md5
164
169
  `;
165
- const _sql = (command, message) => io.run(message, `sudo -u postgres psql -c "${command}"`);
170
+ /** @internal @hidden */
171
+ export const _sql = (command, message) => io.run(message, `sudo -u postgres psql -c "${command}"`);
166
172
  /** Shortcut to output a warning if an error is thrown because relation already exists */
167
173
  export const warnExists = io.someWarnings([/\w+ "[\w.]+" already exists/, 'already exists.']);
168
- const throwUnlessRows = (text) => {
169
- if (text.includes('(0 rows)'))
170
- throw 'missing.';
171
- return text;
172
- };
173
- export async function createIndex(table, column, mod) {
174
- io.start(`Creating index for ${table}.${column}`);
175
- let query = database.schema.createIndex(`${table}_${column}_index`).on(table).column(column);
176
- if (mod)
177
- query = mod(query);
178
- await query.execute().then(io.done).catch(warnExists);
179
- }
180
174
  export async function init(opt) {
181
175
  const env_1 = { stack: [], error: void 0, hasError: false };
182
176
  try {
@@ -210,11 +204,11 @@ export async function init(opt) {
210
204
  await getHBA(opt)
211
205
  .then(([content, writeBack]) => {
212
206
  io.start('Checking for Axium HBA configuration');
213
- if (content.includes(pgHba))
207
+ if (content.includes(_pgHba))
214
208
  throw 'already exists.';
215
209
  io.done();
216
210
  io.start('Adding Axium HBA configuration');
217
- const newContent = content.replace(/^local\s+all\s+all.*$/m, `$&\n${pgHba}`);
211
+ const newContent = content.replace(/^local\s+all\s+all.*$/m, `$&\n${_pgHba}`);
218
212
  io.done();
219
213
  writeBack(newContent);
220
214
  })
@@ -223,91 +217,8 @@ export async function init(opt) {
223
217
  io.start('Connecting to database');
224
218
  const _ = __addDisposableResource(env_1, connect(), true);
225
219
  io.done();
226
- function maybeCheck(table) {
227
- return (e) => {
228
- warnExists(e);
229
- if (opt.check)
230
- return checkTableTypes(table, expectedTypes[table], opt);
231
- };
232
- }
233
- io.start('Creating table users');
234
- await database.schema
235
- .createTable('users')
236
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
237
- .addColumn('name', 'text')
238
- .addColumn('email', 'text', col => col.unique().notNull())
239
- .addColumn('emailVerified', 'timestamptz')
240
- .addColumn('image', 'text')
241
- .addColumn('isAdmin', 'boolean', col => col.notNull().defaultTo(false))
242
- .addColumn('roles', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
243
- .addColumn('tags', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
244
- .addColumn('preferences', 'jsonb', col => col.notNull().defaultTo('{}'))
245
- .addColumn('registeredAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
246
- .addColumn('isSuspended', 'boolean', col => col.notNull().defaultTo(false))
247
- .execute()
248
- .then(io.done)
249
- .catch(maybeCheck('users'));
250
- io.start('Creating table sessions');
251
- await database.schema
252
- .createTable('sessions')
253
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
254
- .addColumn('userId', 'uuid', col => col.references('users.id').onDelete('cascade').notNull())
255
- .addColumn('token', 'text', col => col.notNull().unique())
256
- .addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
257
- .addColumn('expires', 'timestamptz', col => col.notNull())
258
- .addColumn('elevated', 'boolean', col => col.notNull())
259
- .execute()
260
- .then(io.done)
261
- .catch(maybeCheck('sessions'));
262
- await createIndex('sessions', 'id');
263
- io.start('Creating table verifications');
264
- await database.schema
265
- .createTable('verifications')
266
- .addColumn('userId', 'uuid', col => col.references('users.id').onDelete('cascade').notNull())
267
- .addColumn('token', 'text', col => col.notNull().unique())
268
- .addColumn('expires', 'timestamptz', col => col.notNull())
269
- .addColumn('role', 'text', col => col.notNull())
270
- .execute()
271
- .then(io.done)
272
- .catch(maybeCheck('verifications'));
273
- io.start('Creating table passkeys');
274
- await database.schema
275
- .createTable('passkeys')
276
- .addColumn('id', 'text', col => col.primaryKey().notNull())
277
- .addColumn('name', 'text')
278
- .addColumn('createdAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
279
- .addColumn('userId', 'uuid', col => col.notNull().references('users.id').onDelete('cascade').notNull())
280
- .addColumn('publicKey', 'bytea', col => col.notNull())
281
- .addColumn('counter', 'integer', col => col.notNull())
282
- .addColumn('deviceType', 'text', col => col.notNull())
283
- .addColumn('backedUp', 'boolean', col => col.notNull())
284
- .addColumn('transports', sql `text[]`)
285
- .execute()
286
- .then(io.done)
287
- .catch(maybeCheck('passkeys'));
288
- await createIndex('passkeys', 'userId');
289
- io.start('Creating table audit_log');
290
- await database.schema
291
- .createTable('audit_log')
292
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
293
- .addColumn('timestamp', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
294
- .addColumn('userId', 'uuid')
295
- .addColumn('severity', 'integer', col => col.notNull())
296
- .addColumn('name', 'text', col => col.notNull())
297
- .addColumn('source', 'text', col => col.notNull())
298
- .addColumn('tags', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
299
- .addColumn('extra', 'jsonb', col => col.notNull().defaultTo('{}'))
300
- .execute()
301
- .then(io.done)
302
- .catch(maybeCheck('audit_log'));
303
220
  io.start('Creating schema acl');
304
221
  await database.schema.createSchema('acl').execute().then(io.done).catch(warnExists);
305
- for (const plugin of plugins.values()) {
306
- if (!plugin._hooks?.db_init)
307
- continue;
308
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
309
- await plugin._hooks?.db_init(opt);
310
- }
311
222
  }
312
223
  catch (e_1) {
313
224
  env_1.error = e_1;
@@ -319,85 +230,535 @@ export async function init(opt) {
319
230
  await result_1;
320
231
  }
321
232
  }
322
- export const expectedTypes = {
323
- users: {
324
- email: { type: 'text', required: true },
325
- emailVerified: { type: 'timestamptz' },
326
- id: { type: 'uuid', required: true, hasDefault: true },
327
- image: { type: 'text' },
328
- isAdmin: { type: 'bool', required: true, hasDefault: true },
329
- name: { type: 'text' },
330
- preferences: { type: 'jsonb', required: true, hasDefault: true },
331
- registeredAt: { type: 'timestamptz', required: true, hasDefault: true },
332
- roles: { type: '_text', required: true, hasDefault: true },
333
- tags: { type: '_text', required: true, hasDefault: true },
334
- isSuspended: { type: 'bool', required: true, hasDefault: true },
335
- },
336
- verifications: {
337
- userId: { type: 'uuid', required: true },
338
- token: { type: 'text', required: true },
339
- expires: { type: 'timestamptz', required: true },
340
- role: { type: 'text', required: true },
341
- },
342
- passkeys: {
343
- id: { type: 'text', required: true },
344
- name: { type: 'text' },
345
- createdAt: { type: 'timestamptz', required: true, hasDefault: true },
346
- userId: { type: 'uuid', required: true },
347
- publicKey: { type: 'bytea', required: true },
348
- counter: { type: 'int4', required: true },
349
- deviceType: { type: 'text', required: true },
350
- backedUp: { type: 'bool', required: true },
351
- transports: { type: '_text' },
352
- },
353
- sessions: {
354
- id: { type: 'uuid', required: true, hasDefault: true },
355
- userId: { type: 'uuid', required: true },
356
- created: { type: 'timestamptz', required: true, hasDefault: true },
357
- token: { type: 'text', required: true },
358
- expires: { type: 'timestamptz', required: true },
359
- elevated: { type: 'bool', required: true },
360
- },
361
- audit_log: {
362
- userId: { type: 'uuid' },
363
- timestamp: { type: 'timestamptz', required: true, hasDefault: true },
364
- id: { type: 'uuid', required: true, hasDefault: true },
365
- severity: { type: 'int4', required: true },
366
- name: { type: 'text', required: true },
367
- source: { type: 'text', required: true },
368
- tags: { type: '_text', required: true, hasDefault: true },
369
- extra: { type: 'jsonb', required: true, hasDefault: true },
370
- },
233
+ export const Column = z.strictObject({
234
+ type: z.string(),
235
+ required: z.boolean().default(false),
236
+ unique: z.boolean().default(false),
237
+ primary: z.boolean().default(false),
238
+ references: z.string().optional(),
239
+ onDelete: z.enum(['cascade', 'restrict', 'no action', 'set null', 'set default']).optional(),
240
+ default: z.any().optional(),
241
+ check: z.string().optional(),
242
+ });
243
+ export const Constraint = z.discriminatedUnion('type', [
244
+ z.strictObject({
245
+ type: z.literal('primary_key'),
246
+ on: z.string().array(),
247
+ }),
248
+ z.strictObject({
249
+ type: z.literal('foreign_key'),
250
+ on: z.string().array(),
251
+ target: z.string(),
252
+ references: z.string().array(),
253
+ }),
254
+ z.strictObject({
255
+ type: z.literal('unique'),
256
+ on: z.string().array(),
257
+ nulls_not_distinct: z.boolean().optional(),
258
+ }),
259
+ z.strictObject({
260
+ type: z.literal('check'),
261
+ check: z.string(),
262
+ }),
263
+ ]);
264
+ export const Table = z.strictObject({
265
+ columns: z.record(z.string(), Column),
266
+ constraints: z.record(z.string(), Constraint).optional().default({}),
267
+ });
268
+ export const IndexString = z.templateLiteral([z.string(), ':', z.string()]);
269
+ export function parseIndex(value) {
270
+ const [table, column] = value.split(':');
271
+ return { table, column };
272
+ }
273
+ export const SchemaDecl = z.strictObject({
274
+ tables: z.record(z.string(), Table),
275
+ indexes: IndexString.array().optional().default([]),
276
+ });
277
+ export const ColumnDelta = z.strictObject({
278
+ type: z.string().optional(),
279
+ default: z.string().optional(),
280
+ ops: z.literal(['drop_default', 'set_required', 'drop_required']).array().optional(),
281
+ });
282
+ export const TableDelta = z.strictObject({
283
+ add_columns: z.record(z.string(), Column).optional().default({}),
284
+ drop_columns: z.string().array().optional().default([]),
285
+ alter_columns: z.record(z.string(), ColumnDelta).optional().default({}),
286
+ add_constraints: z.record(z.string(), Constraint).optional().default({}),
287
+ drop_constraints: z.string().array().optional().default([]),
288
+ });
289
+ export const VersionDelta = z.strictObject({
290
+ delta: z.literal(true),
291
+ add_tables: z.record(z.string(), Table).optional().default({}),
292
+ drop_tables: z.string().array().optional().default([]),
293
+ alter_tables: z.record(z.string(), TableDelta).optional().default({}),
294
+ add_indexes: IndexString.array().optional().default([]),
295
+ drop_indexes: IndexString.array().optional().default([]),
296
+ });
297
+ export const SchemaFile = z.object({
298
+ format: z.literal(0),
299
+ versions: z.discriminatedUnion('delta', [SchemaDecl.extend({ delta: z.literal(false) }), VersionDelta]).array(),
300
+ /** List of tables to wipe */
301
+ wipe: z.string().array().optional().default([]),
302
+ /** Set the latest version, defaults to the last one */
303
+ latest: z.number().nonnegative().optional(),
304
+ /** Maps tables to their ACL tables, e.g. `"storage": "acl.storage"` */
305
+ acl_tables: z.record(z.string(), z.string()).optional().default({}),
306
+ });
307
+ const { data, error } = SchemaFile.safeParse(rawSchema);
308
+ if (error)
309
+ io.error('Invalid base database schema:\n' + z.prettifyError(error));
310
+ const schema = data;
311
+ export function* getSchemaFiles() {
312
+ yield ['@axium/server', schema];
313
+ for (const [name, plugin] of plugins) {
314
+ if (!plugin._db)
315
+ continue;
316
+ try {
317
+ yield [name, SchemaFile.parse(plugin._db)];
318
+ }
319
+ catch (e) {
320
+ const text = e instanceof z.core.$ZodError ? z.prettifyError(e) : e instanceof Error ? e.message : String(e);
321
+ throw `Invalid database configuration for plugin "${name}":\n${text}`;
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * Get the active schema
327
+ */
328
+ export function getFullSchema(opt = {}) {
329
+ const fullSchema = { tables: {}, indexes: [] };
330
+ for (const [pluginName, file] of getSchemaFiles()) {
331
+ if (opt.exclude?.includes(pluginName))
332
+ continue;
333
+ let currentSchema = { tables: {}, indexes: [] };
334
+ for (const [version, schema] of file.versions.entries()) {
335
+ if (schema.delta)
336
+ applyDeltaToSchema(currentSchema, schema);
337
+ else
338
+ currentSchema = schema;
339
+ if (version === file.latest)
340
+ break;
341
+ }
342
+ for (const name of Object.keys(currentSchema.tables)) {
343
+ if (name in fullSchema.tables)
344
+ throw 'Duplicate table name in database schema: ' + name;
345
+ fullSchema.tables[name] = currentSchema.tables[name];
346
+ }
347
+ for (const index of currentSchema.indexes) {
348
+ if (fullSchema.indexes.includes(index))
349
+ throw 'Duplicate index in database schema: ' + index;
350
+ fullSchema.indexes.push(index);
351
+ }
352
+ }
353
+ return fullSchema;
354
+ }
355
+ const schemaToIntrospected = {
356
+ boolean: 'bool',
357
+ integer: 'int4',
358
+ 'text[]': '_text',
371
359
  };
360
+ const VersionMap = z.record(z.string(), z.int().nonnegative());
361
+ export const UpgradesInfo = z.object({
362
+ current: VersionMap.default({}),
363
+ upgrades: z.object({ timestamp: z.coerce.date(), from: VersionMap, to: VersionMap }).array().default([]),
364
+ });
365
+ const upgradesFilePath = process.getuid?.() == 0 ? join(systemDir, 'db_upgrades.json') : join(dirs.at(-1), 'db_upgrades.json');
366
+ export function getUpgradeInfo() {
367
+ if (!existsSync(upgradesFilePath))
368
+ io.writeJSON(upgradesFilePath, { current: {}, upgrades: [] });
369
+ return io.readJSON(upgradesFilePath, UpgradesInfo);
370
+ }
371
+ export function setUpgradeInfo(info) {
372
+ io.writeJSON(upgradesFilePath, info);
373
+ }
374
+ export function applyTableDeltaToSchema(table, delta) {
375
+ for (const column of delta.drop_columns) {
376
+ if (column in table)
377
+ delete table.columns[column];
378
+ else
379
+ throw `Can't drop column ${column} because it does not exist`;
380
+ }
381
+ for (const [name, column] of Object.entries(delta.add_columns)) {
382
+ if (name in table)
383
+ throw `Can't add column ${name} because it already exists`;
384
+ table.columns[name] = column;
385
+ }
386
+ for (const [name, columnDelta] of Object.entries(delta.alter_columns)) {
387
+ const column = table.columns[name];
388
+ if (!column)
389
+ throw `Can't modify column ${name} because it does not exist`;
390
+ if (columnDelta.type)
391
+ column.type = columnDelta.type;
392
+ if (columnDelta.default)
393
+ column.default = columnDelta.default;
394
+ for (const op of columnDelta.ops || []) {
395
+ switch (op) {
396
+ case 'drop_default':
397
+ delete column.default;
398
+ break;
399
+ case 'set_required':
400
+ column.required = true;
401
+ break;
402
+ case 'drop_required':
403
+ column.required = false;
404
+ break;
405
+ }
406
+ }
407
+ }
408
+ for (const name of delta.drop_constraints) {
409
+ if (table.constraints[name])
410
+ delete table.constraints[name];
411
+ else
412
+ throw `Can't drop constraint ${name} because it does not exist`;
413
+ }
414
+ for (const [name, constraint] of Object.entries(delta.add_constraints)) {
415
+ if (table.constraints[name])
416
+ throw `Can't add constraint ${name} because it already exists`;
417
+ table.constraints[name] = constraint;
418
+ }
419
+ }
420
+ export function applyDeltaToSchema(schema, delta) {
421
+ for (const tableName of delta.drop_tables) {
422
+ if (tableName in schema.tables)
423
+ delete schema.tables[tableName];
424
+ else
425
+ throw `Can't drop table ${tableName} because it does not exist`;
426
+ }
427
+ for (const [tableName, table] of Object.entries(delta.add_tables)) {
428
+ if (tableName in schema.tables)
429
+ throw `Can't add table ${tableName} because it already exists`;
430
+ else
431
+ schema.tables[tableName] = table;
432
+ }
433
+ for (const [tableName, tableDelta] of Object.entries(delta.alter_tables)) {
434
+ if (tableName in schema.tables)
435
+ applyTableDeltaToSchema(schema.tables[tableName], tableDelta);
436
+ else
437
+ throw `Can't modify table ${tableName} because it does not exist`;
438
+ }
439
+ }
440
+ export function validateDelta(delta) {
441
+ const tableNames = [...Object.keys(delta.add_tables), ...Object.keys(delta.alter_tables), delta.drop_tables];
442
+ const uniqueTables = new Set(tableNames);
443
+ for (const table of uniqueTables) {
444
+ tableNames.splice(tableNames.indexOf(table), 1);
445
+ }
446
+ if (tableNames.length) {
447
+ throw `Duplicate table name(s): ${tableNames.join(', ')}`;
448
+ }
449
+ for (const [tableName, table] of Object.entries(delta.alter_tables)) {
450
+ const columnNames = [...Object.keys(table.add_columns), ...table.drop_columns];
451
+ const uniqueColumns = new Set(columnNames);
452
+ for (const column of uniqueColumns) {
453
+ columnNames.splice(columnNames.indexOf(column), 1);
454
+ }
455
+ if (columnNames.length) {
456
+ throw `Duplicate column name(s) in table ${tableName}: ${columnNames.join(', ')}`;
457
+ }
458
+ }
459
+ }
460
+ export function computeDelta(from, to) {
461
+ const fromTables = new Set(Object.keys(from.tables));
462
+ const toTables = new Set(Object.keys(to.tables));
463
+ const fromIndexes = new Set(from.indexes);
464
+ const toIndexes = new Set(to.indexes);
465
+ const add_tables = Object.fromEntries(toTables
466
+ .difference(fromTables)
467
+ .keys()
468
+ .map(name => [name, to.tables[name]]));
469
+ const alter_tables = {};
470
+ for (const name of fromTables.intersection(toTables)) {
471
+ const fromTable = from.tables[name], toTable = to.tables[name];
472
+ const fromColumns = new Set(Object.keys(fromTable));
473
+ const toColumns = new Set(Object.keys(toTable));
474
+ const drop_columns = fromColumns.difference(toColumns);
475
+ const add_columns = Object.fromEntries(toColumns
476
+ .difference(fromColumns)
477
+ .keys()
478
+ .map(colName => [colName, toTable.columns[colName]]));
479
+ const alter_columns = Object.fromEntries(toColumns
480
+ .intersection(fromColumns)
481
+ .keys()
482
+ .map(name => {
483
+ const fromCol = fromTable.columns[name], toCol = toTable.columns[name];
484
+ const alter = { ops: [] };
485
+ if ('default' in fromCol && !('default' in toCol))
486
+ alter.ops.push('drop_default');
487
+ else if (fromCol.default !== toCol.default)
488
+ alter.default = toCol.default;
489
+ if (fromCol.type != toCol.type)
490
+ alter.type = toCol.type;
491
+ if (fromCol.required != toCol.required)
492
+ alter.ops.push(toCol.required ? 'set_required' : 'drop_required');
493
+ return [name, alter];
494
+ }));
495
+ const fromConstraints = new Set(Object.keys(fromTable.constraints || {}));
496
+ const toConstraints = new Set(Object.keys(toTable.constraints || {}));
497
+ const drop_constraints = fromConstraints.difference(toConstraints);
498
+ const add_constraints = Object.fromEntries(toConstraints
499
+ .difference(fromConstraints)
500
+ .keys()
501
+ .map(constName => [constName, toTable.constraints[constName]]));
502
+ alter_tables[name] = {
503
+ add_columns,
504
+ drop_columns: Array.from(drop_columns),
505
+ alter_columns,
506
+ add_constraints,
507
+ drop_constraints: Array.from(drop_constraints),
508
+ };
509
+ }
510
+ return {
511
+ delta: true,
512
+ add_tables,
513
+ drop_tables: Array.from(fromTables.difference(toTables)),
514
+ alter_tables,
515
+ drop_indexes: Array.from(fromIndexes.difference(toIndexes)),
516
+ add_indexes: Array.from(toIndexes.difference(fromIndexes)),
517
+ };
518
+ }
519
+ export function collapseDeltas(deltas) {
520
+ const add_tables = {}, drop_tables = [], alter_tables = {}, add_indexes = [], drop_indexes = [];
521
+ for (const delta of deltas) {
522
+ validateDelta(delta);
523
+ for (const [name, table] of Object.entries(delta.alter_tables)) {
524
+ if (name in add_tables) {
525
+ applyTableDeltaToSchema(add_tables[name], table);
526
+ }
527
+ else if (name in alter_tables) {
528
+ const existing = alter_tables[name];
529
+ for (const [colName, column] of Object.entries(table.add_columns)) {
530
+ existing.add_columns[colName] = column;
531
+ }
532
+ for (const colName of table.drop_columns) {
533
+ if (colName in existing.add_columns)
534
+ delete existing.add_columns[colName];
535
+ else
536
+ existing.drop_columns.push(colName);
537
+ }
538
+ }
539
+ else
540
+ alter_tables[name] = table;
541
+ }
542
+ for (const table of delta.drop_tables) {
543
+ if (table in add_tables)
544
+ delete add_tables[table];
545
+ else
546
+ drop_tables.push(table);
547
+ }
548
+ for (const [name, table] of Object.entries(delta.add_tables)) {
549
+ if (drop_tables.includes(name))
550
+ throw `Can't add and drop table "${name}" in the same change`;
551
+ if (name in alter_tables)
552
+ throw `Can't add and modify table "${name}" in the same change`;
553
+ add_tables[name] = table;
554
+ }
555
+ for (const index of delta.add_indexes) {
556
+ if (drop_indexes.includes(index))
557
+ throw `Can't add and drop index "${index}" in the same change`;
558
+ add_indexes.push(index);
559
+ }
560
+ for (const index of delta.drop_indexes) {
561
+ if (add_indexes.includes(index))
562
+ throw `Can't add and drop index "${index}" in the same change`;
563
+ drop_indexes.push(index);
564
+ }
565
+ }
566
+ return { delta: true, add_tables, drop_tables, alter_tables, add_indexes, drop_indexes };
567
+ }
568
+ export function deltaIsEmpty(delta) {
569
+ return (!Object.keys(delta.add_tables).length &&
570
+ !delta.drop_tables.length &&
571
+ !Object.keys(delta.alter_tables).length &&
572
+ !delta.add_indexes.length &&
573
+ !delta.drop_indexes.length);
574
+ }
575
+ const deltaColors = {
576
+ '+': 'green',
577
+ '-': 'red',
578
+ '*': 'white',
579
+ };
580
+ export function* displayDelta(delta) {
581
+ const tables = [
582
+ ...Object.keys(delta.add_tables).map(name => ({ op: '+', name })),
583
+ ...Object.entries(delta.alter_tables).map(([name, changes]) => ({ op: '*', name, changes })),
584
+ ...delta.drop_tables.map(name => ({ op: '-', name })),
585
+ ];
586
+ tables.sort((a, b) => a.name.localeCompare(b.name));
587
+ for (const table of tables) {
588
+ yield styleText(deltaColors[table.op], `${table.op} table ${table.name}`);
589
+ if (table.op != '*')
590
+ continue;
591
+ const columns = [
592
+ ...Object.keys(table.changes.add_columns).map(name => ({ op: '+', name })),
593
+ ...table.changes.drop_columns.map(name => ({ op: '-', name })),
594
+ ...Object.entries(table.changes.alter_columns).map(([name, changes]) => ({ op: '*', name, ...changes })),
595
+ ];
596
+ columns.sort((a, b) => a.name.localeCompare(b.name));
597
+ for (const column of columns) {
598
+ const columnChanges = column.op == '*'
599
+ ? [...(column.ops ?? []), 'default' in column && 'set_default', 'type' in column && 'set_type']
600
+ .filter((e) => !!e)
601
+ .map(e => e.replaceAll('_', ' '))
602
+ .join(', ')
603
+ : null;
604
+ yield '\t' +
605
+ styleText(deltaColors[column.op], `${column.op} column ${column.name}${column.op != '*' ? '' : ': ' + columnChanges}`);
606
+ }
607
+ const constraints = [
608
+ ...Object.keys(table.changes.add_constraints).map(name => ({ op: '+', name })),
609
+ ...table.changes.drop_constraints.map(name => ({ op: '-', name })),
610
+ ];
611
+ for (const con of constraints) {
612
+ yield '\t' + styleText(deltaColors[con.op], `${con.op} constraint ${con.name}`);
613
+ }
614
+ }
615
+ const indexes = [
616
+ ...delta.add_indexes.map(raw => ({ op: '+', ...parseIndex(raw) })),
617
+ ...delta.drop_indexes.map(raw => ({ op: '-', ...parseIndex(raw) })),
618
+ ];
619
+ indexes.sort((a, b) => a.table.localeCompare(b.table) || a.column.localeCompare(b.column));
620
+ for (const index of indexes) {
621
+ yield styleText(deltaColors[index.op], `${index.op} index on ${index.table}.${index.column}`);
622
+ }
623
+ }
624
+ function columnFromSchema(column, allowPK) {
625
+ return function _addColumn(col) {
626
+ if (column.primary && allowPK)
627
+ col = col.primaryKey();
628
+ if (column.unique)
629
+ col = col.unique();
630
+ if (column.required)
631
+ col = col.notNull();
632
+ else if (column.unique)
633
+ col = col.nullsNotDistinct();
634
+ if (column.references)
635
+ col = col.references(column.references);
636
+ if (column.onDelete)
637
+ col = col.onDelete(column.onDelete);
638
+ if ('default' in column)
639
+ col = col.defaultTo(sql.raw(column.default));
640
+ if (column.check)
641
+ col = col.check(sql.raw(column.check));
642
+ return col;
643
+ };
644
+ }
645
+ export async function applyDelta(delta, forceAbort = false) {
646
+ const tx = await database.startTransaction().execute();
647
+ try {
648
+ for (const [tableName, table] of Object.entries(delta.add_tables)) {
649
+ io.start('Adding table ' + tableName);
650
+ let query = tx.schema.createTable(tableName);
651
+ const columns = Object.entries(table.columns);
652
+ const pkColumns = columns.filter(([, column]) => column.primary).map(([name, column]) => ({ name, ...column }));
653
+ const needsSpecialConstraint = pkColumns.length > 1 || pkColumns.some(col => !col.required);
654
+ for (const [colName, column] of columns) {
655
+ query = query.addColumn(colName, sql.raw(column.type), columnFromSchema(column, !needsSpecialConstraint));
656
+ }
657
+ if (needsSpecialConstraint) {
658
+ query = query.addPrimaryKeyConstraint('PK_' + tableName.replaceAll('.', '_'), pkColumns.map(col => col.name));
659
+ }
660
+ await query.execute();
661
+ io.done();
662
+ }
663
+ for (const tableName of delta.drop_tables) {
664
+ io.start('Dropping table ' + tableName);
665
+ await tx.schema.dropTable(tableName).execute();
666
+ io.done();
667
+ }
668
+ for (const [tableName, tableDelta] of Object.entries(delta.alter_tables)) {
669
+ io.start(`Modifying table ${tableName}`);
670
+ const query = tx.schema.alterTable(tableName);
671
+ for (const constraint of tableDelta.drop_constraints) {
672
+ await query.dropConstraint(constraint).execute();
673
+ }
674
+ for (const colName of tableDelta.drop_columns) {
675
+ await query.dropColumn(colName).execute();
676
+ }
677
+ for (const [colName, column] of Object.entries(tableDelta.add_columns)) {
678
+ await query.addColumn(colName, sql.raw(column.type), columnFromSchema(column, false)).execute();
679
+ }
680
+ for (const [colName, column] of Object.entries(tableDelta.alter_columns)) {
681
+ if (column.default)
682
+ await query.alterColumn(colName, col => col.setDefault(sql.raw(column.default))).execute();
683
+ if (column.type)
684
+ await query.alterColumn(colName, col => col.setDataType(sql.raw(column.type))).execute();
685
+ for (const op of column.ops ?? []) {
686
+ switch (op) {
687
+ case 'drop_default':
688
+ if (column.default)
689
+ throw 'Cannot set and drop default at the same time';
690
+ await query.alterColumn(colName, col => col.dropDefault()).execute();
691
+ break;
692
+ case 'set_required':
693
+ await query.alterColumn(colName, col => col.setNotNull()).execute();
694
+ break;
695
+ case 'drop_required':
696
+ await query.alterColumn(colName, col => col.dropNotNull()).execute();
697
+ break;
698
+ }
699
+ }
700
+ }
701
+ for (const [name, con] of Object.entries(tableDelta.add_constraints)) {
702
+ switch (con.type) {
703
+ case 'unique':
704
+ await query.addUniqueConstraint(name, con.on, b => (con.nulls_not_distinct ? b.nullsNotDistinct() : b)).execute();
705
+ break;
706
+ case 'check':
707
+ await query.addCheckConstraint(name, sql.raw(con.check)).execute();
708
+ break;
709
+ case 'foreign_key':
710
+ await query.addForeignKeyConstraint(name, con.on, con.target, con.references, b => b).execute();
711
+ break;
712
+ case 'primary_key':
713
+ await query.addPrimaryKeyConstraint(name, con.on).execute();
714
+ break;
715
+ }
716
+ }
717
+ io.done();
718
+ }
719
+ if (forceAbort)
720
+ throw 'Rolling back due to --abort';
721
+ io.start('Committing');
722
+ await tx.commit().execute();
723
+ io.done();
724
+ }
725
+ catch (e) {
726
+ await tx.rollback().execute();
727
+ if (e instanceof SuppressedError)
728
+ io.error(e.suppressed);
729
+ throw e;
730
+ }
731
+ }
372
732
  /**
373
733
  * Checks that a table has the expected column types, nullability, and default values.
374
734
  */
375
735
  export async function checkTableTypes(tableName, types, opt) {
376
- io.start(`Checking for table ${tableName}`);
736
+ io.start(`Checking table ${tableName}`);
377
737
  const dbTables = opt._metadata || (await database.introspection.getTables());
378
738
  const table = dbTables.find(t => (t.schema == 'public' ? t.name : `${t.schema}.${t.name}`) === tableName);
379
739
  if (!table)
380
740
  throw 'missing.';
381
- io.done();
382
741
  const columns = Object.fromEntries(table.columns.map(c => [c.name, c]));
383
- for (const [key, { type, required = false, hasDefault = false }] of Object.entries(types)) {
384
- io.start(`Checking column ${tableName}.${key}`);
742
+ const _types = Object.entries(types.columns);
743
+ for (const [i, [key, { type, required = false, default: _default }]] of _types.entries()) {
744
+ io.progress(i, _types.length, key);
385
745
  const col = columns[key];
386
- if (!col)
387
- throw 'missing.';
746
+ const actualType = type in schemaToIntrospected ? schemaToIntrospected[type] : type;
747
+ const hasDefault = _default !== undefined;
388
748
  try {
389
- if (col.dataType != type)
390
- throw `incorrect type "${col.dataType}", expected ${type}`;
749
+ if (!col)
750
+ throw 'missing.';
751
+ if (col.dataType != actualType)
752
+ throw `incorrect type "${col.dataType}", expected ${actualType} (${type})`;
391
753
  if (col.isNullable != !required)
392
754
  throw required ? 'nullable' : 'not nullable';
393
755
  if (col.hasDefaultValue != hasDefault)
394
756
  throw hasDefault ? 'missing default' : 'has default';
395
- io.done();
396
757
  }
397
758
  catch (e) {
398
759
  if (opt.strict)
399
- throw e;
400
- io.warn(e);
760
+ throw `${tableName}.${key}: ${e}`;
761
+ io.warn(`${tableName}.${key}: ${e}`);
401
762
  }
402
763
  delete columns[key];
403
764
  }
@@ -414,43 +775,6 @@ export async function checkTableTypes(tableName, types, opt) {
414
775
  else
415
776
  io.warn(unchecked);
416
777
  }
417
- export async function check(opt) {
418
- const env_2 = { stack: [], error: void 0, hasError: false };
419
- try {
420
- await io.run('Checking for sudo', 'which sudo');
421
- await io.run('Checking for psql', 'which psql');
422
- await _sql(`SELECT 1 FROM pg_database WHERE datname = 'axium'`, 'Checking for database').then(throwUnlessRows);
423
- await _sql(`SELECT 1 FROM pg_roles WHERE rolname = 'axium'`, 'Checking for user').then(throwUnlessRows);
424
- io.start('Connecting to database');
425
- const _ = __addDisposableResource(env_2, connect(), true);
426
- io.done();
427
- io.start('Getting table metadata');
428
- opt._metadata = await database.introspection.getTables();
429
- const tables = Object.fromEntries(opt._metadata.map(t => [t.name, t]));
430
- io.done();
431
- for (const table of Object.keys(expectedTypes)) {
432
- await checkTableTypes(table, expectedTypes[table], opt);
433
- delete tables[table];
434
- }
435
- io.start('Checking for extra tables');
436
- const unchecked = Object.keys(tables).join(', ');
437
- if (!unchecked.length)
438
- io.done();
439
- else if (opt.strict)
440
- throw unchecked;
441
- else
442
- io.warn(unchecked);
443
- }
444
- catch (e_2) {
445
- env_2.error = e_2;
446
- env_2.hasError = true;
447
- }
448
- finally {
449
- const result_2 = __disposeResources(env_2);
450
- if (result_2)
451
- await result_2;
452
- }
453
- }
454
778
  export async function clean(opt) {
455
779
  const now = new Date();
456
780
  io.start('Removing expired sessions');
@@ -464,55 +788,6 @@ export async function clean(opt) {
464
788
  await plugin._hooks?.clean(opt);
465
789
  }
466
790
  }
467
- /**
468
- * Completely remove Axium from the database.
469
- */
470
- export async function uninstall(opt) {
471
- for (const plugin of plugins.values()) {
472
- if (!plugin._hooks?.remove)
473
- continue;
474
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
475
- await plugin._hooks?.remove(opt);
476
- }
477
- await _sql('DROP DATABASE axium', 'Dropping database');
478
- await _sql('REVOKE ALL PRIVILEGES ON SCHEMA public FROM axium', 'Revoking schema privileges');
479
- await _sql('DROP USER axium', 'Dropping user');
480
- await getHBA(opt)
481
- .then(([content, writeBack]) => {
482
- io.start('Checking for Axium HBA configuration');
483
- if (!content.includes(pgHba))
484
- throw 'missing.';
485
- io.done();
486
- io.start('Removing Axium HBA configuration');
487
- const newContent = content.replace(pgHba, '');
488
- io.done();
489
- writeBack(newContent);
490
- })
491
- .catch(io.warn);
492
- }
493
- /**
494
- * Removes all data from tables.
495
- */
496
- export async function wipe(opt) {
497
- for (const plugin of plugins.values()) {
498
- if (!plugin._hooks?.db_wipe)
499
- continue;
500
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
501
- await plugin._hooks?.db_wipe(opt);
502
- }
503
- for (const table of ['users', 'passkeys', 'sessions', 'verifications']) {
504
- io.start(`Wiping ${table}`);
505
- await database.deleteFrom(table).execute();
506
- io.done();
507
- }
508
- for (const table of await database.introspection.getTables()) {
509
- if (!table.name.startsWith('acl.'))
510
- continue;
511
- const name = table.name;
512
- io.debug(`Wiping ${name}`);
513
- await database.deleteFrom(name).execute();
514
- }
515
- }
516
791
  export async function rotatePassword() {
517
792
  io.start('Generating new password');
518
793
  const password = randomBytes(32).toString('base64');