@travetto/model-dynamodb 7.0.0-rc.2 → 7.0.0-rc.3

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/README.md CHANGED
@@ -45,7 +45,7 @@ export class DynamoDBModelConfig {
45
45
  client: dynamodb.DynamoDBClientConfig = {
46
46
  endpoint: undefined
47
47
  };
48
- autoCreate?: boolean;
48
+ modifyStorage?: boolean;
49
49
  namespace?: string;
50
50
  }
51
51
  ```
package/__index__.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './src/service.ts';
2
+ export * from './src/util.ts';
2
3
  export * from './src/config.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-dynamodb",
3
- "version": "7.0.0-rc.2",
3
+ "version": "7.0.0-rc.3",
4
4
  "description": "DynamoDB backing for the travetto model module.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@aws-sdk/client-dynamodb": "^3.940.0",
29
- "@travetto/cli": "^7.0.0-rc.2",
30
- "@travetto/config": "^7.0.0-rc.2",
31
- "@travetto/model": "^7.0.0-rc.2"
29
+ "@travetto/cli": "^7.0.0-rc.3",
30
+ "@travetto/config": "^7.0.0-rc.3",
31
+ "@travetto/model": "^7.0.0-rc.3"
32
32
  },
33
33
  "travetto": {
34
34
  "displayName": "DynamoDB Model Support"
package/src/config.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  import type dynamodb from '@aws-sdk/client-dynamodb';
2
2
 
3
3
  import { Config } from '@travetto/config';
4
+ import { Runtime } from '@travetto/runtime';
4
5
 
5
6
  @Config('model.dynamodb')
6
7
  export class DynamoDBModelConfig {
7
8
  client: dynamodb.DynamoDBClientConfig = {
8
9
  endpoint: undefined
9
10
  };
10
- autoCreate?: boolean;
11
+ modifyStorage?: boolean;
11
12
  namespace?: string;
13
+
14
+ postConstruct(): void {
15
+ if (!Runtime.production) {
16
+ this.client.endpoint ??= 'http://localhost:8000'; // From docker
17
+ }
18
+ }
12
19
  }
package/src/service.ts CHANGED
@@ -1,7 +1,4 @@
1
- import {
2
- type AttributeDefinition, type AttributeValue, DynamoDB, type GlobalSecondaryIndex,
3
- type KeySchemaElement, type PutItemCommandInput, type PutItemCommandOutput
4
- } from '@aws-sdk/client-dynamodb';
1
+ import { type AttributeValue, DynamoDB, type PutItemCommandInput, type PutItemCommandOutput } from '@aws-sdk/client-dynamodb';
5
2
 
6
3
  import { ShutdownManager, TimeUtil, type Class, type DeepPartial } from '@travetto/runtime';
7
4
  import { Injectable } from '@travetto/di';
@@ -13,41 +10,10 @@ import {
13
10
  } from '@travetto/model';
14
11
 
15
12
  import { DynamoDBModelConfig } from './config.ts';
13
+ import { DynamoDBUtil } from './util.ts';
16
14
 
17
15
  const EXP_ATTR = 'expires_at__';
18
16
 
19
- function simpleName(idx: string): string {
20
- return idx.replace(/[^A-Za-z0-9]/g, '');
21
- }
22
-
23
- function toValue(value: string | number | boolean | Date | undefined | null): AttributeValue;
24
- function toValue(value: unknown): AttributeValue | undefined {
25
- if (value === undefined || value === null || value === '') {
26
- return { NULL: true };
27
- } else if (typeof value === 'string') {
28
- return { S: value };
29
- } else if (typeof value === 'number') {
30
- return { N: `${value}` };
31
- } else if (typeof value === 'boolean') {
32
- return { BOOL: value };
33
- } else if (value instanceof Date) {
34
- return { N: `${value.getTime()}` };
35
- }
36
- }
37
-
38
- async function loadAndCheckExpiry<T extends ModelType>(cls: Class<T>, doc: string): Promise<T> {
39
- const item = await ModelCrudUtil.load(cls, doc);
40
- if (ModelRegistryIndex.getConfig(cls).expiresAt) {
41
- const expiry = ModelExpiryUtil.getExpiryState(cls, item);
42
- if (!expiry.expired) {
43
- return item;
44
- }
45
- } else {
46
- return item;
47
- }
48
- throw new NotFoundError(cls, item.id);
49
- }
50
-
51
17
  /**
52
18
  * A model service backed by DynamoDB
53
19
  */
@@ -61,7 +27,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
61
27
  constructor(config: DynamoDBModelConfig) { this.config = config; }
62
28
 
63
29
  #resolveTable(cls: Class): string {
64
- let table = ModelRegistryIndex.getStoreName(cls).toLowerCase().replace(/[^A-Za-z0-9_]+/g, '_');
30
+ let table = ModelRegistryIndex.getStoreName(cls);
65
31
  if (this.config.namespace) {
66
32
  table = `${this.config.namespace}_${table}`;
67
33
  }
@@ -84,35 +50,34 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
84
50
  const indices: Record<string, unknown> = {};
85
51
  for (const idx of config.indices ?? []) {
86
52
  const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
87
- const property = simpleName(idx.name);
88
- indices[`${property}__`] = toValue(key);
53
+ const property = DynamoDBUtil.simpleName(idx.name);
54
+ indices[`${property}__`] = DynamoDBUtil.toValue(key);
89
55
  if (sort) {
90
- indices[`${property}_sort__`] = toValue(+sort);
56
+ indices[`${property}_sort__`] = DynamoDBUtil.toValue(+sort);
91
57
  }
92
58
  }
93
59
  const query: PutItemCommandInput = {
94
60
  TableName: this.#resolveTable(cls),
95
61
  ConditionExpression: 'attribute_not_exists(body)',
96
62
  Item: {
97
- id: toValue(item.id),
98
- body: toValue(JSON.stringify(item)),
99
- ...(expiry !== undefined ? { [EXP_ATTR]: toValue(expiry) } : {}),
63
+ id: DynamoDBUtil.toValue(item.id),
64
+ body: DynamoDBUtil.toValue(JSON.stringify(item)),
65
+ ...(expiry !== undefined ? { [EXP_ATTR]: DynamoDBUtil.toValue(expiry) } : {}),
100
66
  ...indices
101
67
  },
102
68
  ReturnValues: 'NONE'
103
69
  };
104
- console.debug('Querying', { query });
105
70
  return await this.client.putItem(query);
106
71
  } else {
107
72
  const indices: Record<string, unknown> = {};
108
73
  const expr: string[] = [];
109
74
  for (const idx of config.indices ?? []) {
110
75
  const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
111
- const property = simpleName(idx.name);
112
- indices[`:${property}`] = toValue(key);
76
+ const property = DynamoDBUtil.simpleName(idx.name);
77
+ indices[`:${property}`] = DynamoDBUtil.toValue(key);
113
78
  expr.push(`${property}__ = :${property}`);
114
79
  if (sort) {
115
- indices[`:${property}_sort`] = toValue(+sort);
80
+ indices[`:${property}_sort`] = DynamoDBUtil.toValue(+sort);
116
81
  expr.push(`${property}_sort__ = :${property}_sort`);
117
82
  }
118
83
  }
@@ -127,8 +92,8 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
127
92
  ...expr
128
93
  ].filter(part => !!part).join(', ')}`,
