@twin.org/entity-storage-service 0.0.3-next.21 → 0.0.3-next.22

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.
@@ -0,0 +1,42 @@
1
+ // Copyright 2026 IOTA Stiftung.
2
+ // SPDX-License-Identifier: Apache-2.0.
3
+ import { entity, property, EntitySchemaPropertyFormat } from "@twin.org/entity";
4
+ /**
5
+ * Entity that records the currently applied schema version for a managed entity schema.
6
+ * Persisted through a normal entity-storage connector, giving every backend a schemaVersion
7
+ * table/collection for free.
8
+ *
9
+ * SchemaVersionService processes this schema first before all others so that the version
10
+ * store is fully migrated before any version records are written for other schemas.
11
+ */
12
+ let SchemaVersion = class SchemaVersion {
13
+ /**
14
+ * The schema type name (matches the key used in EntitySchemaFactory).
15
+ */
16
+ schemaName;
17
+ /**
18
+ * The version currently applied in storage for this schema.
19
+ */
20
+ version;
21
+ /**
22
+ * ISO 8601 timestamp of the last version write.
23
+ */
24
+ updatedAt;
25
+ };
26
+ __decorate([
27
+ property({ type: "string", isPrimary: true }),
28
+ __metadata("design:type", String)
29
+ ], SchemaVersion.prototype, "schemaName", void 0);
30
+ __decorate([
31
+ property({ type: "integer" }),
32
+ __metadata("design:type", Number)
33
+ ], SchemaVersion.prototype, "version", void 0);
34
+ __decorate([
35
+ property({ type: "string", format: EntitySchemaPropertyFormat.DateTime }),
36
+ __metadata("design:type", String)
37
+ ], SchemaVersion.prototype, "updatedAt", void 0);
38
+ SchemaVersion = __decorate([
39
+ entity()
40
+ ], SchemaVersion);
41
+ export { SchemaVersion };
42
+ //# 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,0BAA0B,EAAE,MAAM,kBAAkB,CAAC;AAEhF;;;;;;;GAOG;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,0BAA0B,CAAC,QAAQ,EAAE,CAAC;;gDAChD;AAjBd,aAAa;IADzB,MAAM,EAAE;GACI,aAAa,CAkBzB","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { entity, property, EntitySchemaPropertyFormat } from \"@twin.org/entity\";\n\n/**\n * Entity that records the currently applied schema version for a managed entity schema.\n * Persisted through a normal entity-storage connector, giving every backend a schemaVersion\n * table/collection for free.\n *\n * SchemaVersionService processes this schema first before all others so that the version\n * store is fully migrated before any version records are written for other schemas.\n */\n@entity()\nexport class SchemaVersion {\n\t/**\n\t * The schema type name (matches the key used in EntitySchemaFactory).\n\t */\n\t@property({ type: \"string\", isPrimary: true })\n\tpublic schemaName!: string;\n\n\t/**\n\t * The version currently applied in storage for 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: EntitySchemaPropertyFormat.DateTime })\n\tpublic updatedAt!: string;\n}\n"]}
package/dist/es/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  // Copyright 2024 IOTA Stiftung.
2
2
  // SPDX-License-Identifier: Apache-2.0.
3
+ export * from "./entities/schemaVersion.js";
3
4
  export * from "./entityStorageRoutes.js";
4
5
  export * from "./entityStorageService.js";
5
- export * from "./models/IEntityStorageServiceConfig.js";
6
6
  export * from "./models/IEntityStorageRoutesExamples.js";
7
+ export * from "./models/IEntityStorageServiceConfig.js";
7
8
  export * from "./models/IEntityStorageServiceConstructorOptions.js";
9
+ export * from "./models/ISchemaVersionServiceConstructorOptions.js";
8
10
  export * from "./restEntryPoints.js";
