@twin.org/entity-storage-models 0.0.3-next.22 → 0.0.3-next.24
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/dist/es/entities/schemaVersion.js +39 -0
- package/dist/es/entities/schemaVersion.js.map +1 -0
- package/dist/es/factories/schemaMigrationFactory.js +1 -1
- package/dist/es/factories/schemaMigrationFactory.js.map +1 -1
- package/dist/es/helpers/entityStorageHelper.js +42 -1
- package/dist/es/helpers/entityStorageHelper.js.map +1 -1
- package/dist/es/index.js +3 -0
- package/dist/es/index.js.map +1 -1
- package/dist/es/models/ISchemaMigration.js.map +1 -1
- package/dist/es/schema.js +11 -0
- package/dist/es/schema.js.map +1 -0
- package/dist/es/services/schemaVersionService.js +265 -0
- package/dist/es/services/schemaVersionService.js.map +1 -0
- package/dist/types/entities/schemaVersion.d.ts +19 -0
- package/dist/types/factories/schemaMigrationFactory.d.ts +1 -1
- package/dist/types/helpers/entityStorageHelper.d.ts +20 -1
- package/dist/types/index.d.ts +3 -0
- package/dist/types/models/ISchemaMigration.d.ts +1 -1
- package/dist/types/schema.d.ts +4 -0
- package/dist/types/services/schemaVersionService.d.ts +60 -0
- package/docs/changelog.md +19 -0
- package/docs/reference/classes/EntityStorageHelper.md +74 -0
- package/docs/reference/classes/SchemaVersion.md +39 -0
- package/docs/reference/classes/SchemaVersionService.md +130 -0
- package/docs/reference/functions/initSchema.md +9 -0
- package/docs/reference/index.md +6 -0
- package/docs/reference/interfaces/ISchemaMigration.md +1 -1
- package/docs/reference/variables/SchemaMigrationFactory.md +1 -1
- package/locales/en.json +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Copyright 2026 IOTA Stiftung.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { entity, property } from "@twin.org/entity";
|
|
4
|
+
/**
|
|
5
|
+
* Tracks the currently applied schema version for each managed entity schema.
|
|
6
|
+
* One record per schema name. Written once on first boot, then updated after
|
|
7
|
+
* each successful migration.
|
|
8
|
+
*/
|
|
9
|
+
let SchemaVersion = class SchemaVersion {
|
|
10
|
+
/**
|
|
11
|
+
* The entity schema type name — primary key.
|
|
12
|
+
*/
|
|
13
|
+
schemaName;
|
|
14
|
+
/**
|
|
15
|
+
* The currently deployed version of this schema.
|
|
16
|
+
*/
|
|
17
|
+
version;
|
|
18
|
+
/**
|
|
19
|
+
* ISO 8601 timestamp of the last version write.
|
|
20
|
+
*/
|
|
21
|
+
updatedAt;
|
|
22
|
+
};
|
|
23
|
+
__decorate([
|
|
24
|
+
property({ type: "string", isPrimary: true }),
|
|
25
|
+
__metadata("design:type", String)
|
|
26
|
+
], SchemaVersion.prototype, "schemaName", void 0);
|
|
27
|
+
__decorate([
|
|
28
|
+
property({ type: "integer" }),
|
|
29
|
+
__metadata("design:type", Number)
|
|
30
|
+
], SchemaVersion.prototype, "version", void 0);
|
|
31
|
+
__decorate([
|
|
32
|
+
property({ type: "string", format: "date-time" }),
|
|
33
|
+
__metadata("design:type", String)
|
|
34
|
+
], SchemaVersion.prototype, "updatedAt", void 0);
|
|
35
|
+
SchemaVersion = __decorate([
|
|
36
|
+
entity()
|
|
37
|
+
], SchemaVersion);
|
|
38
|
+
export { SchemaVersion };
|
|
39
|
+
//# sourceMappingURL=schemaVersion.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemaVersion.js","sourceRoot":"","sources":["../../../src/entities/schemaVersion.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEpD;;;;GAIG;AAEI,IAAM,aAAa,GAAnB,MAAM,aAAa;IACzB;;OAEG;IAEI,UAAU,CAAU;IAE3B;;OAEG;IAEI,OAAO,CAAU;IAExB;;OAEG;IAEI,SAAS,CAAU;CAC1B,CAAA;AAbO;IADN,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;;iDACnB;AAMpB;IADN,QAAQ,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;;8CACN;AAMjB;IADN,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;;gDACxB;AAjBd,aAAa;IADzB,MAAM,EAAE;GACI,aAAa,CAkBzB","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { entity, property } from \"@twin.org/entity\";\n\n/**\n * Tracks the currently applied schema version for each managed entity schema.\n * One record per schema name. Written once on first boot, then updated after\n * each successful migration.\n */\n@entity()\nexport class SchemaVersion {\n\t/**\n\t * The entity schema type name — primary key.\n\t */\n\t@property({ type: \"string\", isPrimary: true })\n\tpublic schemaName!: string;\n\n\t/**\n\t * The currently deployed version of this schema.\n\t */\n\t@property({ type: \"integer\" })\n\tpublic version!: number;\n\n\t/**\n\t * ISO 8601 timestamp of the last version write.\n\t */\n\t@property({ type: \"string\", format: \"date-time\" })\n\tpublic updatedAt!: string;\n}\n"]}
|
|
@@ -9,7 +9,7 @@ import { Factory } from "@twin.org/core";
|
|
|
9
9
|
* SchemaVersionService diffs the two versioned schema classes automatically
|
|
10
10
|
* without needing any factory entry.
|
|
11
11
|
*
|
|
12
|
-
* Keys follow the convention "
|
|
12
|
+
* Keys follow the convention "BaseSchemaName_fromVersion_toVersion",
|
|
13
13
|
* for example "MyEntity_0_1" for the step that migrates MyEntity from version 0 to 1.
|
|
14
14
|
*/
|
|
15
15
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schemaMigrationFactory.js","sourceRoot":"","sources":["../../../src/factories/schemaMigrationFactory.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGzC;;;;;;;;;;GAUG;AACH,gEAAgE;AAChE,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAmB,kBAAkB,CAAC,CAAC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { Factory } from \"@twin.org/core\";\nimport type { ISchemaMigration } from \"../models/ISchemaMigration.js\";\n\n/**\n * Factory for optional per-step migration overrides.\n *\n * Only register an entry when a version step requires property renames or a custom\n * transform hook. For purely structural changes (add/remove/type-change) the\n * SchemaVersionService diffs the two versioned schema classes automatically\n * without needing any factory entry.\n *\n * Keys follow the convention \"
|
|
1
|
+
{"version":3,"file":"schemaMigrationFactory.js","sourceRoot":"","sources":["../../../src/factories/schemaMigrationFactory.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAGzC;;;;;;;;;;GAUG;AACH,gEAAgE;AAChE,MAAM,CAAC,MAAM,sBAAsB,GAAG,OAAO,CAAC,aAAa,CAAmB,kBAAkB,CAAC,CAAC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { Factory } from \"@twin.org/core\";\nimport type { ISchemaMigration } from \"../models/ISchemaMigration.js\";\n\n/**\n * Factory for optional per-step migration overrides.\n *\n * Only register an entry when a version step requires property renames or a custom\n * transform hook. For purely structural changes (add/remove/type-change) the\n * SchemaVersionService diffs the two versioned schema classes automatically\n * without needing any factory entry.\n *\n * Keys follow the convention \"BaseSchemaName_fromVersion_toVersion\",\n * for example \"MyEntity_0_1\" for the step that migrates MyEntity from version 0 to 1.\n */\n// eslint-disable-next-line @typescript-eslint/naming-convention\nexport const SchemaMigrationFactory = Factory.createFactory<ISchemaMigration>(\"schema-migration\");\n"]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright 2026 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
-
import { Is, ObjectHelper } from "@twin.org/core";
|
|
3
|
+
import { GeneralError, Is, ObjectHelper } from "@twin.org/core";
|
|
4
4
|
import { ComparisonOperator, EntitySchemaHelper } from "@twin.org/entity";
|
|
5
5
|
/**
|
|
6
6
|
* Helper class for performing schema migrations between two connectors.
|
|
@@ -60,6 +60,47 @@ export class EntityStorageHelper {
|
|
|
60
60
|
}
|
|
61
61
|
return nonNullEntity;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Validate that every sort property in the list is indexed in the schema (isPrimary, isSecondary,
|
|
65
|
+
* or has a default sortDirection), throwing sortNotIndexed for the first violation found.
|
|
66
|
+
* @param schema The entity schema to validate against.
|
|
67
|
+
* @param sortProperties The sort properties to check.
|
|
68
|
+
* @throws GeneralError If a sort property is not indexed in the schema.
|
|
69
|
+
*/
|
|
70
|
+
static validateSortProperties(schema, sortProperties) {
|
|
71
|
+
if (Is.arrayValue(sortProperties)) {
|
|
72
|
+
for (const sortProperty of sortProperties) {
|
|
73
|
+
const propertySchema = schema.properties?.find(p => p.property === sortProperty.property);
|
|
74
|
+
if (Is.undefined(propertySchema) ||
|
|
75
|
+
(!propertySchema.isPrimary &&
|
|
76
|
+
!propertySchema.isSecondary &&
|
|
77
|
+
Is.empty(propertySchema.sortDirection))) {
|
|
78
|
+
throw new GeneralError(EntityStorageHelper.CLASS_NAME, "sortNotIndexed", {
|
|
79
|
+
property: sortProperty.property
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Validate that every property in the list exists in the schema, throwing propertyNotInSchema
|
|
87
|
+
* for the first property that is not found.
|
|
88
|
+
* @param schema The entity schema to validate against.
|
|
89
|
+
* @param properties The properties to check.
|
|
90
|
+
* @throws GeneralError If a property does not exist in the schema.
|
|
91
|
+
*/
|
|
92
|
+
static validateProperties(schema, properties) {
|
|
93
|
+
if (Is.arrayValue(properties)) {
|
|
94
|
+
for (const property of properties) {
|
|
95
|
+
const propertySchema = schema.properties?.find(p => p.property === property);
|
|
96
|
+
if (Is.undefined(propertySchema)) {
|
|
97
|
+
throw new GeneralError(EntityStorageHelper.CLASS_NAME, "propertyNotInSchema", {
|
|
98
|
+
property
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
63
104
|
/**
|
|
64
105
|
* Deep-clone condition tree and normalise null/undefined to undefined on Equals/NotEquals leaves
|
|
65
106
|
* so in-memory evaluation matches stored-absent semantics (optional absent props are omitted/undefined).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"entityStorageHelper.js","sourceRoot":"","sources":["../../../src/helpers/entityStorageHelper.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,OAAO,EAAE,EAAE,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"entityStorageHelper.js","sourceRoot":"","sources":["../../../src/helpers/entityStorageHelper.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { GeneralError, Is, ObjectHelper } from \"@twin.org/core\";\nimport {\n\tComparisonOperator,\n\ttype EntityCondition,\n\tEntitySchemaHelper,\n\ttype IEntitySchema,\n\ttype SortDirection\n} from \"@twin.org/entity\";\nimport { nameof } from \"@twin.org/nameof\";\n\n/**\n * Helper class for performing schema migrations between two connectors.\n */\nexport class EntityStorageHelper {\n\t/**\n\t * Runtime name for the class.\n\t */\n\tpublic static readonly CLASS_NAME: string = nameof<EntityStorageHelper>();\n\n\t/**\n\t * Prepare the entity by handling undefined and null values and validating it against the schema.\n\t * @param entity The entity to handle undefined and null values for.\n\t * @param schema The schema to validate the entity against.\n\t * @param additionalProperties Optional list of additional properties to set on the entity.\n\t * @param options Options controlling how null/undefined optional properties are stored.\n\t * @param options.nullBehavior \"omit\" strips null/undefined optional properties before writing\n\t * (NoSQL — avoids index-key type errors). \"nullify\" converts undefined to null (SQL — the default).\n\t * @returns The entity with undefined and null values handled.\n\t */\n\tpublic static prepareEntity<T>(\n\t\tentity: T,\n\t\tschema: IEntitySchema<T>,\n\t\tadditionalProperties?: { property: string; value: unknown }[],\n\t\toptions?: { nullBehavior?: \"omit\" | \"nullify\" }\n\t): T {\n\t\tconst entityForValidation = ObjectHelper.clone(entity);\n\t\tEntitySchemaHelper.validateEntity(entityForValidation, schema);\n\n\t\tif (Is.arrayValue(schema.properties)) {\n\t\t\tfor (const property of schema.properties) {\n\t\t\t\tif (property.optional ?? false) {\n\t\t\t\t\tconst propValue = entityForValidation[property.property];\n\t\t\t\t\tif (options?.nullBehavior === \"omit\") {\n\t\t\t\t\t\tif (propValue === undefined || propValue === null) {\n\t\t\t\t\t\t\tObjectHelper.propertyDelete(entityForValidation, property.property as string);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (propValue === undefined) {\n\t\t\t\t\t\tObjectHelper.propertySet(entityForValidation, property.property as string, null);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (Is.arrayValue(additionalProperties)) {\n\t\t\tfor (const additionalProperty of additionalProperties) {\n\t\t\t\tObjectHelper.propertySet(\n\t\t\t\t\tentityForValidation,\n\t\t\t\t\tadditionalProperty.property,\n\t\t\t\t\tadditionalProperty.value\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\treturn entityForValidation;\n\t}\n\n\t/**\n\t * Un-prepare the entity by removing null values.\n\t * @param entity The entity to handle undefined and null values for.\n\t * @param removeProperties Optional list of properties to remove from the entity.\n\t * @returns The entity with undefined and null values handled.\n\t */\n\tpublic static unPrepareEntity<T>(entity: Partial<T> | undefined, removeProperties?: string[]): T {\n\t\tconst nonNullEntity = ObjectHelper.removeEmptyProperties(entity, { removeNull: true });\n\n\t\tif (Is.arrayValue(removeProperties)) {\n\t\t\tfor (const property of removeProperties) {\n\t\t\t\tObjectHelper.propertyDelete(nonNullEntity, property);\n\t\t\t}\n\t\t}\n\n\t\treturn nonNullEntity as T;\n\t}\n\n\t/**\n\t * Validate that every sort property in the list is indexed in the schema (isPrimary, isSecondary,\n\t * or has a default sortDirection), throwing sortNotIndexed for the first violation found.\n\t * @param schema The entity schema to validate against.\n\t * @param sortProperties The sort properties to check.\n\t * @throws GeneralError If a sort property is not indexed in the schema.\n\t */\n\tpublic static validateSortProperties<T>(\n\t\tschema: IEntitySchema<T>,\n\t\tsortProperties?: { property: keyof T; sortDirection: SortDirection }[]\n\t): void {\n\t\tif (Is.arrayValue(sortProperties)) {\n\t\t\tfor (const sortProperty of sortProperties) {\n\t\t\t\tconst propertySchema = schema.properties?.find(p => p.property === sortProperty.property);\n\t\t\t\tif (\n\t\t\t\t\tIs.undefined(propertySchema) ||\n\t\t\t\t\t(!propertySchema.isPrimary &&\n\t\t\t\t\t\t!propertySchema.isSecondary &&\n\t\t\t\t\t\tIs.empty(propertySchema.sortDirection))\n\t\t\t\t) {\n\t\t\t\t\tthrow new GeneralError(EntityStorageHelper.CLASS_NAME, \"sortNotIndexed\", {\n\t\t\t\t\t\tproperty: sortProperty.property\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Validate that every property in the list exists in the schema, throwing propertyNotInSchema\n\t * for the first property that is not found.\n\t * @param schema The entity schema to validate against.\n\t * @param properties The properties to check.\n\t * @throws GeneralError If a property does not exist in the schema.\n\t */\n\tpublic static validateProperties<T>(schema: IEntitySchema<T>, properties?: (keyof T)[]): void {\n\t\tif (Is.arrayValue(properties)) {\n\t\t\tfor (const property of properties) {\n\t\t\t\tconst propertySchema = schema.properties?.find(p => p.property === property);\n\t\t\t\tif (Is.undefined(propertySchema)) {\n\t\t\t\t\tthrow new GeneralError(EntityStorageHelper.CLASS_NAME, \"propertyNotInSchema\", {\n\t\t\t\t\t\tproperty\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Deep-clone condition tree and normalise null/undefined to undefined on Equals/NotEquals leaves\n\t * so in-memory evaluation matches stored-absent semantics (optional absent props are omitted/undefined).\n\t * @param condition The user-supplied condition (not mutated).\n\t * @returns A clone safe to pass to check.\n\t */\n\tpublic static normalizeConditionValues<T>(condition: EntityCondition<T>): EntityCondition<T> {\n\t\tif (\"conditions\" in condition) {\n\t\t\treturn {\n\t\t\t\t...condition,\n\t\t\t\tconditions: condition.conditions.map(c => EntityStorageHelper.normalizeConditionValues(c))\n\t\t\t};\n\t\t}\n\n\t\tconst leaf = condition;\n\t\tif (\n\t\t\t(leaf.comparison === ComparisonOperator.Equals ||\n\t\t\t\tleaf.comparison === ComparisonOperator.NotEquals) &&\n\t\t\t(leaf.value === undefined || leaf.value === null)\n\t\t) {\n\t\t\treturn { ...leaf, value: undefined };\n\t\t}\n\t\treturn { ...leaf };\n\t}\n}\n"]}
|
package/dist/es/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright 2026 IOTA Stiftung.
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
export * from "./entities/schemaVersion.js";
|
|
3
4
|
export * from "./factories/entityStorageConnectorFactory.js";
|
|
4
5
|
export * from "./factories/schemaMigrationFactory.js";
|
|
5
6
|
export * from "./helpers/entityStorageHelper.js";
|
|
@@ -21,4 +22,6 @@ export * from "./models/IEntityStorageMigrationConnector.js";
|
|
|
21
22
|
export * from "./models/IMigrationOptions.js";
|
|
22
23
|
export * from "./models/IResolvedMigrationStep.js";
|
|
23
24
|
export * from "./models/ISchemaMigration.js";
|
|
25
|
+
export * from "./schema.js";
|
|
26
|
+
export * from "./services/schemaVersionService.js";
|
|
24
27
|
//# sourceMappingURL=index.js.map
|
package/dist/es/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,cAAc,8CAA8C,CAAC;AAC7D,cAAc,uCAAuC,CAAC;AACtD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,4CAA4C,CAAC;AAC3D,cAAc,6CAA6C,CAAC;AAC5D,cAAc,4CAA4C,CAAC;AAC3D,cAAc,0CAA0C,CAAC;AACzD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,4CAA4C,CAAC;AAC3D,cAAc,kDAAkD,CAAC;AACjE,cAAc,6CAA6C,CAAC;AAC5D,cAAc,+CAA+C,CAAC;AAC9D,cAAc,0CAA0C,CAAC;AACzD,cAAc,qCAAqC,CAAC;AACpD,cAAc,qCAAqC,CAAC;AACpD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,8BAA8B,CAAC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nexport * from \"./factories/entityStorageConnectorFactory.js\";\nexport * from \"./factories/schemaMigrationFactory.js\";\nexport * from \"./helpers/entityStorageHelper.js\";\nexport * from \"./helpers/migrationHelper.js\";\nexport * from \"./models/api/IEntityStorageCountRequest.js\";\nexport * from \"./models/api/IEntityStorageCountResponse.js\";\nexport * from \"./models/api/IEntityStorageEmptyRequest.js\";\nexport * from \"./models/api/IEntityStorageGetRequest.js\";\nexport * from \"./models/api/IEntityStorageGetResponse.js\";\nexport * from \"./models/api/IEntityStorageListRequest.js\";\nexport * from \"./models/api/IEntityStorageListResponse.js\";\nexport * from \"./models/api/IEntityStorageRemoveBatchRequest.js\";\nexport * from \"./models/api/IEntityStorageRemoveRequest.js\";\nexport * from \"./models/api/IEntityStorageSetBatchRequest.js\";\nexport * from \"./models/api/IEntityStorageSetRequest.js\";\nexport * from \"./models/IEntityStorageComponent.js\";\nexport * from \"./models/IEntityStorageConnector.js\";\nexport * from \"./models/IEntityStorageMigrationConnector.js\";\nexport * from \"./models/IMigrationOptions.js\";\nexport * from \"./models/IResolvedMigrationStep.js\";\nexport * from \"./models/ISchemaMigration.js\";\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8CAA8C,CAAC;AAC7D,cAAc,uCAAuC,CAAC;AACtD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,4CAA4C,CAAC;AAC3D,cAAc,6CAA6C,CAAC;AAC5D,cAAc,4CAA4C,CAAC;AAC3D,cAAc,0CAA0C,CAAC;AACzD,cAAc,2CAA2C,CAAC;AAC1D,cAAc,2CAA2C,CAAC;AAC1D,cAAc,4CAA4C,CAAC;AAC3D,cAAc,kDAAkD,CAAC;AACjE,cAAc,6CAA6C,CAAC;AAC5D,cAAc,+CAA+C,CAAC;AAC9D,cAAc,0CAA0C,CAAC;AACzD,cAAc,qCAAqC,CAAC;AACpD,cAAc,qCAAqC,CAAC;AACpD,cAAc,8CAA8C,CAAC;AAC7D,cAAc,+BAA+B,CAAC;AAC9C,cAAc,oCAAoC,CAAC;AACnD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,aAAa,CAAC;AAC5B,cAAc,oCAAoC,CAAC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nexport * from \"./entities/schemaVersion.js\";\nexport * from \"./factories/entityStorageConnectorFactory.js\";\nexport * from \"./factories/schemaMigrationFactory.js\";\nexport * from \"./helpers/entityStorageHelper.js\";\nexport * from \"./helpers/migrationHelper.js\";\nexport * from \"./models/api/IEntityStorageCountRequest.js\";\nexport * from \"./models/api/IEntityStorageCountResponse.js\";\nexport * from \"./models/api/IEntityStorageEmptyRequest.js\";\nexport * from \"./models/api/IEntityStorageGetRequest.js\";\nexport * from \"./models/api/IEntityStorageGetResponse.js\";\nexport * from \"./models/api/IEntityStorageListRequest.js\";\nexport * from \"./models/api/IEntityStorageListResponse.js\";\nexport * from \"./models/api/IEntityStorageRemoveBatchRequest.js\";\nexport * from \"./models/api/IEntityStorageRemoveRequest.js\";\nexport * from \"./models/api/IEntityStorageSetBatchRequest.js\";\nexport * from \"./models/api/IEntityStorageSetRequest.js\";\nexport * from \"./models/IEntityStorageComponent.js\";\nexport * from \"./models/IEntityStorageConnector.js\";\nexport * from \"./models/IEntityStorageMigrationConnector.js\";\nexport * from \"./models/IMigrationOptions.js\";\nexport * from \"./models/IResolvedMigrationStep.js\";\nexport * from \"./models/ISchemaMigration.js\";\nexport * from \"./schema.js\";\nexport * from \"./services/schemaVersionService.js\";\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ISchemaMigration.js","sourceRoot":"","sources":["../../../src/models/ISchemaMigration.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMigrationOptions } from \"./IMigrationOptions.js\";\n\n/**\n * Optional per-step override for a single version-to-version migration.\n * Only register an entry in SchemaMigrationFactory when a step requires property\n * renames or a custom object/array transform. For purely structural changes\n * (add/remove/type-change fields) no entry is needed — the runner diffs the two\n * versioned schema classes (e.g. MyEntityV0 vs MyEntityV1) from EntitySchemaFactory\n * automatically.\n *\n * Register under the key \"
|
|
1
|
+
{"version":3,"file":"ISchemaMigration.js","sourceRoot":"","sources":["../../../src/models/ISchemaMigration.ts"],"names":[],"mappings":"","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type { IMigrationOptions } from \"./IMigrationOptions.js\";\n\n/**\n * Optional per-step override for a single version-to-version migration.\n * Only register an entry in SchemaMigrationFactory when a step requires property\n * renames or a custom object/array transform. For purely structural changes\n * (add/remove/type-change fields) no entry is needed — the runner diffs the two\n * versioned schema classes (e.g. MyEntityV0 vs MyEntityV1) from EntitySchemaFactory\n * automatically.\n *\n * Register under the key \"BaseSchemaName_fromVersion_toVersion\"\n * e.g. \"MyEntity_0_1\" for the step that migrates from version 0 to version 1.\n * The key itself encodes the version pair; no version field is needed on the object.\n */\nexport interface ISchemaMigration<T = unknown, U = unknown> {\n\t/**\n\t * Optional property renames to apply during this step.\n\t */\n\trenames?: { from: string; to: string }[];\n\n\t/**\n\t * Optional per-property transformer for object/array properties that cannot be\n\t * automatically coerced. T is the source entity type, U is the target entity type.\n\t */\n\ttransformEntityProperty?: IMigrationOptions<T, U>[\"transformEntityProperty\"];\n}\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright 2026 IOTA Stiftung.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { EntitySchemaFactory, EntitySchemaHelper } from "@twin.org/entity";
|
|
4
|
+
import { SchemaVersion } from "./entities/schemaVersion.js";
|
|
5
|
+
/**
|
|
6
|
+
* Initialize the schema for the entity storage models.
|
|
7
|
+
*/
|
|
8
|
+
export function initSchema() {
|
|
9
|
+
EntitySchemaFactory.register("SchemaVersion", () => EntitySchemaHelper.getSchema(SchemaVersion));
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/schema.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,OAAO,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,OAAO,EAAE,aAAa,EAAE,MAAM,6BAA6B,CAAC;AAE5D;;GAEG;AACH,MAAM,UAAU,UAAU;IACzB,mBAAmB,CAAC,QAAQ,kBAA0B,GAAG,EAAE,CAC1D,kBAAkB,CAAC,SAAS,CAAC,aAAa,CAAC,CAC3C,CAAC;AACH,CAAC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { EntitySchemaFactory, EntitySchemaHelper } from \"@twin.org/entity\";\nimport { nameof } from \"@twin.org/nameof\";\nimport { SchemaVersion } from \"./entities/schemaVersion.js\";\n\n/**\n * Initialize the schema for the entity storage models.\n */\nexport function initSchema(): void {\n\tEntitySchemaFactory.register(nameof<SchemaVersion>(), () =>\n\t\tEntitySchemaHelper.getSchema(SchemaVersion)\n\t);\n}\n"]}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Copyright 2026 IOTA Stiftung.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { GeneralError, Guards, Is } from "@twin.org/core";
|
|
4
|
+
import { EntitySchemaFactory, EntitySchemaHelper } from "@twin.org/entity";
|
|
5
|
+
import { SchemaVersion } from "../entities/schemaVersion.js";
|
|
6
|
+
import { EntityStorageConnectorFactory } from "../factories/entityStorageConnectorFactory.js";
|
|
7
|
+
import { SchemaMigrationFactory } from "../factories/schemaMigrationFactory.js";
|
|
8
|
+
import { MigrationHelper } from "../helpers/migrationHelper.js";
|
|
9
|
+
/**
|
|
10
|
+
* IComponent service that checks and applies entity schema migrations at every node start-up.
|
|
11
|
+
*
|
|
12
|
+
* This service must be the first entry in coreTypeInitialisers.json. The engine iterates that
|
|
13
|
+
* array in order to determine start sequence — there is no engine-level priority mechanism, so
|
|
14
|
+
* registration position is the only guarantee that start() runs before any other service.
|
|
15
|
+
* By the time start() is called, all component bootstraps have completed (every table already
|
|
16
|
+
* exists) and EntitySchemaFactory / EntityStorageConnectorFactory are fully populated with every
|
|
17
|
+
* registered schema and connector.
|
|
18
|
+
*
|
|
19
|
+
* Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
|
|
20
|
+
* convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
|
|
21
|
+
* The service groups schemas by base name (strips the trailing V number suffix) and resolves the
|
|
22
|
+
* migration chain automatically by diffing consecutive versioned schemas. For steps that require
|
|
23
|
+
* property renames or a custom transform hook, register an optional ISchemaMigration entry in
|
|
24
|
+
* SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
|
|
25
|
+
*
|
|
26
|
+
* Crash-window note: finalizeMigration and the subsequent version-record write are two
|
|
27
|
+
* separate operations. If the process dies between them the next boot re-runs the chain
|
|
28
|
+
* over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
|
|
29
|
+
* (newly-added optional fields would be dropped on re-run). A transaction spanning both
|
|
30
|
+
* writes is a precondition for production; track this in the concurrency follow-up.
|
|
31
|
+
*/
|
|
32
|
+
export class SchemaVersionService {
|
|
33
|
+
/**
|
|
34
|
+
* Runtime name for the class.
|
|
35
|
+
*/
|
|
36
|
+
static CLASS_NAME = "SchemaVersionService";
|
|
37
|
+
/**
|
|
38
|
+
* Regex to detect a versioned schema name and extract the base name and version number.
|
|
39
|
+
* Matches names like "MyEntityV0", "AuditableItemGraphV2", etc.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
static _VERSION_SUFFIX_RE = /^(.+)V(\d+)$/;
|
|
43
|
+
/**
|
|
44
|
+
* The connector used to read and write SchemaVersion records.
|
|
45
|
+
* @internal
|
|
46
|
+
*/
|
|
47
|
+
_versionConnector;
|
|
48
|
+
/**
|
|
49
|
+
* Create a new SchemaVersionService.
|
|
50
|
+
* @param versionConnector Entity-storage connector backed by the schemaVersion table.
|
|
51
|
+
*/
|
|
52
|
+
constructor(versionConnector) {
|
|
53
|
+
Guards.object(SchemaVersionService.CLASS_NAME, "versionConnector", versionConnector);
|
|
54
|
+
this._versionConnector = versionConnector;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Searches EntityStorageConnectorFactory for the connector whose registered schema type
|
|
58
|
+
* matches the given schema name.
|
|
59
|
+
* @param schemaName The entity type name to look up.
|
|
60
|
+
* @returns The matching connector, or undefined if none is registered.
|
|
61
|
+
* @internal
|
|
62
|
+
*/
|
|
63
|
+
static findConnector(schemaName) {
|
|
64
|
+
for (const name of EntityStorageConnectorFactory.names()) {
|
|
65
|
+
try {
|
|
66
|
+
const connector = EntityStorageConnectorFactory.get(name);
|
|
67
|
+
if (connector.getSchema?.().type === schemaName) {
|
|
68
|
+
return connector;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Connector not yet created or registration issue — skip.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Returns the class name.
|
|
79
|
+
* @returns The class name.
|
|
80
|
+
*/
|
|
81
|
+
className() {
|
|
82
|
+
return SchemaVersionService.CLASS_NAME;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bootstraps the version-store connector so the schemaVersion table exists
|
|
86
|
+
* before start() attempts to read or write version records.
|
|
87
|
+
* @param nodeLoggingComponentType An optional logging component type.
|
|
88
|
+
* @returns True on success.
|
|
89
|
+
*/
|
|
90
|
+
async bootstrap(nodeLoggingComponentType) {
|
|
91
|
+
const bootstrapFn = this._versionConnector.bootstrap?.bind(this._versionConnector);
|
|
92
|
+
if (Is.function(bootstrapFn)) {
|
|
93
|
+
return bootstrapFn(nodeLoggingComponentType);
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads all registered entity schemas, groups versioned schemas by base name, reads the
|
|
99
|
+
* full schemaVersion table in one pass, then orchestrates chain migrations for any schema
|
|
100
|
+
* whose stored version is behind the current version declared in EntitySchemaFactory.
|
|
101
|
+
* SchemaVersion itself is processed first so the version store is migrated before any
|
|
102
|
+
* version records are written for other schemas.
|
|
103
|
+
*
|
|
104
|
+
* Runs after all component bootstraps, so every managed table already exists.
|
|
105
|
+
* @param nodeLoggingComponentType An optional logging component type.
|
|
106
|
+
*/
|
|
107
|
+
async start(nodeLoggingComponentType) {
|
|
108
|
+
// 1. Collect all registered schema names and partition into current vs historical.
|
|
109
|
+
const allNames = EntitySchemaFactory.names();
|
|
110
|
+
const historicalByBase = new Map();
|
|
111
|
+
const currentSchemas = new Map();
|
|
112
|
+
for (const name of allNames) {
|
|
113
|
+
const match = SchemaVersionService._VERSION_SUFFIX_RE.exec(name);
|
|
114
|
+
if (match) {
|
|
115
|
+
const baseName = match[1];
|
|
116
|
+
const version = Number.parseInt(match[2], 10);
|
|
117
|
+
let versions = historicalByBase.get(baseName);
|
|
118
|
+
if (!versions) {
|
|
119
|
+
versions = new Map();
|
|
120
|
+
historicalByBase.set(baseName, versions);
|
|
121
|
+
}
|
|
122
|
+
versions.set(version, EntitySchemaFactory.get(name));
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
currentSchemas.set(name, EntitySchemaFactory.get(name));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// 2. Read ALL stored version records, paging through the full table.
|
|
129
|
+
const storedVersions = new Map();
|
|
130
|
+
let cursor;
|
|
131
|
+
do {
|
|
132
|
+
const queryResult = await this._versionConnector.query(undefined, undefined, undefined, cursor);
|
|
133
|
+
for (const record of queryResult.entities ?? []) {
|
|
134
|
+
if (Is.object(record)) {
|
|
135
|
+
storedVersions.set(record.schemaName, record.version);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
cursor = queryResult.cursor;
|
|
139
|
+
} while (Is.stringValue(cursor));
|
|
140
|
+
// 3. Process SchemaVersion first so the version store itself is fully migrated
|
|
141
|
+
// before any version records are written for other schemas.
|
|
142
|
+
const schemaVersionName = "SchemaVersion";
|
|
143
|
+
const schemaVersionSchema = currentSchemas.get(schemaVersionName);
|
|
144
|
+
if (schemaVersionSchema) {
|
|
145
|
+
currentSchemas.delete(schemaVersionName);
|
|
146
|
+
await this.processSchema(schemaVersionName, schemaVersionSchema, storedVersions, historicalByBase.get(schemaVersionName), nodeLoggingComponentType);
|
|
147
|
+
}
|
|
148
|
+
// 4. Process all remaining schemas.
|
|
149
|
+
for (const [schemaName, schema] of currentSchemas) {
|
|
150
|
+
await this.processSchema(schemaName, schema, storedVersions, historicalByBase.get(schemaName), nodeLoggingComponentType);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Checks and applies any pending migration for a single entity schema.
|
|
155
|
+
* Extracted to avoid continue statements in the outer loop.
|
|
156
|
+
* @param schemaName The base schema name.
|
|
157
|
+
* @param schema The current schema definition.
|
|
158
|
+
* @param storedVersions The full map of stored version records.
|
|
159
|
+
* @param history The versioned-schema map for this schema (historicalByBase.get(schemaName)), or undefined if none exist.
|
|
160
|
+
* @param nodeLoggingComponentType An optional logging component type.
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
async processSchema(schemaName, schema, storedVersions, history, nodeLoggingComponentType) {
|
|
164
|
+
const currentVersion = EntitySchemaHelper.getVersion(schema);
|
|
165
|
+
// Find the entity-storage connector whose schema type matches this schema name.
|
|
166
|
+
// For SchemaVersion itself, use the injected connector directly rather than re-discovering
|
|
167
|
+
// it through the factory, which could resolve a different instance than _versionConnector.
|
|
168
|
+
const connector = schemaName === "SchemaVersion"
|
|
169
|
+
? this._versionConnector
|
|
170
|
+
: SchemaVersionService.findConnector(schemaName);
|
|
171
|
+
if (!connector) {
|
|
172
|
+
// No connector registered for this schema — nothing to migrate.
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Resolve the stored version, applying the backwards-compat baseline when no record exists.
|
|
176
|
+
const stored = storedVersions.get(schemaName);
|
|
177
|
+
let resolvedStored;
|
|
178
|
+
if (stored === undefined) {
|
|
179
|
+
// No version record: treat as v0 regardless of whether the table has data.
|
|
180
|
+
// On SQL connectors the table may have a stale column structure even when empty;
|
|
181
|
+
// running the chain over zero rows still calls finalizeMigration, which reconciles
|
|
182
|
+
// the table shape via a connector swap.
|
|
183
|
+
// Deployment precondition: any pre-existing data is genuinely at v0. A deployment
|
|
184
|
+
// that hand-applied a later schema before this service was introduced would be
|
|
185
|
+
// incorrectly replayed v0→…→current and should be seeded with an explicit record.
|
|
186
|
+
resolvedStored = 0;
|
|
187
|
+
await this.writeVersion(schemaName, 0);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
resolvedStored = stored;
|
|
191
|
+
}
|
|
192
|
+
// No-op: stored version already matches current.
|
|
193
|
+
if (resolvedStored === currentVersion) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Downgrade — not supported.
|
|
197
|
+
if (resolvedStored > currentVersion) {
|
|
198
|
+
throw new GeneralError(SchemaVersionService.CLASS_NAME, "storedVersionNewer", {
|
|
199
|
+
schemaName,
|
|
200
|
+
stored: resolvedStored,
|
|
201
|
+
current: currentVersion
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Migration is needed. If the connector does not support it, throw immediately so
|
|
205
|
+
// the problem surfaces at boot rather than at runtime when writes hit the wrong table shape.
|
|
206
|
+
if (!("createTargetConnector" in connector)) {
|
|
207
|
+
throw new GeneralError(SchemaVersionService.CLASS_NAME, "connectorNotMigrationCapable", {
|
|
208
|
+
schemaName,
|
|
209
|
+
stored: resolvedStored,
|
|
210
|
+
current: currentVersion
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const migrationConnector = connector;
|
|
214
|
+
// Upgrade — resolve and run the chain.
|
|
215
|
+
const steps = [];
|
|
216
|
+
for (let v = resolvedStored; v < currentVersion; v++) {
|
|
217
|
+
const fromSchema = history?.get(v);
|
|
218
|
+
if (!fromSchema) {
|
|
219
|
+
throw new GeneralError(SchemaVersionService.CLASS_NAME, "noMigrationStep", {
|
|
220
|
+
schemaName,
|
|
221
|
+
stored: resolvedStored,
|
|
222
|
+
current: currentVersion,
|
|
223
|
+
missingFromVersion: v,
|
|
224
|
+
missingToVersion: v + 1
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const toSchema = v + 1 < currentVersion ? history?.get(v + 1) : schema;
|
|
228
|
+
if (!toSchema) {
|
|
229
|
+
throw new GeneralError(SchemaVersionService.CLASS_NAME, "noMigrationStepTarget", {
|
|
230
|
+
schemaName,
|
|
231
|
+
stored: resolvedStored,
|
|
232
|
+
current: currentVersion,
|
|
233
|
+
missingFromVersion: v,
|
|
234
|
+
missingToVersion: v + 1
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const overrideKey = `${schemaName}_${v}_${v + 1}`;
|
|
238
|
+
const override = SchemaMigrationFactory.getIfExists(overrideKey);
|
|
239
|
+
steps.push({
|
|
240
|
+
fromProperties: fromSchema.properties ?? [],
|
|
241
|
+
toProperties: toSchema.properties ?? [],
|
|
242
|
+
renames: override?.renames,
|
|
243
|
+
transformEntityProperty: override?.transformEntityProperty
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
await MigrationHelper.migrateWithChain(migrationConnector, schemaName, steps, nodeLoggingComponentType);
|
|
247
|
+
// Advance the stored version only after finalizeMigration has succeeded.
|
|
248
|
+
// See crash-window note in the class comment.
|
|
249
|
+
await this.writeVersion(schemaName, currentVersion);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Upserts a SchemaVersion record for the given schema name.
|
|
253
|
+
* @param schemaName The schema type name.
|
|
254
|
+
* @param version The version to record.
|
|
255
|
+
* @internal
|
|
256
|
+
*/
|
|
257
|
+
async writeVersion(schemaName, version) {
|
|
258
|
+
await this._versionConnector.set({
|
|
259
|
+
schemaName,
|
|
260
|
+
version,
|
|
261
|
+
updatedAt: new Date().toISOString()
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=schemaVersionService.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schemaVersionService.js","sourceRoot":"","sources":["../../../src/services/schemaVersionService.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { GeneralError, Guards, Is, type IComponent } from \"@twin.org/core\";\nimport { EntitySchemaFactory, EntitySchemaHelper, type IEntitySchema } from \"@twin.org/entity\";\nimport { nameof } from \"@twin.org/nameof\";\nimport { SchemaVersion } from \"../entities/schemaVersion.js\";\nimport { EntityStorageConnectorFactory } from \"../factories/entityStorageConnectorFactory.js\";\nimport { SchemaMigrationFactory } from \"../factories/schemaMigrationFactory.js\";\nimport { MigrationHelper } from \"../helpers/migrationHelper.js\";\nimport type { IEntityStorageConnector } from \"../models/IEntityStorageConnector.js\";\nimport type { IEntityStorageMigrationConnector } from \"../models/IEntityStorageMigrationConnector.js\";\nimport type { IResolvedMigrationStep } from \"../models/IResolvedMigrationStep.js\";\n\n/**\n * IComponent service that checks and applies entity schema migrations at every node start-up.\n *\n * This service must be the first entry in coreTypeInitialisers.json. The engine iterates that\n * array in order to determine start sequence — there is no engine-level priority mechanism, so\n * registration position is the only guarantee that start() runs before any other service.\n * By the time start() is called, all component bootstraps have completed (every table already\n * exists) and EntitySchemaFactory / EntityStorageConnectorFactory are fully populated with every\n * registered schema and connector.\n *\n * Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming\n * convention — current schema = \"MyEntity\", first history = \"MyEntityV0\", second = \"MyEntityV1\".\n * The service groups schemas by base name (strips the trailing V number suffix) and resolves the\n * migration chain automatically by diffing consecutive versioned schemas. For steps that require\n * property renames or a custom transform hook, register an optional ISchemaMigration entry in\n * SchemaMigrationFactory under the key \"Base_from_to\" (e.g. \"MyEntity_0_1\").\n *\n * Crash-window note: finalizeMigration and the subsequent version-record write are two\n * separate operations. If the process dies between them the next boot re-runs the chain\n * over already-migrated data. applyEntityTransform is NOT idempotent for structural changes\n * (newly-added optional fields would be dropped on re-run). A transaction spanning both\n * writes is a precondition for production; track this in the concurrency follow-up.\n */\nexport class SchemaVersionService implements IComponent {\n\t/**\n\t * Runtime name for the class.\n\t */\n\tpublic static readonly CLASS_NAME: string = nameof<SchemaVersionService>();\n\n\t/**\n\t * Regex to detect a versioned schema name and extract the base name and version number.\n\t * Matches names like \"MyEntityV0\", \"AuditableItemGraphV2\", etc.\n\t * @internal\n\t */\n\tprivate static readonly _VERSION_SUFFIX_RE = /^(.+)V(\\d+)$/;\n\n\t/**\n\t * The connector used to read and write SchemaVersion records.\n\t * @internal\n\t */\n\tprivate readonly _versionConnector: IEntityStorageConnector<SchemaVersion>;\n\n\t/**\n\t * Create a new SchemaVersionService.\n\t * @param versionConnector Entity-storage connector backed by the schemaVersion table.\n\t */\n\tconstructor(versionConnector: IEntityStorageConnector<SchemaVersion>) {\n\t\tGuards.object<IEntityStorageConnector<SchemaVersion>>(\n\t\t\tSchemaVersionService.CLASS_NAME,\n\t\t\tnameof(versionConnector),\n\t\t\tversionConnector\n\t\t);\n\t\tthis._versionConnector = versionConnector;\n\t}\n\n\t/**\n\t * Searches EntityStorageConnectorFactory for the connector whose registered schema type\n\t * matches the given schema name.\n\t * @param schemaName The entity type name to look up.\n\t * @returns The matching connector, or undefined if none is registered.\n\t * @internal\n\t */\n\tprivate static findConnector(schemaName: string): IEntityStorageConnector | undefined {\n\t\tfor (const name of EntityStorageConnectorFactory.names()) {\n\t\t\ttry {\n\t\t\t\tconst connector = EntityStorageConnectorFactory.get(name);\n\t\t\t\tif (connector.getSchema?.().type === schemaName) {\n\t\t\t\t\treturn connector;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Connector not yet created or registration issue — skip.\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Returns the class name.\n\t * @returns The class name.\n\t */\n\tpublic className(): string {\n\t\treturn SchemaVersionService.CLASS_NAME;\n\t}\n\n\t/**\n\t * Bootstraps the version-store connector so the schemaVersion table exists\n\t * before start() attempts to read or write version records.\n\t * @param nodeLoggingComponentType An optional logging component type.\n\t * @returns True on success.\n\t */\n\tpublic async bootstrap(nodeLoggingComponentType?: string): Promise<boolean> {\n\t\tconst bootstrapFn = this._versionConnector.bootstrap?.bind(this._versionConnector);\n\t\tif (Is.function(bootstrapFn)) {\n\t\t\treturn bootstrapFn(nodeLoggingComponentType);\n\t\t}\n\t\treturn true;\n\t}\n\n\t/**\n\t * Reads all registered entity schemas, groups versioned schemas by base name, reads the\n\t * full schemaVersion table in one pass, then orchestrates chain migrations for any schema\n\t * whose stored version is behind the current version declared in EntitySchemaFactory.\n\t * SchemaVersion itself is processed first so the version store is migrated before any\n\t * version records are written for other schemas.\n\t *\n\t * Runs after all component bootstraps, so every managed table already exists.\n\t * @param nodeLoggingComponentType An optional logging component type.\n\t */\n\tpublic async start(nodeLoggingComponentType?: string): Promise<void> {\n\t\t// 1. Collect all registered schema names and partition into current vs historical.\n\t\tconst allNames = EntitySchemaFactory.names();\n\n\t\tconst historicalByBase = new Map<string, Map<number, IEntitySchema>>();\n\t\tconst currentSchemas = new Map<string, IEntitySchema>();\n\n\t\tfor (const name of allNames) {\n\t\t\tconst match = SchemaVersionService._VERSION_SUFFIX_RE.exec(name);\n\t\t\tif (match) {\n\t\t\t\tconst baseName = match[1];\n\t\t\t\tconst version = Number.parseInt(match[2], 10);\n\t\t\t\tlet versions = historicalByBase.get(baseName);\n\t\t\t\tif (!versions) {\n\t\t\t\t\tversions = new Map();\n\t\t\t\t\thistoricalByBase.set(baseName, versions);\n\t\t\t\t}\n\t\t\t\tversions.set(version, EntitySchemaFactory.get(name));\n\t\t\t} else {\n\t\t\t\tcurrentSchemas.set(name, EntitySchemaFactory.get(name));\n\t\t\t}\n\t\t}\n\n\t\t// 2. Read ALL stored version records, paging through the full table.\n\t\tconst storedVersions = new Map<string, number>();\n\t\tlet cursor: string | undefined;\n\t\tdo {\n\t\t\tconst queryResult = await this._versionConnector.query(\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\tcursor\n\t\t\t);\n\t\t\tfor (const record of queryResult.entities ?? []) {\n\t\t\t\tif (Is.object<SchemaVersion>(record)) {\n\t\t\t\t\tstoredVersions.set(record.schemaName, record.version);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcursor = queryResult.cursor;\n\t\t} while (Is.stringValue(cursor));\n\n\t\t// 3. Process SchemaVersion first so the version store itself is fully migrated\n\t\t// before any version records are written for other schemas.\n\t\tconst schemaVersionName = nameof(SchemaVersion);\n\t\tconst schemaVersionSchema = currentSchemas.get(schemaVersionName);\n\t\tif (schemaVersionSchema) {\n\t\t\tcurrentSchemas.delete(schemaVersionName);\n\t\t\tawait this.processSchema(\n\t\t\t\tschemaVersionName,\n\t\t\t\tschemaVersionSchema,\n\t\t\t\tstoredVersions,\n\t\t\t\thistoricalByBase.get(schemaVersionName),\n\t\t\t\tnodeLoggingComponentType\n\t\t\t);\n\t\t}\n\n\t\t// 4. Process all remaining schemas.\n\t\tfor (const [schemaName, schema] of currentSchemas) {\n\t\t\tawait this.processSchema(\n\t\t\t\tschemaName,\n\t\t\t\tschema,\n\t\t\t\tstoredVersions,\n\t\t\t\thistoricalByBase.get(schemaName),\n\t\t\t\tnodeLoggingComponentType\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Checks and applies any pending migration for a single entity schema.\n\t * Extracted to avoid continue statements in the outer loop.\n\t * @param schemaName The base schema name.\n\t * @param schema The current schema definition.\n\t * @param storedVersions The full map of stored version records.\n\t * @param history The versioned-schema map for this schema (historicalByBase.get(schemaName)), or undefined if none exist.\n\t * @param nodeLoggingComponentType An optional logging component type.\n\t * @internal\n\t */\n\tprivate async processSchema(\n\t\tschemaName: string,\n\t\tschema: IEntitySchema,\n\t\tstoredVersions: Map<string, number>,\n\t\thistory: Map<number, IEntitySchema> | undefined,\n\t\tnodeLoggingComponentType: string | undefined\n\t): Promise<void> {\n\t\tconst currentVersion = EntitySchemaHelper.getVersion(schema);\n\n\t\t// Find the entity-storage connector whose schema type matches this schema name.\n\t\t// For SchemaVersion itself, use the injected connector directly rather than re-discovering\n\t\t// it through the factory, which could resolve a different instance than _versionConnector.\n\t\tconst connector =\n\t\t\tschemaName === nameof(SchemaVersion)\n\t\t\t\t? this._versionConnector\n\t\t\t\t: SchemaVersionService.findConnector(schemaName);\n\t\tif (!connector) {\n\t\t\t// No connector registered for this schema — nothing to migrate.\n\t\t\treturn;\n\t\t}\n\n\t\t// Resolve the stored version, applying the backwards-compat baseline when no record exists.\n\t\tconst stored = storedVersions.get(schemaName);\n\t\tlet resolvedStored: number;\n\n\t\tif (stored === undefined) {\n\t\t\t// No version record: treat as v0 regardless of whether the table has data.\n\t\t\t// On SQL connectors the table may have a stale column structure even when empty;\n\t\t\t// running the chain over zero rows still calls finalizeMigration, which reconciles\n\t\t\t// the table shape via a connector swap.\n\t\t\t// Deployment precondition: any pre-existing data is genuinely at v0. A deployment\n\t\t\t// that hand-applied a later schema before this service was introduced would be\n\t\t\t// incorrectly replayed v0→…→current and should be seeded with an explicit record.\n\t\t\tresolvedStored = 0;\n\t\t\tawait this.writeVersion(schemaName, 0);\n\t\t} else {\n\t\t\tresolvedStored = stored;\n\t\t}\n\n\t\t// No-op: stored version already matches current.\n\t\tif (resolvedStored === currentVersion) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Downgrade — not supported.\n\t\tif (resolvedStored > currentVersion) {\n\t\t\tthrow new GeneralError(SchemaVersionService.CLASS_NAME, \"storedVersionNewer\", {\n\t\t\t\tschemaName,\n\t\t\t\tstored: resolvedStored,\n\t\t\t\tcurrent: currentVersion\n\t\t\t});\n\t\t}\n\n\t\t// Migration is needed. If the connector does not support it, throw immediately so\n\t\t// the problem surfaces at boot rather than at runtime when writes hit the wrong table shape.\n\t\tif (!(\"createTargetConnector\" in connector)) {\n\t\t\tthrow new GeneralError(SchemaVersionService.CLASS_NAME, \"connectorNotMigrationCapable\", {\n\t\t\t\tschemaName,\n\t\t\t\tstored: resolvedStored,\n\t\t\t\tcurrent: currentVersion\n\t\t\t});\n\t\t}\n\n\t\tconst migrationConnector = connector as IEntityStorageMigrationConnector;\n\n\t\t// Upgrade — resolve and run the chain.\n\t\tconst steps: IResolvedMigrationStep[] = [];\n\n\t\tfor (let v = resolvedStored; v < currentVersion; v++) {\n\t\t\tconst fromSchema = history?.get(v);\n\t\t\tif (!fromSchema) {\n\t\t\t\tthrow new GeneralError(SchemaVersionService.CLASS_NAME, \"noMigrationStep\", {\n\t\t\t\t\tschemaName,\n\t\t\t\t\tstored: resolvedStored,\n\t\t\t\t\tcurrent: currentVersion,\n\t\t\t\t\tmissingFromVersion: v,\n\t\t\t\t\tmissingToVersion: v + 1\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst toSchema = v + 1 < currentVersion ? history?.get(v + 1) : schema;\n\t\t\tif (!toSchema) {\n\t\t\t\tthrow new GeneralError(SchemaVersionService.CLASS_NAME, \"noMigrationStepTarget\", {\n\t\t\t\t\tschemaName,\n\t\t\t\t\tstored: resolvedStored,\n\t\t\t\t\tcurrent: currentVersion,\n\t\t\t\t\tmissingFromVersion: v,\n\t\t\t\t\tmissingToVersion: v + 1\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst overrideKey = `${schemaName}_${v}_${v + 1}`;\n\t\t\tconst override = SchemaMigrationFactory.getIfExists(overrideKey);\n\n\t\t\tsteps.push({\n\t\t\t\tfromProperties: fromSchema.properties ?? [],\n\t\t\t\ttoProperties: toSchema.properties ?? [],\n\t\t\t\trenames: override?.renames,\n\t\t\t\ttransformEntityProperty: override?.transformEntityProperty\n\t\t\t});\n\t\t}\n\n\t\tawait MigrationHelper.migrateWithChain(\n\t\t\tmigrationConnector,\n\t\t\tschemaName,\n\t\t\tsteps,\n\t\t\tnodeLoggingComponentType\n\t\t);\n\n\t\t// Advance the stored version only after finalizeMigration has succeeded.\n\t\t// See crash-window note in the class comment.\n\t\tawait this.writeVersion(schemaName, currentVersion);\n\t}\n\n\t/**\n\t * Upserts a SchemaVersion record for the given schema name.\n\t * @param schemaName The schema type name.\n\t * @param version The version to record.\n\t * @internal\n\t */\n\tprivate async writeVersion(schemaName: string, version: number): Promise<void> {\n\t\tawait this._versionConnector.set({\n\t\t\tschemaName,\n\t\t\tversion,\n\t\t\tupdatedAt: new Date().toISOString()\n\t\t});\n\t}\n}\n"]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks the currently applied schema version for each managed entity schema.
|
|
3
|
+
* One record per schema name. Written once on first boot, then updated after
|
|
4
|
+
* each successful migration.
|
|
5
|
+
*/
|
|
6
|
+
export declare class SchemaVersion {
|
|
7
|
+
/**
|
|
8
|
+
* The entity schema type name — primary key.
|
|
9
|
+
*/
|
|
10
|
+
schemaName: string;
|
|
11
|
+
/**
|
|
12
|
+
* The currently deployed version of this schema.
|
|
13
|
+
*/
|
|
14
|
+
version: number;
|
|
15
|
+
/**
|
|
16
|
+
* ISO 8601 timestamp of the last version write.
|
|
17
|
+
*/
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
@@ -8,7 +8,7 @@ import type { ISchemaMigration } from "../models/ISchemaMigration.js";
|
|
|
8
8
|
* SchemaVersionService diffs the two versioned schema classes automatically
|
|
9
9
|
* without needing any factory entry.
|
|
10
10
|
*
|
|
11
|
-
* Keys follow the convention "
|
|
11
|
+
* Keys follow the convention "BaseSchemaName_fromVersion_toVersion",
|
|
12
12
|
* for example "MyEntity_0_1" for the step that migrates MyEntity from version 0 to 1.
|
|
13
13
|
*/
|
|
14
14
|
export declare const SchemaMigrationFactory: Factory<ISchemaMigration<unknown, unknown>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EntityCondition, type IEntitySchema } from "@twin.org/entity";
|
|
1
|
+
import { type EntityCondition, type IEntitySchema, type SortDirection } from "@twin.org/entity";
|
|
2
2
|
/**
|
|
3
3
|
* Helper class for performing schema migrations between two connectors.
|
|
4
4
|
*/
|
|
@@ -30,6 +30,25 @@ export declare class EntityStorageHelper {
|
|
|
30
30
|
* @returns The entity with undefined and null values handled.
|
|
31
31
|
*/
|
|
32
32
|
static unPrepareEntity<T>(entity: Partial<T> | undefined, removeProperties?: string[]): T;
|
|
33
|
+
/**
|
|
34
|
+
* Validate that every sort property in the list is indexed in the schema (isPrimary, isSecondary,
|
|
35
|
+
* or has a default sortDirection), throwing sortNotIndexed for the first violation found.
|
|
36
|
+
* @param schema The entity schema to validate against.
|
|
37
|
+
* @param sortProperties The sort properties to check.
|
|
38
|
+
* @throws GeneralError If a sort property is not indexed in the schema.
|
|
39
|
+
*/
|
|
40
|
+
static validateSortProperties<T>(schema: IEntitySchema<T>, sortProperties?: {
|
|
41
|
+
property: keyof T;
|
|
42
|
+
sortDirection: SortDirection;
|
|
43
|
+
}[]): void;
|
|
44
|
+
/**
|
|
45
|
+
* Validate that every property in the list exists in the schema, throwing propertyNotInSchema
|
|
46
|
+
* for the first property that is not found.
|
|
47
|
+
* @param schema The entity schema to validate against.
|
|
48
|
+
* @param properties The properties to check.
|
|
49
|
+
* @throws GeneralError If a property does not exist in the schema.
|
|
50
|
+
*/
|
|
51
|
+
static validateProperties<T>(schema: IEntitySchema<T>, properties?: (keyof T)[]): void;
|
|
33
52
|
/**
|
|
34
53
|
* Deep-clone condition tree and normalise null/undefined to undefined on Equals/NotEquals leaves
|
|
35
54
|
* so in-memory evaluation matches stored-absent semantics (optional absent props are omitted/undefined).
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export * from "./entities/schemaVersion.js";
|
|
1
2
|
export * from "./factories/entityStorageConnectorFactory.js";
|
|
2
3
|
export * from "./factories/schemaMigrationFactory.js";
|
|
3
4
|
export * from "./helpers/entityStorageHelper.js";
|
|
@@ -19,3 +20,5 @@ export * from "./models/IEntityStorageMigrationConnector.js";
|
|
|
19
20
|
export * from "./models/IMigrationOptions.js";
|
|
20
21
|
export * from "./models/IResolvedMigrationStep.js";
|
|
21
22
|
export * from "./models/ISchemaMigration.js";
|
|
23
|
+
export * from "./schema.js";
|
|
24
|
+
export * from "./services/schemaVersionService.js";
|
|
@@ -7,7 +7,7 @@ import type { IMigrationOptions } from "./IMigrationOptions.js";
|
|
|
7
7
|
* versioned schema classes (e.g. MyEntityV0 vs MyEntityV1) from EntitySchemaFactory
|
|
8
8
|
* automatically.
|
|
9
9
|
*
|
|
10
|
-
* Register under the key "
|
|
10
|
+
* Register under the key "BaseSchemaName_fromVersion_toVersion"
|
|
11
11
|
* e.g. "MyEntity_0_1" for the step that migrates from version 0 to version 1.
|
|
12
12
|
* The key itself encodes the version pair; no version field is needed on the object.
|
|
13
13
|
*/
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type IComponent } from "@twin.org/core";
|
|
2
|
+
import { SchemaVersion } from "../entities/schemaVersion.js";
|
|
3
|
+
import type { IEntityStorageConnector } from "../models/IEntityStorageConnector.js";
|
|
4
|
+
/**
|
|
5
|
+
* IComponent service that checks and applies entity schema migrations at every node start-up.
|
|
6
|
+
*
|
|
7
|
+
* This service must be the first entry in coreTypeInitialisers.json. The engine iterates that
|
|
8
|
+
* array in order to determine start sequence — there is no engine-level priority mechanism, so
|
|
9
|
+
* registration position is the only guarantee that start() runs before any other service.
|
|
10
|
+
* By the time start() is called, all component bootstraps have completed (every table already
|
|
11
|
+
* exists) and EntitySchemaFactory / EntityStorageConnectorFactory are fully populated with every
|
|
12
|
+
* registered schema and connector.
|
|
13
|
+
*
|
|
14
|
+
* Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
|
|
15
|
+
* convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
|
|
16
|
+
* The service groups schemas by base name (strips the trailing V number suffix) and resolves the
|
|
17
|
+
* migration chain automatically by diffing consecutive versioned schemas. For steps that require
|
|
18
|
+
* property renames or a custom transform hook, register an optional ISchemaMigration entry in
|
|
19
|
+
* SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
|
|
20
|
+
*
|
|
21
|
+
* Crash-window note: finalizeMigration and the subsequent version-record write are two
|
|
22
|
+
* separate operations. If the process dies between them the next boot re-runs the chain
|
|
23
|
+
* over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
|
|
24
|
+
* (newly-added optional fields would be dropped on re-run). A transaction spanning both
|
|
25
|
+
* writes is a precondition for production; track this in the concurrency follow-up.
|
|
26
|
+
*/
|
|
27
|
+
export declare class SchemaVersionService implements IComponent {
|
|
28
|
+
/**
|
|
29
|
+
* Runtime name for the class.
|
|
30
|
+
*/
|
|
31
|
+
static readonly CLASS_NAME: string;
|
|
32
|
+
/**
|
|
33
|
+
* Create a new SchemaVersionService.
|
|
34
|
+
* @param versionConnector Entity-storage connector backed by the schemaVersion table.
|
|
35
|
+
*/
|
|
36
|
+
constructor(versionConnector: IEntityStorageConnector<SchemaVersion>);
|
|
37
|
+
/**
|
|
38
|
+
* Returns the class name.
|
|
39
|
+
* @returns The class name.
|
|
40
|
+
*/
|
|
41
|
+
className(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Bootstraps the version-store connector so the schemaVersion table exists
|
|
44
|
+
* before start() attempts to read or write version records.
|
|
45
|
+
* @param nodeLoggingComponentType An optional logging component type.
|
|
46
|
+
* @returns True on success.
|
|
47
|
+
*/
|
|
48
|
+
bootstrap(nodeLoggingComponentType?: string): Promise<boolean>;
|
|
49
|
+
/**
|
|
50
|
+
* Reads all registered entity schemas, groups versioned schemas by base name, reads the
|
|
51
|
+
* full schemaVersion table in one pass, then orchestrates chain migrations for any schema
|
|
52
|
+
* whose stored version is behind the current version declared in EntitySchemaFactory.
|
|
53
|
+
* SchemaVersion itself is processed first so the version store is migrated before any
|
|
54
|
+
* version records are written for other schemas.
|
|
55
|
+
*
|
|
56
|
+
* Runs after all component bootstraps, so every managed table already exists.
|
|
57
|
+
* @param nodeLoggingComponentType An optional logging component type.
|
|
58
|
+
*/
|
|
59
|
+
start(nodeLoggingComponentType?: string): Promise<void>;
|
|
60
|
+
}
|
package/docs/changelog.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.0.3-next.24](https://github.com/iotaledger/twin-entity-storage/compare/entity-storage-models-v0.0.3-next.23...entity-storage-models-v0.0.3-next.24) (2026-06-08)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add SchemaVersionService for automatic schema migrations ([#118](https://github.com/iotaledger/twin-entity-storage/issues/118)) ([b2ad843](https://github.com/iotaledger/twin-entity-storage/commit/b2ad8435185c53304aca99eb4d98582009b3902d))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* docs ([1a30170](https://github.com/iotaledger/twin-entity-storage/commit/1a301707ee0cb48314223347a6e9f3b3a3a7362d))
|
|
14
|
+
|
|
15
|
+
## [0.0.3-next.23](https://github.com/iotaledger/twin-entity-storage/compare/entity-storage-models-v0.0.3-next.22...entity-storage-models-v0.0.3-next.23) (2026-06-08)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* entity storage conditions ([#115](https://github.com/iotaledger/twin-entity-storage/issues/115)) ([7a53884](https://github.com/iotaledger/twin-entity-storage/commit/7a53884f6acb856d77733e4e0f23ec1c00b74cb4))
|
|
21
|
+
|
|
3
22
|
## [0.0.3-next.22](https://github.com/iotaledger/twin-entity-storage/compare/entity-storage-models-v0.0.3-next.21...entity-storage-models-v0.0.3-next.22) (2026-06-08)
|
|
4
23
|
|
|
5
24
|
|
|
@@ -107,6 +107,80 @@ The entity with undefined and null values handled.
|
|
|
107
107
|
|
|
108
108
|
***
|
|
109
109
|
|
|
110
|
+
### validateSortProperties() {#validatesortproperties}
|
|
111
|
+
|
|
112
|
+
> `static` **validateSortProperties**\<`T`\>(`schema`, `sortProperties?`): `void`
|
|
113
|
+
|
|
114
|
+
Validate that every sort property in the list is indexed in the schema (isPrimary, isSecondary,
|
|
115
|
+
or has a default sortDirection), throwing sortNotIndexed for the first violation found.
|
|
116
|
+
|
|
117
|
+
#### Type Parameters
|
|
118
|
+
|
|
119
|
+
##### T
|
|
120
|
+
|
|
121
|
+
`T`
|
|
122
|
+
|
|
123
|
+
#### Parameters
|
|
124
|
+
|
|
125
|
+
##### schema
|
|
126
|
+
|
|
127
|
+
`IEntitySchema`\<`T`\>
|
|
128
|
+
|
|
129
|
+
The entity schema to validate against.
|
|
130
|
+
|
|
131
|
+
##### sortProperties?
|
|
132
|
+
|
|
133
|
+
`object`[]
|
|
134
|
+
|
|
135
|
+
The sort properties to check.
|
|
136
|
+
|
|
137
|
+
#### Returns
|
|
138
|
+
|
|
139
|
+
`void`
|
|
140
|
+
|
|
141
|
+
#### Throws
|
|
142
|
+
|
|
143
|
+
GeneralError If a sort property is not indexed in the schema.
|
|
144
|
+
|
|
145
|
+
***
|
|
146
|
+
|
|
147
|
+
### validateProperties() {#validateproperties}
|
|
148
|
+
|
|
149
|
+
> `static` **validateProperties**\<`T`\>(`schema`, `properties?`): `void`
|
|
150
|
+
|
|
151
|
+
Validate that every property in the list exists in the schema, throwing propertyNotInSchema
|
|
152
|
+
for the first property that is not found.
|
|
153
|
+
|
|
154
|
+
#### Type Parameters
|
|
155
|
+
|
|
156
|
+
##### T
|
|
157
|
+
|
|
158
|
+
`T`
|
|
159
|
+
|
|
160
|
+
#### Parameters
|
|
161
|
+
|
|
162
|
+
##### schema
|
|
163
|
+
|
|
164
|
+
`IEntitySchema`\<`T`\>
|
|
165
|
+
|
|
166
|
+
The entity schema to validate against.
|
|
167
|
+
|
|
168
|
+
##### properties?
|
|
169
|
+
|
|
170
|
+
keyof `T`[]
|
|
171
|
+
|
|
172
|
+
The properties to check.
|
|
173
|
+
|
|
174
|
+
#### Returns
|
|
175
|
+
|
|
176
|
+
`void`
|
|
177
|
+
|
|
178
|
+
#### Throws
|
|
179
|
+
|
|
180
|
+
GeneralError If a property does not exist in the schema.
|
|
181
|
+
|
|
182
|
+
***
|
|
183
|
+
|
|
110
184
|
### normalizeConditionValues() {#normalizeconditionvalues}
|
|
111
185
|
|
|
112
186
|
> `static` **normalizeConditionValues**\<`T`\>(`condition`): `EntityCondition`\<`T`\>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Class: SchemaVersion
|
|
2
|
+
|
|
3
|
+
Tracks the currently applied schema version for each managed entity schema.
|
|
4
|
+
One record per schema name. Written once on first boot, then updated after
|
|
5
|
+
each successful migration.
|
|
6
|
+
|
|
7
|
+
## Constructors
|
|
8
|
+
|
|
9
|
+
### Constructor
|
|
10
|
+
|
|
11
|
+
> **new SchemaVersion**(): `SchemaVersion`
|
|
12
|
+
|
|
13
|
+
#### Returns
|
|
14
|
+
|
|
15
|
+
`SchemaVersion`
|
|
16
|
+
|
|
17
|
+
## Properties
|
|
18
|
+
|
|
19
|
+
### schemaName {#schemaname}
|
|
20
|
+
|
|
21
|
+
> **schemaName**: `string`
|
|
22
|
+
|
|
23
|
+
The entity schema type name — primary key.
|
|
24
|
+
|
|
25
|
+
***
|
|
26
|
+
|
|
27
|
+
### version {#version}
|
|
28
|
+
|
|
29
|
+
> **version**: `number`
|
|
30
|
+
|
|
31
|
+
The currently deployed version of this schema.
|
|
32
|
+
|
|
33
|
+
***
|
|
34
|
+
|
|
35
|
+
### updatedAt {#updatedat}
|
|
36
|
+
|
|
37
|
+
> **updatedAt**: `string`
|
|
38
|
+
|
|
39
|
+
ISO 8601 timestamp of the last version write.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Class: SchemaVersionService
|
|
2
|
+
|
|
3
|
+
IComponent service that checks and applies entity schema migrations at every node start-up.
|
|
4
|
+
|
|
5
|
+
This service must be the first entry in coreTypeInitialisers.json. The engine iterates that
|
|
6
|
+
array in order to determine start sequence — there is no engine-level priority mechanism, so
|
|
7
|
+
registration position is the only guarantee that start() runs before any other service.
|
|
8
|
+
By the time start() is called, all component bootstraps have completed (every table already
|
|
9
|
+
exists) and EntitySchemaFactory / EntityStorageConnectorFactory are fully populated with every
|
|
10
|
+
registered schema and connector.
|
|
11
|
+
|
|
12
|
+
Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
|
|
13
|
+
convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
|
|
14
|
+
The service groups schemas by base name (strips the trailing V number suffix) and resolves the
|
|
15
|
+
migration chain automatically by diffing consecutive versioned schemas. For steps that require
|
|
16
|
+
property renames or a custom transform hook, register an optional ISchemaMigration entry in
|
|
17
|
+
SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
|
|
18
|
+
|
|
19
|
+
Crash-window note: finalizeMigration and the subsequent version-record write are two
|
|
20
|
+
separate operations. If the process dies between them the next boot re-runs the chain
|
|
21
|
+
over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
|
|
22
|
+
(newly-added optional fields would be dropped on re-run). A transaction spanning both
|
|
23
|
+
writes is a precondition for production; track this in the concurrency follow-up.
|
|
24
|
+
|
|
25
|
+
## Implements
|
|
26
|
+
|
|
27
|
+
- `IComponent`
|
|
28
|
+
|
|
29
|
+
## Constructors
|
|
30
|
+
|
|
31
|
+
### Constructor
|
|
32
|
+
|
|
33
|
+
> **new SchemaVersionService**(`versionConnector`): `SchemaVersionService`
|
|
34
|
+
|
|
35
|
+
Create a new SchemaVersionService.
|
|
36
|
+
|
|
37
|
+
#### Parameters
|
|
38
|
+
|
|
39
|
+
##### versionConnector
|
|
40
|
+
|
|
41
|
+
[`IEntityStorageConnector`](../interfaces/IEntityStorageConnector.md)\<[`SchemaVersion`](SchemaVersion.md)\>
|
|
42
|
+
|
|
43
|
+
Entity-storage connector backed by the schemaVersion table.
|
|
44
|
+
|
|
45
|
+
#### Returns
|
|
46
|
+
|
|
47
|
+
`SchemaVersionService`
|
|
48
|
+
|
|
49
|
+
## Properties
|
|
50
|
+
|
|
51
|
+
### CLASS\_NAME {#class_name}
|
|
52
|
+
|
|
53
|
+
> `readonly` `static` **CLASS\_NAME**: `string`
|
|
54
|
+
|
|
55
|
+
Runtime name for the class.
|
|
56
|
+
|
|
57
|
+
## Methods
|
|
58
|
+
|
|
59
|
+
### className() {#classname}
|
|
60
|
+
|
|
61
|
+
> **className**(): `string`
|
|
62
|
+
|
|
63
|
+
Returns the class name.
|
|
64
|
+
|
|
65
|
+
#### Returns
|
|
66
|
+
|
|
67
|
+
`string`
|
|
68
|
+
|
|
69
|
+
The class name.
|
|
70
|
+
|
|
71
|
+
#### Implementation of
|
|
72
|
+
|
|
73
|
+
`IComponent.className`
|
|
74
|
+
|
|
75
|
+
***
|
|
76
|
+
|
|
77
|
+
### bootstrap() {#bootstrap}
|
|
78
|
+
|
|
79
|
+
> **bootstrap**(`nodeLoggingComponentType?`): `Promise`\<`boolean`\>
|
|
80
|
+
|
|
81
|
+
Bootstraps the version-store connector so the schemaVersion table exists
|
|
82
|
+
before start() attempts to read or write version records.
|
|
83
|
+
|
|
84
|
+
#### Parameters
|
|
85
|
+
|
|
86
|
+
##### nodeLoggingComponentType?
|
|
87
|
+
|
|
88
|
+
`string`
|
|
89
|
+
|
|
90
|
+
An optional logging component type.
|
|
91
|
+
|
|
92
|
+
#### Returns
|
|
93
|
+
|
|
94
|
+
`Promise`\<`boolean`\>
|
|
95
|
+
|
|
96
|
+
True on success.
|
|
97
|
+
|
|
98
|
+
#### Implementation of
|
|
99
|
+
|
|
100
|
+
`IComponent.bootstrap`
|
|
101
|
+
|
|
102
|
+
***
|
|
103
|
+
|
|
104
|
+
### start() {#start}
|
|
105
|
+
|
|
106
|
+
> **start**(`nodeLoggingComponentType?`): `Promise`\<`void`\>
|
|
107
|
+
|
|
108
|
+
Reads all registered entity schemas, groups versioned schemas by base name, reads the
|
|
109
|
+
full schemaVersion table in one pass, then orchestrates chain migrations for any schema
|
|
110
|
+
whose stored version is behind the current version declared in EntitySchemaFactory.
|
|
111
|
+
SchemaVersion itself is processed first so the version store is migrated before any
|
|
112
|
+
version records are written for other schemas.
|
|
113
|
+
|
|
114
|
+
Runs after all component bootstraps, so every managed table already exists.
|
|
115
|
+
|
|
116
|
+
#### Parameters
|
|
117
|
+
|
|
118
|
+
##### nodeLoggingComponentType?
|
|
119
|
+
|
|
120
|
+
`string`
|
|
121
|
+
|
|
122
|
+
An optional logging component type.
|
|
123
|
+
|
|
124
|
+
#### Returns
|
|
125
|
+
|
|
126
|
+
`Promise`\<`void`\>
|
|
127
|
+
|
|
128
|
+
#### Implementation of
|
|
129
|
+
|
|
130
|
+
`IComponent.start`
|
package/docs/reference/index.md
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Classes
|
|
4
4
|
|
|
5
|
+
- [SchemaVersion](classes/SchemaVersion.md)
|
|
5
6
|
- [EntityStorageHelper](classes/EntityStorageHelper.md)
|
|
6
7
|
- [MigrationHelper](classes/MigrationHelper.md)
|
|
8
|
+
- [SchemaVersionService](classes/SchemaVersionService.md)
|
|
7
9
|
|
|
8
10
|
## Interfaces
|
|
9
11
|
|
|
@@ -29,3 +31,7 @@
|
|
|
29
31
|
|
|
30
32
|
- [EntityStorageConnectorFactory](variables/EntityStorageConnectorFactory.md)
|
|
31
33
|
- [SchemaMigrationFactory](variables/SchemaMigrationFactory.md)
|
|
34
|
+
|
|
35
|
+
## Functions
|
|
36
|
+
|
|
37
|
+
- [initSchema](functions/initSchema.md)
|
|
@@ -7,7 +7,7 @@ renames or a custom object/array transform. For purely structural changes
|
|
|
7
7
|
versioned schema classes (e.g. MyEntityV0 vs MyEntityV1) from EntitySchemaFactory
|
|
8
8
|
automatically.
|
|
9
9
|
|
|
10
|
-
Register under the key "
|
|
10
|
+
Register under the key "BaseSchemaName_fromVersion_toVersion"
|
|
11
11
|
e.g. "MyEntity_0_1" for the step that migrates from version 0 to version 1.
|
|
12
12
|
The key itself encodes the version pair; no version field is needed on the object.
|
|
13
13
|
|
|
@@ -9,5 +9,5 @@ transform hook. For purely structural changes (add/remove/type-change) the
|
|
|
9
9
|
SchemaVersionService diffs the two versioned schema classes automatically
|
|
10
10
|
without needing any factory entry.
|
|
11
11
|
|
|
12
|
-
Keys follow the convention "
|
|
12
|
+
Keys follow the convention "BaseSchemaName_fromVersion_toVersion",
|
|
13
13
|
for example "MyEntity_0_1" for the step that migrates MyEntity from version 0 to 1.
|
package/locales/en.json
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"error": {
|
|
3
|
+
"entityStorageHelper": {
|
|
4
|
+
"sortNotIndexed": "The property \"{property}\" is not indexed and cannot be used for sorting",
|
|
5
|
+
"propertyNotInSchema": "The property \"{property}\" does not exist in the schema and cannot be used for projection"
|
|
6
|
+
},
|
|
3
7
|
"migrationHelper": {
|
|
4
8
|
"migrationFailed": "Migration failed.",
|
|
5
9
|
"transformRequiredForProperty": "A transformation function is required to migrate property \"{from}\" to \"{to}\" of type \"{type}\"",
|
|
6
10
|
"coercionProducedUndefined": "Coercion of property \"{property}\" to type \"{type}\" produced undefined but the property is not optional"
|
|
11
|
+
},
|
|
12
|
+
"schemaVersionService": {
|
|
13
|
+
"storedVersionNewer": "Stored schema version ({stored}) for \"{schemaName}\" is newer than the current version ({current}). Downgrade is not supported.",
|
|
14
|
+
"noMigrationStep": "No migration step from version {missingFromVersion} to {missingToVersion} found for \"{schemaName}\". Cannot complete migration from {stored} to {current}. Register the versioned schema class \"{schemaName}V{missingFromVersion}\" in EntitySchemaFactory.",
|
|
15
|
+
"noMigrationStepTarget": "No target schema for migration step {missingFromVersion} to {missingToVersion} found for \"{schemaName}\". Cannot complete migration from {stored} to {current}. Register the versioned schema class \"{schemaName}V{missingToVersion}\" in EntitySchemaFactory.",
|
|
16
|
+
"connectorNotMigrationCapable": "Schema \"{schemaName}\" needs migration from version {stored} to {current} but its connector does not support automatic migration. Please migrate the schema manually before starting the node."
|
|
7
17
|
}
|
|
8
18
|
}
|
|
9
19
|
}
|
package/package.json
CHANGED