@arcaelas/dynamite 1.0.29 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +13 -13
  2. package/{src → build/cjs}/@types/index.d.ts +26 -1
  3. package/build/cjs/@types/index.js.map +1 -0
  4. package/{src → build/cjs}/core/client.d.ts +22 -19
  5. package/build/cjs/core/client.js +384 -0
  6. package/build/cjs/core/client.js.map +1 -0
  7. package/build/cjs/core/decorator.d.ts +50 -0
  8. package/build/cjs/core/decorator.js +52 -0
  9. package/build/cjs/core/decorator.js.map +1 -0
  10. package/build/cjs/core/table.d.ts +73 -0
  11. package/build/cjs/core/table.js +953 -0
  12. package/build/cjs/core/table.js.map +1 -0
  13. package/build/cjs/decorators/hooks.d.ts +35 -0
  14. package/build/cjs/decorators/hooks.js +50 -0
  15. package/build/cjs/decorators/hooks.js.map +1 -0
  16. package/build/cjs/decorators/indexes.d.ts +20 -0
  17. package/build/cjs/decorators/indexes.js +45 -0
  18. package/build/cjs/decorators/indexes.js.map +1 -0
  19. package/{src → build/cjs}/decorators/relations.d.ts +8 -7
  20. package/{src → build/cjs}/decorators/relations.js +9 -8
  21. package/build/cjs/decorators/relations.js.map +1 -0
  22. package/build/cjs/decorators/timestamps.d.ts +20 -0
  23. package/build/cjs/decorators/timestamps.js +34 -0
  24. package/build/cjs/decorators/timestamps.js.map +1 -0
  25. package/build/cjs/decorators/transforms.d.ts +41 -0
  26. package/build/cjs/decorators/transforms.js +98 -0
  27. package/build/cjs/decorators/transforms.js.map +1 -0
  28. package/{src → build/cjs}/index.d.ts +7 -3
  29. package/{src → build/cjs}/index.js +15 -6
  30. package/build/cjs/index.js.map +1 -0
  31. package/build/cjs/package.json +1 -0
  32. package/build/cjs/test/basic.d.ts +1 -0
  33. package/build/cjs/test/basic.js +248 -0
  34. package/build/cjs/test/basic.js.map +1 -0
  35. package/build/cjs/test/bulk.d.ts +1 -0
  36. package/build/cjs/test/bulk.js +108 -0
  37. package/build/cjs/test/bulk.js.map +1 -0
  38. package/build/cjs/test/contracts.d.ts +1 -0
  39. package/build/cjs/test/contracts.js +343 -0
  40. package/build/cjs/test/contracts.js.map +1 -0
  41. package/build/cjs/test/filters.d.ts +1 -0
  42. package/build/cjs/test/filters.js +190 -0
  43. package/build/cjs/test/filters.js.map +1 -0
  44. package/build/cjs/test/hooks.d.ts +1 -0
  45. package/build/cjs/test/hooks.js +191 -0
  46. package/build/cjs/test/hooks.js.map +1 -0
  47. package/build/cjs/test/index.js +38 -0
  48. package/build/cjs/test/index.js.map +1 -0
  49. package/build/cjs/test/query_scan.d.ts +1 -0
  50. package/build/cjs/test/query_scan.js +195 -0
  51. package/build/cjs/test/query_scan.js.map +1 -0
  52. package/build/cjs/test/relations.d.ts +1 -0
  53. package/build/cjs/test/relations.js +246 -0
  54. package/build/cjs/test/relations.js.map +1 -0
  55. package/build/cjs/test/transactions.d.ts +1 -0
  56. package/build/cjs/test/transactions.js +145 -0
  57. package/build/cjs/test/transactions.js.map +1 -0
  58. package/{src → build/cjs}/utils/relations.js +1 -8
  59. package/build/cjs/utils/relations.js.map +1 -0
  60. package/build/cjs/utils/ulid.d.ts +10 -0
  61. package/build/cjs/utils/ulid.js +55 -0
  62. package/build/cjs/utils/ulid.js.map +1 -0
  63. package/build/esm/@types/index.d.ts +213 -0
  64. package/build/esm/@types/index.js +8 -0
  65. package/build/esm/@types/index.js.map +1 -0
  66. package/build/esm/core/client.d.ts +96 -0
  67. package/build/esm/core/client.js +375 -0
  68. package/build/esm/core/client.js.map +1 -0
  69. package/build/esm/core/decorator.d.ts +50 -0
  70. package/build/esm/core/decorator.js +47 -0
  71. package/build/esm/core/decorator.js.map +1 -0
  72. package/build/esm/core/table.d.ts +73 -0
  73. package/build/esm/core/table.js +950 -0
  74. package/build/esm/core/table.js.map +1 -0
  75. package/build/esm/decorators/hooks.d.ts +35 -0
  76. package/build/esm/decorators/hooks.js +47 -0
  77. package/build/esm/decorators/hooks.js.map +1 -0
  78. package/build/esm/decorators/indexes.d.ts +20 -0
  79. package/build/esm/decorators/indexes.js +42 -0
  80. package/build/esm/decorators/indexes.js.map +1 -0
  81. package/build/esm/decorators/relations.d.ts +75 -0
  82. package/build/esm/decorators/relations.js +112 -0
  83. package/build/esm/decorators/relations.js.map +1 -0
  84. package/build/esm/decorators/timestamps.d.ts +20 -0
  85. package/build/esm/decorators/timestamps.js +31 -0
  86. package/build/esm/decorators/timestamps.js.map +1 -0
  87. package/build/esm/decorators/transforms.d.ts +41 -0
  88. package/build/esm/decorators/transforms.js +92 -0
  89. package/build/esm/decorators/transforms.js.map +1 -0
  90. package/build/esm/index.d.ts +19 -0
  91. package/build/esm/index.js +26 -0
  92. package/build/esm/index.js.map +1 -0
  93. package/build/esm/package.json +1 -0
  94. package/build/esm/test/basic.d.ts +1 -0
  95. package/build/esm/test/basic.js +245 -0
  96. package/build/esm/test/basic.js.map +1 -0
  97. package/build/esm/test/bulk.d.ts +1 -0
  98. package/build/esm/test/bulk.js +105 -0
  99. package/build/esm/test/bulk.js.map +1 -0
  100. package/build/esm/test/contracts.d.ts +1 -0
  101. package/build/esm/test/contracts.js +340 -0
  102. package/build/esm/test/contracts.js.map +1 -0
  103. package/build/esm/test/filters.d.ts +1 -0
  104. package/build/esm/test/filters.js +187 -0
  105. package/build/esm/test/filters.js.map +1 -0
  106. package/build/esm/test/hooks.d.ts +1 -0
  107. package/build/esm/test/hooks.js +188 -0
  108. package/build/esm/test/hooks.js.map +1 -0
  109. package/build/esm/test/index.d.ts +1 -0
  110. package/build/esm/test/index.js +33 -0
  111. package/build/esm/test/index.js.map +1 -0
  112. package/build/esm/test/query_scan.d.ts +1 -0
  113. package/build/esm/test/query_scan.js +192 -0
  114. package/build/esm/test/query_scan.js.map +1 -0
  115. package/build/esm/test/relations.d.ts +1 -0
  116. package/build/esm/test/relations.js +243 -0
  117. package/build/esm/test/relations.js.map +1 -0
  118. package/build/esm/test/transactions.d.ts +1 -0
  119. package/build/esm/test/transactions.js +142 -0
  120. package/build/esm/test/transactions.js.map +1 -0
  121. package/build/esm/utils/relations.d.ts +42 -0
  122. package/build/esm/utils/relations.js +207 -0
  123. package/build/esm/utils/relations.js.map +1 -0
  124. package/build/esm/utils/ulid.d.ts +10 -0
  125. package/build/esm/utils/ulid.js +52 -0
  126. package/build/esm/utils/ulid.js.map +1 -0
  127. package/package.json +31 -9
  128. package/src/core/client.js +0 -296
  129. package/src/core/decorator.d.ts +0 -29
  130. package/src/core/decorator.js +0 -103
  131. package/src/core/table.d.ts +0 -81
  132. package/src/core/table.js +0 -892
  133. package/src/decorators/indexes.d.ts +0 -38
  134. package/src/decorators/indexes.js +0 -59
  135. package/src/decorators/timestamps.d.ts +0 -54
  136. package/src/decorators/timestamps.js +0 -72
  137. package/src/decorators/transforms.d.ts +0 -99
  138. package/src/decorators/transforms.js +0 -166
  139. package/src/index.test.js +0 -37
  140. /package/{src → build/cjs}/@types/index.js +0 -0
  141. /package/{src/index.test.d.ts → build/cjs/test/index.d.ts} +0 -0
  142. /package/{src → build/cjs}/utils/relations.d.ts +0 -0