129
94
  ExpressionAttributeValues: {
130
- ':body': toValue(JSON.stringify(item)),
131
- ...(expiry !== undefined ? { ':expr': toValue(expiry) } : {}),
95
+ ':body': DynamoDBUtil.toValue(JSON.stringify(item)),
96
+ ...(expiry !== undefined ? { ':expr': DynamoDBUtil.toValue(expiry) } : {}),
132
97
  ...indices
133
98
  },
134
99
  ReturnValues: 'ALL_NEW'
@@ -146,45 +111,9 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
146
111
  }
147
112
  }
148
113
 
149
- #computeIndexConfig<T extends ModelType>(cls: Class<T>): { indices?: GlobalSecondaryIndex[], attributes: AttributeDefinition[] } {
150
- const config = ModelRegistryIndex.getConfig(cls);
151
- const attributes: AttributeDefinition[] = [];
152
- const indices: GlobalSecondaryIndex[] = [];
153
-
154
- for (const idx of config.indices ?? []) {
155
- const idxName = simpleName(idx.name);
156
- attributes.push({ AttributeName: `${idxName}__`, AttributeType: 'S' });
157
-
158
- const keys: KeySchemaElement[] = [{
159
- AttributeName: `${idxName}__`,
160
- KeyType: 'HASH'
161
- }];
162
-
163
- if (idx.type === 'sorted') {
164
- keys.push({
165
- AttributeName: `${idxName}_sort__`,
166
- KeyType: 'RANGE'
167
- });
168
- attributes.push({ AttributeName: `${idxName}_sort__`, AttributeType: 'N' });
169
- }
170
-
171
- indices.push({
172
- IndexName: idxName,
173
- // ProvisionedThroughput: '',
174
- Projection: {
175
- ProjectionType: 'INCLUDE',
176
- NonKeyAttributes: ['body', 'id']
177
- },
178
- KeySchema: keys
179
- });
180
- }
181
-
182
- return { indices: indices.length ? indices : undefined, attributes };
183
- }
184
-
185
114
  async postConstruct(): Promise<void> {
186
115
  this.client = new DynamoDB({ ...this.config.client });
187
- await ModelStorageUtil.registerModelChangeListener(this);
116
+ await ModelStorageUtil.storageInitialization(this);
188
117
  ShutdownManager.onGracefulShutdown(async () => this.client.destroy());
189
118
  }