11
+ export * from "./schemaVersionService.js";
12
+ export * from "./schema.js";
9
13
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC;AACvC,cAAc,0BAA0B,CAAC;AACzC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,yCAAyC,CAAC;AACxD,cAAc,0CAA0C,CAAC;AACzD,cAAc,qDAAqD,CAAC;AACpE,cAAc,sBAAsB,CAAC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nexport * from \"./entityStorageRoutes.js\";\nexport * from \"./entityStorageService.js\";\nexport * from \"./models/IEntityStorageServiceConfig.js\";\nexport * from \"./models/IEntityStorageRoutesExamples.js\";\nexport * from \"./models/IEntityStorageServiceConstructorOptions.js\";\nexport * from \"./restEntryPoints.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,0BAA0B,CAAC;AACzC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0CAA0C,CAAC;AACzD,cAAc,yCAAyC,CAAC;AACxD,cAAc,qDAAqD,CAAC;AACpE,cAAc,qDAAqD,CAAC;AACpE,cAAc,sBAAsB,CAAC;AACrC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,aAAa,CAAC","sourcesContent":["// Copyright 2024 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nexport * from \"./entities/schemaVersion.js\";\nexport * from \"./entityStorageRoutes.js\";\nexport * from \"./entityStorageService.js\";\nexport * from \"./models/IEntityStorageRoutesExamples.js\";\nexport * from \"./models/IEntityStorageServiceConfig.js\";\nexport * from \"./models/IEntityStorageServiceConstructorOptions.js\";\nexport * from \"./models/ISchemaVersionServiceConstructorOptions.js\";\nexport * from \"./restEntryPoints.js\";\nexport * from \"./schemaVersionService.js\";\nexport * from \"./schema.js\";\n"]}
@@ -0,0 +1,4 @@
1
+ // Copyright 2026 IOTA Stiftung.
2
+ // SPDX-License-Identifier: Apache-2.0.
3
+ export {};
4
+ //# sourceMappingURL=ISchemaVersionServiceConstructorOptions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ISchemaVersionServiceConstructorOptions.js","sourceRoot":"","sources":["../../../src/models/ISchemaVersionServiceConstructorOptions.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,uCAAuC","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\n\n/**\n * Constructor options for SchemaVersionService.\n */\nexport interface ISchemaVersionServiceConstructorOptions {\n\t/**\n\t * The version storage type.\n\t * @default schema-version\n\t */\n\tschemaVersionStorageType?: string;\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.
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.\n */\nexport function initSchema(): void {\n\tEntitySchemaFactory.register(nameof<SchemaVersion>(), () =>\n\t\tEntitySchemaHelper.getSchema(SchemaVersion)\n\t);\n}\n"]}
@@ -0,0 +1,239 @@
1
+ // Copyright 2026 IOTA Stiftung.
2
+ // SPDX-License-Identifier: Apache-2.0.
3
+ import { GeneralError, Is } from "@twin.org/core";
4
+ import { EntitySchemaFactory, EntitySchemaHelper } from "@twin.org/entity";
5
+ import { EntityStorageConnectorFactory, MigrationHelper, SchemaMigrationFactory } from "@twin.org/entity-storage-models";
6
+ import { SchemaVersion } from "./entities/schemaVersion.js";
7
+ /**
8
+ * Service that checks and applies entity schema migrations at every node start-up.
9
+ *
10
+ * This service should be registered as the first component so that its start() runs before
11
+ * any other service. By the time start() is called, all component bootstraps have completed
12
+ * (every table already exists) and EntitySchemaFactory / EntityStorageConnectorFactory are
13
+ * fully populated with every registered schema and connector.
14
+ *
15
+ * Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
16
+ * convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
17
+ * The service groups schemas by base name (strips the trailing V number suffix) and resolves the
18
+ * migration chain automatically by diffing consecutive versioned schemas. For steps that require
19
+ * property renames or a custom transform hook, register an optional ISchemaMigration entry in
20
+ * SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
21
+ *
22
+ * Crash-window note: finalizeMigration and the subsequent version-record write are two
23
+ * separate operations. If the process dies between them the next boot re-runs the chain
24
+ * over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
25
+ * (newly-added optional fields would be dropped on re-run). A transaction spanning both
26
+ * writes is a precondition for production; track this in the concurrency follow-up.
27
+ */
28
+ export class SchemaVersionService {
29
+ /**
30
+ * Runtime name for the class.
31
+ */
32
+ static CLASS_NAME = "SchemaVersionService";
33
+ /**
34
+ * Regex to detect a versioned schema name and extract the base name and version number.
35
+ * Matches names like "MyEntityV0", "AuditableItemGraphV2", etc.
36
+ * @internal
37
+ */
38
+ static _VERSION_SUFFIX_RE = /^(.+)V(\d+)$/;
39
+ /**
40
+ * The connector used to read and write SchemaVersion records.
41
+ * @internal
42
+ */
43
+ _schemaVersionConnector;
44
+ /**
45
+ * Create a new SchemaVersionService.
46
+ * @param options The constructor options.
47
+ */
48
+ constructor(options) {
49
+ this._schemaVersionConnector = EntityStorageConnectorFactory.get(options.schemaVersionStorageType ?? "schema-version");
50
+ }
51
+ /**
52
+ * Returns the class name.
53
+ * @returns The class name.
54
+ */
55
+ className() {
56
+ return SchemaVersionService.CLASS_NAME;
57
+ }
58
+ /**
59
+ * Reads all registered entity schemas, groups versioned schemas by base name, reads the
60
+ * full schemaVersion table in one pass, then orchestrates chain migrations for any schema
61
+ * whose stored version is behind the current version declared in EntitySchemaFactory.
62
+ * SchemaVersion itself is processed first so the version store is migrated before any
63
+ * version records are written for other schemas.
64
+ *
65
+ * Runs after all component bootstraps, so every managed table already exists.
66
+ * @param nodeLoggingComponentType An optional logging component type.
67
+ */
68
+ async start(nodeLoggingComponentType) {
69
+ // 1. Collect all registered schema names and partition into current vs historical.
70
+ const allNames = EntitySchemaFactory.names();
71
+ const historicalByBase = new Map();
72
+ const currentSchemas = new Map();
73
+ for (const name of allNames) {
74
+ const match = SchemaVersionService._VERSION_SUFFIX_RE.exec(name);
75
+ if (match) {
76
+ const baseName = match[1];
77
+ const version = Number.parseInt(match[2], 10);
78
+ let versions = historicalByBase.get(baseName);
79
+ if (!versions) {
80
+ versions = new Map();
81
+ historicalByBase.set(baseName, versions);
82
+ }
83
+ versions.set(version, EntitySchemaFactory.get(name));
84
+ }
85
+ else {
86
+ currentSchemas.set(name, EntitySchemaFactory.get(name));
87
+ }
88
+ }
89
+ // 2. Read ALL stored version records in one query.
90
+ const queryResult = await this._schemaVersionConnector.query();
91
+ const storedVersions = new Map();
92
+ for (const record of queryResult.entities ?? []) {
93
+ if (Is.object(record)) {
94
+ storedVersions.set(record.schemaName, record.version);
95
+ }
96
+ }
97
+ // 3. Process SchemaVersion first so the version store itself is fully migrated
98
+ // before any version records are written for other schemas.
99
+ const schemaVersionName = "SchemaVersion";
100
+ const schemaVersionSchema = currentSchemas.get(schemaVersionName);
101
+ if (schemaVersionSchema) {
102
+ currentSchemas.delete(schemaVersionName);
103
+ await this.processSchema(schemaVersionName, schemaVersionSchema, storedVersions, historicalByBase.get(schemaVersionName), nodeLoggingComponentType);
104
+ }
105
+ // 4. Process all remaining schemas.
106
+ for (const [schemaName, schema] of currentSchemas) {
107
+ await this.processSchema(schemaName, schema, storedVersions, historicalByBase.get(schemaName), nodeLoggingComponentType);
108
+ }
109
+ }
110
+ /**
111
+ * Checks and applies any pending migration for a single entity schema.
112
+ * Extracted to avoid continue statements in the outer loop.
113
+ * @param schemaName The base schema name.
114
+ * @param schema The current schema definition.
115
+ * @param storedVersions The full map of stored version records.
116
+ * @param history The versioned-schema map for this schema (historicalByBase.get(schemaName)), or undefined if none exist.
117
+ * @param nodeLoggingComponentType An optional logging component type.
118
+ * @internal
119
+ */
120
+ async processSchema(schemaName, schema, storedVersions, history, nodeLoggingComponentType) {
121
+ const currentVersion = EntitySchemaHelper.getVersion(schema);
122
+ // Find the entity-storage connector whose schema type matches this schema name.
123
+ const connector = this.findConnector(schemaName);
124
+ if (!connector) {
125
+ // No connector registered for this schema — nothing to migrate.
126
+ return;
127
+ }
128
+ // Resolve the stored version, applying the backwards-compat baseline when no record exists.
129
+ const stored = storedVersions.get(schemaName);
130
+ let resolvedStored;
131
+ if (stored === undefined) {
132
+ // No version record: treat as v0 regardless of whether the table has data.
133
+ // On SQL connectors the table may have a stale column structure even when empty;
134
+ // running the chain over zero rows still calls finalizeMigration, which reconciles
135
+ // the table shape via a connector swap.
136
+ // Deployment precondition: any pre-existing data is genuinely at v0. A deployment
137
+ // that hand-applied a later schema before this service was introduced would be
138
+ // incorrectly replayed v0→…→current and should be seeded with an explicit record.
139
+ resolvedStored = 0;
140
+ await this.writeVersion(schemaName, 0);
141
+ }
142
+ else {
143
+ resolvedStored = stored;
144
+ }
145
+ // No-op: stored version already matches current.
146
+ if (resolvedStored === currentVersion) {
147
+ return;
148
+ }
149
+ // Downgrade — not supported.
150
+ if (resolvedStored > currentVersion) {
151
+ throw new GeneralError(SchemaVersionService.CLASS_NAME, "storedVersionNewer", {
152
+ schemaName,
153
+ stored: resolvedStored,
154
+ current: currentVersion
155
+ });
156
+ }
157
+ // Migration is needed. If the connector does not support it, throw immediately so
158
+ // the problem surfaces at boot rather than at runtime when writes hit the wrong table shape.
159
+ if (!("createTargetConnector" in connector)) {
160
+ throw new GeneralError(SchemaVersionService.CLASS_NAME, "connectorNotMigrationCapable", {
161
+ schemaName,
162
+ stored: resolvedStored,
163
+ current: currentVersion
164
+ });
165
+ }
166
+ const migrationConnector = connector;
167
+ // Upgrade — resolve and run the chain.
168
+ const steps = [];
169
+ for (let v = resolvedStored; v < currentVersion; v++) {
170
+ const fromSchema = history?.get(v);
171
+ if (!fromSchema) {
172
+ throw new GeneralError(SchemaVersionService.CLASS_NAME, "noMigrationStep", {
173
+ schemaName,
174
+ stored: resolvedStored,
175
+ current: currentVersion,
176
+ missingFromVersion: v,
177
+ missingToVersion: v + 1
178
+ });
179
+ }
180
+ const toSchema = v + 1 < currentVersion ? history?.get(v + 1) : schema;
181
+ if (!toSchema) {
182
+ throw new GeneralError(SchemaVersionService.CLASS_NAME, "noMigrationStepTarget", {
183
+ schemaName,
184
+ stored: resolvedStored,
185
+ current: currentVersion,
186
+ missingFromVersion: v,
187
+ missingToVersion: v + 1
188
+ });
189
+ }
190
+ const overrideKey = `${schemaName}_${v}_${v + 1}`;
191
+ const override = SchemaMigrationFactory.getIfExists(overrideKey);
192
+ steps.push({
193
+ fromProperties: fromSchema.properties ?? [],
194
+ toProperties: toSchema.properties ?? [],
195
+ renames: override?.renames,
196
+ transformEntityProperty: override?.transformEntityProperty
197
+ });
198
+ }
199
+ await MigrationHelper.migrateWithChain(migrationConnector, schemaName, steps, nodeLoggingComponentType);
200
+ // Advance the stored version only after finalizeMigration has succeeded.
201
+ // See crash-window note in the class comment.
202
+ await this.writeVersion(schemaName, currentVersion);
203
+ }
204
+ /**
205
+ * Upserts a SchemaVersion record for the given schema name.
206
+ * @param schemaName The schema type name.
207
+ * @param version The version to record.
208
+ * @internal
209
+ */
210
+ async writeVersion(schemaName, version) {
211
+ await this._schemaVersionConnector.set({
212
+ schemaName,
213
+ version,
214
+ updatedAt: new Date().toISOString()
215
+ });
216
+ }
217
+ /**
218
+ * Searches EntityStorageConnectorFactory for the connector whose registered schema type
219
+ * matches the given schema name.
220
+ * @param schemaName The entity type name to look up.
221
+ * @returns The matching connector, or undefined if none is registered.
222
+ * @internal
223
+ */
224
+ findConnector(schemaName) {
225
+ for (const name of EntityStorageConnectorFactory.names()) {
226
+ try {
227
+ const connector = EntityStorageConnectorFactory.get(name);
228
+ if (connector.getSchema?.().type === schemaName) {
229
+ return connector;
230
+ }
231
+ }
232
+ catch {
233
+ // Connector not yet created or registration issue — skip.
234
+ }
235
+ }
236
+ return undefined;
237
+ }
238
+ }
239
+ //# sourceMappingURL=schemaVersionService.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schemaVersionService.js","sourceRoot":"","sources":["../../src/schemaVersionService.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport { GeneralError, Is, type IComponent } from \"@twin.org/core\";\nimport { EntitySchemaFactory, EntitySchemaHelper, type IEntitySchema } from \"@twin.org/entity\";\nimport {\n\tEntityStorageConnectorFactory,\n\tMigrationHelper,\n\tSchemaMigrationFactory,\n\ttype IEntityStorageConnector,\n\ttype IEntityStorageMigrationConnector,\n\ttype IResolvedMigrationStep\n} from \"@twin.org/entity-storage-models\";\nimport { nameof } from \"@twin.org/nameof\";\nimport { SchemaVersion } from \"./entities/schemaVersion.js\";\nimport type { ISchemaVersionServiceConstructorOptions } from \"./models/ISchemaVersionServiceConstructorOptions.js\";\n\n/**\n * Service that checks and applies entity schema migrations at every node start-up.\n *\n * This service should be registered as the first component so that its start() runs before\n * any other service. By the time start() is called, all component bootstraps have completed\n * (every table already exists) and EntitySchemaFactory / EntityStorageConnectorFactory are\n * fully populated with every 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 _schemaVersionConnector: IEntityStorageConnector<SchemaVersion>;\n\n\t/**\n\t * Create a new SchemaVersionService.\n\t * @param options The constructor options.\n\t */\n\tconstructor(options: ISchemaVersionServiceConstructorOptions) {\n\t\tthis._schemaVersionConnector = EntityStorageConnectorFactory.get(\n\t\t\toptions.schemaVersionStorageType ?? \"schema-version\"\n\t\t);\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 * 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 in one query.\n\t\tconst queryResult = await this._schemaVersionConnector.query();\n\t\tconst storedVersions = new Map<string, number>();\n\t\tfor (const record of queryResult.entities ?? []) {\n\t\t\tif (Is.object<SchemaVersion>(record)) {\n\t\t\t\tstoredVersions.set(record.schemaName, record.version);\n\t\t\t}\n\t\t}\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\tconst connector = this.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._schemaVersionConnector.set({\n\t\t\tschemaName,\n\t\t\tversion,\n\t\t\tupdatedAt: new Date().toISOString()\n\t\t});\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 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"]}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Entity that records the currently applied schema version for a managed entity schema.
3
+ * Persisted through a normal entity-storage connector, giving every backend a schemaVersion
4
+ * table/collection for free.
5
+ *
6
+ * SchemaVersionService processes this schema first before all others so that the version
7
+ * store is fully migrated before any version records are written for other schemas.
8
+ */
9
+ export declare class SchemaVersion {
10
+ /**
11
+ * The schema type name (matches the key used in EntitySchemaFactory).
12
+ */
13
+ schemaName: string;
14
+ /**
15
+ * The version currently applied in storage for this schema.
16
+ */
17
+ version: number;
18
+ /**
19
+ * ISO 8601 timestamp of the last version write.
20
+ */
21
+ updatedAt: string;
22
+ }
@@ -1,6 +1,10 @@
1
+ export * from "./entities/schemaVersion.js";
1
2
  export * from "./entityStorageRoutes.js";
