@zerotal/orm 1.8.0 → 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 +45 -0
- package/api-surface.md +0 -186
- package/package.json +3 -3
- package/src/commands/DbBackupCommand.ts +143 -0
- package/src/commands/_backup.ts +356 -0
- package/src/db/DB.ts +27 -2
- package/src/db/ReadWriteRouter.ts +2 -0
- package/src/db/dialects/MysqlDialect.ts +2 -0
- package/src/db/dialects/PostgresDialect.ts +2 -0
- package/src/db/dialects/SqliteDialect.ts +2 -0
- package/src/db/dialects/index.ts +2 -0
- package/src/db/dialects/types.ts +12 -2
- package/src/db/resolver.ts +4 -0
- package/src/diagnostics/pendingMigrationsCheck.ts +60 -0
- package/src/implicitBinding.ts +1 -0
- package/src/model/BaseModel.ts +6 -0
- package/src/model/ModelQueryBuilder.ts +1 -0
- package/src/model/OrmContext.ts +14 -2
- package/src/model/hooks/HookRegistry.ts +2 -0
- package/src/observability.ts +2 -0
- package/src/provider/DatabaseProvider.ts +17 -1
- package/src/schema/MigrationCodegen.ts +2 -0
- package/src/schema/ModelInspector.ts +4 -0
- package/src/schema/SchemaDiffer.ts +6 -0
- package/src/schema/SchemaInspector.ts +4 -0
- package/src/schema/autoMigrate.ts +14 -2
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.
|
|
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.
|
|
35
|
-
"@zerotal/validator": "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
|
+
}
|