@@ -0,0 +1,950 @@
1
+ /**
2
+ * @file table.ts
3
+ * @description Tabla autocontenida con arquitectura minimalista y Symbol storage
4
+ * @autor Miguel Alejandro
5
+ * @fecha 2025-01-28
6
+ */
7
+ import { DeleteItemCommand, GetItemCommand, PutItemCommand, QueryCommand, ScanCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb";
8
+ import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
9
+ import { processIncludes } from "../utils/relations.js";
10
+ import { requireClient } from "./client.js";
11
+ import { SCHEMA } from "./decorator.js";
12
+ const OP_MAP = {
13
+ '=': '=', '<>': '<>', '!=': '<>', '<': '<', '<=': '<=', '>': '>', '>=': '>=',
14
+ in: 'in', include: 'include', contains: 'include',
15
+ $eq: '=', $ne: '<>', $lt: '<', $lte: '<=', $gt: '>', $gte: '>=',
16
+ $in: 'in', $include: 'include', $contains: 'include',
17
+ $exists: 'attribute_exists', $notExists: 'attribute_not_exists',
18
+ };
19
+ const OPERATORS = new Set(Object.keys(OP_MAP));
20
+ export default class Table {
21
+ constructor(props = {}) {
22
+ requireClient();
23
+ const schema = this.constructor[SCHEMA];
24
+ // Flag de persistencia con closure (no enumerable)
25
+ let __isPersisted = false;
26
+ Object.defineProperty(this, "__isPersisted", {
27
+ enumerable: false,
28
+ configurable: false,
29
+ get: () => __isPersisted,
30
+ set: (v) => {
31
+ __isPersisted = v;
32
+ },
33
+ });
34
+ for (const column_name in schema.columns) {
35
+ const column = schema.columns[column_name];
36
+ if (column.store?.relation) {
37
+ let relation_value = props[column_name] ?? undefined;
38
+ let cached = undefined;
39
+ let dirty = true;
40
+ Object.defineProperty(this, column_name, {
41
+ enumerable: true,
42
+ configurable: true,
43
+ set: (v) => {
44
+ relation_value = v;
45
+ dirty = true;
46
+ },
47
+ get: () => {
48
+ if (relation_value === undefined)
49
+ return undefined;
50
+ if (!dirty)
51
+ return cached;
52
+ const RelatedModel = column.store.relation.model();
53
+ const type = column.store.relation.type;
54
+ if (type === "HasMany" || type === "ManyToMany") {
55
+ cached = []
56
+ .concat(relation_value ?? [])
57
+ .filter(Boolean)
58
+ .map((item) => item instanceof RelatedModel ? item : new RelatedModel(item));
59
+ }
60
+ else {
61
+ if (relation_value === null) {
62
+ cached = null;
63
+ dirty = false;
64
+ return null;
65
+ }
66
+ cached =
67
+ relation_value instanceof RelatedModel
68
+ ? relation_value
69
+ : new RelatedModel(relation_value);
70
+ }
71
+ dirty = false;
72
+ return cached;
73
+ },
74
+ });
75
+ }
76
+ else {
77
+ // Columna normal con closure
78
+ let value = props[column_name];
79
+ Object.defineProperty(this, column_name, {
80
+ enumerable: true,
81
+ configurable: true,
82
+ get: () => {
83
+ const computed = column.get.reduce((v, fn) => fn(v), value);
84
+ // Cache default values so they don't regenerate on each access
85
+ if (value == null && computed != null) {
86
+ value = computed;
87
+ }
88
+ return computed;
89
+ },
90
+ set: (next) => {
91
+ value = column.set.reduce((accumulated, fn) => fn(accumulated, value), next);
92
+ },
93
+ });
94
+ this[column_name] = props[column_name];
95
+ }
96
+ }
97
+ }
98
+ toJSON() {
99
+ const schema = this.constructor[SCHEMA];
100
+ const result = {};
101
+ for (const column_name in schema.columns) {
102
+ const column = schema.columns[column_name];
103
+ const value = this[column_name];
104
+ if (value === null || value === undefined)
105
+ continue;
106
+ if (column.store?.relation) {
107
+ result[column_name] = Array.isArray(value)
108
+ ? value.map((item) => (item?.toJSON ? item.toJSON() : item))
109
+ : value?.toJSON
110
+ ? value.toJSON()
111
+ : value;
112
+ }
113
+ else {
114
+ result[column_name] = value;
115
+ }
116
+ }
117
+ return result;
118
+ }
119
+ /**
120
+ * @description Convierte la instancia a un payload listo para DynamoDB
121
+ * @returns Objeto con nombres de columnas de DB y valores apropiados
122
+ */
123
+ _toDBPayload() {
124
+ const schema = this.constructor[SCHEMA];
125
+ const payload = {};
126
+ for (const prop_name in schema.columns) {
127
+ const column = schema.columns[prop_name];
128
+ // Skip relations
129
+ if (column.store?.relation) {
130
+ continue;
131
+ }
132
+ const value = this[prop_name];
133
+ const db_name = column.name || prop_name;
134
+ // Skip undefined values (DynamoDB doesn't support them)
135
+ if (value !== undefined) {
136
+ payload[db_name] = value;
137
+ }
138
+ }
139
+ return payload;
140
+ }
141
+ toString() {
142
+ return JSON.stringify(this);
143
+ }
144
+ async save(options) {
145
+ const schema = this.constructor[SCHEMA];
146
+ const tx = options?.tx;
147
+ if (this.__isPersisted) {
148
+ if (options?.hook)
149
+ await Table._run_hooks(this, 'beforeUpdate', {});
150
+ if (tx) {
151
+ tx.addPut(schema.name, this._toDBPayload());
152
+ if (options?.hook)
153
+ tx.onCommit(() => Table._run_hooks(this, 'afterUpdate', {}));
154
+ }
155
+ else {
156
+ await requireClient().send(new PutItemCommand({
157
+ TableName: schema.name,
158
+ Item: marshall(this._toDBPayload(), { removeUndefinedValues: true }),
159
+ }));
160
+ if (options?.hook)
161
+ await Table._run_hooks(this, 'afterUpdate', {});
162
+ }
163
+ return true;
164
+ }
165
+ if (options?.hook)
166
+ await Table._run_hooks(this, 'beforeCreate');
167
+ const created = await this.constructor.create(Object.fromEntries(Object.keys(schema.columns)
168
+ .filter(k => !schema.columns[k].store?.relation)
169
+ .map(k => [k, this[k]])), { hook: false, tx });
170
+ for (const key in schema.columns) {
171
+ if (!schema.columns[key].store?.relation) {
172
+ this[key] = created[key];
173
+ }
174
+ }
175
+ this.__isPersisted = true;
176
+ if (options?.hook)
177
+ await Table._run_hooks(this, 'afterCreate');
178
+ return true;
179
+ }
180
+ async update(data, options) {
181
+ const schema = this.constructor[SCHEMA];
182
+ // Filtrar relaciones (ignorarlas) en vez de lanzar error
183
+ const filtered_data = {};
184
+ for (const key in data) {
185
+ const column = schema.columns[key];
186
+ // Solo incluir campos que NO son relaciones
187
+ if (!column?.store?.relation) {
188
+ filtered_data[key] = data[key];
189
+ }
190
+ }
191
+ // Ruta con hooks: aplicar cambios y persistir sobre esta misma instancia
192
+ if (options?.hook) {
193
+ for (const key in filtered_data) {
194
+ this[key] = filtered_data[key];
195
+ }
196
+ // Auto-renovar campos @UpdatedAt no incluidos en los cambios
197
+ for (const col_name in schema.columns) {
198
+ if (schema.columns[col_name].store?.updatedAt && !(col_name in filtered_data)) {
199
+ this[col_name] = undefined;
200
+ }
201
+ }
202
+ await Table._run_hooks(this, 'beforeUpdate', filtered_data);
203
+ const tx = options.tx;
204
+ if (tx) {
205
+ tx.addPut(schema.name, this._toDBPayload());
206
+ tx.onCommit(() => Table._run_hooks(this, 'afterUpdate', filtered_data));
207
+ }
208
+ else {
209
+ await requireClient().send(new PutItemCommand({
210
+ TableName: schema.name,
211
+ Item: marshall(this._toDBPayload(), { removeUndefinedValues: true }),
212
+ }));
213
+ await Table._run_hooks(this, 'afterUpdate', filtered_data);
214
+ }
215
+ return true;
216
+ }
217
+ const affected = await this.constructor.update(filtered_data, {
218
+ [schema.primary_key]: this[schema.primary_key],
219
+ }, { tx: options?.tx });
220
+ if (affected > 0) {
221
+ // Actualizar la instancia con los nuevos valores (incluyendo updated_at)
222
+ for (const key in filtered_data) {
223
+ this[key] = filtered_data[key];
224
+ }
225
+ }
226
+ return affected > 0;
227
+ }
228
+ async destroy(options) {
229
+ const schema = this.constructor[SCHEMA];
230
+ const id = this[schema.primary_key];
231
+ if (!id)
232
+ throw new Error("Cannot destroy record without ID");
233
+ if (options?.hook)
234
+ await Table._run_hooks(this, 'beforeDestroy');
235
+ let soft_col = null;
236
+ for (const column_name in schema.columns) {
237
+ if (schema.columns[column_name].store?.softDelete) {
238
+ soft_col = column_name;
239
+ break;
240
+ }
241
+ }
242
+ if (soft_col) {
243
+ this[soft_col] = new Date().toISOString();
244
+ await this.save({ tx: options?.tx });
245
+ }
246
+ else {
247
+ await this.forceDestroy({ tx: options?.tx });
248
+ }
249
+ if (options?.hook) {
250
+ if (options.tx)
251
+ options.tx.onCommit(() => Table._run_hooks(this, 'afterDestroy'));
252
+ else
253
+ await Table._run_hooks(this, 'afterDestroy');
254
+ }
255
+ return null;
256
+ }
257
+ async forceDestroy(options) {
258
+ const schema = this.constructor[SCHEMA];
259
+ const id = this[schema.primary_key];
260
+ if (!id)
261
+ throw new Error("Cannot destroy record without ID");
262
+ if (options?.hook)
263
+ await Table._run_hooks(this, 'beforeDestroy');
264
+ const tx = options?.tx;
265
+ if (tx) {
266
+ tx.addDelete(schema.name, { [schema.primary_key]: id });
267
+ }
268
+ else {
269
+ await requireClient().send(new DeleteItemCommand({
270
+ TableName: schema.name,
271
+ Key: marshall({ [schema.primary_key]: id }),
272
+ }));
273
+ }
274
+ if (options?.hook) {
275
+ if (tx)
276
+ tx.onCommit(() => Table._run_hooks(this, 'afterDestroy'));
277
+ else
278
+ await Table._run_hooks(this, 'afterDestroy');
279
+ }
280
+ return null;
281
+ }
282
+ async attach(RelatedModel, related_id, pivot_data) {
283
+ const schema = this.constructor[SCHEMA];
284
+ const primary_key = schema.primary_key || "id";
285
+ // VALIDACIÓN PRIORITARIA: Verificar que la instancia esté persistida
286
+ const local_id = this[primary_key];
287
+ const is_persisted = this.__isPersisted;
288
+ if (!local_id || !is_persisted) {
289
+ throw new Error("No se puede attach sin ID: la instancia debe persistirse primero con save() o create()");
290
+ }
291
+ const related_table_name = RelatedModel[SCHEMA]?.name;
292
+ if (!related_table_name) {
293
+ throw new Error("Related model no tiene SCHEMA definido");
294
+ }
295
+ let relation = null;
296
+ for (const column_name in schema.columns) {
297
+ const rel = schema.columns[column_name].store?.relation;
298
+ if (rel?.type === "ManyToMany" &&
299
+ rel.model()[SCHEMA]?.name === related_table_name) {
300
+ relation = rel;
301
+ break;
302
+ }
303
+ }
304
+ if (!relation) {
305
+ throw new Error(`No se encontró relación ManyToMany entre ${schema.name} y ${related_table_name}`);
306
+ }
307
+ const foreign_key_value = this[relation.localKey];
308
+ const result = await requireClient().send(new ScanCommand({
309
+ TableName: relation.pivotTable,
310
+ FilterExpression: "#fk = :local_id AND #rk = :related_id",
311
+ ExpressionAttributeNames: {
312
+ "#fk": relation.foreignKey,
313
+ "#rk": relation.relatedKey,
314
+ },
315
+ ExpressionAttributeValues: marshall({
316
+ ":local_id": foreign_key_value,
317
+ ":related_id": related_id,
318
+ }),
319
+ }));
320
+ if (result.Items && result.Items.length > 0)
321
+ return;
322
+ await requireClient().send(new PutItemCommand({
323
+ TableName: relation.pivotTable,
324
+ Item: marshall({
325
+ id: `${foreign_key_value}_${related_id}`,
326
+ [relation.foreignKey]: foreign_key_value,
327
+ [relation.relatedKey]: related_id,
328
+ created_at: new Date().toISOString(),
329
+ ...pivot_data,
330
+ }, { removeUndefinedValues: true }),
331
+ }));
332
+ }
333
+ async detach(RelatedModel, related_id) {
334
+ const schema = this.constructor[SCHEMA];
335
+ const related_table_name = RelatedModel[SCHEMA]?.name;
336
+ if (!related_table_name)
337
+ return;
338
+ let relation = null;
339
+ for (const column_name in schema.columns) {
340
+ const rel = schema.columns[column_name].store?.relation;
341
+ if (rel?.type === "ManyToMany" &&
342
+ rel.model()[SCHEMA]?.name === related_table_name) {
343
+ relation = rel;
344
+ break;
345
+ }
346
+ }
347
+ if (!relation)
348
+ return;
349
+ const local_id = this[relation.localKey];
350
+ if (!local_id)
351
+ return;
352
+ const result = await requireClient().send(new ScanCommand({
353
+ TableName: relation.pivotTable,
354
+ FilterExpression: "#fk = :local_id AND #rk = :related_id",
355
+ ExpressionAttributeNames: {
356
+ "#fk": relation.foreignKey,
357
+ "#rk": relation.relatedKey,
358
+ },
359
+ ExpressionAttributeValues: marshall({
360
+ ":local_id": local_id,
361
+ ":related_id": related_id,
362
+ }),
363
+ }));
364
+ if (!result.Items || result.Items.length === 0)
365
+ return;
366
+ await requireClient().send(new DeleteItemCommand({
367
+ TableName: relation.pivotTable,
368
+ Key: marshall({ id: unmarshall(result.Items[0]).id }),
369
+ }));
370
+ }
371
+ /**
372
+ * Sincronizar relación ManyToMany reemplazando todas las relaciones existentes
373
+ * @param RelatedModel Modelo relacionado
374
+ * @param related_ids Array de IDs a sincronizar
375
+ */
376
+ async sync(RelatedModel, related_ids) {
377
+ const schema = this.constructor[SCHEMA];
378
+ const related_table_name = RelatedModel[SCHEMA]?.name;
379
+ if (!related_table_name) {
380
+ throw new Error(`No se encontró schema para el modelo relacionado`);
381
+ }
382
+ // Buscar la relación ManyToMany
383
+ let relation = null;
384
+ for (const column_name in schema.columns) {
385
+ const rel = schema.columns[column_name].store?.relation;
386
+ if (rel?.type === "ManyToMany" &&
387
+ rel.model()[SCHEMA]?.name === related_table_name) {
388
+ relation = rel;
389
+ break;
390
+ }
391
+ }
392
+ if (!relation) {
393
+ throw new Error(`No se encontró relación ManyToMany entre ${schema.name} y ${related_table_name}`);
394
+ }
395
+ const local_id = this[relation.localKey];
396
+ if (!local_id) {
397
+ throw new Error(`El valor de ${relation.localKey} no está definido`);
398
+ }
399
+ // 1. Obtener todas las relaciones existentes
400
+ const scan_result = await requireClient().send(new ScanCommand({
401
+ TableName: relation.pivotTable,
402
+ FilterExpression: "#fk = :local_id",
403
+ ExpressionAttributeNames: { "#fk": relation.foreignKey },
404
+ ExpressionAttributeValues: marshall({ ":local_id": local_id }),
405
+ }));
406
+ const existing_ids = new Set((scan_result.Items || []).map((item) => unmarshall(item)[relation.relatedKey]));
407
+ const target_ids = new Set(related_ids);
408
+ // 2. Detach relaciones que no están en la lista objetivo
409
+ for (const existing_id of existing_ids) {
410
+ if (!target_ids.has(existing_id)) {
411
+ await this.detach(RelatedModel, existing_id);
412
+ }
413
+ }
414
+ // 3. Attach nuevas relaciones que no existen
415
+ for (const target_id of target_ids) {
416
+ if (!existing_ids.has(target_id)) {
417
+ await this.attach(RelatedModel, target_id);
418
+ }
419
+ }
420
+ }
421
+ /**
422
+ * @description Run the registered hooks of a given type on an instance, in declaration order.
423
+ * @description Ejecuta los hooks registrados de un tipo dado sobre una instancia, en orden de declaración.
424
+ */
425
+ static async _run_hooks(instance, type, changes) {
426
+ const schema = instance.constructor[SCHEMA];
427
+ for (const method_name of schema.hooks[type]) {
428
+ await instance[method_name]?.(changes);
429
+ }
430
+ }
431
+ static async create(data, options) {
432
+ const instance = new this(data);
433
+ const schema = this[SCHEMA];
434
+ const tx = options?.tx;
435
+ if (options?.hook)
436
+ await Table._run_hooks(instance, 'beforeCreate');
437
+ const payload = instance._toDBPayload();
438
+ const pk_db_name = schema.columns[schema.primary_key]?.name || schema.primary_key;
439
+ const condition = {
440
+ expression: 'attribute_not_exists(#pk)',
441
+ names: { '#pk': pk_db_name },
442
+ };
443
+ if (tx) {
444
+ tx.addPut(schema.name, payload, condition);
445
+ tx.onCommit(() => { instance.__isPersisted = true; });
446
+ if (options?.hook)
447
+ tx.onCommit(() => Table._run_hooks(instance, 'afterCreate'));
448
+ }
449
+ else {
450
+ try {
451
+ await requireClient().send(new PutItemCommand({
452
+ TableName: schema.name,
453
+ Item: marshall(payload, { removeUndefinedValues: true }),
454
+ ConditionExpression: condition.expression,
455
+ ExpressionAttributeNames: condition.names,
456
+ }));
457
+ }
458
+ catch (e) {
459
+ if (e.name === 'ConditionalCheckFailedException') {
460
+ throw new Error(`Record with ${schema.primary_key} '${instance[schema.primary_key]}' already exists in ${schema.name}`);
461
+ }
462
+ throw e;
463
+ }
464
+ instance.__isPersisted = true;
465
+ if (options?.hook)
466
+ await Table._run_hooks(instance, 'afterCreate');
467
+ }
468
+ return instance;
469
+ }
470
+ /**
471
+ * @description Extract PK value from filters if the filter is a simple PK equality. Returns null otherwise.
472
+ * @description Extrae el valor de PK de los filtros si es una igualdad simple por PK. Retorna null en otro caso.
473
+ */
474
+ static _extractPK(filters) {
475
+ const schema = this[SCHEMA];
476
+ const keys = Object.keys(filters);
477
+ if (keys.length !== 1 || keys[0] !== schema.primary_key)
478
+ return null;
479
+ const val = filters[schema.primary_key];
480
+ if (val === null || val === undefined)
481
+ return null;
482
+ // Valor plano o { $eq: value }
483
+ if (typeof val !== 'object' || Array.isArray(val))
484
+ return val;
485
+ const op_keys = Object.keys(val);
486
+ if (op_keys.length === 1 && (OP_MAP[op_keys[0]] || op_keys[0]) === '=')
487
+ return val[op_keys[0]];
488
+ return null;
489
+ }
490
+ static async update(updates, filters, options) {
491
+ const schema = this[SCHEMA];
492
+ const tx = options?.tx;
493
+ const parsed_updates = {};
494
+ for (const key in updates) {
495
+ const column = schema.columns[key];
496
+ if (!column?.store?.relation) {
497
+ parsed_updates[key] = updates[key];
498
+ }
499
+ }
500
+ // Optimización: si el filtro es PK exacta, GetItem directo
501
+ const pk_value = this._extractPK(filters);
502
+ let records;
503
+ if (pk_value !== null) {
504
+ const client = requireClient();
505
+ const result = await client.send(new GetItemCommand({
506
+ TableName: schema.name,
507
+ Key: marshall({ [schema.primary_key]: pk_value }),
508
+ }));
509
+ if (!result.Item)
510
+ return 0;
511
+ const raw = unmarshall(result.Item);
512
+ const db_to_prop = {};
513
+ for (const p in schema.columns)
514
+ db_to_prop[schema.columns[p].name] = p;
515
+ const mapped = {};
516
+ for (const k in raw) {
517
+ if (raw[k] != null)
518
+ mapped[db_to_prop[k] || k] = raw[k];
519
+ }
520
+ const instance = new this(mapped);
521
+ instance.__isPersisted = true;
522
+ records = [instance];
523
+ }
524
+ else {
525
+ records = await this.where(filters);
526
+ if (records.length === 0)
527
+ return 0;
528
+ }
529
+ for (const record of records) {
530
+ for (const [key, value] of Object.entries(parsed_updates)) {
531
+ record[key] = value;
532
+ }
533
+ // Auto-renovar campos @UpdatedAt
534
+ for (const col_name in schema.columns) {
535
+ if (schema.columns[col_name].store?.updatedAt && !(col_name in parsed_updates)) {
536
+ record[col_name] = undefined;
537
+ }
538
+ }
539
+ if (options?.hook)
540
+ await Table._run_hooks(record, 'beforeUpdate', parsed_updates);
541
+ if (tx) {
542
+ tx.addPut(schema.name, record._toDBPayload());
543
+ if (options?.hook)
544
+ tx.onCommit(() => Table._run_hooks(record, 'afterUpdate', parsed_updates));
545
+ }
546
+ else {
547
+ await requireClient().send(new PutItemCommand({
548
+ TableName: schema.name,
549
+ Item: marshall(record._toDBPayload(), { removeUndefinedValues: true }),
550
+ }));
551
+ if (options?.hook)
552
+ await Table._run_hooks(record, 'afterUpdate', parsed_updates);
553
+ }
554
+ }
555
+ return records.length;
556
+ }
557
+ static async delete(filters, options) {
558
+ const schema = this[SCHEMA];
559
+ const tx = options?.tx;
560
+ // Optimización: si el filtro es PK exacta, sin softDelete y sin hooks de destroy, DeleteItem directo
561
+ const pk_value = this._extractPK(filters);
562
+ const has_soft_delete = Object.values(schema.columns).some(c => c.store.softDelete);
563
+ const has_destroy_hooks = !!options?.hook && (schema.hooks.beforeDestroy.length > 0 || schema.hooks.afterDestroy.length > 0);
564
+ if (pk_value !== null && !has_soft_delete && !has_destroy_hooks) {
565
+ if (tx) {
566
+ tx.addDelete(schema.name, { [schema.primary_key]: pk_value });
567
+ }
568
+ else {
569
+ await requireClient().send(new DeleteItemCommand({
570
+ TableName: schema.name,
571
+ Key: marshall({ [schema.primary_key]: pk_value }),
572
+ }));
573
+ }
574
+ return 1;
575
+ }
576
+ // Fallback: where() + delete por cada uno
577
+ const records = await this.where(filters);
578
+ if (records.length === 0)
579
+ return 0;
580
+ for (const record of records) {
581
+ const id = record[schema.primary_key];
582
+ if (!id)
583
+ continue;
584
+ if (options?.hook)
585
+ await Table._run_hooks(record, 'beforeDestroy');
586
+ if (tx) {
587
+ tx.addDelete(schema.name, { [schema.primary_key]: id });
588
+ if (options?.hook)
589
+ tx.onCommit(() => Table._run_hooks(record, 'afterDestroy'));
590
+ }
591
+ else {
592
+ await requireClient().send(new DeleteItemCommand({
593
+ TableName: schema.name,
594
+ Key: marshall({ [schema.primary_key]: id }),
595
+ }));
596
+ if (options?.hook)
597
+ await Table._run_hooks(record, 'afterDestroy');
598
+ }
599
+ }
600
+ return records.length;
601
+ }
602
+ /**
603
+ * @description Atomically increment a numeric field by amount. Uses DynamoDB SET expression.
604
+ * @description Incrementa atómicamente un campo numérico. Usa expresión SET de DynamoDB.
605
+ */
606
+ static async _atomicAdd(table_class, field, amount, filters, tx) {
607
+ const schema = table_class[SCHEMA];
608
+ const column = schema.columns[field];
609
+ if (!column)
610
+ throw new Error(`Unknown column '${field}' in ${schema.name}`);
611
+ const db_name = column.name || field;
612
+ const expr = `SET #f = if_not_exists(#f, :zero) + :amt`;
613
+ const names = { '#f': db_name };
614
+ const values = { ':amt': amount, ':zero': 0 };
615
+ const pk_value = table_class._extractPK(filters);
616
+ if (pk_value !== null) {
617
+ const key = { [schema.primary_key]: pk_value };
618
+ if (tx) {
619
+ tx.addUpdate(schema.name, key, expr, names, values);
620
+ }
621
+ else {
622
+ await requireClient().send(new UpdateItemCommand({
623
+ TableName: schema.name,
624
+ Key: marshall(key),
625
+ UpdateExpression: expr,
626
+ ExpressionAttributeNames: names,
627
+ ExpressionAttributeValues: marshall(values),
628
+ }));
629
+ }
630
+ return 1;
631
+ }
632
+ const records = await table_class.where(filters);
633
+ if (records.length === 0)
634
+ return 0;
635
+ if (tx) {
636
+ for (const record of records) {
637
+ tx.addUpdate(schema.name, { [schema.primary_key]: record[schema.primary_key] }, expr, names, values);
638
+ }
639
+ }
640
+ else {
641
+ const client = requireClient();
642
+ await Promise.all(records.map((record) => client.send(new UpdateItemCommand({
643
+ TableName: schema.name,
644
+ Key: marshall({ [schema.primary_key]: record[schema.primary_key] }),
645
+ UpdateExpression: expr,
646
+ ExpressionAttributeNames: names,
647
+ ExpressionAttributeValues: marshall(values),
648
+ }))));
649
+ }
650
+ return records.length;
651
+ }
652
+ static async increment(field, amount, filters, options) {
653
+ return Table._atomicAdd(this, field, amount, filters, options?.tx);
654
+ }
655
+ static async decrement(field, amount, filters, options) {
656
+ return Table._atomicAdd(this, field, -amount, filters, options?.tx);
657
+ }
658
+ async increment(field, amount = 1) {
659
+ const schema = this.constructor[SCHEMA];
660
+ const pk = this[schema.primary_key];
661
+ if (!pk)
662
+ throw new Error('Cannot increment without primary key');
663
+ await Table._atomicAdd(this.constructor, field, amount, { [schema.primary_key]: pk });
664
+ this[field] = (this[field] || 0) + amount;
665
+ }
666
+ async decrement(field, amount = 1) {
667
+ const schema = this.constructor[SCHEMA];
668
+ const pk = this[schema.primary_key];
669
+ if (!pk)
670
+ throw new Error('Cannot decrement without primary key');
671
+ await Table._atomicAdd(this.constructor, field, -amount, { [schema.primary_key]: pk });
672
+ this[field] = (this[field] || 0) - amount;
673
+ }
674
+ static async where(field_or_filters, operator_or_value, value, options) {
675
+ const schema = this[SCHEMA];
676
+ // -- Normalización: todas las sobrecargas -> { field: { $op: value } } --
677
+ let raw_filters;
678
+ let opts;
679
+ if (typeof field_or_filters === 'string') {
680
+ if (OPERATORS.has(operator_or_value)) {
681
+ raw_filters = { [field_or_filters]: { [operator_or_value]: value } };
682
+ opts = options || {};
683
+ }
684
+ else {
685
+ raw_filters = { [field_or_filters]: { $eq: operator_or_value } };
686
+ opts = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
687
+ }
688
+ }
689
+ else {
690
+ raw_filters = field_or_filters ?? {};
691
+ opts = operator_or_value && typeof operator_or_value === 'object' && !Array.isArray(operator_or_value)
692
+ ? operator_or_value : {};
693
+ }
694
+ const filters = {};
695
+ for (const [field, val] of Object.entries(raw_filters)) {
696
+ if (val === undefined)
697
+ continue;
698
+ if (!schema.columns[field])
699
+ throw new Error(`Unknown column '${field}' in ${schema.name}`);
700
+ if (val !== null && typeof val === 'object' && !Array.isArray(val) && Object.keys(val).some(k => OPERATORS.has(k))) {
701
+ filters[field] = val;
702
+ }
703
+ else {
704
+ filters[field] = { $eq: val };
705
+ }
706
+ }
707
+ if (opts.limit === 0)
708
+ return [];
709
+ // Soft delete: excluir registros eliminados salvo que se pida lo contrario
710
+ if (!opts._includeTrashed) {
711
+ for (const col_name in schema.columns) {
712
+ if (schema.columns[col_name].store?.softDelete && !(col_name in filters)) {
713
+ filters[col_name] = { $notExists: true };
714
+ break;
715
+ }
716
+ }
717
+ }
718
+ // -- Índice inverso db_name → prop_name --
719
+ const db_to_prop = {};
720
+ for (const prop_name in schema.columns) {
721
+ db_to_prop[schema.columns[prop_name].name] = prop_name;
722
+ }
723
+ // -- Detectar mejor índice para QueryCommand --
724
+ // Prioridad: 1) PK con $eq 2) GSI con $eq 3) Scan
725
+ let query_field = null;
726
+ let query_value = null;
727
+ let query_index = undefined;
728
+ // Primero buscar PK
729
+ for (const [field, ops] of Object.entries(filters)) {
730
+ if (field !== schema.primary_key)
731
+ continue;
732
+ const eq_key = Object.keys(ops).find(k => (OP_MAP[k] || k) === '=');
733
+ if (eq_key && ops[eq_key] !== null) {
734
+ query_field = field;
735
+ query_value = ops[eq_key];
736
+ query_index = undefined;
737
+ break;
738
+ }
739
+ }
740
+ // Si no hay PK, buscar el mejor GSI
741
+ if (!query_field) {
742
+ for (const [field, ops] of Object.entries(filters)) {
743
+ const eq_key = Object.keys(ops).find(k => (OP_MAP[k] || k) === '=');
744
+ if (!eq_key || ops[eq_key] === null)
745
+ continue;
746
+ const db_name = schema.columns[field]?.name || field;
747
+ if (schema.gsis?.has(db_name)) {
748
+ query_field = field;
749
+ query_value = ops[eq_key];
750
+ query_index = `${db_name}_index`;
751
+ break;
752
+ }
753
+ }
754
+ }
755
+ // -- Construir expressions --
756
+ // El campo elegido para Query va a KeyConditionExpression
757
+ // TODO el resto (incluyendo soft delete, otros filtros) va a FilterExpression (server-side)
758
+ const key_expressions = [];
759
+ const filter_expressions = [];
760
+ const attr_names = {};
761
+ const attr_values = {};
762
+ let idx = 0;
763
+ for (const [field, ops] of Object.entries(filters)) {
764
+ const column = schema.columns[field];
765
+ const db_name = column?.name || field;
766
+ for (const [op_key, op_val] of Object.entries(ops)) {
767
+ if (op_val === undefined)
768
+ continue;
769
+ const op = OP_MAP[op_key] || op_key;
770
+ const nk = `#a${idx}`;
771
+ const vk = `:v${idx}`;
772
+ attr_names[nk] = db_name;
773
+ // KeyConditionExpression: solo el campo de Query con $eq
774
+ const is_query_key = query_field === field && op === '=' && op_val === query_value;
775
+ if ((op === '=' && op_val === null) || op === 'attribute_not_exists') {
776
+ filter_expressions.push(`attribute_not_exists(${nk})`);
777
+ }
778
+ else if ((op === '<>' && op_val === null) || op === 'attribute_exists') {
779
+ filter_expressions.push(`attribute_exists(${nk})`);
780
+ }
781
+ else if (op === 'in' && Array.isArray(op_val)) {
782
+ if (op_val.length === 0)
783
+ throw new Error(`Operator 'in' requires a non-empty array.`);
784
+ const conds = op_val.map((v, i) => { const k = `${vk}_${i}`; attr_values[k] = v; return `${nk} = ${k}`; });
785
+ filter_expressions.push(`(${conds.join(' OR ')})`);
786
+ }
787
+ else if (op === 'include') {
788
+ attr_values[vk] = op_val;
789
+ filter_expressions.push(`contains(${nk}, ${vk})`);
790
+ }
791
+ else if (is_query_key) {
792
+ attr_values[vk] = op_val;
793
+ key_expressions.push(`${nk} ${op} ${vk}`);
794
+ }
795
+ else {
796
+ attr_values[vk] = op_val;
797
+ filter_expressions.push(`${nk} ${op} ${vk}`);
798
+ }
799
+ idx++;
800
+ }
801
+ }
802
+ // Proyección
803
+ if (opts.attributes?.length) {
804
+ for (const attr of opts.attributes) {
805
+ const col = schema.columns[String(attr)];
806
+ const pk = `#p${idx++}`;
807
+ attr_names[pk] = col?.name || String(attr);
808
+ }
809
+ }
810
+ // -- Ejecución: Query o Scan --
811
+ let use_query = query_field !== null && key_expressions.length > 0;
812
+ let items = [];
813
+ const base_params = { TableName: schema.name };
814
+ if (Object.keys(attr_names).length > 0)
815
+ base_params.ExpressionAttributeNames = attr_names;
816
+ if (Object.keys(attr_values).length > 0)
817
+ base_params.ExpressionAttributeValues = marshall(attr_values, { removeUndefinedValues: true });
818
+ if (filter_expressions.length > 0)
819
+ base_params.FilterExpression = filter_expressions.join(' AND ');
820
+ if (opts.attributes?.length) {
821
+ base_params.ProjectionExpression = Object.keys(attr_names).filter(k => k.startsWith('#p')).join(', ');
822
+ }
823
+ if (use_query) {
824
+ base_params.KeyConditionExpression = key_expressions.join(' AND ');
825
+ if (query_index)
826
+ base_params.IndexName = query_index;
827
+ }
828
+ const client = requireClient();
829
+ const unmarshal_items = (raw_items) => {
830
+ for (const item of raw_items) {
831
+ const raw = unmarshall(item);
832
+ const mapped = {};
833
+ for (const k in raw) {
834
+ if (raw[k] != null)
835
+ mapped[db_to_prop[k] || k] = raw[k];
836
+ }
837
+ items.push(mapped);
838
+ }
839
+ };
840
+ // Intentar Query. Si el GSI no existe, fall back a Scan y desregistrar GSI.
841
+ if (use_query && query_index) {
842
+ try {
843
+ let last_key;
844
+ do {
845
+ if (last_key)
846
+ base_params.ExclusiveStartKey = last_key;
847
+ const result = await client.send(new QueryCommand(base_params));
848
+ if (result.Items)
849
+ unmarshal_items(result.Items);
850
+ last_key = result.LastEvaluatedKey;
851
+ } while (last_key);
852
+ }
853
+ catch (e) {
854
+ if (e.name === 'ResourceNotFoundException' || e.message?.includes('index')) {
855
+ // GSI no existe: remover del cache, mover KeyCondition a Filter, reintentar como Scan
856
+ schema.gsis.delete(query_field);
857
+ delete base_params.KeyConditionExpression;
858
+ delete base_params.IndexName;
859
+ delete base_params.ExclusiveStartKey;
860
+ const key_as_filter = key_expressions.join(' AND ');
861
+ base_params.FilterExpression = base_params.FilterExpression
862
+ ? `${key_as_filter} AND ${base_params.FilterExpression}`
863
+ : key_as_filter;
864
+ use_query = false;
865
+ items = [];
866
+ }
867
+ else {
868
+ throw e;
869
+ }
870
+ }
871
+ }
872
+ // Query por PK (sin GSI, no puede fallar por índice faltante) o Scan fallback
873
+ if (!use_query || (use_query && !query_index)) {
874
+ let last_key;
875
+ const cmd = use_query ? QueryCommand : ScanCommand;
876
+ do {
877
+ if (last_key)
878
+ base_params.ExclusiveStartKey = last_key;
879
+ const result = await client.send(new cmd(base_params));
880
+ if (result.Items)
881
+ unmarshal_items(result.Items);
882
+ last_key = result.LastEvaluatedKey;
883
+ } while (last_key);
884
+ }
885
+ // Ordenar antes de paginar
886
+ if (opts.order) {
887
+ let sort_field = schema.primary_key;
888
+ let sort_dir = 'ASC';
889
+ if (typeof opts.order === 'string') {
890
+ sort_dir = opts.order;
891
+ for (const cn in schema.columns) {
892
+ if (schema.columns[cn].store?.createdAt) {
893
+ sort_field = cn;
894
+ break;
895
+ }
896
+ }
897
+ }
898
+ else {
899
+ const [f] = Object.keys(opts.order);
900
+ sort_field = f;
901
+ sort_dir = opts.order[f];
902
+ }
903
+ items.sort((a, b) => {
904
+ if (a[sort_field] < b[sort_field])
905
+ return sort_dir === 'ASC' ? -1 : 1;
906
+ if (a[sort_field] > b[sort_field])
907
+ return sort_dir === 'ASC' ? 1 : -1;
908
+ return 0;
909
+ });
910
+ }
911
+ // Paginar
912
+ const skip = opts.skip ?? opts.offset ?? 0;
913
+ if (skip > 0 || opts.limit !== undefined) {
914
+ items = items.slice(skip, opts.limit !== undefined ? skip + opts.limit : undefined);
915
+ }
916
+ // Instanciar
917
+ const instances = items.map((item) => {
918
+ if (opts.attributes) {
919
+ const instance = Object.create(this.prototype);
920
+ for (const attr of opts.attributes) {
921
+ const column = schema.columns[attr];
922
+ if (!column)
923
+ continue;
924
+ const val = item[attr] ?? null;
925
+ Object.defineProperty(instance, attr, {
926
+ enumerable: true, configurable: true,
927
+ get: () => column.get.reduce((v, fn) => fn(v), val),
928
+ });
929
+ }
930
+ return instance;
931
+ }
932
+ const instance = new this(item);
933
+ instance.__isPersisted = true;
934
+ return instance;
935
+ });
936
+ if (opts.include) {
937
+ await processIncludes(instances, opts.include, this);
938
+ }
939
+ return instances;
940
+ }
941
+ static async first(filters, options) {
942
+ const results = await this.where(filters, { ...options, limit: 1 });
943
+ return results[0];
944
+ }
945
+ static async last(filters, options) {
946
+ const results = await this.where(filters ?? {}, { ...options, order: 'DESC', limit: 1 });
947
+ return results[0];
948
+ }
949
+ }
950
+ //# sourceMappingURL=table.js.map