@zerotal/orm 1.8.1 → 1.10.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/api-surface.md +34 -220
  3. package/package.json +3 -3
  4. package/src/commands/DbBackupCommand.ts +143 -0
  5. package/src/commands/_backup.ts +356 -0
  6. package/src/config.ts +10 -8
  7. package/src/db/DB.ts +27 -2
  8. package/src/db/NPlusOneDetector.ts +2 -2
  9. package/src/db/ReadWriteRouter.ts +2 -0
  10. package/src/db/dialects/MysqlDialect.ts +2 -0
  11. package/src/db/dialects/PostgresDialect.ts +2 -0
  12. package/src/db/dialects/SqliteDialect.ts +2 -0
  13. package/src/db/dialects/index.ts +2 -0
  14. package/src/db/dialects/types.ts +12 -2
  15. package/src/db/resolver.ts +4 -0
  16. package/src/db/types.ts +5 -5
  17. package/src/diagnostics/pendingMigrationsCheck.ts +60 -0
  18. package/src/implicitBinding.ts +1 -0
  19. package/src/model/BaseModel.ts +6 -0
  20. package/src/model/ModelQueryBuilder.ts +1 -0
  21. package/src/model/OrmContext.ts +14 -2
  22. package/src/model/decorators/belongsTo.ts +2 -2
  23. package/src/model/decorators/column.ts +8 -7
  24. package/src/model/decorators/hasMany.ts +1 -1
  25. package/src/model/decorators/hasManyThrough.ts +2 -2
  26. package/src/model/decorators/hasOne.ts +1 -1
  27. package/src/model/decorators/manyToMany.ts +4 -4
  28. package/src/model/decorators/morphMany.ts +1 -1
  29. package/src/model/decorators/morphOne.ts +1 -1
  30. package/src/model/decorators/morphTo.ts +2 -2
  31. package/src/model/decorators/morphToMany.ts +3 -3
  32. package/src/model/decorators/morphedByMany.ts +3 -3
  33. package/src/model/decorators/table.ts +2 -2
  34. package/src/model/hooks/HookRegistry.ts +2 -0
  35. package/src/observability.ts +2 -0
  36. package/src/provider/DatabaseProvider.ts +17 -1
  37. package/src/schema/MigrationCodegen.ts +2 -0
  38. package/src/schema/ModelInspector.ts +4 -0
  39. package/src/schema/SchemaDiffer.ts +6 -0
  40. package/src/schema/SchemaInspector.ts +4 -0
  41. package/src/schema/autoMigrate.ts +15 -3
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
@@ -909,14 +806,14 @@ interface CastField = {
909
806
  }
910
807
 
911
808
  interface ColumnOptions = {
912
- cast?: 'boolean' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'enum' | 'immutable_datetime' | 'encrypted' | 'encrypted:json' | `decimal:${number}` | { get?: (dbValue: unknown) => unknown; set?: (jsValue: unknown) => unknown;} | CastContract<unknown>
809
+ cast?: 'boolean' | 'date' | 'datetime' | 'array' | 'integer' | 'json' | 'float' | 'enum' | 'immutable_datetime' | 'encrypted' | 'encrypted:json' | `decimal:${number}` | { get?: (dbValue: unknown) => unknown; set?: (jsValue: unknown) => unknown;} | CastContract<unknown> | undefined
913
810
  default?: unknown
914
- enumValues?: Record<string, string | number>
915
- index?: boolean
916
- nullable?: boolean
917
- primary?: boolean
918
- type?: 'string' | 'number' | 'boolean' | 'text' | 'datetime' | 'json'
919
- unique?: boolean
811
+ enumValues?: Record<string, string | number> | undefined
812
+ index?: boolean | undefined
813
+ nullable?: boolean | undefined
814
+ primary?: boolean | undefined
815
+ type?: 'string' | 'number' | 'boolean' | 'text' | 'datetime' | 'json' | undefined
816
+ unique?: boolean | undefined
920
817
  }
921
818
 
