@axium/server 0.26.3 → 0.28.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/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() {
@@ -93,6 +97,30 @@ export function userFromId(builder) {
93
97
  .$castTo()
94
98
  .as('user');
95
99
  }
100
+ /**
101
+ * Used for `update ... set ... from`
102
+ */
103
+ export function values(records, alias) {
104
+ if (!records?.length)
105
+ throw new Error('Can not create values() with empty records array');
106
+ // Assume there's at least one record and all records
107
+ // have the same keys.
108
+ const keys = Object.keys(records[0]);
109
+ // Transform the records into a list of lists such as
110
+ // ($1, $2, $3), ($4, $5, $6)
111
+ const values = sql.join(records.map(r => sql `(${sql.join(keys.map(k => r[k]))})`));
112
+ // Create the alias `v(id, v1, v2)` that specifies the table alias
113
+ // AND a name for each column.
114
+ const wrappedAlias = sql.ref(alias);
115
+ // eslint-disable-next-line @typescript-eslint/unbound-method
116
+ const wrappedColumns = sql.join(keys.map(sql.ref));
117
+ const aliasSql = sql `${wrappedAlias}(${wrappedColumns})`;
118
+ // Finally create a single `AliasedRawBuilder` instance of the
119
+ // whole thing. Note that we need to explicitly specify
120
+ // the alias type using `.as<A>` because we are using a
121
+ // raw sql snippet as the alias.
122
+ return sql `(values ${values})`.as(aliasSql);
123
+ }
96
124
  export async function statText() {
97
125
  try {
98
126
  const stats = await count('users', 'passkeys', 'sessions');
@@ -133,26 +161,16 @@ export async function getHBA(opt) {
133
161
  };
134
162
  return [content, writeBack];
135
163
  }
136
- const pgHba = `
164
+ /** @internal @hidden */
165
+ export const _pgHba = `
137
166
  local axium axium md5
138
167
  host axium axium 127.0.0.1/32 md5
139
168
  host axium axium ::1/128 md5
140
169
  `;
141
- 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}"`);
142
172
  /** Shortcut to output a warning if an error is thrown because relation already exists */
143
173
  export const warnExists = io.someWarnings([/\w+ "[\w.]+" already exists/, 'already exists.']);
144
- const throwUnlessRows = (text) => {
145
- if (text.includes('(0 rows)'))
146
- throw 'missing.';
147
- return text;
148
- };
149
- export async function createIndex(table, column, mod) {
150
- io.start(`Creating index for ${table}.${column}`);
151
- let query = database.schema.createIndex(`${table}_${column}_index`).on(table).column(column);
152
- if (mod)
153
- query = mod(query);
154
- await query.execute().then(io.done).catch(warnExists);
155
- }
156
174
  export async function init(opt) {
157
175
  const env_1 = { stack: [], error: void 0, hasError: false };
158
176
  try {
@@ -186,11 +204,11 @@ export async function init(opt) {
186
204
  await getHBA(opt)
187
205
  .then(([content, writeBack]) => {
188
206
  io.start('Checking for Axium HBA configuration');
189
- if (content.includes(pgHba))
207
+ if (content.includes(_pgHba))
190
208
  throw 'already exists.';
191
209
  io.done();
192
210
  io.start('Adding Axium HBA configuration');
193
- 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}`);
194
212
  io.done();
195
213
  writeBack(newContent);
196
214
  })
@@ -199,91 +217,8 @@ export async function init(opt) {
199
217
  io.start('Connecting to database');
200
218
  const _ = __addDisposableResource(env_1, connect(), true);
201
219
  io.done();
202
- function maybeCheck(table) {
203
- return (e) => {
204
- warnExists(e);
205
- if (opt.check)
206
- return checkTableTypes(table, expectedTypes[table], opt);
207
- };
208
- }
209
- io.start('Creating table users');
210
- await database.schema
211
- .createTable('users')
212
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
213
- .addColumn('name', 'text')
214
- .addColumn('email', 'text', col => col.unique().notNull())
215
- .addColumn('emailVerified', 'timestamptz')
216
- .addColumn('image', 'text')
217
- .addColumn('isAdmin', 'boolean', col => col.notNull().defaultTo(false))
218
- .addColumn('roles', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
219
- .addColumn('tags', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
220
- .addColumn('preferences', 'jsonb', col => col.notNull().defaultTo('{}'))
221
- .addColumn('registeredAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
222
- .addColumn('isSuspended', 'boolean', col => col.notNull().defaultTo(false))
223
- .execute()
224
- .then(io.done)
225
- .catch(maybeCheck('users'));
226
- io.start('Creating table sessions');
227
- await database.schema
228
- .createTable('sessions')
229
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
230
- .addColumn('userId', 'uuid', col => col.references('users.id').onDelete('cascade').notNull())
231
- .addColumn('token', 'text', col => col.notNull().unique())
232
- .addColumn('created', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
233
- .addColumn('expires', 'timestamptz', col => col.notNull())
234
- .addColumn('elevated', 'boolean', col => col.notNull())
235
- .execute()
236
- .then(io.done)
237
- .catch(maybeCheck('sessions'));
238
- await createIndex('sessions', 'id');
239
- io.start('Creating table verifications');
240
- await database.schema
241
- .createTable('verifications')
242
- .addColumn('userId', 'uuid', col => col.references('users.id').onDelete('cascade').notNull())
243
- .addColumn('token', 'text', col => col.notNull().unique())
244
- .addColumn('expires', 'timestamptz', col => col.notNull())
245
- .addColumn('role', 'text', col => col.notNull())
246
- .execute()
247
- .then(io.done)
248
- .catch(maybeCheck('verifications'));
249
- io.start('Creating table passkeys');
250
- await database.schema
251
- .createTable('passkeys')
252
- .addColumn('id', 'text', col => col.primaryKey().notNull())
253
- .addColumn('name', 'text')
254
- .addColumn('createdAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
255
- .addColumn('userId', 'uuid', col => col.notNull().references('users.id').onDelete('cascade').notNull())
256
- .addColumn('publicKey', 'bytea', col => col.notNull())
257
- .addColumn('counter', 'integer', col => col.notNull())
258
- .addColumn('deviceType', 'text', col => col.notNull())
259
- .addColumn('backedUp', 'boolean', col => col.notNull())
260
- .addColumn('transports', sql `text[]`)
261
- .execute()
262
- .then(io.done)
263
- .catch(maybeCheck('passkeys'));
264
- await createIndex('passkeys', 'userId');
265
- io.start('Creating table audit_log');
266
- await database.schema
267
- .createTable('audit_log')
268
- .addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
269
- .addColumn('timestamp', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
270
- .addColumn('userId', 'uuid')
271
- .addColumn('severity', 'integer', col => col.notNull())
272
- .addColumn('name', 'text', col => col.notNull())
273
- .addColumn('source', 'text', col => col.notNull())
274
- .addColumn('tags', sql `text[]`, col => col.notNull().defaultTo(sql `'{}'::text[]`))
275
- .addColumn('extra', 'jsonb', col => col.notNull().defaultTo('{}'))
276
- .execute()
277
- .then(io.done)
278
- .catch(maybeCheck('audit_log'));
279
220
  io.start('Creating schema acl');
280
221
  await database.schema.createSchema('acl').execute().then(io.done).catch(warnExists);
281
- for (const plugin of plugins.values()) {
282
- if (!plugin._hooks?.db_init)
283
- continue;
284
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
285
- await plugin._hooks?.db_init(opt);
286
- }
287
222
  }
288
223
  catch (e_1) {
289
224
  env_1.error = e_1;
@@ -295,85 +230,535 @@ export async function init(opt) {
295
230
  await result_1;
296
231
  }
297
232
  }
298
- export const expectedTypes = {
299
- users: {
300
- email: { type: 'text', required: true },
301
- emailVerified: { type: 'timestamptz' },
302
- id: { type: 'uuid', required: true, hasDefault: true },
303
- image: { type: 'text' },
304
- isAdmin: { type: 'bool', required: true, hasDefault: true },
305
- name: { type: 'text' },
306
- preferences: { type: 'jsonb', required: true, hasDefault: true },
307
- registeredAt: { type: 'timestamptz', required: true, hasDefault: true },
308
- roles: { type: '_text', required: true, hasDefault: true },
309
- tags: { type: '_text', required: true, hasDefault: true },
310
- isSuspended: { type: 'bool', required: true, hasDefault: true },
311
- },
312
- verifications: {
313
- userId: { type: 'uuid', required: true },
314
- token: { type: 'text', required: true },
315
- expires: { type: 'timestamptz', required: true },
316
- role: { type: 'text', required: true },
317
- },
318
- passkeys: {
319
- id: { type: 'text', required: true },
320
- name: { type: 'text' },
321
- createdAt: { type: 'timestamptz', required: true, hasDefault: true },
322
- userId: { type: 'uuid', required: true },
323
- publicKey: { type: 'bytea', required: true },
324
- counter: { type: 'int4', required: true },
325
- deviceType: { type: 'text', required: true },
326
- backedUp: { type: 'bool', required: true },
327
- transports: { type: '_text' },
328
- },
329
- sessions: {
330
- id: { type: 'uuid', required: true, hasDefault: true },
331
- userId: { type: 'uuid', required: true },
332
- created: { type: 'timestamptz', required: true, hasDefault: true },
333
- token: { type: 'text', required: true },
334
- expires: { type: 'timestamptz', required: true },
335
- elevated: { type: 'bool', required: true },
336
- },
337
- audit_log: {
338
- userId: { type: 'uuid' },
339
- timestamp: { type: 'timestamptz', required: true, hasDefault: true },
340
- id: { type: 'uuid', required: true, hasDefault: true },
341
- severity: { type: 'int4', required: true },
342
- name: { type: 'text', required: true },
343
- source: { type: 'text', required: true },
344
- tags: { type: '_text', required: true, hasDefault: true },
345
- extra: { type: 'jsonb', required: true, hasDefault: true },
346
- },
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',
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',
347
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
+ }
348
732
  /**
349
733
  * Checks that a table has the expected column types, nullability, and default values.
350
734
  */
351
735
  export async function checkTableTypes(tableName, types, opt) {
352
- io.start(`Checking for table ${tableName}`);
736
+ io.start(`Checking table ${tableName}`);
353
737
  const dbTables = opt._metadata || (await database.introspection.getTables());
354
738
  const table = dbTables.find(t => (t.schema == 'public' ? t.name : `${t.schema}.${t.name}`) === tableName);
355
739
  if (!table)
356
740
  throw 'missing.';
357
- io.done();
358
741
  const columns = Object.fromEntries(table.columns.map(c => [c.name, c]));
359
- for (const [key, { type, required = false, hasDefault = false }] of Object.entries(types)) {
360
- 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);
361
745
  const col = columns[key];
362
- if (!col)
363
- throw 'missing.';
746
+ const actualType = type in schemaToIntrospected ? schemaToIntrospected[type] : type;
747
+ const hasDefault = _default !== undefined;
364
748
  try {
365
- if (col.dataType != type)
366
- 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})`;
367
753
  if (col.isNullable != !required)
368
754
  throw required ? 'nullable' : 'not nullable';
369
755
  if (col.hasDefaultValue != hasDefault)
370
756
  throw hasDefault ? 'missing default' : 'has default';
371
- io.done();
372
757
  }
373
758
  catch (e) {
374
759
  if (opt.strict)
375
- throw e;
376
- io.warn(e);
760
+ throw `${tableName}.${key}: ${e}`;
761
+ io.warn(`${tableName}.${key}: ${e}`);
377
762
  }
378
763
  delete columns[key];
379
764
  }
@@ -390,43 +775,6 @@ export async function checkTableTypes(tableName, types, opt) {
390
775
  else
391
776
  io.warn(unchecked);
392
777
  }
393
- export async function check(opt) {
394
- const env_2 = { stack: [], error: void 0, hasError: false };
395
- try {
396
- await io.run('Checking for sudo', 'which sudo');
397
- await io.run('Checking for psql', 'which psql');
398
- await _sql(`SELECT 1 FROM pg_database WHERE datname = 'axium'`, 'Checking for database').then(throwUnlessRows);
399
- await _sql(`SELECT 1 FROM pg_roles WHERE rolname = 'axium'`, 'Checking for user').then(throwUnlessRows);
400
- io.start('Connecting to database');
401
- const _ = __addDisposableResource(env_2, connect(), true);
402
- io.done();
403
- io.start('Getting table metadata');
404
- opt._metadata = await database.introspection.getTables();
405
- const tables = Object.fromEntries(opt._metadata.map(t => [t.name, t]));
406
- io.done();
407
- for (const table of Object.keys(expectedTypes)) {
408
- await checkTableTypes(table, expectedTypes[table], opt);
409
- delete tables[table];
410
- }
411
- io.start('Checking for extra tables');
412
- const unchecked = Object.keys(tables).join(', ');
413
- if (!unchecked.length)
414
- io.done();
415
- else if (opt.strict)
416
- throw unchecked;
417
- else
418
- io.warn(unchecked);
419
- }
420
- catch (e_2) {
421
- env_2.error = e_2;
422
- env_2.hasError = true;
423
- }
424
- finally {
425
- const result_2 = __disposeResources(env_2);
426
- if (result_2)
427
- await result_2;
428
- }
429
- }
430
778
  export async function clean(opt) {
431
779
  const now = new Date();
432
780
  io.start('Removing expired sessions');
@@ -440,55 +788,6 @@ export async function clean(opt) {
440
788
  await plugin._hooks?.clean(opt);
441
789
  }
442
790
  }
443
- /**
444
- * Completely remove Axium from the database.
445
- */
446
- export async function uninstall(opt) {
447
- for (const plugin of plugins.values()) {
448
- if (!plugin._hooks?.remove)
449
- continue;
450
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
451
- await plugin._hooks?.remove(opt);
452
- }
453
- await _sql('DROP DATABASE axium', 'Dropping database');
454
- await _sql('REVOKE ALL PRIVILEGES ON SCHEMA public FROM axium', 'Revoking schema privileges');
455
- await _sql('DROP USER axium', 'Dropping user');
456
- await getHBA(opt)
457
- .then(([content, writeBack]) => {
458
- io.start('Checking for Axium HBA configuration');
459
- if (!content.includes(pgHba))
460
- throw 'missing.';
461
- io.done();
462
- io.start('Removing Axium HBA configuration');
463
- const newContent = content.replace(pgHba, '');
464
- io.done();
465
- writeBack(newContent);
466
- })
467
- .catch(io.warn);
468
- }
469
- /**
470
- * Removes all data from tables.
471
- */
472
- export async function wipe(opt) {
473
- for (const plugin of plugins.values()) {
474
- if (!plugin._hooks?.db_wipe)
475
- continue;
476
- io.log(styleText('whiteBright', 'Running plugin: '), plugin.name);
477
- await plugin._hooks?.db_wipe(opt);
478
- }
479
- for (const table of ['users', 'passkeys', 'sessions', 'verifications']) {
480
- io.start(`Wiping ${table}`);
481
- await database.deleteFrom(table).execute();
482
- io.done();
483
- }
484
- for (const table of await database.introspection.getTables()) {
485
- if (!table.name.startsWith('acl.'))
486
- continue;
487
- const name = table.name;
488
- io.debug(`Wiping ${name}`);
489
- await database.deleteFrom(name).execute();
490
- }
491
- }
492
791
  export async function rotatePassword() {
493
792
  io.start('Generating new password');
494
793
  const password = randomBytes(32).toString('base64');