@nest-yalc-2/omnikernel-module 2.2.2 → 2.2.4
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/package.json +17 -15
- package/src/__tests__/omni-collection.entity.spec.ts +49 -49
- package/src/__tests__/omni-collection.service.spec.ts +83 -83
- package/src/__tests__/omni-document.entity.spec.ts +55 -55
- package/src/__tests__/omni-document.service.spec.ts +83 -83
- package/src/__tests__/omni-extension-projection-constraints.spec.ts +518 -518
- package/src/__tests__/omni-extension-projection.registration.spec.ts +947 -946
- package/src/__tests__/omni-extension-projection.service.spec.ts +806 -806
- package/src/__tests__/omni-external-ref.entity.spec.ts +36 -36
- package/src/__tests__/omni-external-ref.service.spec.ts +252 -252
- package/src/__tests__/omni-named.entity.spec.ts +33 -33
- package/src/__tests__/omni-record.entity.spec.ts +32 -32
- package/src/__tests__/omni-record.service.spec.ts +107 -107
- package/src/__tests__/omni-relation-semantics.spec.ts +102 -102
- package/src/__tests__/omni-relation.entity.spec.ts +38 -38
- package/src/__tests__/omni-scope-substrate.spec.ts +81 -81
- package/src/__tests__/omni-scoped.backend.spec.ts +115 -115
- package/src/__tests__/omni-scoped.service.spec.ts +354 -354
- package/src/__tests__/omnikernel.diagnostics.spec.ts +94 -94
- package/src/__tests__/omnikernel.module.spec.ts +38 -40
- package/src/__tests__/omnikernel.persistence.spec.ts +154 -154
- package/src/__tests__/omnikernel.public-api.spec.ts +238 -238
- package/src/__tests__/omnikernel.query.service.spec.ts +155 -155
- package/src/base/omni-base.entity.ts +41 -41
- package/src/base/omni-external-ref.entity.ts +53 -53
- package/src/base/omni-named.entity.ts +16 -16
- package/src/base/omni-record.entity.ts +51 -51
- package/src/base/omni-relation.entity.ts +72 -72
- package/src/index.ts +49 -49
- package/src/omni-collection-kind.enum.ts +10 -10
- package/src/omni-collection.backend.ts +16 -16
- package/src/omni-collection.dto.ts +170 -170
- package/src/omni-collection.entity.ts +21 -21
- package/src/omni-collection.service.ts +75 -75
- package/src/omni-document-kind.enum.ts +12 -12
- package/src/omni-document.backend.ts +16 -16
- package/src/omni-document.dto.ts +198 -198
- package/src/omni-document.entity.ts +30 -30
- package/src/omni-document.service.ts +75 -75
- package/src/omni-dto.helpers.ts +21 -21
- package/src/omni-extension-projection.definition.ts +294 -294
- package/src/omni-extension-projection.resource.ts +274 -274
- package/src/omni-extension-projection.service.ts +452 -452
- package/src/omni-external-ref-binding.validator.ts +53 -53
- package/src/omni-external-ref-internal-type.enum.ts +11 -11
- package/src/omni-external-ref.backend.ts +25 -25
- package/src/omni-external-ref.dto.ts +128 -128
- package/src/omni-external-ref.service.ts +255 -255
- package/src/omni-migration.ts +365 -365
- package/src/omni-named.backend.ts +11 -11
- package/src/omni-named.dto.ts +86 -86
- package/src/omni-projection.catalog.ts +345 -345
- package/src/omni-projection.lifecycle.ts +53 -53
- package/src/omni-record-status.enum.ts +11 -11
- package/src/omni-record.backend.ts +19 -19
- package/src/omni-record.dto.ts +157 -157
- package/src/omni-record.service.ts +113 -113
- package/src/omni-relation-kind.contract.ts +49 -49
- package/src/omni-relation-kind.enum.ts +12 -12
- package/src/omni-relation-projection.definition.ts +166 -166
- package/src/omni-relation-projection.resource.ts +360 -360
- package/src/omni-relation-projection.service.ts +610 -610
- package/src/omni-relation-semantics.ts +52 -52
- package/src/omni-relation-status.enum.ts +11 -11
- package/src/omni-relation.backend.ts +21 -21
- package/src/omni-relation.dto.ts +142 -142
- package/src/omni-relation.service.ts +159 -159
- package/src/omni-scope.ts +161 -161
- package/src/omni-scoped.backend.ts +110 -110
- package/src/omni-scoped.repository.ts +34 -34
- package/src/omni-scoped.service.ts +338 -338
- package/src/omnikernel.diagnostics.ts +83 -83
- package/src/omnikernel.module.ts +97 -97
- package/src/omnikernel.query.service.ts +108 -108
package/src/omni-migration.ts
CHANGED
|
@@ -1,365 +1,365 @@
|
|
|
1
|
-
import type { DataSource, EntityTarget, ObjectLiteral } from 'typeorm';
|
|
2
|
-
import { Table, TableForeignKey, type TableOptions } from 'typeorm';
|
|
3
|
-
import {
|
|
4
|
-
createProjectionDialect,
|
|
5
|
-
type ProjectionResourceDefinition,
|
|
6
|
-
} from '@nest-yalc-2/crud-gen';
|
|
7
|
-
import { OmniExternalRefEntity } from './base/omni-external-ref.entity.js';
|
|
8
|
-
import { OmniNamedEntity } from './base/omni-named.entity.js';
|
|
9
|
-
import { OmniRecordEntity } from './base/omni-record.entity.js';
|
|
10
|
-
import { OmniRelationEntity } from './base/omni-relation.entity.js';
|
|
11
|
-
import { OmniCollectionEntity } from './omni-collection.entity.js';
|
|
12
|
-
import { OmniDocumentEntity } from './omni-document.entity.js';
|
|
13
|
-
|
|
14
|
-
export interface OmniMigrationSnapshot {
|
|
15
|
-
/** A migration-owned identifier, for example `omni-v1`. */
|
|
16
|
-
readonly version: string;
|
|
17
|
-
/** The driver whose physical column types were captured. */
|
|
18
|
-
readonly dialect: 'sqlite' | 'postgres';
|
|
19
|
-
/** Immutable, serializable TypeORM TableOptions captured during authoring. */
|
|
20
|
-
readonly tables: readonly TableOptions[];
|
|
21
|
-
/** Immutable ProjectionDialect DDL for JSON/expression indexes. */
|
|
22
|
-
readonly indexStatements: readonly string[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The portable subset of a migration runner used by an Omni migration plan.
|
|
27
|
-
*
|
|
28
|
-
* It deliberately does not reference TypeORM's `QueryRunner` type. A package
|
|
29
|
-
* manager may give the module and a consuming migration distinct physical
|
|
30
|
-
* TypeORM installations, even at the same version; exposing that branded
|
|
31
|
-
* framework type would then reject the application's runner. TypeORM runners
|
|
32
|
-
* satisfy this structural capability contract without a consumer cast.
|
|
33
|
-
*/
|
|
34
|
-
export interface OmniMigrationRunner {
|
|
35
|
-
createTable(
|
|
36
|
-
table: unknown,
|
|
37
|
-
ifNotExist?: boolean,
|
|
38
|
-
createForeignKeys?: boolean,
|
|
39
|
-
createIndices?: boolean,
|
|
40
|
-
): Promise<void>;
|
|
41
|
-
query(statement: string): Promise<unknown>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export interface OmniMigrationPlan {
|
|
45
|
-
readonly version: string;
|
|
46
|
-
readonly tableNames: readonly string[];
|
|
47
|
-
/** Dialect-compiled index DDL that the plan executes after all tables exist. */
|
|
48
|
-
readonly indexStatements: readonly string[];
|
|
49
|
-
/** Returns fresh mutable Table instances from the versioned source snapshot. */
|
|
50
|
-
createTables(): Table[];
|
|
51
|
-
/** Intended only from a TypeORM migration up() method. */
|
|
52
|
-
create(queryRunner: OmniMigrationRunner): Promise<void>;
|
|
53
|
-
/** Intended only from a TypeORM migration down() method. */
|
|
54
|
-
drop(queryRunner: OmniMigrationRunner): Promise<void>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The migration-relevant subset of a registered extension composition. It
|
|
59
|
-
* keeps schema authoring tied to the same immutable projection definition as
|
|
60
|
-
* the generated transport and service, without making runtime metadata a
|
|
61
|
-
* migration dependency.
|
|
62
|
-
*/
|
|
63
|
-
export interface OmniMigrationExtensionRegistration {
|
|
64
|
-
readonly entities: readonly EntityTarget<any>[];
|
|
65
|
-
readonly definition: ProjectionResourceDefinition;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const omniBaseEntities = [
|
|
69
|
-
OmniNamedEntity,
|
|
70
|
-
OmniRecordEntity,
|
|
71
|
-
OmniDocumentEntity,
|
|
72
|
-
OmniCollectionEntity,
|
|
73
|
-
OmniRelationEntity,
|
|
74
|
-
OmniExternalRefEntity,
|
|
75
|
-
] as const;
|
|
76
|
-
|
|
77
|
-
function freezeDeep<T>(value: T): T {
|
|
78
|
-
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
79
|
-
for (const child of Object.values(value as Record<string, unknown>)) {
|
|
80
|
-
freezeDeep(child);
|
|
81
|
-
}
|
|
82
|
-
Object.freeze(value);
|
|
83
|
-
}
|
|
84
|
-
return value;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function dialectFor(dataSource: DataSource): 'sqlite' | 'postgres' {
|
|
88
|
-
if (dataSource.options.type === 'sqlite') return 'sqlite';
|
|
89
|
-
if (dataSource.options.type === 'postgres') return 'postgres';
|
|
90
|
-
throw new TypeError('Omni migration snapshots support SQLite or PostgreSQL.');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function tableOptions(table: Table): TableOptions {
|
|
94
|
-
return {
|
|
95
|
-
database: table.database,
|
|
96
|
-
schema: table.schema,
|
|
97
|
-
name: table.name,
|
|
98
|
-
withoutRowid: table.withoutRowid,
|
|
99
|
-
engine: table.engine,
|
|
100
|
-
comment: table.comment,
|
|
101
|
-
columns: table.columns.map((column) => ({
|
|
102
|
-
name: column.name,
|
|
103
|
-
type: column.type,
|
|
104
|
-
default: column.default,
|
|
105
|
-
onUpdate: column.onUpdate,
|
|
106
|
-
isNullable: column.isNullable,
|
|
107
|
-
isGenerated: column.isGenerated,
|
|
108
|
-
generationStrategy: column.generationStrategy,
|
|
109
|
-
isPrimary: column.isPrimary,
|
|
110
|
-
isUnique: column.isUnique,
|
|
111
|
-
isArray: column.isArray,
|
|
112
|
-
comment: column.comment,
|
|
113
|
-
length: column.length,
|
|
114
|
-
width: column.width,
|
|
115
|
-
charset: column.charset,
|
|
116
|
-
collation: column.collation,
|
|
117
|
-
precision: column.precision,
|
|
118
|
-
scale: column.scale,
|
|
119
|
-
zerofill: column.zerofill,
|
|
120
|
-
unsigned: column.unsigned,
|
|
121
|
-
enum: column.enum ? [...column.enum] : undefined,
|
|
122
|
-
enumName: column.enumName,
|
|
123
|
-
primaryKeyConstraintName: column.primaryKeyConstraintName,
|
|
124
|
-
asExpression: column.asExpression,
|
|
125
|
-
generatedType: column.generatedType,
|
|
126
|
-
generatedIdentity: column.generatedIdentity,
|
|
127
|
-
spatialFeatureType: column.spatialFeatureType,
|
|
128
|
-
srid: column.srid,
|
|
129
|
-
})),
|
|
130
|
-
indices: table.indices.map((index) => ({
|
|
131
|
-
name: index.name,
|
|
132
|
-
columnNames: [...index.columnNames],
|
|
133
|
-
isUnique: index.isUnique,
|
|
134
|
-
isSpatial: index.isSpatial,
|
|
135
|
-
isConcurrent: index.isConcurrent,
|
|
136
|
-
isFulltext: index.isFulltext,
|
|
137
|
-
isNullFiltered: index.isNullFiltered,
|
|
138
|
-
parser: index.parser,
|
|
139
|
-
where: index.where,
|
|
140
|
-
})),
|
|
141
|
-
foreignKeys: table.foreignKeys.map((foreignKey) => ({
|
|
142
|
-
name: foreignKey.name,
|
|
143
|
-
columnNames: [...foreignKey.columnNames],
|
|
144
|
-
referencedDatabase: foreignKey.referencedDatabase,
|
|
145
|
-
referencedSchema: foreignKey.referencedSchema,
|
|
146
|
-
referencedTableName: foreignKey.referencedTableName,
|
|
147
|
-
referencedColumnNames: [...foreignKey.referencedColumnNames],
|
|
148
|
-
onDelete: foreignKey.onDelete,
|
|
149
|
-
onUpdate: foreignKey.onUpdate,
|
|
150
|
-
deferrable: foreignKey.deferrable,
|
|
151
|
-
})),
|
|
152
|
-
uniques: table.uniques.map((unique) => ({
|
|
153
|
-
name: unique.name,
|
|
154
|
-
columnNames: [...unique.columnNames],
|
|
155
|
-
deferrable: unique.deferrable,
|
|
156
|
-
})),
|
|
157
|
-
checks: table.checks.map((check) => ({
|
|
158
|
-
name: check.name,
|
|
159
|
-
columnNames: check.columnNames ? [...check.columnNames] : undefined,
|
|
160
|
-
expression: check.expression,
|
|
161
|
-
})),
|
|
162
|
-
exclusions: table.exclusions.map((exclusion) => ({
|
|
163
|
-
name: exclusion.name,
|
|
164
|
-
expression: exclusion.expression,
|
|
165
|
-
})),
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function tableSnapshots(
|
|
170
|
-
dataSource: DataSource,
|
|
171
|
-
entities: readonly EntityTarget<ObjectLiteral>[],
|
|
172
|
-
): TableOptions[] {
|
|
173
|
-
const byTableName = new Map<string, Table>();
|
|
174
|
-
for (const entity of entities) {
|
|
175
|
-
const metadata = dataSource.getMetadata(entity);
|
|
176
|
-
const table = Table.create(metadata, dataSource.driver);
|
|
177
|
-
table.foreignKeys = metadata.foreignKeys.map((foreignKey) =>
|
|
178
|
-
TableForeignKey.create(foreignKey, dataSource.driver),
|
|
179
|
-
);
|
|
180
|
-
const current = byTableName.get(table.name);
|
|
181
|
-
if (!current || current.columns.length < table.columns.length) {
|
|
182
|
-
byTableName.set(table.name, table);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
return orderTablesTopologically([...byTableName.values()].map(tableOptions));
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Orders a complete migration snapshot from FK parents to children. A
|
|
191
|
-
* migration may not defer this to registration order: PostgreSQL requires the
|
|
192
|
-
* referenced table to exist at FK creation time. Self references are valid;
|
|
193
|
-
* cross-table cycles are rejected during migration authoring.
|
|
194
|
-
*/
|
|
195
|
-
function orderTablesTopologically(
|
|
196
|
-
tables: readonly TableOptions[],
|
|
197
|
-
): TableOptions[] {
|
|
198
|
-
const byName = new Map<string, TableOptions>();
|
|
199
|
-
for (const table of tables) {
|
|
200
|
-
if (!table.name)
|
|
201
|
-
throw new TypeError('Omni migration table name is required.');
|
|
202
|
-
byName.set(table.name, table);
|
|
203
|
-
}
|
|
204
|
-
const dependencies = new Map<string, Set<string>>();
|
|
205
|
-
for (const table of tables) {
|
|
206
|
-
const tableName = table.name!;
|
|
207
|
-
const parents = new Set<string>();
|
|
208
|
-
for (const foreignKey of table.foreignKeys ?? []) {
|
|
209
|
-
const parent = foreignKey.referencedTableName;
|
|
210
|
-
if (!parent || parent === tableName) continue;
|
|
211
|
-
if (!byName.has(parent)) {
|
|
212
|
-
throw new TypeError(
|
|
213
|
-
`Omni migration table ${tableName} references ${parent}, which is absent from this snapshot.`,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
parents.add(parent);
|
|
217
|
-
}
|
|
218
|
-
dependencies.set(tableName, parents);
|
|
219
|
-
}
|
|
220
|
-
const remaining = new Map(
|
|
221
|
-
[...dependencies.entries()].map(([name, parents]) => [
|
|
222
|
-
name,
|
|
223
|
-
new Set(parents),
|
|
224
|
-
]),
|
|
225
|
-
);
|
|
226
|
-
const ordered: TableOptions[] = [];
|
|
227
|
-
while (remaining.size > 0) {
|
|
228
|
-
const ready = tables.filter(
|
|
229
|
-
(table) =>
|
|
230
|
-
table.name !== undefined && remaining.get(table.name)?.size === 0,
|
|
231
|
-
);
|
|
232
|
-
if (ready.length === 0) {
|
|
233
|
-
throw new TypeError(
|
|
234
|
-
`Omni migration snapshot has a cross-table foreign-key cycle: ${[
|
|
235
|
-
...remaining.keys(),
|
|
236
|
-
].join(', ')}.`,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
for (const table of ready) {
|
|
240
|
-
const name = table.name!;
|
|
241
|
-
remaining.delete(name);
|
|
242
|
-
ordered.push(table);
|
|
243
|
-
for (const parents of remaining.values()) parents.delete(name);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
return ordered;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function quotedIdentifier(identifier: string): string {
|
|
250
|
-
return `"${identifier.replaceAll('"', '""')}"`;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function quotedTableName(table: TableOptions): string {
|
|
254
|
-
if (!table.name)
|
|
255
|
-
throw new TypeError('Omni migration table name is required.');
|
|
256
|
-
return [table.schema, table.name]
|
|
257
|
-
.filter(
|
|
258
|
-
(part): part is string => typeof part === 'string' && part.length > 0,
|
|
259
|
-
)
|
|
260
|
-
.map(quotedIdentifier)
|
|
261
|
-
.join('.');
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Authoring-only helper. Run this against a metadata-only DataSource and copy
|
|
266
|
-
* the returned value into a versioned migration source using
|
|
267
|
-
* defineOmniMigrationSnapshot. Never invoke it from a migration at runtime.
|
|
268
|
-
*/
|
|
269
|
-
export function captureOmniMigrationSnapshot(
|
|
270
|
-
version: string,
|
|
271
|
-
dataSource: DataSource,
|
|
272
|
-
extensions: readonly OmniMigrationExtensionRegistration[] = [],
|
|
273
|
-
): OmniMigrationSnapshot {
|
|
274
|
-
const dialect = dialectFor(dataSource);
|
|
275
|
-
return defineOmniMigrationSnapshot({
|
|
276
|
-
version,
|
|
277
|
-
dialect,
|
|
278
|
-
tables: tableSnapshots(dataSource, [
|
|
279
|
-
...omniBaseEntities,
|
|
280
|
-
...extensions.flatMap((extension) => extension.entities),
|
|
281
|
-
]),
|
|
282
|
-
indexStatements: extensions.flatMap((extension) =>
|
|
283
|
-
createProjectionDialect(dialect).compileIndexStatements(
|
|
284
|
-
extension.definition,
|
|
285
|
-
),
|
|
286
|
-
),
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Freezes a migration-owned table snapshot. Old migration source calls this
|
|
292
|
-
* with a literal captured at authoring time, so future entity changes cannot
|
|
293
|
-
* alter its DDL.
|
|
294
|
-
*/
|
|
295
|
-
export function defineOmniMigrationSnapshot(
|
|
296
|
-
snapshot: OmniMigrationSnapshot,
|
|
297
|
-
): OmniMigrationSnapshot {
|
|
298
|
-
if (
|
|
299
|
-
typeof snapshot.version !== 'string' ||
|
|
300
|
-
snapshot.version.trim().length === 0
|
|
301
|
-
) {
|
|
302
|
-
throw new TypeError('Omni migration snapshot version is required.');
|
|
303
|
-
}
|
|
304
|
-
if (snapshot.dialect !== 'sqlite' && snapshot.dialect !== 'postgres') {
|
|
305
|
-
throw new TypeError('Omni migration snapshot dialect is unsupported.');
|
|
306
|
-
}
|
|
307
|
-
if (!Array.isArray(snapshot.tables) || snapshot.tables.length === 0) {
|
|
308
|
-
throw new TypeError('Omni migration snapshot requires at least one table.');
|
|
309
|
-
}
|
|
310
|
-
const names = snapshot.tables.map((table) => table.name);
|
|
311
|
-
if (
|
|
312
|
-
names.some((name) => typeof name !== 'string' || name.length === 0) ||
|
|
313
|
-
new Set(names).size !== names.length
|
|
314
|
-
) {
|
|
315
|
-
throw new TypeError('Omni migration snapshot table names must be unique.');
|
|
316
|
-
}
|
|
317
|
-
if (
|
|
318
|
-
!Array.isArray(snapshot.indexStatements) ||
|
|
319
|
-
snapshot.indexStatements.some(
|
|
320
|
-
(statement) =>
|
|
321
|
-
typeof statement !== 'string' || statement.trim().length === 0,
|
|
322
|
-
) ||
|
|
323
|
-
new Set(snapshot.indexStatements).size !== snapshot.indexStatements.length
|
|
324
|
-
) {
|
|
325
|
-
throw new TypeError(
|
|
326
|
-
'Omni migration snapshot index statements must be unique non-empty strings.',
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
return freezeDeep(structuredClone(snapshot));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Creates a migration executor from an already-versioned snapshot. This API
|
|
334
|
-
* deliberately accepts no DataSource or entity classes: runtime metadata is
|
|
335
|
-
* never a migration authority.
|
|
336
|
-
*/
|
|
337
|
-
export function createOmniMigrationPlan(
|
|
338
|
-
snapshot: OmniMigrationSnapshot,
|
|
339
|
-
): OmniMigrationPlan {
|
|
340
|
-
const source = defineOmniMigrationSnapshot(snapshot);
|
|
341
|
-
const tables = orderTablesTopologically(source.tables);
|
|
342
|
-
|
|
343
|
-
return Object.freeze({
|
|
344
|
-
version: source.version,
|
|
345
|
-
tableNames: Object.freeze(tables.map((table) => table.name)),
|
|
346
|
-
indexStatements: Object.freeze([...source.indexStatements]),
|
|
347
|
-
createTables: () =>
|
|
348
|
-
tables.map((table) => new Table(structuredClone(table))),
|
|
349
|
-
create: async (queryRunner: OmniMigrationRunner) => {
|
|
350
|
-
for (const table of tables) {
|
|
351
|
-
await queryRunner.createTable(new Table(structuredClone(table)));
|
|
352
|
-
}
|
|
353
|
-
for (const statement of source.indexStatements) {
|
|
354
|
-
await queryRunner.query(statement);
|
|
355
|
-
}
|
|
356
|
-
},
|
|
357
|
-
drop: async (queryRunner: OmniMigrationRunner) => {
|
|
358
|
-
for (const table of [...tables].reverse()) {
|
|
359
|
-
await queryRunner.query(
|
|
360
|
-
`DROP TABLE IF EXISTS ${quotedTableName(table)}`,
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
},
|
|
364
|
-
});
|
|
365
|
-
}
|
|
1
|
+
import type { DataSource, EntityTarget, ObjectLiteral } from 'typeorm';
|
|
2
|
+
import { Table, TableForeignKey, type TableOptions } from 'typeorm';
|
|
3
|
+
import {
|
|
4
|
+
createProjectionDialect,
|
|
5
|
+
type ProjectionResourceDefinition,
|
|
6
|
+
} from '@nest-yalc-2/crud-gen';
|
|
7
|
+
import { OmniExternalRefEntity } from './base/omni-external-ref.entity.js';
|
|
8
|
+
import { OmniNamedEntity } from './base/omni-named.entity.js';
|
|
9
|
+
import { OmniRecordEntity } from './base/omni-record.entity.js';
|
|
10
|
+
import { OmniRelationEntity } from './base/omni-relation.entity.js';
|
|
11
|
+
import { OmniCollectionEntity } from './omni-collection.entity.js';
|
|
12
|
+
import { OmniDocumentEntity } from './omni-document.entity.js';
|
|
13
|
+
|
|
14
|
+
export interface OmniMigrationSnapshot {
|
|
15
|
+
/** A migration-owned identifier, for example `omni-v1`. */
|
|
16
|
+
readonly version: string;
|
|
17
|
+
/** The driver whose physical column types were captured. */
|
|
18
|
+
readonly dialect: 'sqlite' | 'postgres';
|
|
19
|
+
/** Immutable, serializable TypeORM TableOptions captured during authoring. */
|
|
20
|
+
readonly tables: readonly TableOptions[];
|
|
21
|
+
/** Immutable ProjectionDialect DDL for JSON/expression indexes. */
|
|
22
|
+
readonly indexStatements: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The portable subset of a migration runner used by an Omni migration plan.
|
|
27
|
+
*
|
|
28
|
+
* It deliberately does not reference TypeORM's `QueryRunner` type. A package
|
|
29
|
+
* manager may give the module and a consuming migration distinct physical
|
|
30
|
+
* TypeORM installations, even at the same version; exposing that branded
|
|
31
|
+
* framework type would then reject the application's runner. TypeORM runners
|
|
32
|
+
* satisfy this structural capability contract without a consumer cast.
|
|
33
|
+
*/
|
|
34
|
+
export interface OmniMigrationRunner {
|
|
35
|
+
createTable(
|
|
36
|
+
table: unknown,
|
|
37
|
+
ifNotExist?: boolean,
|
|
38
|
+
createForeignKeys?: boolean,
|
|
39
|
+
createIndices?: boolean,
|
|
40
|
+
): Promise<void>;
|
|
41
|
+
query(statement: string): Promise<unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OmniMigrationPlan {
|
|
45
|
+
readonly version: string;
|
|
46
|
+
readonly tableNames: readonly string[];
|
|
47
|
+
/** Dialect-compiled index DDL that the plan executes after all tables exist. */
|
|
48
|
+
readonly indexStatements: readonly string[];
|
|
49
|
+
/** Returns fresh mutable Table instances from the versioned source snapshot. */
|
|
50
|
+
createTables(): Table[];
|
|
51
|
+
/** Intended only from a TypeORM migration up() method. */
|
|
52
|
+
create(queryRunner: OmniMigrationRunner): Promise<void>;
|
|
53
|
+
/** Intended only from a TypeORM migration down() method. */
|
|
54
|
+
drop(queryRunner: OmniMigrationRunner): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The migration-relevant subset of a registered extension composition. It
|
|
59
|
+
* keeps schema authoring tied to the same immutable projection definition as
|
|
60
|
+
* the generated transport and service, without making runtime metadata a
|
|
61
|
+
* migration dependency.
|
|
62
|
+
*/
|
|
63
|
+
export interface OmniMigrationExtensionRegistration {
|
|
64
|
+
readonly entities: readonly EntityTarget<any>[];
|
|
65
|
+
readonly definition: ProjectionResourceDefinition;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const omniBaseEntities = [
|
|
69
|
+
OmniNamedEntity,
|
|
70
|
+
OmniRecordEntity,
|
|
71
|
+
OmniDocumentEntity,
|
|
72
|
+
OmniCollectionEntity,
|
|
73
|
+
OmniRelationEntity,
|
|
74
|
+
OmniExternalRefEntity,
|
|
75
|
+
] as const;
|
|
76
|
+
|
|
77
|
+
function freezeDeep<T>(value: T): T {
|
|
78
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
79
|
+
for (const child of Object.values(value as Record<string, unknown>)) {
|
|
80
|
+
freezeDeep(child);
|
|
81
|
+
}
|
|
82
|
+
Object.freeze(value);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function dialectFor(dataSource: DataSource): 'sqlite' | 'postgres' {
|
|
88
|
+
if (dataSource.options.type === 'sqlite') return 'sqlite';
|
|
89
|
+
if (dataSource.options.type === 'postgres') return 'postgres';
|
|
90
|
+
throw new TypeError('Omni migration snapshots support SQLite or PostgreSQL.');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tableOptions(table: Table): TableOptions {
|
|
94
|
+
return {
|
|
95
|
+
database: table.database,
|
|
96
|
+
schema: table.schema,
|
|
97
|
+
name: table.name,
|
|
98
|
+
withoutRowid: table.withoutRowid,
|
|
99
|
+
engine: table.engine,
|
|
100
|
+
comment: table.comment,
|
|
101
|
+
columns: table.columns.map((column) => ({
|
|
102
|
+
name: column.name,
|
|
103
|
+
type: column.type,
|
|
104
|
+
default: column.default,
|
|
105
|
+
onUpdate: column.onUpdate,
|
|
106
|
+
isNullable: column.isNullable,
|
|
107
|
+
isGenerated: column.isGenerated,
|
|
108
|
+
generationStrategy: column.generationStrategy,
|
|
109
|
+
isPrimary: column.isPrimary,
|
|
110
|
+
isUnique: column.isUnique,
|
|
111
|
+
isArray: column.isArray,
|
|
112
|
+
comment: column.comment,
|
|
113
|
+
length: column.length,
|
|
114
|
+
width: column.width,
|
|
115
|
+
charset: column.charset,
|
|
116
|
+
collation: column.collation,
|
|
117
|
+
precision: column.precision,
|
|
118
|
+
scale: column.scale,
|
|
119
|
+
zerofill: column.zerofill,
|
|
120
|
+
unsigned: column.unsigned,
|
|
121
|
+
enum: column.enum ? [...column.enum] : undefined,
|
|
122
|
+
enumName: column.enumName,
|
|
123
|
+
primaryKeyConstraintName: column.primaryKeyConstraintName,
|
|
124
|
+
asExpression: column.asExpression,
|
|
125
|
+
generatedType: column.generatedType,
|
|
126
|
+
generatedIdentity: column.generatedIdentity,
|
|
127
|
+
spatialFeatureType: column.spatialFeatureType,
|
|
128
|
+
srid: column.srid,
|
|
129
|
+
})),
|
|
130
|
+
indices: table.indices.map((index) => ({
|
|
131
|
+
name: index.name,
|
|
132
|
+
columnNames: [...index.columnNames],
|
|
133
|
+
isUnique: index.isUnique,
|
|
134
|
+
isSpatial: index.isSpatial,
|
|
135
|
+
isConcurrent: index.isConcurrent,
|
|
136
|
+
isFulltext: index.isFulltext,
|
|
137
|
+
isNullFiltered: index.isNullFiltered,
|
|
138
|
+
parser: index.parser,
|
|
139
|
+
where: index.where,
|
|
140
|
+
})),
|
|
141
|
+
foreignKeys: table.foreignKeys.map((foreignKey) => ({
|
|
142
|
+
name: foreignKey.name,
|
|
143
|
+
columnNames: [...foreignKey.columnNames],
|
|
144
|
+
referencedDatabase: foreignKey.referencedDatabase,
|
|
145
|
+
referencedSchema: foreignKey.referencedSchema,
|
|
146
|
+
referencedTableName: foreignKey.referencedTableName,
|
|
147
|
+
referencedColumnNames: [...foreignKey.referencedColumnNames],
|
|
148
|
+
onDelete: foreignKey.onDelete,
|
|
149
|
+
onUpdate: foreignKey.onUpdate,
|
|
150
|
+
deferrable: foreignKey.deferrable,
|
|
151
|
+
})),
|
|
152
|
+
uniques: table.uniques.map((unique) => ({
|
|
153
|
+
name: unique.name,
|
|
154
|
+
columnNames: [...unique.columnNames],
|
|
155
|
+
deferrable: unique.deferrable,
|
|
156
|
+
})),
|
|
157
|
+
checks: table.checks.map((check) => ({
|
|
158
|
+
name: check.name,
|
|
159
|
+
columnNames: check.columnNames ? [...check.columnNames] : undefined,
|
|
160
|
+
expression: check.expression,
|
|
161
|
+
})),
|
|
162
|
+
exclusions: table.exclusions.map((exclusion) => ({
|
|
163
|
+
name: exclusion.name,
|
|
164
|
+
expression: exclusion.expression,
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function tableSnapshots(
|
|
170
|
+
dataSource: DataSource,
|
|
171
|
+
entities: readonly EntityTarget<ObjectLiteral>[],
|
|
172
|
+
): TableOptions[] {
|
|
173
|
+
const byTableName = new Map<string, Table>();
|
|
174
|
+
for (const entity of entities) {
|
|
175
|
+
const metadata = dataSource.getMetadata(entity);
|
|
176
|
+
const table = Table.create(metadata, dataSource.driver);
|
|
177
|
+
table.foreignKeys = metadata.foreignKeys.map((foreignKey) =>
|
|
178
|
+
TableForeignKey.create(foreignKey, dataSource.driver),
|
|
179
|
+
);
|
|
180
|
+
const current = byTableName.get(table.name);
|
|
181
|
+
if (!current || current.columns.length < table.columns.length) {
|
|
182
|
+
byTableName.set(table.name, table);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return orderTablesTopologically([...byTableName.values()].map(tableOptions));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Orders a complete migration snapshot from FK parents to children. A
|
|
191
|
+
* migration may not defer this to registration order: PostgreSQL requires the
|
|
192
|
+
* referenced table to exist at FK creation time. Self references are valid;
|
|
193
|
+
* cross-table cycles are rejected during migration authoring.
|
|
194
|
+
*/
|
|
195
|
+
function orderTablesTopologically(
|
|
196
|
+
tables: readonly TableOptions[],
|
|
197
|
+
): TableOptions[] {
|
|
198
|
+
const byName = new Map<string, TableOptions>();
|
|
199
|
+
for (const table of tables) {
|
|
200
|
+
if (!table.name)
|
|
201
|
+
throw new TypeError('Omni migration table name is required.');
|
|
202
|
+
byName.set(table.name, table);
|
|
203
|
+
}
|
|
204
|
+
const dependencies = new Map<string, Set<string>>();
|
|
205
|
+
for (const table of tables) {
|
|
206
|
+
const tableName = table.name!;
|
|
207
|
+
const parents = new Set<string>();
|
|
208
|
+
for (const foreignKey of table.foreignKeys ?? []) {
|
|
209
|
+
const parent = foreignKey.referencedTableName;
|
|
210
|
+
if (!parent || parent === tableName) continue;
|
|
211
|
+
if (!byName.has(parent)) {
|
|
212
|
+
throw new TypeError(
|
|
213
|
+
`Omni migration table ${tableName} references ${parent}, which is absent from this snapshot.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
parents.add(parent);
|
|
217
|
+
}
|
|
218
|
+
dependencies.set(tableName, parents);
|
|
219
|
+
}
|
|
220
|
+
const remaining = new Map(
|
|
221
|
+
[...dependencies.entries()].map(([name, parents]) => [
|
|
222
|
+
name,
|
|
223
|
+
new Set(parents),
|
|
224
|
+
]),
|
|
225
|
+
);
|
|
226
|
+
const ordered: TableOptions[] = [];
|
|
227
|
+
while (remaining.size > 0) {
|
|
228
|
+
const ready = tables.filter(
|
|
229
|
+
(table) =>
|
|
230
|
+
table.name !== undefined && remaining.get(table.name)?.size === 0,
|
|
231
|
+
);
|
|
232
|
+
if (ready.length === 0) {
|
|
233
|
+
throw new TypeError(
|
|
234
|
+
`Omni migration snapshot has a cross-table foreign-key cycle: ${[
|
|
235
|
+
...remaining.keys(),
|
|
236
|
+
].join(', ')}.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
for (const table of ready) {
|
|
240
|
+
const name = table.name!;
|
|
241
|
+
remaining.delete(name);
|
|
242
|
+
ordered.push(table);
|
|
243
|
+
for (const parents of remaining.values()) parents.delete(name);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return ordered;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function quotedIdentifier(identifier: string): string {
|
|
250
|
+
return `"${identifier.replaceAll('"', '""')}"`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function quotedTableName(table: TableOptions): string {
|
|
254
|
+
if (!table.name)
|
|
255
|
+
throw new TypeError('Omni migration table name is required.');
|
|
256
|
+
return [table.schema, table.name]
|
|
257
|
+
.filter(
|
|
258
|
+
(part): part is string => typeof part === 'string' && part.length > 0,
|
|
259
|
+
)
|
|
260
|
+
.map(quotedIdentifier)
|
|
261
|
+
.join('.');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Authoring-only helper. Run this against a metadata-only DataSource and copy
|
|
266
|
+
* the returned value into a versioned migration source using
|
|
267
|
+
* defineOmniMigrationSnapshot. Never invoke it from a migration at runtime.
|
|
268
|
+
*/
|
|
269
|
+
export function captureOmniMigrationSnapshot(
|
|
270
|
+
version: string,
|
|
271
|
+
dataSource: DataSource,
|
|
272
|
+
extensions: readonly OmniMigrationExtensionRegistration[] = [],
|
|
273
|
+
): OmniMigrationSnapshot {
|
|
274
|
+
const dialect = dialectFor(dataSource);
|
|
275
|
+
return defineOmniMigrationSnapshot({
|
|
276
|
+
version,
|
|
277
|
+
dialect,
|
|
278
|
+
tables: tableSnapshots(dataSource, [
|
|
279
|
+
...omniBaseEntities,
|
|
280
|
+
...extensions.flatMap((extension) => extension.entities),
|
|
281
|
+
]),
|
|
282
|
+
indexStatements: extensions.flatMap((extension) =>
|
|
283
|
+
createProjectionDialect(dialect).compileIndexStatements(
|
|
284
|
+
extension.definition,
|
|
285
|
+
),
|
|
286
|
+
),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Freezes a migration-owned table snapshot. Old migration source calls this
|
|
292
|
+
* with a literal captured at authoring time, so future entity changes cannot
|
|
293
|
+
* alter its DDL.
|
|
294
|
+
*/
|
|
295
|
+
export function defineOmniMigrationSnapshot(
|
|
296
|
+
snapshot: OmniMigrationSnapshot,
|
|
297
|
+
): OmniMigrationSnapshot {
|
|
298
|
+
if (
|
|
299
|
+
typeof snapshot.version !== 'string' ||
|
|
300
|
+
snapshot.version.trim().length === 0
|
|
301
|
+
) {
|
|
302
|
+
throw new TypeError('Omni migration snapshot version is required.');
|
|
303
|
+
}
|
|
304
|
+
if (snapshot.dialect !== 'sqlite' && snapshot.dialect !== 'postgres') {
|
|
305
|
+
throw new TypeError('Omni migration snapshot dialect is unsupported.');
|
|
306
|
+
}
|
|
307
|
+
if (!Array.isArray(snapshot.tables) || snapshot.tables.length === 0) {
|
|
308
|
+
throw new TypeError('Omni migration snapshot requires at least one table.');
|
|
309
|
+
}
|
|
310
|
+
const names = snapshot.tables.map((table) => table.name);
|
|
311
|
+
if (
|
|
312
|
+
names.some((name) => typeof name !== 'string' || name.length === 0) ||
|
|
313
|
+
new Set(names).size !== names.length
|
|
314
|
+
) {
|
|
315
|
+
throw new TypeError('Omni migration snapshot table names must be unique.');
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
!Array.isArray(snapshot.indexStatements) ||
|
|
319
|
+
snapshot.indexStatements.some(
|
|
320
|
+
(statement) =>
|
|
321
|
+
typeof statement !== 'string' || statement.trim().length === 0,
|
|
322
|
+
) ||
|
|
323
|
+
new Set(snapshot.indexStatements).size !== snapshot.indexStatements.length
|
|
324
|
+
) {
|
|
325
|
+
throw new TypeError(
|
|
326
|
+
'Omni migration snapshot index statements must be unique non-empty strings.',
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return freezeDeep(structuredClone(snapshot));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Creates a migration executor from an already-versioned snapshot. This API
|
|
334
|
+
* deliberately accepts no DataSource or entity classes: runtime metadata is
|
|
335
|
+
* never a migration authority.
|
|
336
|
+
*/
|
|
337
|
+
export function createOmniMigrationPlan(
|
|
338
|
+
snapshot: OmniMigrationSnapshot,
|
|
339
|
+
): OmniMigrationPlan {
|
|
340
|
+
const source = defineOmniMigrationSnapshot(snapshot);
|
|
341
|
+
const tables = orderTablesTopologically(source.tables);
|
|
342
|
+
|
|
343
|
+
return Object.freeze({
|
|
344
|
+
version: source.version,
|
|
345
|
+
tableNames: Object.freeze(tables.map((table) => table.name)),
|
|
346
|
+
indexStatements: Object.freeze([...source.indexStatements]),
|
|
347
|
+
createTables: () =>
|
|
348
|
+
tables.map((table) => new Table(structuredClone(table))),
|
|
349
|
+
create: async (queryRunner: OmniMigrationRunner) => {
|
|
350
|
+
for (const table of tables) {
|
|
351
|
+
await queryRunner.createTable(new Table(structuredClone(table)));
|
|
352
|
+
}
|
|
353
|
+
for (const statement of source.indexStatements) {
|
|
354
|
+
await queryRunner.query(statement);
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
drop: async (queryRunner: OmniMigrationRunner) => {
|
|
358
|
+
for (const table of [...tables].reverse()) {
|
|
359
|
+
await queryRunner.query(
|
|
360
|
+
`DROP TABLE IF EXISTS ${quotedTableName(table)}`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|