922
819
  interface Compose = {
@@ -939,29 +836,13 @@ interface CursorPaginateResult = {
939
836
 
940
837
  interface DatabaseConfigShape = {
941
838
  driver: 'sqlite' | 'postgres' | 'mysql'
942
- pool?: { max?: number; idleTimeout?: number;}
943
- replicas?: string[]
839
+ pool?: { max?: number; idleTimeout?: number;} | undefined
840
+ replicas?: string[] | undefined
944
841
  sqlite: { path: string;}
945
- synchronize?: boolean | { enabled: boolean; disruptive?: boolean;}
842
+ synchronize?: boolean | { enabled: boolean; disruptive?: boolean;} | undefined
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
@@ -969,9 +850,9 @@ interface HasMany = {
969
850
 
970
851
  interface HasManyThroughOptions = {
971
852
  firstKey: string
972
- localKey?: string
853
+ localKey?: string | undefined
973
854
  secondKey: string
974
- throughLocalKey?: string
855
+ throughLocalKey?: string | undefined
975
856
  }
976
857
 
977
858
  interface HasOne = {
@@ -980,10 +861,10 @@ interface HasOne = {
980
861
  }
981
862
 
982
863
  interface KeysetOptions = {
983
- column?: string
984
- cursor?: string | null
985
- direction?: 'asc' | 'desc'
986
- limit?: number
864
+ column?: string | undefined
865
+ cursor?: string | null | undefined
866
+ direction?: 'asc' | 'desc' | undefined
867
+ limit?: number | undefined
987
868
  }
988
869
 
989
870
  interface KeysetPaginateResult = {
@@ -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
@@ -1021,13 +890,13 @@ interface ManyToMany = {
1021
890
  }
1022
891
 
1023
892
  interface ManyToManyOptions = {
1024
- localKey?: string
893
+ localKey?: string | undefined
1025
894
  pivotForeignKey: string
1026
895
  pivotRelatedKey: string
1027
896
  pivotTable: string
1028
- relatedKey?: string
1029
- withPivot?: string[]
1030
- withTimestamps?: boolean
897
+ relatedKey?: string | undefined
898
+ withPivot?: string[] | undefined
899
+ withTimestamps?: boolean | undefined
1031
900
  }
1032
901
 
1033
902
  interface MigrationEntry = {
@@ -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,20 +928,12 @@ 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
1083
- pivotTable?: string
1084
- withPivot?: string[]
1085
- withTimestamps?: boolean
934
+ pivotTable?: string | undefined
935
+ withPivot?: string[] | undefined
936
+ withTimestamps?: boolean | undefined
1086
937
  }
1087
938
 
1088
939
  interface MorphMany = {
@@ -1091,7 +942,7 @@ interface MorphMany = {
1091
942
  }
1092
943
 
1093
944
  interface MorphManyOptions = {
1094
- localKey?: string
945
+ localKey?: string | undefined
1095
946
  morphName: string
1096
947
  }
1097
948
 
@@ -1101,7 +952,7 @@ interface MorphOne = {
1101
952
  }
1102
953
 
1103
954
  interface MorphOneOptions = {
1104
- localKey?: string
955
+ localKey?: string | undefined
1105
956
  morphName: string
1106
957
  }
1107
958
 
@@ -1112,30 +963,21 @@ interface MorphTo = {
1112
963
 
1113
964
  interface MorphToManyOptions = {
1114
965
  morphName: string
1115
- pivotTable?: string
966
+ pivotTable?: string | undefined
1116
967
  relatedPivotKey: string
1117
- withPivot?: string[]
1118
- withTimestamps?: boolean
968
+ withPivot?: string[] | undefined
969
+ withTimestamps?: boolean | undefined
1119
970
  }
1120
971
 
1121
972
  interface MorphToOptions = {
1122
- morphForeignKey?: string
973
+ morphForeignKey?: string | undefined
1123
974
  morphMap: Record<string, () => unknown>
1124
- morphTypeColumn?: string
1125
- }
1126
-
1127
- interface NewColumn = {
1128
- column: ModelColumn
1129
- table: string
1130
- }
1131
-
1132
- interface NewTable = {
1133
- schema: ModelSchema
975
+ morphTypeColumn?: string | undefined
1134
976
  }
1135
977
 
1136
978
  interface NPlusOneOptions = {
1137
- mode?: 'warn' | 'throw'
1138
- threshold?: number
979
+ mode?: 'warn' | 'throw' | undefined
980
+ threshold?: number | undefined
1139
981
  }
1140
982
 
1141
983
  interface PaginateMeta = {
@@ -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
@@ -1249,8 +1067,8 @@ interface TableDecoratorBuilder = {
1249
1067
  }
1250
1068
 
1251
1069
  interface TableOptions = {
1252
- primaryKey?: string
1253
- timestamps?: boolean
1070
+ primaryKey?: string | undefined
1071
+ timestamps?: boolean | undefined
1254
1072
  }
1255
1073
 
1256
1074
  interface TransitionContext = {
@@ -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.10.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.10.0",
35
+ "@zerotal/validator": "1.10.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
+ }