190
119
 
@@ -194,31 +123,51 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
194
123
  * Add a new model
195
124
  * @param cls
196
125
  */
197
- async createModel(cls: Class<ModelType>): Promise<void> {
126
+ async upsertModel(cls: Class<ModelType>): Promise<void> {
198
127
  const table = this.#resolveTable(cls);
199
- const idx = this.#computeIndexConfig(cls);
128
+ const idx = DynamoDBUtil.computeIndexConfig(cls);
200
129
 
201
- const existing = await this.client.describeTable({ TableName: table }).then(() => true, () => false);
130
+ const [currentTable, currentTTL] = await Promise.all([
131
+ this.client.describeTable({ TableName: table }).catch(() => undefined),
132
+ this.client.describeTimeToLive({ TableName: table }).catch(() => ({ TimeToLiveDescription: undefined }))
133
+ ]);
202
134
 
203
- if (existing) {
204
- return;
135
+ if (!currentTable) {
136
+ console.debug('Creating Table', { table, idx });
137
+ await this.client.createTable({
138
+ TableName: table,
139
+ KeySchema: [{ KeyType: 'HASH', AttributeName: 'id' }],
140
+ BillingMode: 'PAY_PER_REQUEST',
141
+ AttributeDefinitions: [
142
+ { AttributeName: 'id', AttributeType: 'S' },
143
+ ...idx.attributes
144
+ ],
145
+ GlobalSecondaryIndexes: idx.indices
146
+ });
147
+ } else {
148
+ const indexUpdates = DynamoDBUtil.findChangedGlobalIndexes(currentTable.Table?.GlobalSecondaryIndexes, idx.indices);
149
+ const changedAttributes = DynamoDBUtil.findChangedAttributes(currentTable.Table?.AttributeDefinitions, idx.attributes);
150
+
151
+ console.debug('Updating Table', { table, idx, current: currentTable.Table, indexUpdates, changedAttributes });
152
+
153
+ if (changedAttributes.length || indexUpdates?.length) {
154
+ await this.client.updateTable({
155
+ TableName: table,
156
+ AttributeDefinitions: [
157
+ { AttributeName: 'id', AttributeType: 'S' },
158
+ ...idx.attributes
159
+ ],
160
+ GlobalSecondaryIndexUpdates: indexUpdates
161
+ });
162
+ }
205
163
  }
206
164
 
207
- await this.client.createTable({
208
- TableName: table,
209
- KeySchema: [{ KeyType: 'HASH', AttributeName: 'id' }],
210
- BillingMode: 'PAY_PER_REQUEST',
211
- AttributeDefinitions: [
212
- { AttributeName: 'id', AttributeType: 'S' },
213
- ...idx.attributes
214
- ],
215
- GlobalSecondaryIndexes: idx.indices
216
- });
217
-
218
- if (ModelRegistryIndex.getConfig(cls).expiresAt) {
165
+ const ttlRequired = ModelRegistryIndex.getConfig(cls).expiresAt !== undefined;
166
+ const ttlEnabled = currentTTL.TimeToLiveDescription?.TimeToLiveStatus === 'ENABLED';
167
+ if (ttlEnabled !== ttlRequired) {
219
168
  await this.client.updateTimeToLive({
220
169
  TableName: table,
221
- TimeToLiveSpecification: { AttributeName: EXP_ATTR, Enabled: true }
170
+ TimeToLiveSpecification: { AttributeName: ttlRequired ? EXP_ATTR : undefined, Enabled: ttlRequired }
222
171
  });
223
172
  }
224
173
  }
@@ -235,25 +184,6 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
235
184
  }
236
185
  }
237
186
 
238
- /**
239
- * When the model changes
240
- * @param cls
241
- */
242
- async changeModel(cls: Class<ModelType>): Promise<void> {
243
- const table = this.#resolveTable(cls);
244
- const idx = this.#computeIndexConfig(cls);
245
- // const existing = await this.cl.describeTable({ TableName: table });
246
-
247
- await this.client.updateTable({
248
- TableName: table,
249
- AttributeDefinitions: [
250
- { AttributeName: 'id', AttributeType: 'S' },
251
- ...idx.attributes
252
- ],
253
- // TODO: Fill out index computation
254
- });
255
- }
256
-
257
187
  async createStorage(): Promise<void> {
258
188
  // Do nothing
259
189
  }
@@ -270,11 +200,11 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
270
200
  async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
271
201
  const result = await this.client.getItem({
272
202
  TableName: this.#resolveTable(cls),
273
- Key: { id: toValue(id) }
203
+ Key: { id: DynamoDBUtil.toValue(id) }
274
204
  });
275
205
 
276
206
  if (result && result.Item && result.Item.body) {
277
- return loadAndCheckExpiry(cls, result.Item.body.S!);
207
+ return DynamoDBUtil.loadAndCheckExpiry(cls, result.Item.body.S!);
278
208
  }
279
209
  throw new NotFoundError(cls, id);
280
210
  }