2
3
  export * from "./entityStorageService.js";
3
- export * from "./models/IEntityStorageServiceConfig.js";
4
4
  export * from "./models/IEntityStorageRoutesExamples.js";
5
+ export * from "./models/IEntityStorageServiceConfig.js";
5
6
  export * from "./models/IEntityStorageServiceConstructorOptions.js";
7
+ export * from "./models/ISchemaVersionServiceConstructorOptions.js";
6
8
  export * from "./restEntryPoints.js";
9
+ export * from "./schemaVersionService.js";
10
+ export * from "./schema.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Constructor options for SchemaVersionService.
3
+ */
4
+ export interface ISchemaVersionServiceConstructorOptions {
5
+ /**
6
+ * The version storage type.
7
+ * @default schema-version
8
+ */
9
+ schemaVersionStorageType?: string;
10
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Initialize the schema for the entity storage.
3
+ */
4
+ export declare function initSchema(): void;
@@ -0,0 +1,50 @@
1
+ import { type IComponent } from "@twin.org/core";
2
+ import type { ISchemaVersionServiceConstructorOptions } from "./models/ISchemaVersionServiceConstructorOptions.js";
3
+ /**
4
+ * Service that checks and applies entity schema migrations at every node start-up.
5
+ *
6
+ * This service should be registered as the first component so that its start() runs before
7
+ * any other service. By the time start() is called, all component bootstraps have completed
8
+ * (every table already exists) and EntitySchemaFactory / EntityStorageConnectorFactory are
9
+ * fully populated with every registered schema and connector.
10
+ *
11
+ * Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
12
+ * convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
13
+ * The service groups schemas by base name (strips the trailing V number suffix) and resolves the
14
+ * migration chain automatically by diffing consecutive versioned schemas. For steps that require
15
+ * property renames or a custom transform hook, register an optional ISchemaMigration entry in
16
+ * SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
17
+ *
18
+ * Crash-window note: finalizeMigration and the subsequent version-record write are two
19
+ * separate operations. If the process dies between them the next boot re-runs the chain
20
+ * over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
21
+ * (newly-added optional fields would be dropped on re-run). A transaction spanning both
22
+ * writes is a precondition for production; track this in the concurrency follow-up.
23
+ */
24
+ export declare class SchemaVersionService implements IComponent {
25
+ /**
26
+ * Runtime name for the class.
27
+ */
28
+ static readonly CLASS_NAME: string;
29
+ /**
30
+ * Create a new SchemaVersionService.
31
+ * @param options The constructor options.
32
+ */
33
+ constructor(options: ISchemaVersionServiceConstructorOptions);
34
+ /**
35
+ * Returns the class name.
36
+ * @returns The class name.
37
+ */
38
+ className(): string;
39
+ /**
40
+ * Reads all registered entity schemas, groups versioned schemas by base name, reads the
41
+ * full schemaVersion table in one pass, then orchestrates chain migrations for any schema
42
+ * whose stored version is behind the current version declared in EntitySchemaFactory.
43
+ * SchemaVersion itself is processed first so the version store is migrated before any
44
+ * version records are written for other schemas.
45
+ *
46
+ * Runs after all component bootstraps, so every managed table already exists.
47
+ * @param nodeLoggingComponentType An optional logging component type.
48
+ */
49
+ start(nodeLoggingComponentType?: string): Promise<void>;
50
+ }
package/docs/changelog.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.0.3-next.22](https://github.com/iotaledger/twin-entity-storage/compare/entity-storage-service-v0.0.3-next.21...entity-storage-service-v0.0.3-next.22) (2026-06-08)
4
+
5
+
6
+ ### Features
7
+
8
+ * add ISchemaMigration chain, SchemaVersionMigrator runner and version store ([#110](https://github.com/iotaledger/twin-entity-storage/issues/110)) ([2dac924](https://github.com/iotaledger/twin-entity-storage/commit/2dac9244a752cb58304d1649ff03c3a2469783dd))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @twin.org/entity-storage-models bumped from 0.0.3-next.21 to 0.0.3-next.22
16
+ * devDependencies
17
+ * @twin.org/entity-storage-connector-memory bumped from 0.0.3-next.21 to 0.0.3-next.22
18
+
3
19
  ## [0.0.3-next.21](https://github.com/iotaledger/twin-entity-storage/compare/entity-storage-service-v0.0.3-next.20...entity-storage-service-v0.0.3-next.21) (2026-06-01)
4
20
 
5
21
 
@@ -0,0 +1,42 @@
1
+ # Class: SchemaVersion
2
+
3
+ Entity that records the currently applied schema version for a managed entity schema.
4
+ Persisted through a normal entity-storage connector, giving every backend a schemaVersion
5
+ table/collection for free.
6
+
7
+ SchemaVersionService processes this schema first before all others so that the version
8
+ store is fully migrated before any version records are written for other schemas.
9
+
10
+ ## Constructors
11
+
12
+ ### Constructor
13
+
14
+ > **new SchemaVersion**(): `SchemaVersion`
15
+
16
+ #### Returns
17
+
18
+ `SchemaVersion`
19
+
20
+ ## Properties
21
+
22
+ ### schemaName {#schemaname}
23
+
24
+ > **schemaName**: `string`
25
+
26
+ The schema type name (matches the key used in EntitySchemaFactory).
27
+
28
+ ***
29
+
30
+ ### version {#version}
31
+
32
+ > **version**: `number`
33
+
34
+ The version currently applied in storage for this schema.
35
+
36
+ ***
37
+
38
+ ### updatedAt {#updatedat}
39
+
40
+ > **updatedAt**: `string`
41
+
42
+ ISO 8601 timestamp of the last version write.
@@ -0,0 +1,101 @@
1
+ # Class: SchemaVersionService
2
+
3
+ Service that checks and applies entity schema migrations at every node start-up.
4
+
5
+ This service should be registered as the first component so that its start() runs before
6
+ any other service. By the time start() is called, all component bootstraps have completed
7
+ (every table already exists) and EntitySchemaFactory / EntityStorageConnectorFactory are
8
+ fully populated with every registered schema and connector.
9
+
10
+ Migration mechanics: old schema versions are registered in EntitySchemaFactory by naming
11
+ convention — current schema = "MyEntity", first history = "MyEntityV0", second = "MyEntityV1".
12
+ The service groups schemas by base name (strips the trailing V number suffix) and resolves the
13
+ migration chain automatically by diffing consecutive versioned schemas. For steps that require
14
+ property renames or a custom transform hook, register an optional ISchemaMigration entry in
15
+ SchemaMigrationFactory under the key "Base_from_to" (e.g. "MyEntity_0_1").
16
+
17
+ Crash-window note: finalizeMigration and the subsequent version-record write are two
18
+ separate operations. If the process dies between them the next boot re-runs the chain
19
+ over already-migrated data. applyEntityTransform is NOT idempotent for structural changes
20
+ (newly-added optional fields would be dropped on re-run). A transaction spanning both
21
+ writes is a precondition for production; track this in the concurrency follow-up.
22
+
23
+ ## Implements
24
+
25
+ - `IComponent`
26
+
27
+ ## Constructors
28
+
29
+ ### Constructor
30
+
31
+ > **new SchemaVersionService**(`options`): `SchemaVersionService`
32
+
33
+ Create a new SchemaVersionService.
34
+
35
+ #### Parameters
36
+
37
+ ##### options
38
+
39
+ [`ISchemaVersionServiceConstructorOptions`](../interfaces/ISchemaVersionServiceConstructorOptions.md)
40
+
41
+ The constructor options.
42
+
43
+ #### Returns
44
+
45
+ `SchemaVersionService`
46
+
47
+ ## Properties
48
+
49
+ ### CLASS\_NAME {#class_name}
50
+
51
+ > `readonly` `static` **CLASS\_NAME**: `string`
52
+
53
+ Runtime name for the class.
54
+
55
+ ## Methods
56
+
57
+ ### className() {#classname}
58
+
59
+ > **className**(): `string`
60
+
61
+ Returns the class name.
62
+
63
+ #### Returns
64
+
65
+ `string`
66
+
67
+ The class name.
68
+
69
+ #### Implementation of
70
+
71
+ `IComponent.className`
72
+
73
+ ***
74
+
75
+ ### start() {#start}
76
+
77
+ > **start**(`nodeLoggingComponentType?`): `Promise`\<`void`\>
78
+
79
+ Reads all registered entity schemas, groups versioned schemas by base name, reads the
80
+ full schemaVersion table in one pass, then orchestrates chain migrations for any schema
81
+ whose stored version is behind the current version declared in EntitySchemaFactory.
82
+ SchemaVersion itself is processed first so the version store is migrated before any
83
+ version records are written for other schemas.
84
+
85
+ Runs after all component bootstraps, so every managed table already exists.
86
+
87
+ #### Parameters
88
+
89
+ ##### nodeLoggingComponentType?
90
+
91
+ `string`
92
+
93
+ An optional logging component type.
94
+
95
+ #### Returns
96
+
97
+ `Promise`\<`void`\>
98
+
99
+ #### Implementation of
100
+
101
+ `IComponent.start`
@@ -0,0 +1,9 @@
1
+ # Function: initSchema()
2
+
3
+ > **initSchema**(): `void`
4
+
5
+ Initialize the schema for the entity storage.
6
+
7
+ ## Returns
8
+
9
+ `void`
@@ -2,13 +2,16 @@
2
2
 
3
3
  ## Classes
4
4
 
5
+ - [SchemaVersion](classes/SchemaVersion.md)
5
6
  - [EntityStorageService](classes/EntityStorageService.md)
7
+ - [SchemaVersionService](classes/SchemaVersionService.md)
6
8
 
7
9
  ## Interfaces
8
10
 
9
11
  - [IEntityStorageRoutesExamples](interfaces/IEntityStorageRoutesExamples.md)
10
12
  - [IEntityStorageServiceConfig](interfaces/IEntityStorageServiceConfig.md)
11
13
  - [IEntityStorageServiceConstructorOptions](interfaces/IEntityStorageServiceConstructorOptions.md)
14
+ - [ISchemaVersionServiceConstructorOptions](interfaces/ISchemaVersionServiceConstructorOptions.md)
12
15
 
13
16
  ## Variables
14
17
 
@@ -26,3 +29,4 @@
26
29
  - [entityStorageList](functions/entityStorageList.md)
27
30
  - [entityStorageCount](functions/entityStorageCount.md)
28
31
  - [entityStorageRemoveBatch](functions/entityStorageRemoveBatch.md)
32
+ - [initSchema](functions/initSchema.md)
@@ -0,0 +1,17 @@
1
+ # Interface: ISchemaVersionServiceConstructorOptions
2
+
3
+ Constructor options for SchemaVersionService.
4
+
5
+ ## Properties
6
+
7
+ ### schemaVersionStorageType? {#schemaversionstoragetype}
8
+
9
+ > `optional` **schemaVersionStorageType?**: `string`
10
+
11
+ The version storage type.
12
+
13
+ #### Default
14
+
15
+ ```ts
16
+ schema-version
17
+ ```
package/locales/en.json CHANGED
@@ -1 +1,10 @@
1
- {}
1
+ {
2
+ "error": {
3
+ "schemaVersionService": {
4
+ "storedVersionNewer": "Stored schema version ({stored}) for \"{schemaName}\" is newer than the current version ({current}). Downgrade is not supported.",
5
+ "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.",
6
+ "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.",
7
+ "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."
8
+ }
9
+ }
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twin.org/entity-storage-service",
3
- "version": "0.0.3-next.21",
3
+ "version": "0.0.3-next.22",
4
4
  "description": "Service layer exposing storage contracts and REST endpoint definitions.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,7 +17,7 @@
17
17
  "@twin.org/api-models": "next",
18
18
  "@twin.org/core": "next",
19
19
  "@twin.org/entity": "next",
20
- "@twin.org/entity-storage-models": "0.0.3-next.21",
20
+ "@twin.org/entity-storage-models": "0.0.3-next.22",
21
21
  "@twin.org/nameof": "next",
22
22
  "@twin.org/web": "next"
23
23
  },