@futdevpro/nts-dynamo 1.15.117 → 1.15.119

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 (35) hide show
  1. package/.dynamo/logs/cicd-pipeline/output.log +1646 -1607
  2. package/.dynamo/logs/cicd-pipeline/status.json +40 -34
  3. package/build/_collections/field-encryption.util.d.ts +52 -0
  4. package/build/_collections/field-encryption.util.d.ts.map +1 -0
  5. package/build/_collections/field-encryption.util.js +148 -0
  6. package/build/_collections/field-encryption.util.js.map +1 -0
  7. package/build/_models/data-models/schema-migration.data-model.d.ts +29 -0
  8. package/build/_models/data-models/schema-migration.data-model.d.ts.map +1 -0
  9. package/build/_models/data-models/schema-migration.data-model.js +47 -0
  10. package/build/_models/data-models/schema-migration.data-model.js.map +1 -0
  11. package/build/_models/interfaces/migration-entry.interface.d.ts +20 -0
  12. package/build/_models/interfaces/migration-entry.interface.d.ts.map +1 -0
  13. package/build/_models/interfaces/migration-entry.interface.js +3 -0
  14. package/build/_models/interfaces/migration-entry.interface.js.map +1 -0
  15. package/build/_services/base/db.service.d.ts.map +1 -1
  16. package/build/_services/base/db.service.js +15 -2
  17. package/build/_services/base/db.service.js.map +1 -1
  18. package/build/_services/core/migration-runner.service.d.ts +40 -0
  19. package/build/_services/core/migration-runner.service.d.ts.map +1 -0
  20. package/build/_services/core/migration-runner.service.js +75 -0
  21. package/build/_services/core/migration-runner.service.js.map +1 -0
  22. package/build/index.d.ts +3 -0
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +4 -0
  25. package/build/index.js.map +1 -1
  26. package/package.json +2 -2
  27. package/src/_collections/field-encryption.util.spec.ts +109 -0
  28. package/src/_collections/field-encryption.util.ts +173 -0
  29. package/src/_models/data-models/schema-migration.data-model.ts +50 -0
  30. package/src/_models/interfaces/migration-entry.interface.ts +19 -0
  31. package/src/_services/base/db.service.encryption.spec.ts +76 -0
  32. package/src/_services/base/db.service.ts +22 -6
  33. package/src/_services/core/migration-runner.service.spec.ts +91 -0
  34. package/src/_services/core/migration-runner.service.ts +106 -0
  35. package/src/index.ts +5 -0