@@ -334,7 +264,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
334
264
  if (batch.Count && batch.Items) {
335
265
  for (const item of batch.Items) {
336
266
  try {
337
- yield await loadAndCheckExpiry(cls, item.body.S!);
267
+ yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
338
268
  } catch (error) {
339
269
  if (!(error instanceof NotFoundError)) {
340
270
  throw error;
@@ -368,7 +298,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
368
298
  throw new IndexNotSupported(cls, idxConfig, 'Sorted indices require the sort field');
369
299
  }
370
300
 
371
- const idxName = simpleName(idx);
301
+ const idxName = DynamoDBUtil.simpleName(idx);
372
302
 
373
303
  const query = {
374
304
  TableName: this.#resolveTable(cls),
@@ -378,8 +308,8 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
378
308
  .filter(expr => !!expr)
379
309
  .join(' and '),
380
310
  ExpressionAttributeValues: {
381
- [`:${idxName}`]: toValue(key),
382
- ...(sort ? { [`:${idxName}_sort`]: toValue(+sort) } : {})
311
+ [`:${idxName}`]: DynamoDBUtil.toValue(key),
312
+ ...(sort ? { [`:${idxName}_sort`]: DynamoDBUtil.toValue(+sort) } : {})
383
313
  }
384
314
  };
385
315
 
@@ -410,7 +340,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
410
340
  const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
411
341
  const { key } = ModelIndexedUtil.computeIndexKey(cls, config, body, { emptySortValue: null });
412
342
 
413
- const idxName = simpleName(idx);
343
+ const idxName = DynamoDBUtil.simpleName(idx);
414
344
 
415
345
  let done = false;
416
346
  let token: Record<string, AttributeValue> | undefined;
@@ -421,7 +351,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
421
351
  ProjectionExpression: 'body',
422
352
  KeyConditionExpression: `${idxName}__ = :${idxName}`,
423
353
  ExpressionAttributeValues: {
424
- [`:${idxName}`]: toValue(key)
354
+ [`:${idxName}`]: DynamoDBUtil.toValue(key)
425
355
  },
426
356
  ExclusiveStartKey: token
427
357
  });
@@ -429,7 +359,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
429
359
  if (batch.Count && batch.Items) {
430
360
  for (const item of batch.Items) {
431
361
  try {
432
- yield await loadAndCheckExpiry(cls, item.body.S!);
362
+ yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
433
363
  } catch (error) {
434
364
  if (!(error instanceof NotFoundError)) {
435
365
  throw error;
package/src/util.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type {
2
+ AttributeDefinition, AttributeValue, GlobalSecondaryIndex, GlobalSecondaryIndexDescription,
3
+ GlobalSecondaryIndexUpdate, KeySchemaElement
4
+ } from '@aws-sdk/client-dynamodb';
5
+
6
+ import type { Class } from '@travetto/runtime';
7
+ import { ModelCrudUtil, ModelExpiryUtil, ModelRegistryIndex, NotFoundError, type ModelType } from '@travetto/model';
8
+
9
+ /**
10
+ * Configuration for DynamoDB indices
11
+ */
12
+ type DynamoIndexConfig = {
13
+ indices?: GlobalSecondaryIndex[];
14
+ attributes: AttributeDefinition[];
15
+ };
16
+
17
+ /**
18
+ * Utility class for DynamoDB operations and transformations.
19
+ */
20
+ export class DynamoDBUtil {
21
+
22
+ /**
23
+ * Converts an index name to a simplified format by removing non-alphanumeric characters
24
+ */
25
+ static simpleName(idx: string): string {
26
+ return idx.replace(/[^A-Za-z0-9]/g, '');
27
+ }
28
+
29
+ /**
30
+ * Converts a JavaScript value to a DynamoDB AttributeValue format
31
+ * @param value The value to convert (string, number, boolean, Date, null, or undefined)
32
+ * @returns The DynamoDB AttributeValue representation
33
+ */
34
+ static toValue(value: string | number | boolean | Date | undefined | null): AttributeValue;
35
+ static toValue(value: unknown): AttributeValue | undefined {
36
+ if (value === undefined || value === null || value === '') {
37
+ return { NULL: true };
38
+ } else if (typeof value === 'string') {
39
+ return { S: value };
40
+ } else if (typeof value === 'number') {
41
+ return { N: `${value}` };
42
+ } else if (typeof value === 'boolean') {
43
+ return { BOOL: value };
44
+ } else if (value instanceof Date) {
45
+ return { N: `${value.getTime()}` };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Computes the DynamoDB index configuration for a model class.
51
+ * Generates global secondary indices and attribute definitions based on the model's index configuration.
52
+ */
53
+ static computeIndexConfig<T extends ModelType>(cls: Class<T>): DynamoIndexConfig {
54
+ const config = ModelRegistryIndex.getConfig(cls);
55
+ const attributes: AttributeDefinition[] = [];
56
+ const indices: GlobalSecondaryIndex[] = [];
57
+
58
+ for (const idx of config.indices ?? []) {
59
+ const idxName = this.simpleName(idx.name);
60
+ attributes.push({ AttributeName: `${idxName}__`, AttributeType: 'S' });
61
+
62
+ const keys: KeySchemaElement[] = [{
63
+ AttributeName: `${idxName}__`,
64
+ KeyType: 'HASH'
65
+ }];
66
+
67
+ if (idx.type === 'sorted') {
68
+ keys.push({
69
+ AttributeName: `${idxName}_sort__`,
70
+ KeyType: 'RANGE'
71
+ });
72
+ attributes.push({ AttributeName: `${idxName}_sort__`, AttributeType: 'N' });
73
+ }
74
+
75
+ indices.push({
76
+ IndexName: idxName,
77
+ // ProvisionedThroughput: '',
78
+ Projection: {
79
+ ProjectionType: 'INCLUDE',
80
+ NonKeyAttributes: ['body', 'id']
81
+ },
82
+ KeySchema: keys
83
+ });
84
+ }
85
+
86
+ return { indices: indices.length ? indices : undefined, attributes };
87
+ }
88
+
89
+ /**
90
+ * Identifies attribute definitions that have changed between current and requested configurations.
91
+ * Compares attribute names and types to determine additions, removals, or modifications.
92
+ */
93
+ static findChangedAttributes(current: AttributeDefinition[] | undefined, requested: AttributeDefinition[] | undefined): AttributeDefinition[] {
94
+ const currentMap = Object.fromEntries((current ?? []).map(attr => [attr.AttributeName, attr]));
95
+ const pendingMap = Object.fromEntries((requested ?? []).map(attr => [attr.AttributeName, attr]));
96
+
97
+ const changed: AttributeDefinition[] = [];
98
+ for (const attr of requested ?? []) {
99
+ if (!attr.AttributeName || attr.AttributeName === 'id') {
100
+ continue;
101
+ }
102
+ if (!currentMap[attr.AttributeName] || currentMap[attr.AttributeName].AttributeType !== attr.AttributeType) {
103
+ changed.push(attr);
104
+ }
105
+ }
106
+
107
+ for (const attr of current ?? []) {
108
+ if (!attr.AttributeName || attr.AttributeName === 'id') {
109
+ continue;
110
+ }
111
+ if (!pendingMap[attr.AttributeName]) {
112
+ changed.push(attr);
113
+ }
114
+ }
115
+ return changed;
116
+ }
117
+
118
+ /**
119
+ * Identifies global secondary indices that have changed between current and requested configurations.
120
+ * Generates update operations for creating new indices or deleting removed indices.
121
+ */
122
+ static findChangedGlobalIndexes(current: GlobalSecondaryIndexDescription[] | undefined, requested: GlobalSecondaryIndex[] | undefined): GlobalSecondaryIndexUpdate[] {
123
+ const existingMap = Object.fromEntries((current ?? []).map(index => [index.IndexName, index]));
124
+ const pendingMap = Object.fromEntries((requested ?? []).map(index => [index.IndexName, index]));
125
+
126
+ const out: GlobalSecondaryIndexUpdate[] = [];
127
+
128
+ for (const index of requested ?? []) {
129
+ if (!existingMap[index.IndexName!]) {
130
+ out.push({ Create: index });
131
+ }
132
+ }
133
+
134
+ for (const index of current ?? []) {
135
+ if (!pendingMap[index.IndexName!]) {
136
+ out.push({ Delete: { IndexName: index.IndexName! } });
137
+ }
138
+ }
139
+
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * Loads a document from the model store and validates its expiry status.
145
+ * If the model has an expiry configuration and the document is expired, throws NotFoundError.
146
+ */
147
+ static async loadAndCheckExpiry<T extends ModelType>(cls: Class<T>, doc: string): Promise<T> {
148
+ const item = await ModelCrudUtil.load(cls, doc);
149
+ if (ModelRegistryIndex.getConfig(cls).expiresAt) {
150
+ const expiry = ModelExpiryUtil.getExpiryState(cls, item);
151
+ if (!expiry.expired) {
152
+ return item;
153
+ }
154
+ } else {
155
+ return item;
156
+ }
157
+ throw new NotFoundError(cls, item.id);
158
+ }
159
+ }