@zerotal/orm 1.8.1 → 1.9.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/CHANGELOG.md CHANGED
@@ -8,6 +8,51 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.9.0] — 2026-08-29
12
+
13
+ ### Changed
14
+
15
+ - **INTERNAL: 41 framework-wiring exports are marked `@internal`.** The connection and context
16
+ plumbing (`_getConnection`, `setConnectionResolver`, `useOrmContext`, `createReadWriteRouter`,
17
+ `registerImplicitBinding`, …), the schema-diff machinery behind `migrate:generate` and
18
+ `synchronize` (`SchemaDiffer`, `SchemaInspector`, `ModelInspector`, `synchronizeSchema`, and
19
+ their result shapes), and the dialect layer (`SqliteDialect`, `PostgresDialect`,
20
+ `MysqlDialect`, `getDialect`).
21
+
22
+ **Nothing is removed and nothing breaks** — they are still exported and still work. What
23
+ changes is the promise: they leave the recorded API surface, because an app never calls any of
24
+ them, and the only callers outside `@zerotal/orm` are `@zerotal/testing`, `@zerotal/tenancy`
25
+ and `@zerotal/arch` wiring themselves in.
26
+
27
+ Everything an app does use is unaffected, and the parts of it that were undocumented are now
28
+ documented: the relation types and option shapes in
29
+ [Relationships](/docs/orm/relationships#types), and the column builders plus `MigrationRunner`
30
+ in [Migrations](/docs/migrations#types).
31
+
32
+ - **`doctor` warns when migrations have not run**, and names them. The development error
33
+ overlay already answers this after a request has failed with `no such table`; the check asks
34
+ it before anything breaks, which is the cheaper moment to hear it. A warning rather than a
35
+ failure, because pending migrations are the ordinary state of a checkout that just pulled,
36
+ and a doctor that fails there is one people learn to ignore.
37
+
38
+ ### Added
39
+
40
+ - **`zt db:backup`** — a verified snapshot of the SQLite database. SQLite is the default
41
+ driver, which makes the database one file and makes `cp` look like a backup; it is not one,
42
+ because copying a live database can capture a half-written page and produce a file that
43
+ restores as corrupt, months later, from the one file you were relying on. This uses
44
+ `VACUUM INTO`, which takes a read lock and writes a complete database while the server
45
+ keeps serving, and needs no `sqlite3` binary on the box.
46
+
47
+ Every snapshot is opened and integrity-checked the moment it is written, and every failure
48
+ path exits non-zero — a backup job that reports success while writing nothing buys the
49
+ confidence without the file. `--require-rows` names the tables whose loss would end the
50
+ business and fails when the snapshot has none of them; `--rehearse` performs the actual
51
+ restore, because a backup nobody has restored is a hope. A snapshot that fails any check is
52
+ removed rather than left in the retention directory, where it would be indistinguishable
53
+ from a good one and, being newest, would push a verified older one out on the next prune.
54
+ Retention (`--keep`) only ever touches files this command wrote.
55
+
11
56
  ## [1.7.4] — 2026-08-21
12
57
 
13
58
  ### Fixed
package/api-surface.md CHANGED
@@ -554,22 +554,6 @@ class ModelQueryBuilder = {
554
554
  withoutTenancy: () => ModelQueryBuilder<M>
555
555
  }
556
556
 
557
- class MysqlDialect = {
558
- new (): MysqlDialect
559
- advisoryLockSql: (key: number) => DialectQuery
560
- advisoryUnlockSql: (key: number) => DialectQuery
561
- autoIncrementColumn: (column: string) => string
562
- booleanLiteral: (value: boolean) => string
563
- dateExpr: (part: DatePart, column: string) => string
564
- hasColumnSql: (table: string, column: string) => DialectQuery
565
- hasTableSql: (table: string) => DialectQuery
566
- readonly booleanType: 'INTEGER'
567
- readonly name: 'mysql'
568
- readonly supportsAdvisoryLocks: true
569
- readonly supportsTransactionalDdl: false
570
- stringType: (length: number) => string
571
- }
572
-
573
557
  class NPlusOneDetected = {
574
558
  new (fingerprint: string, count: number, ctx: object | undefined): NPlusOneDetected
575
559
  readonly count: number
@@ -587,31 +571,6 @@ class NPlusOneError = {
587
571
  readonly status: number
588
572
  }
589
573
 
590
- class OrmContext = {
591
- new (): OrmContext
592
- globalScopes: Map<ClassRef, Map<string, unknown>>
593
- hooks: Map<ClassRef, Map<string, unknown[]>>
594
- namedConnections: Map<string, SQLInstance>
595
- overrideConnection: SQLInstance | null
596
- transitionCallbacks: Map<ClassRef, Map<string, unknown[]>>
597
- }
598
-
599
- class PostgresDialect = {
600
- new (): PostgresDialect
601
- advisoryLockSql: (key: number) => DialectQuery
602
- advisoryUnlockSql: (key: number) => DialectQuery
603
- autoIncrementColumn: (column: string) => string
604
- booleanLiteral: (value: boolean) => string
605
- dateExpr: (part: DatePart, column: string) => string
606
- hasColumnSql: (table: string, column: string) => DialectQuery
607
- hasTableSql: (table: string) => DialectQuery
608
- readonly booleanType: 'BOOLEAN'
609
- readonly name: 'postgres'
610
- readonly supportsAdvisoryLocks: true
611
- readonly supportsTransactionalDdl: true
612
- stringType: () => string
613
- }
614
-
615
574
  class QueryBuilder = {
616
575
  new (table: string, sql: SQLInstance): QueryBuilder
617
576
  _markUserWhereStart: () => void
@@ -736,22 +695,6 @@ class Seeder = {
736
695
  run: () => Promise<void>
737
696
  }
738
697
 
739
- class SqliteDialect = {
740
- new (): SqliteDialect
741
- advisoryLockSql: () => DialectQuery | null
742
- advisoryUnlockSql: () => DialectQuery | null
743
- autoIncrementColumn: (column: string) => string
744
- booleanLiteral: (value: boolean) => string
745
- dateExpr: (part: DatePart, column: string) => string
746
- hasColumnSql: (table: string, column: string) => DialectQuery
747
- hasTableSql: (table: string) => DialectQuery
748
- readonly booleanType: 'INTEGER'
749
- readonly name: 'sqlite'
750
- readonly supportsAdvisoryLocks: false
751
- readonly supportsTransactionalDdl: true
752
- stringType: () => string
753
- }
754
-
755
698
  class StateError = {
756
699
  new (model: string, from: string, to: string, detail?: string): StateError
757
700
  readonly code: string
@@ -796,30 +739,12 @@ class UnsupportedDialectError = {
796
739
 
797
740
  const DB = { table(tableName: string): QueryBuilder; raw<T = Record<string, unknown>>(sql: TemplateStringsArray | string, ...rest: unknown[]): Promise<T[]>; transaction<T>(callback: (tx?: SQLInstance) => Promise<T>, attempts?: number): Promise<T>; beginTransaction(): Promise<ManualTransaction>; onPrimary(): { table(name: string): QueryBuilder; }; currentTx(): unknown | undefined; advisoryLock<T>(key: number, callback: () => Promise<T>): Promise<T>; preventNPlusOne(options?: NPlusOneOptions): void; allowNPlusOne(pattern: string, options?: { once?: boolean; }): void;}
798
741
 
799
- const ModelInspector = { load(pattern: string, cwd?: string): Promise<void>; all(): ModelSchema[]; fromClass(ctor: ClassRef): ModelSchema | null;}
800
-
801
742
  const relationRegistry = Map<ClassRef, Map<string, RelationMetadata>>
802
743
 
803
744
  const Schema = { create(table: string, callback: (bp: Blueprint) => void): Promise<void>; createIfNotExists(table: string, callback: (bp: Blueprint) => void): Promise<void>; table(name: string, callback: (bp: Blueprint) => void): Promise<void>; alter(name: string, callback: (bp: Blueprint) => void): Promise<void>; drop(table: string): Promise<void>; dropIfExists(table: string): Promise<void>; rename(from: string, to: string): Promise<void>; hasTable(table: string): Promise<boolean>; hasColumn(table: string, column: string): Promise<boolean>;}
804
745
 
805
- const SchemaDiffer = { diff(schemas: ModelSchema[]): Promise<DiffResult>; isEmpty(diff: DiffResult): boolean;}
806
-
807
- const SchemaInspector = { tables(): Promise<string[]>; columns(table: string): Promise<LiveColumn[] | null>; describe(table: string): Promise<LiveTable | null>;}
808
-
809
746
  const TransactionContext = AsyncLocalStorage<SQLInstance>
810
747
 
811
- function _getConnection = () => SQLInstance
812
-
813
- function _getDbConnectionOverride = () => SQLInstance | null
814
-
815
- function _globalScopeRegistry = () => Map<ClassRef, Map<string, GlobalScopeCallback>>
816
-
817
- function _setDbConnection = (conn: SQLInstance | null) => void
818
-
819
- function _setReadReplicas = (primary: SQLInstance, replicas: SQLInstance[]) => void
820
-
821
- function _suppressHooks = <T>(fn: () => Promise<T>) => Promise<T>
822
-
823
748
  function allowNPlusOne = (pattern: string, options?: { once?: boolean;}, ctx?: object | null) => void
824
749
 
825
750
  function arrayOf = <T = unknown>(mapper?: CastMapper<T>) => ArrayCast<T>
@@ -828,16 +753,8 @@ function belongsTo = (related: () => unknown, options: BelongsToOptions) => (_va
828
753
 
829
754
  function column = { (): ColumnDecorator; (type: ColumnShorthand): ColumnDecorator; (options: ColumnOptions): ColumnDecorator; (type: ColumnShorthand, options: Omit<ColumnOptions, 'type'>): ColumnDecorator;}
830
755
 
831
- function createReadWriteRouter = (primary: SQLInstance, replicas: SQLInstance[]) => SQLInstance
832
-
833
- function currentOrmContext = () => OrmContext
834
-
835
756
  function DatabaseConfig = (options?: Partial<DatabaseConfigShape>) => DatabaseConfigShape
836
757
 
837
- function generateMigrationContent = (className: string, diff: DiffResult) => string
838
-
839
- function getDialect = (name: DialectName) => SqlDialect
840
-
841
758
  function hasMany = (related: () => unknown, options: HasManyOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
842
759
 
843
760
  function hasManyThrough = (related: () => unknown, through: () => unknown, options: HasManyThroughOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
@@ -846,8 +763,6 @@ function hasOne = (related: () => unknown, options: HasOneOptions) => (_value: u
846
763
 
847
764
  function hasOneThrough = (related: () => unknown, through: () => unknown, options: HasManyThroughOptions) => (_value: unknown, context: ClassFieldDecoratorContext) => void
848
765
 
849
- function installOrmObservability = (app: Application) => () => void
850
-
851
766
  function isEncryptedCast = (cast: unknown) => cast is EncryptedCastName
852
767
 
853
768
  function json = <T = unknown>(mapper?: CastMapper<T>) => JsonCast<T>
@@ -868,30 +783,12 @@ function objectOf = <T = unknown>(mapper?: CastMapper<T>) => JsonCast<T>
868
783
 
869
784
  function preventNPlusOne = (options?: NPlusOneOptions) => void
870
785
 
871
- function registerConnectionResolver = (fn: ContextConnectionResolver | null) => void
872
-
873
- function registerImplicitBinding = () => void
874
-
875
- function registerModelConnection = (name: string, conn: SQLInstance, dialect?: Dialect) => void
876
-
877
- function resetOrmContext = () => void
878
-
879
- function resolveContainerConnection = () => SQLInstance | undefined
880
-
881
- function resolveSyncOptions = (raw: unknown) => ResolvedSyncOptions
882
-
883
- function setConnectionResolver = (fn: ConnectionResolver | null) => void
884
-
885
786
  function SoftDeletes = <TBase extends Constructor>(Base: TBase) => { new (...args: any[]): SoftDeletes; prototype: SoftDeletes<any>.SoftDeletes; softDeletes: boolean; withTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T>; onlyTrashed<T extends BaseModel>(this: SoftDeleteModelClass<T>): ModelQueryBuilder<T>;} & TBase
886
787
 
887
788
  function State = <TBase extends Constructor>(Base: TBase) => { new (...args: any[]): State; prototype: State<any>.State; stateField: string; states?: Record<string, StateDefinition<string, any>>; onTransition<T>(this: { new (...args: any[]): T; }, toState: string, callback: TransitionCallback<T>): void;} & TBase
888
789
 
889
- function synchronizeSchema = (options?: SynchronizeOptions) => Promise<DiffResult>
890
-
891
790
  function table = (tableName: string, options?: TableOptions) => TableDecoratorBuilder
892
791
 
893
- function useOrmContext = (ctx?: OrmContext) => OrmContext
894
-
895
792
  interface BelongsTo = {
896
793
  __@___relation__: 'belongsTo'
897
794
  __type__: T
@@ -946,22 +843,6 @@ interface DatabaseConfigShape = {
946
843
  url: string
947
844
  }
948
845
 
949
- interface DialectQuery = {
950
- params: unknown[]
951
- sql: string
952
- }
953
-
954
- interface DiffResult = {
955
- droppedColumns: DroppedColumn[]
956
- newColumns: NewColumn[]
957
- newTables: NewTable[]
958
- }
959
-
960
- interface DroppedColumn = {
961
- column: string
962
- table: string
963
- }
964
-
965
846
  interface HasMany = {
966
847
  __@___relation__: 'hasMany'
967
848
  __type__: T
@@ -991,18 +872,6 @@ interface KeysetPaginateResult = {
991
872
  nextCursor: string | null
992
873
  }
993
874
 
994
- interface LiveColumn = {
995
- name: string
996
- nullable: boolean
997
- primary: boolean
998
- rawType: string
999
- }
1000
-
1001
- interface LiveTable = {
1002
- columns: LiveColumn[]
1003
- name: string
1004
- }
1005
-
1006
875
  interface ManualTransaction = {
1007
876
  commit: () => Promise<void>
1008
877
  readonly sql: SQLInstance
@@ -1047,16 +916,6 @@ interface MigrationStatus = {
1047
916
  ranAt?: Date
1048
917
  }
1049
918
 
1050
- interface ModelColumn = {
1051
- default: unknown
1052
- index?: boolean
1053
- name: string
1054
- nullable: boolean
1055
- primary: boolean
1056
- type: 'string' | 'number' | 'boolean' | 'text' | 'datetime' | 'json' | undefined
1057
- unique?: boolean
1058
- }
1059
-
1060
919
  interface ModelObserver = {
1061
920
  created?: (model: T) => Promise<void> | void
1062
921
  creating?: (model: T) => Promise<void> | void
@@ -1069,14 +928,6 @@ interface ModelObserver = {
1069
928
  updating?: (model: T) => Promise<void> | void
1070
929
  }
1071
930
 
1072
- interface ModelSchema = {
1073
- columns: ModelColumn[]
1074
- primaryKey: string
1075
- softDeletes: boolean
1076
- table: string
1077
- timestamps: boolean
1078
- }
1079
-
1080
931
  interface MorphedByManyOptions = {
1081
932
  morphName: string
1082
933
  parentPivotKey: string
@@ -1124,15 +975,6 @@ interface MorphToOptions = {
1124
975
  morphTypeColumn?: string
1125
976
  }
1126
977
 
1127
- interface NewColumn = {
1128
- column: ModelColumn
1129
- table: string
1130
- }
1131
-
1132
- interface NewTable = {
1133
- schema: ModelSchema
1134
- }
1135
-
1136
978
  interface NPlusOneOptions = {
1137
979
  mode?: 'warn' | 'throw'
1138
980
  threshold?: number
@@ -1189,11 +1031,6 @@ interface RelationMetadata = {
1189
1031
  withDefault?: boolean | Record<string, unknown> | ((model: unknown) => void)
1190
1032
  }
1191
1033
 
1192
- interface ResolvedSyncOptions = {
1193
- disruptive: boolean
1194
- enabled: boolean
1195
- }
1196
-
1197
1034
  interface ScopeApplicator = {
1198
1035
  apply: (query: QueryBuilder) => void
1199
1036
  }
@@ -1211,21 +1048,6 @@ interface SimplePaginateResult = {
1211
1048
  url: (page: number, baseUrl?: string, query?: Record<string, string>) => string
1212
1049
  }
1213
1050
 
1214
- interface SqlDialect = {
1215
- advisoryLockSql: (key: number) => DialectQuery | null
1216
- advisoryUnlockSql: (key: number) => DialectQuery | null
1217
- autoIncrementColumn: (column: string) => string
1218
- booleanLiteral: (value: boolean) => string
1219
- dateExpr: (part: DatePart, column: string) => string
1220
- hasColumnSql: (table: string, column: string) => DialectQuery
1221
- hasTableSql: (table: string) => DialectQuery
1222
- readonly booleanType: string
1223
- readonly name: DialectName
1224
- readonly supportsAdvisoryLocks: boolean
1225
- readonly supportsTransactionalDdl: boolean
1226
- stringType: (length: number) => string
1227
- }
1228
-
1229
1051
  interface SQLInstance = {
1230
1052
  <T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>
1231
1053
  begin: <T>(fn: (tx: SQLInstance) => Promise<T>) => Promise<T>
@@ -1237,10 +1059,6 @@ interface StateDefinition = {
1237
1059
  guard?: StateGuard<T>
1238
1060
  }
1239
1061
 
1240
- interface SynchronizeOptions = {
1241
- disruptive?: boolean
1242
- }
1243
-
1244
1062
  interface TableDecoratorBuilder = {
1245
1063
  (target: ClassRef, context?: unknown): void
1246
1064
  primaryKey: (key: string) => TableDecoratorBuilder
@@ -1267,12 +1085,8 @@ type ColumnShorthand = 'string' | 'text' | 'integer' | 'number' | 'float' | 'boo
1267
1085
 
1268
1086
  type Constructor = new (...args: any[]) => T
1269
1087
 
1270
- type ContextConnectionResolver = (ModelClass?: typeof BaseModel) => SQLInstance | null
1271
-
1272
1088
  type DatePart = 'date' | 'time' | 'day' | 'month' | 'year'
1273
1089
 
1274
- type DialectName = 'sqlite' | 'postgres' | 'mysql'
1275
-
1276
1090
  type EncryptedCastName = 'encrypted' | 'encrypted:json'
1277
1091
 
1278
1092
  type FKAction = 'CASCADE' | 'SET NULL' | 'RESTRICT' | 'NO ACTION'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/orm",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,8 +31,8 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.8.1",
35
- "@zerotal/validator": "1.8.1"
34
+ "@zerotal/core": "1.9.0",
35
+ "@zerotal/validator": "1.9.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "typescript": "^5.8.0"
@@ -0,0 +1,143 @@
1
+ import { Command, type FlagDef } from "@zerotal/core";
2
+ import type { ConfigManager } from "@zerotal/core/config";
3
+ import { _rawStatement } from "../db/DB.ts";
4
+ import { takeBackup, BackupError, type BackupResult } from "./_backup.ts";
5
+
6
+ /**
7
+ * `bun zt db:backup` — take a verified snapshot of the SQLite database.
8
+ *
9
+ * SQLite is the framework's default, and `migrate`, `migrate:fresh` and `db:seed`
10
+ * all assume the file will be there. This is the command that makes that
11
+ * assumption survivable. It uses `VACUUM INTO`, which is safe to run against a
12
+ * database the server is still writing to, then opens what it wrote and checks it
13
+ * before saying the word "backed up".
14
+ *
15
+ * It exits non-zero on every failure, so a systemd timer that wraps it leaves a
16
+ * failed unit somebody can see. That is deliberate and it is the whole design: a
17
+ * backup job that prints a problem and exits 0 buys the confidence without the
18
+ * file, which is worse than having no job at all.
19
+ *
20
+ * @example
21
+ * ```bash
22
+ * bun zt db:backup
23
+ * bun zt db:backup --dir=/var/backups/app --keep=30
24
+ * bun zt db:backup --require-rows=bookings,invoices # nightly
25
+ * bun zt db:backup --rehearse --require-rows=bookings # weekly
26
+ * ```
27
+ *
28
+ * @category Database
29
+ */
30
+ export class DbBackupCommand extends Command {
31
+ static commandName = "db:backup";
32
+ static description = "Take a verified snapshot of the SQLite database";
33
+ static needsApp = true;
34
+
35
+ /** Where snapshots go when nothing says otherwise. */
36
+ static readonly DEFAULT_DIR = "storage/backups";
37
+
38
+ /**
39
+ * Snapshots kept by default.
40
+ *
41
+ * Two weeks: long enough that corruption introduced on a Friday is still
42
+ * recoverable after somebody notices it on the following Monday week, short
43
+ * enough that nobody turns retention off to reclaim a disk.
44
+ */
45
+ static readonly DEFAULT_KEEP = 14;
46
+
47
+ static flags: FlagDef[] = [
48
+ {
49
+ name: "dir",
50
+ type: "string",
51
+ description: `Directory to write snapshots into (default ${DbBackupCommand.DEFAULT_DIR})`,
52
+ default: DbBackupCommand.DEFAULT_DIR,
53
+ },
54
+ {
55
+ name: "keep",
56
+ type: "number",
57
+ description: `Snapshots to keep, newest first; 0 keeps every one (default ${DbBackupCommand.DEFAULT_KEEP})`,
58
+ default: DbBackupCommand.DEFAULT_KEEP,
59
+ },
60
+ {
61
+ name: "require-rows",
62
+ type: "string",
63
+ description:
64
+ "Comma-separated tables that must not be empty in the snapshot, e.g. bookings,invoices",
65
+ default: "",
66
+ },
67
+ {
68
+ name: "rehearse",
69
+ type: "boolean",
70
+ description: "Also perform the restore: copy the snapshot, open the copy, and check it",
71
+ default: false,
72
+ },
73
+ ];
74
+
75
+ async run(): Promise<void> {
76
+ const dir = String(this.flags["dir"] ?? DbBackupCommand.DEFAULT_DIR);
77
+ const keep = Number(this.flags["keep"] ?? DbBackupCommand.DEFAULT_KEEP);
78
+ const requireRows = String(this.flags["require-rows"] ?? "")
79
+ .split(",")
80
+ .map((t) => t.trim())
81
+ .filter(Boolean);
82
+ const rehearse = this.flags["rehearse"] === true;
83
+
84
+ const source = this.#databaseUrl();
85
+
86
+ this.section("Database backup");
87
+ this.dim(`source ${source}`);
88
+ this.dim(`directory ${dir}`);
89
+ this.dim(`keep ${keep === 0 ? "all" : keep}`);
90
+
91
+ let result: BackupResult;
92
+ try {
93
+ result = await takeBackup((sql) => _rawStatement(sql), {
94
+ source,
95
+ dir,
96
+ keep,
97
+ requireRows,
98
+ rehearse,
99
+ });
100
+ } catch (error) {
101
+ // Rethrown, not printed. The runner turns a throw into a non-zero exit, and
102
+ // a non-zero exit is the only part of this a timer can act on.
103
+ if (error instanceof BackupError) throw error;
104
+ throw new BackupError(`Backup failed: ${(error as Error).message}`);
105
+ }
106
+
107
+ this.newLine();
108
+ this.info(`Wrote ${result.path}`);
109
+ const rows: [string, string][] = [
110
+ ["size", `${(result.bytes / 1024 / 1024).toFixed(2)} MB`],
111
+ ["tables", String(result.tables.length)],
112
+ ["integrity", "ok"],
113
+ ];
114
+ for (const [table, count] of Object.entries(result.rows)) {
115
+ rows.push([`rows in ${table}`, String(count)]);
116
+ }
117
+ rows.push(["restore rehearsed", result.rehearsed ? "yes" : "no (pass --rehearse)"]);
118
+ if (result.pruned.length > 0) {
119
+ rows.push(["pruned", `${result.pruned.length} older snapshot(s)`]);
120
+ }
121
+ this.table(rows);
122
+
123
+ if (requireRows.length === 0) {
124
+ this.newLine();
125
+ this.warn(
126
+ "Nothing was asserted about the contents. Pass --require-rows with the tables " +
127
+ "whose loss would end the business, so an empty snapshot fails here.",
128
+ );
129
+ }
130
+ }
131
+
132
+ /** The configured database URL, or `:memory:` when there is no config to read. */
133
+ #databaseUrl(): string {
134
+ try {
135
+ const config = (
136
+ this.app as { container: { makeSync(k: string): unknown } }
137
+ ).container.makeSync("config") as ConfigManager;
138
+ return config.get<string>("database.url", ":memory:");
139
+ } catch {
140
+ return ":memory:";
141
+ }
142
+ }
143
+ }