@@ -0,0 +1,173 @@
1
+ import {
2
+ DyFM_DataProperties,
3
+ DyFM_Error,
4
+ } from '@futdevpro/fsm-dynamo';
5
+ import { DyFM_Crypto } from '@futdevpro/fsm-dynamo/crypto';
6
+
7
+ /**
8
+ * AT-REST field-encryption transzform (Option B, bedrock). A DBService a közös írási úton
9
+ * (`createData` / `modifyData`) a persist ELŐTT titkosítja, a read-post-processor
10
+ * (`stringifyDataId`) a load UTÁN visszafejti az `encrypt:true` mezőket.
11
+ *
12
+ * **App-rétegben fut, NEM mongoose-hook** — a `modifyData` `findByIdAndUpdate`-je megkerüli a
13
+ * mongoose settereket/pre-save-hookokat, ezért a transzform explicit itt történik.
14
+ *
15
+ * **Opt-in, default-off:** ha egy modell egyetlen mezője sem `encrypt:true`, a transzform tiszta
16
+ * NO-OP (ugyanaz az objektum-referencia, nincs klónozás, nincs viselkedés-változás, kulcs sem kell).
17
+ *
18
+ * **Legacy-plaintext biztonságos:** a read-transzform CSAK a `DYENC1:` envelope-értékeket fejti vissza
19
+ * (`isEncryptedDbValue`); a régi, még nem migrált plaintext értékek változatlanul jönnek vissza.
20
+ *
21
+ * Kulcs: `FDP_CORE_DBCONTENT_CRYPT_KEY` (Keystore/CI-env). Verzió: `FDP_CORE_DBCONTENT_CRYPT_KEY_VERSION`
22
+ * (default 1) — a kulcs-rotációhoz (a verzió az envelope-ban utazik).
23
+ */
24
+ export class DyNTS_FieldEncryption_Util {
25
+ static readonly ENV_KEY: string = 'FDP_CORE_DBCONTENT_CRYPT_KEY';
26
+ static readonly ENV_KEY_VERSION: string = 'FDP_CORE_DBCONTENT_CRYPT_KEY_VERSION';
27
+
28
+ /** `hasEncryptedFields` cache per properties-objektum (referencia szerint). */
29
+ private static readonly _hasEncCache: WeakMap<object, boolean> = new WeakMap();
30
+
31
+ /** A titkosító kulcs env-ből (üres string, ha nincs beállítva). */
32
+ static getKey(): string {
33
+ return process.env[this.ENV_KEY] ?? '';
34
+ }
35
+
36
+ /** Az aktuális kulcs-verzió (default 1). */
37
+ static getKeyVersion(): number {
38
+ const parsed: number = Number(process.env[this.ENV_KEY_VERSION]);
39
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
40
+ }
41
+
42
+ /**
43
+ * True, ha a properties-fa (rekurzívan a `subObjectParams`-on át) tartalmaz `encrypt:true` mezőt.
44
+ * Cache-elt (a properties-objektum immutábilis a modell-élettartam alatt).
45
+ */
46
+ static hasEncryptedFields(properties?: DyFM_DataProperties<any>): boolean {
47
+ if (!properties) {
48
+ return false;
49
+ }
50
+
51
+ const cached: boolean | undefined = this._hasEncCache.get(properties);
52
+ if (cached !== undefined) {
53
+ return cached;
54
+ }
55
+
56
+ let has: boolean = false;
57
+ for (const key in properties) {
58
+ const prop: any = properties[key]; // ccap-review-disable-line no-any-type
59
+ if (!prop) {
60
+ continue;
61
+ }
62
+ if (prop.encrypt === true) {
63
+ has = true;
64
+ break;
65
+ }
66
+ if (prop.subObjectParams && this.hasEncryptedFields(prop.subObjectParams)) {
67
+ has = true;
68
+ break;
69
+ }
70
+ }
71
+
72
+ this._hasEncCache.set(properties, has);
73
+ return has;
74
+ }
75
+
76
+ /**
77
+ * Titkosítja a doc `encrypt:true` mezőit persist előtt. **Klónt ad vissza** — az eredeti (hívó)
78
+ * objektumot NEM mutálja (különben a `this.data` ciphertextté válna). NO-OP, ha nincs enc-mező.
79
+ * @throws ha van enc-mező, de a kulcs env-var nincs beállítva (fail-loud misconfiguration).
80
+ */
81
+ static encryptDoc<T>(data: T, properties?: DyFM_DataProperties<any>): T {
82
+ if (data == null || !this.hasEncryptedFields(properties)) {
83
+ return data;
84
+ }
85
+
86
+ const key: string = this.getKey();
87
+ if (!key) {
88
+ throw new DyFM_Error({
89
+ status: 500,
90
+ errorCode: 'DyNTS-ENC-KEY-MISSING',
91
+ message:
92
+ `At-rest encryption: "${this.ENV_KEY}" env-var is not set, but a model field is encrypt:true. ` +
93
+ `Set the key in the Keystore/CI-env and redeploy.`,
94
+ });
95
+ }
96
+
97
+ return this._transform(data, properties as DyFM_DataProperties<any>, 'encrypt', key, this.getKeyVersion());
98
+ }
99
+
100
+ /**
101
+ * Visszafejti a doc `encrypt:true` mezőit load után. CSAK a `DYENC1:` envelope-értékeket bántja
102
+ * (legacy plaintext változatlan). NO-OP, ha nincs enc-mező. Kulcs hiányában read-en NEM dob
103
+ * (a read ne törjön el) — az envelope-értékek visszafejtetlenül maradnak (a hívó látja + a hiba loggolt).
104
+ */
105
+ static decryptDoc<T>(data: T, properties?: DyFM_DataProperties<any>): T {
106
+ if (data == null || !this.hasEncryptedFields(properties)) {
107
+ return data;
108
+ }
109
+
110
+ const key: string = this.getKey();
111
+ if (!key) {
112
+ return data;
113
+ }
114
+
115
+ return this._transform(data, properties as DyFM_DataProperties<any>, 'decrypt', key, this.getKeyVersion());
116
+ }
117
+
118
+ /**
119
+ * Rekurzív per-field walk: az `encrypt:true` mezőket titkosítja/visszafejti, a `subObjectParams`-os
120
+ * mezőkbe (objektum VAGY objektum-tömb) leszáll. Immutábilis: minden érintett konténert sekélyen
121
+ * klónoz (az eredetit nem mutálja); a nem-érintett ágakat referencia szerint megosztja (read-only).
122
+ */
123
+ private static _transform<T>(
124
+ value: T,
125
+ properties: DyFM_DataProperties<any>,
126
+ mode: 'encrypt' | 'decrypt',
127
+ key: string,
128
+ keyVersion: number,
129
+ ): T {
130
+ if (value == null || typeof value !== 'object') {
131
+ return value;
132
+ }
133
+
134
+ const result: any = Array.isArray(value) ? [ ...(value as any[]) ] : { ...(value as any) }; // ccap-review-disable-line no-any-type
135
+
136
+ for (const propKey in properties) {
137
+ const prop: any = properties[propKey]; // ccap-review-disable-line no-any-type
138
+ if (!prop) {
139
+ continue;
140
+ }
141
+
142
+ if (prop.encrypt === true) {
143
+ const current: any = result[propKey]; // ccap-review-disable-line no-any-type
144
+
145
+ if (mode === 'encrypt') {
146
+ // Üres/undefined/null-t NEM titkosítunk (a DyFM_Crypto ezekre dob); már-envelope-ot sem (idempotens).
147
+ if (current != null && current !== '' && !DyFM_Crypto.isEncryptedDbValue(current)) {
148
+ result[propKey] = DyFM_Crypto.encryptDbValue(current, key, keyVersion);
149
+ }
150
+ } else {
151
+ // CSAK envelope-ot fejtünk vissza (legacy plaintext változatlan).
152
+ if (DyFM_Crypto.isEncryptedDbValue(current)) {
153
+ result[propKey] = DyFM_Crypto.decryptDbValue(current, key);
154
+ }
155
+ }
156
+ } else if (prop.subObjectParams) {
157
+ const current: any = result[propKey]; // ccap-review-disable-line no-any-type
158
+
159
+ if (current != null && typeof current === 'object') {
160
+ if (Array.isArray(current)) {
161
+ result[propKey] = current.map(
162
+ (item: any): any => this._transform(item, prop.subObjectParams, mode, key, keyVersion), // ccap-review-disable-line no-any-type
163
+ );
164
+ } else {
165
+ result[propKey] = this._transform(current, prop.subObjectParams, mode, key, keyVersion);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ return result as T;
172
+ }
173
+ }
@@ -0,0 +1,50 @@
1
+ import { DyFM_DataModel_Params, DyFM_Metadata } from '@futdevpro/fsm-dynamo';
2
+
3
+ /**
4
+ * **Migráció-nyilvántartó ("server info") LEDGER** — bedrock, újrahasználható az egész flottának.
5
+ * (A token-service app-lokál `SchemaMigration` mintájából emelve bedrock nts-be.)
6
+ *
7
+ * Egy-egy `migrationId` **PONTOSAN EGYSZER** fut le rendszerenként: a `DyNTS_Migration_Runner.runPending()`
8
+ * boot-on (az app `app.server.postProcess()`-éből) lekéri a már-alkalmazott ID-kat, a pending
9
+ * migrációkat lefuttatja, majd egy sort rögzít ide. A `migrationId` **unique index** → race-safe
10
+ * (két párhuzamos instance esetén az egyik `save`-je duplicate-key-re bukik → skip).
11
+ *
12
+ * A ledger a KÖTELEZŐ kanon-adatokat tartja: **mely migrációk futottak** (id + idő) + **mely app-verzió**
13
+ * alkalmazta (a `appliedVersion`-ökből a "mely verziók futottak" levezethető).
14
+ *
15
+ * Az app a saját DB-jében regisztrálja a `dyNTS_schemaMigration_dataParams`-t (`getGlobalServiceCollection
16
+ * ().dbModels`), így per-app saját `dynts_schema_migrations` collection-t kap.
17
+ */
18
+ export class DyNTS_SchemaMigration extends DyFM_Metadata {
19
+
20
+ /** A migráció egyedi azonosítója (konvenció: `YYYY-MM-DD-NNN-descriptor`). */
21
+ migrationId?: string;
22
+
23
+ /** Alkalmazás időbélyege. */
24
+ appliedAt?: Date;
25
+
26
+ /** Az alkalmazó app-verzió (a `package.json` version-je — audit-trail + "mely verziók futottak"). */
27
+ appliedVersion?: string;
28
+
29
+ /** Diag — pl. `touched=<n>` vagy hiba-részletek. */
30
+ notes?: string;
31
+
32
+ constructor(set?: DyNTS_SchemaMigration) {
33
+ super(set);
34
+ if (set) {
35
+ Object.assign(this, set);
36
+ }
37
+ }
38
+ }
39
+
40
+ export const dyNTS_schemaMigration_dataParams: DyFM_DataModel_Params<DyNTS_SchemaMigration> =
41
+ new DyFM_DataModel_Params<DyNTS_SchemaMigration>({
42
+ dataName: 'dynts_schema_migrations',
43
+ addArchive: false,
44
+ properties: {
45
+ migrationId: { type: 'string', required: true, unique: true, index: true },
46
+ appliedAt: { type: 'date', required: true },
47
+ appliedVersion: { type: 'string' },
48
+ notes: { type: 'string' },
49
+ },
50
+ });
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Egy regisztrált deploy-integrált migráció leírója. Az app egy `DyNTS_MigrationEntry[]` registry-t
3
+ * ad át a `DyNTS_Migration_Runner.runPending(...)`-nek; a runner a pending (még nem alkalmazott)
4
+ * entry-ket **egyszer** futtatja le, sorrendben, majd a `DyNTS_SchemaMigration` ledgerbe rögzíti.
5
+ */
6
+ export interface DyNTS_MigrationEntry {
7
+ /** Egyedi, monoton bővíthető ID (konvenció: `YYYY-MM-DD-NNN-descriptor`). A ledger ez alapján run-once-ol. */
8
+ id: string;
9
+
10
+ /** Rövid leíró (admin/debug/log). */
11
+ description: string;
12
+
13
+ /**
14
+ * A tényleges migráció-logika. Az app már-csatlakozott Mongo-kapcsolatát használja (a runner a
15
+ * `postProcess`-ből fut, ahol a DB már él). Hibára DOBHAT — a runner catch-eli, a `notes`-ba menti,
16
+ * és NEM állítja meg a boot-ot. Visszaad: `{ touched }` (a módosított dokumentumok száma — count/riport).
17
+ */
18
+ run: (issuer: string) => Promise<{ touched: number }>;
19
+ }
@@ -0,0 +1,76 @@
1
+ import {
2
+ DyFM_DataModel_Params,
3
+ DyFM_Metadata,
4
+ DyFM_BasicProperty_Type,
5
+ } from '@futdevpro/fsm-dynamo';
6
+ import { DyFM_Crypto } from '@futdevpro/fsm-dynamo/crypto';
7
+ import * as mongoose from 'mongoose';
8
+
9
+ import { DyNTS_DBService } from './db.service';
10
+ import { DyNTS_FieldEncryption_Util } from '../../_collections/field-encryption.util';
11
+
12
+ class EncMeta extends DyFM_Metadata {
13
+ name: string = '';
14
+ secret: string = '';
15
+ }
16
+
17
+ const encParams: DyFM_DataModel_Params<EncMeta> = new DyFM_DataModel_Params<EncMeta>({
18
+ dataName: 'test_enc_data',
19
+ properties: {
20
+ name: { key: 'name', type: DyFM_BasicProperty_Type.string },
21
+ secret: { key: 'secret', type: DyFM_BasicProperty_Type.string, encrypt: true },
22
+ },
23
+ });
24
+
25
+ /**
26
+ * AT-REST encryption a DB-layer create/update útján. A `createData`/`modifyData` mongoose-hívása
27
+ * DB-kapcsolat nélkül nem futtatható végig (l. db.service.spec: mongoose.model getter-only), de:
28
+ * - a `findByIdAndUpdate` INSTANCE-property-ként spy-olható → az UPDATE-út (a findByIdAndUpdate
29
+ * hook-bypass caveat) igazolható;
30
+ * - a CREATE-út az AZONOS `encryptDoc`-ot hívja `new this.dataModel()` ELŐTT → spy-val igazolható,
31
+ * hogy a persist előtt lefut (a save DB nélkül elhasal, de már az encrypt UTÁN).
32
+ */
33
+ describe('| DyNTS_DBService at-rest field-encryption (create + update path)', () => {
34
+ let db: DyNTS_DBService<EncMeta>;
35
+ const KEY: string = 'db-content-crypt-key-abcdefgh';
36
+
37
+ beforeEach(() => {
38
+ process.env.FDP_CORE_DBCONTENT_CRYPT_KEY = KEY;
39
+ db = new DyNTS_DBService<EncMeta>(encParams);
40
+ });
41
+
42
+ afterEach(() => {
43
+ delete process.env.FDP_CORE_DBCONTENT_CRYPT_KEY;
44
+ delete (mongoose.models as Record<string, unknown>)['test_enc_data'];
45
+ });
46
+
47
+ it('| UPDATE: modifyData → a findByIdAndUpdate CIPHERTEXT-et kap (hook-bypass ellenére), a return plaintext', async () => {
48
+ const spy: jasmine.Spy = spyOn(db['dataModel'], 'findByIdAndUpdate').and.returnValue(Promise.resolve({}) as never);
49
+
50
+ const result: EncMeta = await db.modifyData({ _id: 'id1', name: 'n', secret: 'plain-secret' } as EncMeta, 'issuer');
51
+
52
+ const persisted: any = spy.calls.mostRecent().args[1]; // ccap-review-disable-line no-any-type
53
+ expect(DyFM_Crypto.isEncryptedDbValue(persisted.secret)).toBe(true); // DB-be ciphertext ment
54
+ expect(persisted.name).toBe('n'); // sima mező érintetlen
55
+ expect(result.secret).toBe('plain-secret'); // a hívó plaintextet kap vissza
56
+ });
57
+
58
+ it('| CREATE: createData a persist (new dataModel/save) ELŐTT titkosít (encryptDoc a create-adattal)', async () => {
59
+ const encSpy: jasmine.Spy = spyOn(DyNTS_FieldEncryption_Util, 'encryptDoc').and.callThrough();
60
+
61
+ // A .save() DB-kapcsolat nélkül buffer-elne (~10s). Kikapcsoljuk a bufferelést → GYORS fail.
62
+ const prevBuffer: boolean | undefined = mongoose.get('bufferCommands') as boolean | undefined;
63
+ mongoose.set('bufferCommands', false);
64
+ try {
65
+ await db.createData({ name: 'n', secret: 'plain-secret' } as EncMeta, 'issuer');
66
+ } catch {
67
+ // A .save() DB nélkül elhasal — a lényeg, hogy az encrypt-transzform ELŐTTE lefutott.
68
+ } finally {
69
+ mongoose.set('bufferCommands', prevBuffer ?? true);
70
+ }
71
+
72
+ expect(encSpy).toHaveBeenCalled();
73
+ const passed: any = encSpy.calls.mostRecent().args[0]; // ccap-review-disable-line no-any-type
74
+ expect(passed.secret).toBe('plain-secret');
75
+ });
76
+ });
@@ -16,6 +16,7 @@ import {
16
16
  DyFM_Object
17
17
  } from '@futdevpro/fsm-dynamo';
18
18
  import { DyNTS_archiveSuffix } from '../../_collections/archive.util';
19
+ import { DyNTS_FieldEncryption_Util } from '../../_collections/field-encryption.util';
19
20
  import { DyNTS_DBUpdate } from '../../_models/types/db-update.type';
20
21
  import { DyNTS_DBQueryOptions } from '../../_models/interfaces/db-query-options.interface';
21
22
  import { DyNTS_global_settings } from '../../_collections/global-settings.const';
@@ -100,7 +101,11 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
100
101
  data.__createdBy = issuer;
101
102
  data.__lastModifiedBy = issuer;
102
103
 
103
- const dataModel = new this.dataModel(data);
104
+ // AT-REST field-encryption (opt-in): az encrypt:true mezőket a persist ELŐTT titkosítjuk, egy
105
+ // KLÓNON (a hívó `data`-ja plaintext marad). NO-OP, ha nincs encrypt:true mező.
106
+ const encData: T = DyNTS_FieldEncryption_Util.encryptDoc(data, this.dataParams.properties);
107
+
108
+ const dataModel = new this.dataModel(encData);
104
109
  const newData: T = await dataModel.save().then((res): T => {
105
110
  if (res) {
106
111
  return res?.toObject() as T;
@@ -157,12 +162,18 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
157
162
  data.__lastModifiedBy = issuer;
158
163
  }
159
164
 
160
- /**
161
- * EZ A SZAR TELJESEN SZAR, nem friss, nem a db-be mentett adatokat ad vissza,
162
- * átír random value-kat össze vissza, WTF
165
+ // AT-REST field-encryption (opt-in): az encrypt:true mezőket a persist ELŐTT titkosítjuk, egy
166
+ // KLÓNON. KRITIKUS: a `findByIdAndUpdate` MEGKERÜLI a mongoose settereket/pre-save-hookokat, ezért
167
+ // a titkosítás itt, app-rétegben történik (nem mongoose-hookban). A hívó `data`-ja plaintext marad,
168
+ // így a `stringifyDataId(data)` alant no-op decrypttel a plaintextet adja vissza. NO-OP enc-mező nélkül.
169
+ const encData: T = DyNTS_FieldEncryption_Util.encryptDoc(data, this.dataParams.properties);
170
+
171
+ /**
172
+ * EZ A SZAR TELJESEN SZAR, nem friss, nem a db-be mentett adatokat ad vissza,
173
+ * átír random value-kat össze vissza, WTF
163
174
  * */
164
- /* let newData: T = */
165
- await this.dataModel.findByIdAndUpdate(data._id, data)/* .then((res) => {
175
+ /* let newData: T = */
176
+ await this.dataModel.findByIdAndUpdate(data._id, encData)/* .then((res) => {
166
177
  if (res) {
167
178
  //return res?.toObject() as T;
168
179
  } else {
@@ -1277,6 +1288,11 @@ export class DyNTS_DBService<T extends DyFM_Metadata> {
1277
1288
  // PRIVATE FUNCTIONS
1278
1289
 
1279
1290
  private stringifyDataId(data: T, fnName: string): T {
1291
+ // AT-REST field-decryption (opt-in): ez a KÖZÖS read+write post-processor (minden read + a
1292
+ // create/modify EREDMÉNYE is átmegy rajta), ezért itt fejtjük vissza az encrypt:true envelope-
1293
+ // mezőket load után. NO-OP, ha nincs encrypt:true mező (ugyanaz a referencia — nulla változás).
1294
+ data = DyNTS_FieldEncryption_Util.decryptDoc(data, this.dataParams.properties);
1295
+
1280
1296
  if (data?._id && (typeof data._id !== 'string' || typeof data._id === 'object')) {
1281
1297
  data._id = `${data._id}`;
1282
1298
 
@@ -0,0 +1,91 @@
1
+ import { DyNTS_Migration_Runner } from './migration-runner.service';
2
+ import { DyNTS_SchemaMigration } from '../../_models/data-models/schema-migration.data-model';
3
+ import { DyNTS_MigrationEntry } from '../../_models/interfaces/migration-entry.interface';
4
+
5
+ function entry(id: string, run: () => Promise<{ touched: number }>): DyNTS_MigrationEntry {
6
+ return { id: id, description: id, run: run };
7
+ }
8
+
9
+ describe('| DyNTS_Migration_Runner', () => {
10
+
11
+ describe('| selectPending (pure)', () => {
12
+ it('| kiszűri a már-alkalmazottakat, megőrzi a sorrendet', () => {
13
+ const migs: DyNTS_MigrationEntry[] = [
14
+ entry('a', async () => ({ touched: 0 })),
15
+ entry('b', async () => ({ touched: 0 })),
16
+ entry('c', async () => ({ touched: 0 })),
17
+ ];
18
+ const pending: DyNTS_MigrationEntry[] = DyNTS_Migration_Runner.selectPending(migs, new Set([ 'b' ]));
19
+ expect(pending.map((m) => m.id)).toEqual([ 'a', 'c' ]);
20
+ });
21
+
22
+ it('| mind alkalmazva → üres; üres registry → üres', () => {
23
+ const migs: DyNTS_MigrationEntry[] = [ entry('a', async () => ({ touched: 0 })) ];
24
+ expect(DyNTS_Migration_Runner.selectPending(migs, new Set([ 'a' ])).length).toBe(0);
25
+ expect(DyNTS_Migration_Runner.selectPending([], new Set()).length).toBe(0);
26
+ });
27
+ });
28
+
29
+ describe('| runPending (mock ledger)', () => {
30
+ let runner: DyNTS_Migration_Runner;
31
+ let saved: DyNTS_SchemaMigration[];
32
+ let appliedRecords: DyNTS_SchemaMigration[];
33
+
34
+ beforeEach(() => {
35
+ runner = DyNTS_Migration_Runner.getInstance();
36
+ saved = [];
37
+ appliedRecords = [];
38
+
39
+ const fakeLedger: any = { // ccap-review-disable-line no-any-type
40
+ dataDBService: { find: async (): Promise<DyNTS_SchemaMigration[]> => appliedRecords },
41
+ data: null,
42
+ saveData: async function (): Promise<void> { saved.push(this.data); appliedRecords.push(this.data); },
43
+ };
44
+
45
+ spyOn(runner as any, 'createLedgerDataService').and.returnValue(fakeLedger); // ccap-review-disable-line no-any-type
46
+ });
47
+
48
+ it('| a pending-et futtatja + rögzíti (id+version+touched), a már-alkalmazottat SKIP-eli', async () => {
49
+ appliedRecords.push(new DyNTS_SchemaMigration({ migrationId: 'm1' }));
50
+ const ran: string[] = [];
51
+
52
+ const migs: DyNTS_MigrationEntry[] = [
53
+ entry('m1', async () => { ran.push('m1'); return { touched: 1 }; }),
54
+ entry('m2', async () => { ran.push('m2'); return { touched: 5 }; }),
55
+ ];
56
+
57
+ const res = await runner.runPending(migs, '1.0.0');
58
+
59
+ expect(ran).toEqual([ 'm2' ]); // m1 skip (már applied), m2 fut
60
+ expect(res.applied).toBe(1);
61
+ expect(res.skipped).toBe(1);
62
+ expect(saved[0].migrationId).toBe('m2');
63
+ expect(saved[0].appliedVersion).toBe('1.0.0');
64
+ expect(saved[0].notes).toBe('touched=5');
65
+ });
66
+
67
+ it('| RUN-ONCE: második sweep-en (m2 már a ledgerben) semmi nem fut', async () => {
68
+ appliedRecords.push(new DyNTS_SchemaMigration({ migrationId: 'm2' }));
69
+ const ran: string[] = [];
70
+ const migs: DyNTS_MigrationEntry[] = [ entry('m2', async () => { ran.push('m2'); return { touched: 0 }; }) ];
71
+
72
+ const res = await runner.runPending(migs, '1.0.0');
73
+ expect(ran.length).toBe(0);
74
+ expect(res.applied).toBe(0);
75
+ expect(res.skipped).toBe(1);
76
+ });
77
+
78
+ it('| BOOT-SAFE: egy hibás migráció NEM dob + NEM állítja meg a többit', async () => {
79
+ const ran: string[] = [];
80
+ const migs: DyNTS_MigrationEntry[] = [
81
+ entry('bad', async () => { throw new Error('boom'); }),
82
+ entry('good', async () => { ran.push('good'); return { touched: 2 }; }),
83
+ ];
84
+
85
+ const res = await runner.runPending(migs, '1.0.0');
86
+ expect(res.failed).toBe(1);
87
+ expect(res.applied).toBe(1); // 'good' a hiba UTÁN is lefutott
88
+ expect(ran).toEqual([ 'good' ]);
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,106 @@
1
+ import { DyFM_Error, DyFM_Log } from '@futdevpro/fsm-dynamo';
2
+
3
+ import { DyNTS_SingletonService } from '../base/singleton.service';
4
+ import { DyNTS_DataService } from '../base/data.service';
5
+ import {
6
+ DyNTS_SchemaMigration, dyNTS_schemaMigration_dataParams,
7
+ } from '../../_models/data-models/schema-migration.data-model';
8
+ import { DyNTS_MigrationEntry } from '../../_models/interfaces/migration-entry.interface';
9
+
10
+ /** A `runPending` összesítő eredménye. */
11
+ export interface DyNTS_MigrationRunResult {
12
+ applied: number;
13
+ skipped: number;
14
+ failed: number;
15
+ }
16
+
17
+ /**
18
+ * **Deploy-integrált migráció-futtató** — bedrock, újrahasználható. Az app a `app.server.postProcess()`-
19
+ * ből hívja: `DyNTS_Migration_Runner.getInstance().runPending(ALL_MIGRATIONS, appVersion)`.
20
+ *
21
+ * Mechanizmus (kanon `deploy-integrated-migrations.md`):
22
+ * - Boot-on lekéri a `DyNTS_SchemaMigration` ledgerből a MÁR-alkalmazott `migrationId`-kat.
23
+ * - A **pending** (még nem alkalmazott) entry-ket **sorrendben, egyszer** lefuttatja.
24
+ * - Mindegyik után egy ledger-sort rögzít (`migrationId` + `appliedAt` + `appliedVersion` + `notes`).
25
+ * - **RUN-ONCE:** a `migrationId` unique-index → re-boot / párhuzamos instance esetén skip (duplicate-key).
26
+ * - **Boot-safe:** egy hibás migráció NEM állítja meg a boot-ot (catch + notes; a runner tovább megy).
27
+ */
28
+ export class DyNTS_Migration_Runner extends DyNTS_SingletonService {
29
+
30
+ static getInstance(): DyNTS_Migration_Runner {
31
+ return DyNTS_Migration_Runner.getSingletonInstance();
32
+ }
33
+
34
+ /**
35
+ * TISZTA pending-szelekció (DB nélkül tesztelhető): azok az entry-k, amiknek az `id`-ja NINCS az
36
+ * `appliedIds`-ban. A regisztráció sorrendje megőrződik (a bővítés a tömb VÉGÉRE).
37
+ */
38
+ static selectPending(
39
+ migrations: DyNTS_MigrationEntry[],
40
+ appliedIds: Set<string>,
41
+ ): DyNTS_MigrationEntry[] {
42
+ return (migrations ?? []).filter((migration: DyNTS_MigrationEntry): boolean => !appliedIds.has(migration.id));
43
+ }
44
+
45
+ /** A ledger-DataService előállítása. `protected` → a spec felül tudja írni (mock-hoz). */
46
+ protected createLedgerDataService(issuer: string): DyNTS_DataService<DyNTS_SchemaMigration> {
47
+ return new DyNTS_DataService<DyNTS_SchemaMigration>(
48
+ new DyNTS_SchemaMigration(),
49
+ dyNTS_schemaMigration_dataParams,
50
+ issuer,
51
+ );
52
+ }
53
+
54
+ /**
55
+ * A pending migrációk lefuttatása + rögzítése.
56
+ *
57
+ * @param migrations - az app registry-je (`DyNTS_MigrationEntry[]`)
58
+ * @param appVersion - az app aktuális verziója (a ledger `appliedVersion`-jébe)
59
+ * @param issuer - opcionális issuer-cimke (default `DyNTS_Migration_Runner`)
60
+ */
61
+ async runPending(
62
+ migrations: DyNTS_MigrationEntry[],
63
+ appVersion: string,
64
+ issuer: string = 'DyNTS_Migration_Runner',
65
+ ): Promise<DyNTS_MigrationRunResult> {
66
+ const result: DyNTS_MigrationRunResult = { applied: 0, skipped: 0, failed: 0 };
67
+
68
+ DyFM_Log.info(`[dynts-migration] sweep start — ${migrations?.length ?? 0} migration(s) registered · appVersion=${appVersion}`);
69
+
70
+ const ledger_DS: DyNTS_DataService<DyNTS_SchemaMigration> = this.createLedgerDataService(issuer);
71
+
72
+ const applied: DyNTS_SchemaMigration[] = await ledger_DS.dataDBService.find({} as never);
73
+ const appliedIds: Set<string> = new Set(
74
+ applied.map((record: DyNTS_SchemaMigration): string => record.migrationId ?? ''),
75
+ );
76
+
77
+ const pending: DyNTS_MigrationEntry[] = DyNTS_Migration_Runner.selectPending(migrations, appliedIds);
78
+ result.skipped = (migrations?.length ?? 0) - pending.length;
79
+
80
+ for (const migration of pending) {
81
+ try {
82
+ DyFM_Log.info(`[dynts-migration] applying ${migration.id} — ${migration.description}`);
83
+ const outcome: { touched: number } = await migration.run(issuer);
84
+
85
+ // Atomikus claim — a unique-index dob duplicate-ra (race-safe, két párhuzamos instance).
86
+ ledger_DS.data = new DyNTS_SchemaMigration({
87
+ migrationId: migration.id,
88
+ appliedAt: new Date(),
89
+ appliedVersion: appVersion,
90
+ notes: `touched=${outcome?.touched ?? 0}`,
91
+ });
92
+ await ledger_DS.saveData();
93
+
94
+ DyFM_Log.success(`[dynts-migration] applied ${migration.id} (touched=${outcome?.touched ?? 0})`);
95
+ result.applied++;
96
+ } catch (error) {
97
+ // Egy hibás migráció NEM állíthatja meg a boot-ot (catch + log; a következő entry mehet).
98
+ DyFM_Error.logSimple(`[dynts-migration] FAILED ${migration.id}`, error);
99
+ result.failed++;
100
+ }
101
+ }
102
+
103
+ DyFM_Log.info(`[dynts-migration] sweep done — applied=${result.applied} skipped=${result.skipped} failed=${result.failed}`);
104
+ return result;
105
+ }
106
+ }
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export * from './_models/interfaces/static-client-settings.interface';
53
53
  export * from './_models/interfaces/cors-settings.interface';
54
54
  export * from './_models/interfaces/security-headers-settings.interface';
55
55
  export * from './_models/interfaces/db-query-options.interface';
56
+ export * from './_models/interfaces/migration-entry.interface';
56
57
  export * from './_collections/security-headers.util';
57
58
  export * from './_collections/client-safe-error.util';
58
59
  export * from './_collections/load-shed.util';
@@ -70,6 +71,9 @@ export * from './_models/control-models/system-control.control-model';
70
71
  // models/TYPES
71
72
  export * from './_models/types/db-update.type';
72
73
 
74
+ // models/DATA-MODELS
75
+ export * from './_models/data-models/schema-migration.data-model';
76
+
73
77
 
74
78
  // SERVICES
75
79
  // services/CORE
@@ -82,6 +86,7 @@ export * from './_services/core/global.service';
82
86
  export * from './_services/core/memory-guard.service';
83
87
 
84
88
  export * from './_services/core/service-collection.service';
89
+ export * from './_services/core/migration-runner.service';
85
90
 
86
91
  // services/BASE
87
92
  export * from './_services/base/api.service-base';