graphddb 0.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.
- package/README.md +553 -0
- package/dist/chunk-347U24SB.js +1818 -0
- package/dist/chunk-6LEHSX45.js +4276 -0
- package/dist/chunk-UNRQ5YJT.js +461 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +890 -0
- package/dist/index.d.ts +5309 -0
- package/dist/index.js +2887 -0
- package/dist/testing/index.d.ts +182 -0
- package/dist/testing/index.js +898 -0
- package/dist/types-CDrWiPxp.d.ts +1203 -0
- package/package.json +76 -0
|
@@ -0,0 +1,1818 @@
|
|
|
1
|
+
// src/select/column.ts
|
|
2
|
+
var COLUMN_REF = /* @__PURE__ */ Symbol.for("graphddb.columnRef");
|
|
3
|
+
function isColumn(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && value[COLUMN_REF] === true;
|
|
5
|
+
}
|
|
6
|
+
function createColumnMap() {
|
|
7
|
+
const cache = /* @__PURE__ */ new Map();
|
|
8
|
+
return new Proxy(
|
|
9
|
+
{},
|
|
10
|
+
{
|
|
11
|
+
get(_target, prop) {
|
|
12
|
+
if (typeof prop !== "string") return void 0;
|
|
13
|
+
let col = cache.get(prop);
|
|
14
|
+
if (!col) {
|
|
15
|
+
col = { [COLUMN_REF]: true, name: prop };
|
|
16
|
+
cache.set(prop, col);
|
|
17
|
+
}
|
|
18
|
+
return col;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/decorators/key.ts
|
|
25
|
+
var KEY_MARKER = /* @__PURE__ */ Symbol("graphddb:key");
|
|
26
|
+
var KEY_SEGMENT = /* @__PURE__ */ Symbol("graphddb:keySegment");
|
|
27
|
+
function isKeySegment(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && value[KEY_SEGMENT] === true;
|
|
29
|
+
}
|
|
30
|
+
function k(literals, ...columns) {
|
|
31
|
+
for (let i = 0; i < columns.length; i++) {
|
|
32
|
+
if (!isColumn(columns[i])) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`k\`...\`: interpolation #${i} must be a typed column reference (e.g. \`c.userId\`), not ${typeof columns[i]}. Key segments name fields via column references so they stay type-safe.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
[KEY_SEGMENT]: true,
|
|
40
|
+
literals: [...literals],
|
|
41
|
+
columns
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function isKeyDefinition(value) {
|
|
45
|
+
return typeof value === "object" && value !== null && KEY_MARKER in value;
|
|
46
|
+
}
|
|
47
|
+
function normalizeSegments(spec) {
|
|
48
|
+
if (spec === void 0) return [];
|
|
49
|
+
const list = Array.isArray(spec) ? spec : [spec];
|
|
50
|
+
for (const seg of list) {
|
|
51
|
+
if (!isKeySegment(seg)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
"key()/gsi(): pk and sk must be built with the `k` tagged template (e.g. k`USER#${c.userId}`) or an array of them."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return list;
|
|
58
|
+
}
|
|
59
|
+
function segmentFieldNames(segments) {
|
|
60
|
+
const seen = /* @__PURE__ */ new Set();
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const seg of segments) {
|
|
63
|
+
for (const col of seg.columns) {
|
|
64
|
+
if (!seen.has(col.name)) {
|
|
65
|
+
seen.add(col.name);
|
|
66
|
+
out.push(col.name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
function buildSegmentedKey(structure) {
|
|
73
|
+
const pkSegments = normalizeSegments(structure.pk);
|
|
74
|
+
const skSegments = normalizeSegments(structure.sk);
|
|
75
|
+
if (pkSegments.length === 0) {
|
|
76
|
+
throw new Error("key()/gsi(): pk must define at least one segment.");
|
|
77
|
+
}
|
|
78
|
+
const seen = /* @__PURE__ */ new Set();
|
|
79
|
+
const inputFieldNames = [];
|
|
80
|
+
for (const name of [
|
|
81
|
+
...segmentFieldNames(pkSegments),
|
|
82
|
+
...segmentFieldNames(skSegments)
|
|
83
|
+
]) {
|
|
84
|
+
if (!seen.has(name)) {
|
|
85
|
+
seen.add(name);
|
|
86
|
+
inputFieldNames.push(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { segmented: { pkSegments, skSegments }, inputFieldNames };
|
|
90
|
+
}
|
|
91
|
+
function key(builder) {
|
|
92
|
+
const accessor = createColumnMap();
|
|
93
|
+
const structure = builder(accessor);
|
|
94
|
+
const { segmented, inputFieldNames } = buildSegmentedKey(structure);
|
|
95
|
+
return {
|
|
96
|
+
[KEY_MARKER]: true,
|
|
97
|
+
segmented,
|
|
98
|
+
inputFieldNames
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/decorators/gsi.ts
|
|
103
|
+
var GSI_MARKER = /* @__PURE__ */ Symbol("graphddb:gsi");
|
|
104
|
+
function isGsiDefinition(value) {
|
|
105
|
+
return typeof value === "object" && value !== null && GSI_MARKER in value;
|
|
106
|
+
}
|
|
107
|
+
function gsi(indexName, builder, options) {
|
|
108
|
+
const accessor = createColumnMap();
|
|
109
|
+
const structure = builder(accessor);
|
|
110
|
+
const { segmented, inputFieldNames } = buildSegmentedKey(structure);
|
|
111
|
+
return {
|
|
112
|
+
[GSI_MARKER]: true,
|
|
113
|
+
indexName,
|
|
114
|
+
segmented,
|
|
115
|
+
inputFieldNames,
|
|
116
|
+
unique: options?.unique ?? false
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/linter/linter.ts
|
|
121
|
+
var Linter = class {
|
|
122
|
+
rules = [];
|
|
123
|
+
addRule(rule) {
|
|
124
|
+
this.rules.push(rule);
|
|
125
|
+
}
|
|
126
|
+
run(metadata, registry) {
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const rule of this.rules) {
|
|
129
|
+
results.push(...rule.check(metadata, registry));
|
|
130
|
+
}
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
runAll(registry) {
|
|
134
|
+
const results = [];
|
|
135
|
+
const all = registry.getAll();
|
|
136
|
+
for (const [, metadata] of all) {
|
|
137
|
+
results.push(...this.run(metadata, registry));
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// src/linter/rules/no-scan.ts
|
|
144
|
+
var noScanRule = {
|
|
145
|
+
id: "no-scan",
|
|
146
|
+
severity: "error",
|
|
147
|
+
check(metadata) {
|
|
148
|
+
if (metadata.primaryKey === null && metadata.gsiDefinitions.length === 0) {
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
ruleId: "no-scan",
|
|
152
|
+
severity: "error",
|
|
153
|
+
message: `Entity '${metadata.prefix}' has no primaryKey or GSI defined. All reads would require a full table scan.`,
|
|
154
|
+
entity: metadata.prefix
|
|
155
|
+
}
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// src/linter/rules/require-limit.ts
|
|
163
|
+
var requireLimitRule = {
|
|
164
|
+
id: "require-limit",
|
|
165
|
+
severity: "warning",
|
|
166
|
+
check(metadata) {
|
|
167
|
+
const results = [];
|
|
168
|
+
for (const relation of metadata.relations) {
|
|
169
|
+
if (relation.type !== "hasMany") continue;
|
|
170
|
+
const limit = relation.options?.limit;
|
|
171
|
+
if (!limit || limit.default === void 0 || limit.max === void 0) {
|
|
172
|
+
const kb = Object.keys(relation.keyBinding).join(", ");
|
|
173
|
+
results.push({
|
|
174
|
+
ruleId: "require-limit",
|
|
175
|
+
severity: "warning",
|
|
176
|
+
message: `hasMany relation (keyBinding: [${kb}]) on entity '${metadata.prefix}' is missing limit.default or limit.max. Unbounded queries may consume excessive RCU.`,
|
|
177
|
+
entity: metadata.prefix
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return results;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// src/linter/rules/gsi-ambiguity.ts
|
|
186
|
+
function isSubsetOf(a, b) {
|
|
187
|
+
const setB = new Set(b);
|
|
188
|
+
return a.length > 0 && a.every((f) => setB.has(f));
|
|
189
|
+
}
|
|
190
|
+
var gsiAmbiguityRule = {
|
|
191
|
+
id: "gsi-ambiguity",
|
|
192
|
+
severity: "error",
|
|
193
|
+
check(metadata) {
|
|
194
|
+
const results = [];
|
|
195
|
+
const patterns = [];
|
|
196
|
+
if (metadata.primaryKey) {
|
|
197
|
+
patterns.push({
|
|
198
|
+
label: "primaryKey",
|
|
199
|
+
fieldNames: metadata.primaryKey.inputFieldNames
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
for (const gsi2 of metadata.gsiDefinitions) {
|
|
203
|
+
patterns.push({ label: gsi2.indexName, fieldNames: gsi2.inputFieldNames });
|
|
204
|
+
}
|
|
205
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
206
|
+
for (let j = i + 1; j < patterns.length; j++) {
|
|
207
|
+
const a = patterns[i];
|
|
208
|
+
const b = patterns[j];
|
|
209
|
+
if (isSubsetOf(a.fieldNames, b.fieldNames) || isSubsetOf(b.fieldNames, a.fieldNames)) {
|
|
210
|
+
const smaller = a.fieldNames.length <= b.fieldNames.length ? a : b;
|
|
211
|
+
results.push({
|
|
212
|
+
ruleId: "gsi-ambiguity",
|
|
213
|
+
severity: "error",
|
|
214
|
+
message: `Ambiguous GSI resolution: field set [${smaller.fieldNames.join(", ")}] matches both '${a.label}' and '${b.label}'. Each access pattern must have a unique field combination.`,
|
|
215
|
+
entity: metadata.prefix,
|
|
216
|
+
field: smaller.fieldNames.join(", ")
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/planner/key-resolver.ts
|
|
226
|
+
function fieldSetsEqual(a, b) {
|
|
227
|
+
if (a.length !== b.length) return false;
|
|
228
|
+
const sorted = [...b].sort();
|
|
229
|
+
return [...a].sort().every((v, i) => v === sorted[i]);
|
|
230
|
+
}
|
|
231
|
+
function classifySegmentMatch(segmented, queryFields) {
|
|
232
|
+
const pkFields = segmentFieldNames(segmented.pkSegments);
|
|
233
|
+
const present = new Set(queryFields);
|
|
234
|
+
const allFields = /* @__PURE__ */ new Set([
|
|
235
|
+
...pkFields,
|
|
236
|
+
...segmentFieldNames(segmented.skSegments)
|
|
237
|
+
]);
|
|
238
|
+
for (const f of queryFields) {
|
|
239
|
+
if (!allFields.has(f)) return null;
|
|
240
|
+
}
|
|
241
|
+
for (const f of pkFields) {
|
|
242
|
+
if (!present.has(f)) return null;
|
|
243
|
+
}
|
|
244
|
+
const sk = segmented.skSegments;
|
|
245
|
+
let consumed = 0;
|
|
246
|
+
let truncated = false;
|
|
247
|
+
for (let i = 0; i < sk.length; i++) {
|
|
248
|
+
const fields = sk[i].columns.map((c) => c.name);
|
|
249
|
+
const all = fields.every((f) => present.has(f));
|
|
250
|
+
const any = fields.some((f) => present.has(f));
|
|
251
|
+
if (all && fields.length > 0) {
|
|
252
|
+
if (truncated) return null;
|
|
253
|
+
consumed = i + 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (fields.length === 0) {
|
|
257
|
+
if (!truncated) consumed = i + 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (any) return null;
|
|
261
|
+
truncated = true;
|
|
262
|
+
}
|
|
263
|
+
const skFieldCount = segmentFieldNames(sk).length;
|
|
264
|
+
const allSkPresent = segmentFieldNames(sk).every((f) => present.has(f));
|
|
265
|
+
if (skFieldCount === 0 || allSkPresent && consumed === sk.length) {
|
|
266
|
+
return "full";
|
|
267
|
+
}
|
|
268
|
+
return "partial";
|
|
269
|
+
}
|
|
270
|
+
function resolveKey(queryFields, metadata) {
|
|
271
|
+
if (metadata.primaryKey && fieldSetsEqual(queryFields, metadata.primaryKey.inputFieldNames)) {
|
|
272
|
+
return {
|
|
273
|
+
type: "pk",
|
|
274
|
+
partial: false,
|
|
275
|
+
inputFieldNames: metadata.primaryKey.inputFieldNames
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
for (const gsi2 of metadata.gsiDefinitions) {
|
|
279
|
+
if (fieldSetsEqual(queryFields, gsi2.inputFieldNames)) {
|
|
280
|
+
return {
|
|
281
|
+
type: "gsi",
|
|
282
|
+
indexName: gsi2.indexName,
|
|
283
|
+
unique: gsi2.unique,
|
|
284
|
+
partial: false,
|
|
285
|
+
inputFieldNames: gsi2.inputFieldNames
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (metadata.primaryKey) {
|
|
290
|
+
const match = classifySegmentMatch(metadata.primaryKey.segmented, queryFields);
|
|
291
|
+
if (match === "partial") {
|
|
292
|
+
return { type: "pk", partial: true, inputFieldNames: queryFields };
|
|
293
|
+
}
|
|
294
|
+
if (match === "full") {
|
|
295
|
+
return { type: "pk", partial: false, inputFieldNames: queryFields };
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const gsi2 of metadata.gsiDefinitions) {
|
|
299
|
+
const match = classifySegmentMatch(gsi2.segmented, queryFields);
|
|
300
|
+
if (match === "partial") {
|
|
301
|
+
return {
|
|
302
|
+
type: "gsi",
|
|
303
|
+
indexName: gsi2.indexName,
|
|
304
|
+
unique: false,
|
|
305
|
+
partial: true,
|
|
306
|
+
inputFieldNames: queryFields
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (match === "full") {
|
|
310
|
+
return {
|
|
311
|
+
type: "gsi",
|
|
312
|
+
indexName: gsi2.indexName,
|
|
313
|
+
unique: gsi2.unique,
|
|
314
|
+
partial: false,
|
|
315
|
+
inputFieldNames: queryFields
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
throw new Error(
|
|
320
|
+
`No access pattern found for fields [${queryFields.join(", ")}]. Available: primaryKey([${metadata.primaryKey?.inputFieldNames.join(", ") ?? ""}])` + metadata.gsiDefinitions.map((g) => `, ${g.indexName}([${g.inputFieldNames.join(", ")}])`).join("")
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/linter/rules/missing-gsi.ts
|
|
325
|
+
var missingGsiRule = {
|
|
326
|
+
id: "missing-gsi",
|
|
327
|
+
severity: "error",
|
|
328
|
+
check(metadata, registry) {
|
|
329
|
+
const results = [];
|
|
330
|
+
for (const relation of metadata.relations) {
|
|
331
|
+
const targetFields = Object.keys(relation.keyBinding);
|
|
332
|
+
if (targetFields.length === 0) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
let targetMeta;
|
|
336
|
+
try {
|
|
337
|
+
const targetClass = relation.targetFactory();
|
|
338
|
+
targetMeta = registry.get(targetClass);
|
|
339
|
+
} catch {
|
|
340
|
+
results.push({
|
|
341
|
+
ruleId: "missing-gsi",
|
|
342
|
+
severity: "error",
|
|
343
|
+
message: `Relation '${relation.propertyName}' on entity '${metadata.prefix}' targets an entity that is not registered in MetadataRegistry.`,
|
|
344
|
+
entity: metadata.prefix,
|
|
345
|
+
field: relation.propertyName
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
resolveKey(targetFields, targetMeta);
|
|
351
|
+
} catch {
|
|
352
|
+
results.push({
|
|
353
|
+
ruleId: "missing-gsi",
|
|
354
|
+
severity: "error",
|
|
355
|
+
message: `Relation '${relation.propertyName}' on entity '${metadata.prefix}' requires access pattern [${targetFields.join(", ")}] on target entity '${targetMeta.prefix}', but no matching primary key or GSI is defined.`,
|
|
356
|
+
entity: metadata.prefix,
|
|
357
|
+
field: relation.propertyName
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return results;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// src/linter/rules/relation-depth.ts
|
|
366
|
+
var DEFAULT_MAX_DEPTH = 1;
|
|
367
|
+
function computeRelationDepth(metadata, registry, visited = /* @__PURE__ */ new Set()) {
|
|
368
|
+
if (metadata.relations.length === 0) {
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
371
|
+
let maxDepth = 0;
|
|
372
|
+
for (const relation of metadata.relations) {
|
|
373
|
+
let targetClass;
|
|
374
|
+
try {
|
|
375
|
+
targetClass = relation.targetFactory();
|
|
376
|
+
} catch {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (visited.has(targetClass)) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
let targetMeta;
|
|
383
|
+
try {
|
|
384
|
+
targetMeta = registry.get(targetClass);
|
|
385
|
+
} catch {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const nextVisited = new Set(visited);
|
|
389
|
+
nextVisited.add(targetClass);
|
|
390
|
+
const childDepth = 1 + computeRelationDepth(targetMeta, registry, nextVisited);
|
|
391
|
+
maxDepth = Math.max(maxDepth, childDepth);
|
|
392
|
+
}
|
|
393
|
+
return maxDepth;
|
|
394
|
+
}
|
|
395
|
+
var relationDepthRule = {
|
|
396
|
+
id: "relation-depth",
|
|
397
|
+
severity: "warning",
|
|
398
|
+
check(metadata, registry) {
|
|
399
|
+
const depth = computeRelationDepth(metadata, registry);
|
|
400
|
+
if (depth <= DEFAULT_MAX_DEPTH) {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
return [
|
|
404
|
+
{
|
|
405
|
+
ruleId: "relation-depth",
|
|
406
|
+
severity: "warning",
|
|
407
|
+
message: `Entity '${metadata.prefix}' has relation paths exceeding default maxDepth ${DEFAULT_MAX_DEPTH} (detected depth: ${depth}). Deep traversal requires explicit maxDepth at runtime.`,
|
|
408
|
+
entity: metadata.prefix
|
|
409
|
+
}
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// src/linter/default-linter.ts
|
|
415
|
+
function createDefaultLinter() {
|
|
416
|
+
const linter = new Linter();
|
|
417
|
+
linter.addRule(noScanRule);
|
|
418
|
+
linter.addRule(requireLimitRule);
|
|
419
|
+
linter.addRule(gsiAmbiguityRule);
|
|
420
|
+
linter.addRule(missingGsiRule);
|
|
421
|
+
linter.addRule(relationDepthRule);
|
|
422
|
+
return linter;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/metadata/registry.ts
|
|
426
|
+
var MetadataRegistry = class {
|
|
427
|
+
static store = /* @__PURE__ */ new Map();
|
|
428
|
+
static _linter = createDefaultLinter();
|
|
429
|
+
static register(target, metadata) {
|
|
430
|
+
this.store.set(target, { ...metadata, _finalized: false });
|
|
431
|
+
}
|
|
432
|
+
static get(target) {
|
|
433
|
+
const meta = this.store.get(target);
|
|
434
|
+
if (!meta) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`No metadata registered for '${target.name ?? "(anonymous)"}'. Did you apply @model() to this class?`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
if (!meta._finalized) {
|
|
440
|
+
this.finalize(target, meta);
|
|
441
|
+
}
|
|
442
|
+
return meta;
|
|
443
|
+
}
|
|
444
|
+
static has(target) {
|
|
445
|
+
return this.store.has(target);
|
|
446
|
+
}
|
|
447
|
+
static getAll() {
|
|
448
|
+
for (const [target, meta] of this.store) {
|
|
449
|
+
if (!meta._finalized) {
|
|
450
|
+
this.finalize(target, meta);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return new Map(this.store);
|
|
454
|
+
}
|
|
455
|
+
static get linter() {
|
|
456
|
+
return this._linter;
|
|
457
|
+
}
|
|
458
|
+
static set linter(linter) {
|
|
459
|
+
this._linter = linter;
|
|
460
|
+
}
|
|
461
|
+
/** Resets linter to the default built-in rule set. */
|
|
462
|
+
static resetLinter() {
|
|
463
|
+
this._linter = createDefaultLinter();
|
|
464
|
+
}
|
|
465
|
+
/** @internal — for testing only */
|
|
466
|
+
static clear() {
|
|
467
|
+
this.store.clear();
|
|
468
|
+
this._linter = new Linter();
|
|
469
|
+
}
|
|
470
|
+
static finalize(target, meta) {
|
|
471
|
+
for (const name of Object.getOwnPropertyNames(target)) {
|
|
472
|
+
let value;
|
|
473
|
+
try {
|
|
474
|
+
value = target[name];
|
|
475
|
+
} catch {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (isKeyDefinition(value)) {
|
|
479
|
+
meta.primaryKey = {
|
|
480
|
+
segmented: value.segmented,
|
|
481
|
+
inputFieldNames: value.inputFieldNames
|
|
482
|
+
};
|
|
483
|
+
} else if (isGsiDefinition(value)) {
|
|
484
|
+
meta.gsiDefinitions.push({
|
|
485
|
+
indexName: value.indexName,
|
|
486
|
+
segmented: value.segmented,
|
|
487
|
+
inputFieldNames: value.inputFieldNames,
|
|
488
|
+
unique: value.unique
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const results = this._linter.run(meta, this);
|
|
493
|
+
this.handleLintResults(results);
|
|
494
|
+
meta._finalized = true;
|
|
495
|
+
}
|
|
496
|
+
static handleLintResults(results) {
|
|
497
|
+
const errors = [];
|
|
498
|
+
for (const result of results) {
|
|
499
|
+
if (result.severity === "error") {
|
|
500
|
+
errors.push(result);
|
|
501
|
+
} else if (result.severity === "warning") {
|
|
502
|
+
console.warn(`[graphddb] ${result.ruleId}: ${result.message}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (errors.length > 0) {
|
|
506
|
+
const messages = errors.map((e) => `${e.ruleId}: ${e.message}`).join("\n");
|
|
507
|
+
throw new Error(`Entity lint errors:
|
|
508
|
+
${messages}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// src/client/TableMapping.ts
|
|
514
|
+
var TableMapping = class {
|
|
515
|
+
static mapping = {};
|
|
516
|
+
static set(mapping) {
|
|
517
|
+
this.mapping = { ...mapping };
|
|
518
|
+
}
|
|
519
|
+
static resolve(declaredName) {
|
|
520
|
+
return this.mapping[declaredName] ?? declaredName;
|
|
521
|
+
}
|
|
522
|
+
/** @internal — for testing only */
|
|
523
|
+
static reset() {
|
|
524
|
+
this.mapping = {};
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// src/client/lib-dynamodb.ts
|
|
529
|
+
var cached = null;
|
|
530
|
+
async function loadLibDynamoDB() {
|
|
531
|
+
return cached ??= await import("@aws-sdk/lib-dynamodb");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/operations/batch-retry.ts
|
|
535
|
+
var BATCH_GET_MAX_KEYS = 100;
|
|
536
|
+
var BATCH_WRITE_MAX_ITEMS = 25;
|
|
537
|
+
var BATCH_MAX_RETRY_ATTEMPTS = 10;
|
|
538
|
+
function sleep(ms) {
|
|
539
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
540
|
+
}
|
|
541
|
+
function computeBackoffDelay(attempt) {
|
|
542
|
+
return Math.min(1e3, 50 * 2 ** (attempt - 1));
|
|
543
|
+
}
|
|
544
|
+
function chunkArray(items, size) {
|
|
545
|
+
const chunks = [];
|
|
546
|
+
for (let i = 0; i < items.length; i += size) {
|
|
547
|
+
chunks.push(items.slice(i, i + size));
|
|
548
|
+
}
|
|
549
|
+
return chunks;
|
|
550
|
+
}
|
|
551
|
+
async function batchGetChunkWithRetry(docClient, tableName, request) {
|
|
552
|
+
const { BatchGetCommand } = await loadLibDynamoDB();
|
|
553
|
+
let pendingKeys = request.Keys;
|
|
554
|
+
let attempt = 0;
|
|
555
|
+
const items = [];
|
|
556
|
+
while (pendingKeys.length > 0) {
|
|
557
|
+
const cmd = new BatchGetCommand({
|
|
558
|
+
RequestItems: {
|
|
559
|
+
[tableName]: {
|
|
560
|
+
...request,
|
|
561
|
+
Keys: pendingKeys
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
const result = await docClient.send(cmd);
|
|
566
|
+
const response = result.Responses?.[tableName] ?? [];
|
|
567
|
+
items.push(...response);
|
|
568
|
+
const unprocessed = result.UnprocessedKeys?.[tableName]?.Keys ?? [];
|
|
569
|
+
if (unprocessed.length === 0) {
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
if (attempt >= BATCH_MAX_RETRY_ATTEMPTS) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
`BatchGet exceeded the maximum of ${BATCH_MAX_RETRY_ATTEMPTS} retry attempts with ${unprocessed.length} key(s) still unprocessed for table "${tableName}". DynamoDB kept returning UnprocessedKeys (likely sustained throttling).`
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
pendingKeys = unprocessed;
|
|
578
|
+
attempt += 1;
|
|
579
|
+
await sleep(computeBackoffDelay(attempt));
|
|
580
|
+
}
|
|
581
|
+
return items;
|
|
582
|
+
}
|
|
583
|
+
async function batchWriteChunkWithRetry(docClient, tableName, entries, toCommandItem, matchUnprocessed2) {
|
|
584
|
+
const { BatchWriteCommand } = await loadLibDynamoDB();
|
|
585
|
+
let pendingEntries = entries;
|
|
586
|
+
let attempt = 0;
|
|
587
|
+
while (pendingEntries.length > 0) {
|
|
588
|
+
const cmd = new BatchWriteCommand({
|
|
589
|
+
RequestItems: {
|
|
590
|
+
[tableName]: pendingEntries.map(toCommandItem)
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
const result = await docClient.send(cmd);
|
|
594
|
+
const unprocessed = result.UnprocessedItems?.[tableName] ?? [];
|
|
595
|
+
if (unprocessed.length === 0) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (attempt >= BATCH_MAX_RETRY_ATTEMPTS) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`BatchWrite exceeded the maximum of ${BATCH_MAX_RETRY_ATTEMPTS} retry attempts with ${unprocessed.length} item(s) still unprocessed for table "${tableName}". DynamoDB kept returning UnprocessedItems (likely sustained throttling).`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
pendingEntries = matchUnprocessed2(unprocessed, pendingEntries);
|
|
604
|
+
attempt += 1;
|
|
605
|
+
await sleep(computeBackoffDelay(attempt));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/client/ClientManager.ts
|
|
610
|
+
var ClientManager = class {
|
|
611
|
+
static client = null;
|
|
612
|
+
static documentClient = null;
|
|
613
|
+
/**
|
|
614
|
+
* The active I/O executor (issue #76). `null` ⇒ use the lazily-constructed
|
|
615
|
+
* default {@link DynamoExecutor}. Tests / the memory adapter replace it via
|
|
616
|
+
* {@link setExecutor}. Constructed lazily (and imported lazily) so importing
|
|
617
|
+
* GraphDDB for planning / linting / codegen never pulls in the AWS SDK.
|
|
618
|
+
*/
|
|
619
|
+
static executor = null;
|
|
620
|
+
static defaultExecutor = null;
|
|
621
|
+
static setClient(client) {
|
|
622
|
+
this.client = client;
|
|
623
|
+
this.documentClient = null;
|
|
624
|
+
}
|
|
625
|
+
static getClient() {
|
|
626
|
+
if (!this.client) {
|
|
627
|
+
throw new Error(
|
|
628
|
+
"DynamoDB client is not configured. Call DDBModel.setClient(new DynamoDBClient({...})) first."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
return this.client;
|
|
632
|
+
}
|
|
633
|
+
static async getDocumentClient() {
|
|
634
|
+
if (!this.documentClient) {
|
|
635
|
+
const client = this.getClient();
|
|
636
|
+
const { DynamoDBDocumentClient: DocClient } = await import("@aws-sdk/lib-dynamodb");
|
|
637
|
+
this.documentClient = DocClient.from(client);
|
|
638
|
+
}
|
|
639
|
+
return this.documentClient;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* The active I/O executor (issue #76). All read/write I/O flows through this
|
|
643
|
+
* seam. Defaults to a {@link DynamoExecutor} (current real-DynamoDB
|
|
644
|
+
* behavior); tests inject a `MemoryExecutor` via {@link setExecutor}.
|
|
645
|
+
*
|
|
646
|
+
* The default is constructed lazily and imported lazily so importing
|
|
647
|
+
* GraphDDB for planning / linting / codegen does not eagerly load the
|
|
648
|
+
* executor module graph.
|
|
649
|
+
*/
|
|
650
|
+
static getExecutor() {
|
|
651
|
+
if (this.executor) {
|
|
652
|
+
return this.executor;
|
|
653
|
+
}
|
|
654
|
+
if (!this.defaultExecutor) {
|
|
655
|
+
this.defaultExecutor = new DynamoExecutor();
|
|
656
|
+
}
|
|
657
|
+
return this.defaultExecutor;
|
|
658
|
+
}
|
|
659
|
+
/** Inject a custom executor (test adapter / memory engine). */
|
|
660
|
+
static setExecutor(executor) {
|
|
661
|
+
this.executor = executor;
|
|
662
|
+
}
|
|
663
|
+
/** Restore the default {@link DynamoExecutor}. */
|
|
664
|
+
static resetExecutor() {
|
|
665
|
+
this.executor = null;
|
|
666
|
+
}
|
|
667
|
+
/** @internal — for testing only */
|
|
668
|
+
static reset() {
|
|
669
|
+
this.client = null;
|
|
670
|
+
this.documentClient = null;
|
|
671
|
+
this.executor = null;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// src/executor/batch-executor.ts
|
|
676
|
+
async function executeBatchGet(docClient, operation) {
|
|
677
|
+
if (operation.keys.length === 0) {
|
|
678
|
+
return { items: [] };
|
|
679
|
+
}
|
|
680
|
+
const chunks = chunkArray(operation.keys, BATCH_GET_MAX_KEYS);
|
|
681
|
+
const chunkResults = await Promise.all(
|
|
682
|
+
chunks.map(
|
|
683
|
+
(chunk) => batchGetChunkWithRetry(docClient, operation.tableName, {
|
|
684
|
+
Keys: chunk,
|
|
685
|
+
...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
|
|
686
|
+
...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
|
|
687
|
+
...operation.consistentRead ? { ConsistentRead: true } : {}
|
|
688
|
+
})
|
|
689
|
+
)
|
|
690
|
+
);
|
|
691
|
+
const allItems = [];
|
|
692
|
+
for (const chunkItems of chunkResults) {
|
|
693
|
+
allItems.push(...chunkItems);
|
|
694
|
+
}
|
|
695
|
+
return { items: allItems };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/executor/dynamo-executor.ts
|
|
699
|
+
var DynamoExecutor = class {
|
|
700
|
+
async execute(operation) {
|
|
701
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
702
|
+
if (operation.type === "GetItem") {
|
|
703
|
+
return executeGetItem(docClient, operation);
|
|
704
|
+
}
|
|
705
|
+
if (operation.type === "BatchGetItem") {
|
|
706
|
+
return this.batchGet(operation);
|
|
707
|
+
}
|
|
708
|
+
return executeQuery(docClient, operation);
|
|
709
|
+
}
|
|
710
|
+
async batchGet(input) {
|
|
711
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
712
|
+
return executeBatchGet(docClient, { type: "BatchGetItem", ...input });
|
|
713
|
+
}
|
|
714
|
+
async put(input, options) {
|
|
715
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
716
|
+
const { PutCommand } = await loadLibDynamoDB();
|
|
717
|
+
const result = await docClient.send(
|
|
718
|
+
new PutCommand(
|
|
719
|
+
options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
|
|
720
|
+
)
|
|
721
|
+
);
|
|
722
|
+
return toWriteResult(result);
|
|
723
|
+
}
|
|
724
|
+
async update(input, options) {
|
|
725
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
726
|
+
const { UpdateCommand } = await loadLibDynamoDB();
|
|
727
|
+
const result = await docClient.send(
|
|
728
|
+
new UpdateCommand(
|
|
729
|
+
options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
|
|
730
|
+
)
|
|
731
|
+
);
|
|
732
|
+
return toWriteResult(result);
|
|
733
|
+
}
|
|
734
|
+
async delete(input, options) {
|
|
735
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
736
|
+
const { DeleteCommand } = await loadLibDynamoDB();
|
|
737
|
+
const result = await docClient.send(
|
|
738
|
+
new DeleteCommand(
|
|
739
|
+
options?.returnOldImage ? { ...input, ReturnValues: "ALL_OLD" } : input
|
|
740
|
+
)
|
|
741
|
+
);
|
|
742
|
+
return toWriteResult(result);
|
|
743
|
+
}
|
|
744
|
+
async batchWrite(tableName, items) {
|
|
745
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
746
|
+
for (const chunk of chunkArray(items, BATCH_WRITE_MAX_ITEMS)) {
|
|
747
|
+
await batchWriteChunkWithRetry(
|
|
748
|
+
docClient,
|
|
749
|
+
tableName,
|
|
750
|
+
chunk,
|
|
751
|
+
toBatchWriteCommandItem,
|
|
752
|
+
matchUnprocessed
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
async transactWrite(items) {
|
|
757
|
+
if (items.length === 0) return;
|
|
758
|
+
const docClient = await ClientManager.getDocumentClient();
|
|
759
|
+
const { TransactWriteCommand } = await loadLibDynamoDB();
|
|
760
|
+
await docClient.send(
|
|
761
|
+
new TransactWriteCommand({ TransactItems: items })
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
function toWriteResult(result) {
|
|
766
|
+
const oldItem = result.Attributes;
|
|
767
|
+
return oldItem ? { oldItem } : {};
|
|
768
|
+
}
|
|
769
|
+
function toBatchWriteCommandItem(item) {
|
|
770
|
+
if (item.type === "put") {
|
|
771
|
+
return { PutRequest: { Item: item.item } };
|
|
772
|
+
}
|
|
773
|
+
return { DeleteRequest: { Key: item.key } };
|
|
774
|
+
}
|
|
775
|
+
function matchUnprocessed(unprocessed, items) {
|
|
776
|
+
return unprocessed.map((u) => {
|
|
777
|
+
if (u.PutRequest?.Item) {
|
|
778
|
+
const { PK, SK } = u.PutRequest.Item;
|
|
779
|
+
const match = items.find(
|
|
780
|
+
(it) => it.type === "put" && it.item.PK === PK && it.item.SK === SK
|
|
781
|
+
);
|
|
782
|
+
if (!match) {
|
|
783
|
+
throw new Error("Unable to match unprocessed BatchWrite put request");
|
|
784
|
+
}
|
|
785
|
+
return match;
|
|
786
|
+
}
|
|
787
|
+
if (u.DeleteRequest?.Key) {
|
|
788
|
+
const { PK, SK } = u.DeleteRequest.Key;
|
|
789
|
+
const match = items.find(
|
|
790
|
+
(it) => it.type === "delete" && it.key.PK === PK && it.key.SK === SK
|
|
791
|
+
);
|
|
792
|
+
if (!match) {
|
|
793
|
+
throw new Error("Unable to match unprocessed BatchWrite delete request");
|
|
794
|
+
}
|
|
795
|
+
return match;
|
|
796
|
+
}
|
|
797
|
+
throw new Error("Unsupported unprocessed BatchWrite request shape");
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
async function executeGetItem(docClient, operation) {
|
|
801
|
+
const { GetCommand } = await loadLibDynamoDB();
|
|
802
|
+
const cmd = new GetCommand({
|
|
803
|
+
TableName: operation.tableName,
|
|
804
|
+
Key: operation.keyCondition,
|
|
805
|
+
...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
|
|
806
|
+
...operation.expressionAttributeNames ? { ExpressionAttributeNames: operation.expressionAttributeNames } : {},
|
|
807
|
+
...operation.consistentRead ? { ConsistentRead: true } : {}
|
|
808
|
+
});
|
|
809
|
+
const result = await docClient.send(cmd);
|
|
810
|
+
if (result.Item) {
|
|
811
|
+
return { items: [result.Item] };
|
|
812
|
+
}
|
|
813
|
+
return { items: [] };
|
|
814
|
+
}
|
|
815
|
+
async function executeQuery(docClient, operation) {
|
|
816
|
+
const { QueryCommand } = await loadLibDynamoDB();
|
|
817
|
+
const keyEntries = Object.entries(operation.keyCondition);
|
|
818
|
+
const conditionParts = [];
|
|
819
|
+
const exprAttrNames = {
|
|
820
|
+
...operation.expressionAttributeNames ?? {},
|
|
821
|
+
...operation.filterExpressionAttributeNames ?? {}
|
|
822
|
+
};
|
|
823
|
+
const exprAttrValues = {
|
|
824
|
+
...operation.filterExpressionAttributeValues ?? {}
|
|
825
|
+
};
|
|
826
|
+
for (let i = 0; i < keyEntries.length; i++) {
|
|
827
|
+
const [attrName, value] = keyEntries[i];
|
|
828
|
+
const nameAlias = `#k${i}`;
|
|
829
|
+
const valueAlias = `:k${i}`;
|
|
830
|
+
exprAttrNames[nameAlias] = attrName;
|
|
831
|
+
exprAttrValues[valueAlias] = value;
|
|
832
|
+
conditionParts.push(`${nameAlias} = ${valueAlias}`);
|
|
833
|
+
}
|
|
834
|
+
if (operation.rangeCondition) {
|
|
835
|
+
const rc = operation.rangeCondition;
|
|
836
|
+
const idx = keyEntries.length;
|
|
837
|
+
const nameAlias = `#k${idx}`;
|
|
838
|
+
const valueAlias = `:k${idx}`;
|
|
839
|
+
exprAttrNames[nameAlias] = rc.key;
|
|
840
|
+
exprAttrValues[valueAlias] = rc.value;
|
|
841
|
+
conditionParts.push(`begins_with(${nameAlias}, ${valueAlias})`);
|
|
842
|
+
}
|
|
843
|
+
const cmd = new QueryCommand({
|
|
844
|
+
TableName: operation.tableName,
|
|
845
|
+
KeyConditionExpression: conditionParts.join(" AND "),
|
|
846
|
+
ExpressionAttributeNames: exprAttrNames,
|
|
847
|
+
ExpressionAttributeValues: exprAttrValues,
|
|
848
|
+
...operation.indexName ? { IndexName: operation.indexName } : {},
|
|
849
|
+
...operation.filterExpression ? { FilterExpression: operation.filterExpression } : {},
|
|
850
|
+
...operation.projectionExpression ? { ProjectionExpression: operation.projectionExpression } : {},
|
|
851
|
+
...operation.scanIndexForward != null ? { ScanIndexForward: operation.scanIndexForward } : {},
|
|
852
|
+
...operation.limit != null ? { Limit: operation.limit } : {},
|
|
853
|
+
...operation.exclusiveStartKey ? { ExclusiveStartKey: operation.exclusiveStartKey } : {},
|
|
854
|
+
...operation.consistentRead ? { ConsistentRead: true } : {}
|
|
855
|
+
});
|
|
856
|
+
const result = await docClient.send(cmd);
|
|
857
|
+
return {
|
|
858
|
+
items: result.Items ?? [],
|
|
859
|
+
lastEvaluatedKey: result.LastEvaluatedKey
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// src/expression/condition-expression.ts
|
|
864
|
+
function buildConditionExpression(condition) {
|
|
865
|
+
if (condition.notExists === true) {
|
|
866
|
+
return {
|
|
867
|
+
expression: "attribute_not_exists(PK)",
|
|
868
|
+
names: {},
|
|
869
|
+
values: {}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
if (typeof condition.attributeExists === "string") {
|
|
873
|
+
const field = condition.attributeExists;
|
|
874
|
+
const nameKey = `#cond_${field}`;
|
|
875
|
+
return {
|
|
876
|
+
expression: `attribute_exists(${nameKey})`,
|
|
877
|
+
names: { [nameKey]: field },
|
|
878
|
+
values: {}
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
if (typeof condition.attributeNotExists === "string") {
|
|
882
|
+
const field = condition.attributeNotExists;
|
|
883
|
+
const nameKey = `#cond_${field}`;
|
|
884
|
+
return {
|
|
885
|
+
expression: `attribute_not_exists(${nameKey})`,
|
|
886
|
+
names: { [nameKey]: field },
|
|
887
|
+
values: {}
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const clauses = [];
|
|
891
|
+
const names = {};
|
|
892
|
+
const values = {};
|
|
893
|
+
let counter = 0;
|
|
894
|
+
for (const [fieldName, value] of Object.entries(condition)) {
|
|
895
|
+
const nameKey = `#cond_${fieldName}`;
|
|
896
|
+
const valueKey = `:cond${counter++}`;
|
|
897
|
+
names[nameKey] = fieldName;
|
|
898
|
+
values[valueKey] = value;
|
|
899
|
+
clauses.push(`${nameKey} = ${valueKey}`);
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
expression: clauses.join(" AND "),
|
|
903
|
+
names,
|
|
904
|
+
values
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/operations/serialize.ts
|
|
909
|
+
function serializeFieldValue(value, fieldMeta) {
|
|
910
|
+
if (fieldMeta.options?.serialize) {
|
|
911
|
+
return fieldMeta.options.serialize(value);
|
|
912
|
+
}
|
|
913
|
+
if (value instanceof Date) {
|
|
914
|
+
if (fieldMeta.options?.format === "date") {
|
|
915
|
+
return value.toISOString().split("T")[0];
|
|
916
|
+
}
|
|
917
|
+
if (fieldMeta.options?.format === "datetime") {
|
|
918
|
+
return value.toISOString();
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return value;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// src/metadata/segments.ts
|
|
925
|
+
var SEGMENT_DELIMITER = "#";
|
|
926
|
+
function segmentComplete(segment, values) {
|
|
927
|
+
return segment.columns.every((col) => {
|
|
928
|
+
const v = values[col.name];
|
|
929
|
+
return v !== void 0 && v !== null;
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
function renderSegment(segment, values) {
|
|
933
|
+
let out = segment.literals[0];
|
|
934
|
+
for (let i = 0; i < segment.columns.length; i++) {
|
|
935
|
+
out += String(values[segment.columns[i].name]);
|
|
936
|
+
out += segment.literals[i + 1];
|
|
937
|
+
}
|
|
938
|
+
return out;
|
|
939
|
+
}
|
|
940
|
+
function segmentTemplate(segment, scope = "param", nameOf = (f) => f) {
|
|
941
|
+
let out = segment.literals[0];
|
|
942
|
+
for (let i = 0; i < segment.columns.length; i++) {
|
|
943
|
+
const name = nameOf(segment.columns[i].name);
|
|
944
|
+
out += scope === "result" ? `{result.${name}}` : `{${name}}`;
|
|
945
|
+
out += segment.literals[i + 1];
|
|
946
|
+
}
|
|
947
|
+
return out;
|
|
948
|
+
}
|
|
949
|
+
function pkTemplate(segmented, scope = "param", nameOf = (f) => f) {
|
|
950
|
+
return segmented.pkSegments.map((s) => segmentTemplate(s, scope, nameOf)).join(SEGMENT_DELIMITER);
|
|
951
|
+
}
|
|
952
|
+
function walkSkSegments(segments, isPresent, context) {
|
|
953
|
+
if (segments.length === 0) return null;
|
|
954
|
+
let consumed = 0;
|
|
955
|
+
let truncatedAt = -1;
|
|
956
|
+
let boundaryLiteral = "";
|
|
957
|
+
for (let i = 0; i < segments.length; i++) {
|
|
958
|
+
const seg = segments[i];
|
|
959
|
+
const fields = seg.columns.map((c) => c.name);
|
|
960
|
+
const all = fields.every((f) => isPresent(f));
|
|
961
|
+
const any = fields.some((f) => isPresent(f));
|
|
962
|
+
if (fields.length === 0) {
|
|
963
|
+
if (truncatedAt < 0) consumed = i + 1;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (all) {
|
|
967
|
+
if (truncatedAt >= 0) {
|
|
968
|
+
throw new Error(
|
|
969
|
+
`Non-contiguous ${context}: segment #${i} is supplied but the earlier segment #${truncatedAt} is missing. Segments must be a contiguous prefix.`
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
consumed = i + 1;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (any) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`Non-contiguous ${context}: segment #${i} has some but not all of its fields supplied. Segments must be a contiguous prefix.`
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
if (truncatedAt < 0) {
|
|
981
|
+
truncatedAt = i;
|
|
982
|
+
boundaryLiteral = seg.literals[0] ?? "";
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
const truncated = truncatedAt >= 0;
|
|
986
|
+
if (!truncated) {
|
|
987
|
+
return { consumed, truncated: false, boundaryLiteral: "" };
|
|
988
|
+
}
|
|
989
|
+
if (consumed === 0 && boundaryLiteral === "") return null;
|
|
990
|
+
return { consumed, truncated: true, boundaryLiteral };
|
|
991
|
+
}
|
|
992
|
+
function skTemplate(segmented, presentFields, scope = "param", nameOf = (f) => f, context = "sort key") {
|
|
993
|
+
const segments = segmented.skSegments;
|
|
994
|
+
const isPresent = (f) => presentFields === void 0 || presentFields.has(f);
|
|
995
|
+
const walk = walkSkSegments(segments, isPresent, context);
|
|
996
|
+
if (!walk) return void 0;
|
|
997
|
+
const consumedPart = segments.slice(0, walk.consumed).map((s) => segmentTemplate(s, scope, nameOf)).join(SEGMENT_DELIMITER);
|
|
998
|
+
if (!walk.truncated) {
|
|
999
|
+
return { template: consumedPart, truncated: false };
|
|
1000
|
+
}
|
|
1001
|
+
return {
|
|
1002
|
+
template: composeTruncated(consumedPart, walk.boundaryLiteral),
|
|
1003
|
+
truncated: true
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function composeTruncated(consumedPart, boundaryLiteral) {
|
|
1007
|
+
let out = consumedPart;
|
|
1008
|
+
if (out !== "") out += SEGMENT_DELIMITER;
|
|
1009
|
+
out += boundaryLiteral;
|
|
1010
|
+
if (!out.endsWith(SEGMENT_DELIMITER)) out += SEGMENT_DELIMITER;
|
|
1011
|
+
return out;
|
|
1012
|
+
}
|
|
1013
|
+
function resolveSegmentedKey(segmented, values, context = "key") {
|
|
1014
|
+
for (const seg of segmented.pkSegments) {
|
|
1015
|
+
if (!segmentComplete(seg, values)) {
|
|
1016
|
+
const missing = seg.columns.filter((c) => values[c.name] === void 0 || values[c.name] === null).map((c) => `'${c.name}'`).join(", ");
|
|
1017
|
+
throw new Error(
|
|
1018
|
+
`Cannot resolve ${context}: partition-key field(s) ${missing} are missing. Every partition-key segment must be supplied.`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
const pk = segmented.pkSegments.map((s) => renderSegment(s, values)).join(SEGMENT_DELIMITER);
|
|
1023
|
+
const isPresent = (f) => {
|
|
1024
|
+
const v = values[f];
|
|
1025
|
+
return v !== void 0 && v !== null;
|
|
1026
|
+
};
|
|
1027
|
+
const walk = walkSkSegments(segmented.skSegments, isPresent, `${context} (sort key)`);
|
|
1028
|
+
if (!walk) return { pk, sk: void 0, skTruncated: false };
|
|
1029
|
+
const consumedPart = segmented.skSegments.slice(0, walk.consumed).map((s) => renderSegment(s, values)).join(SEGMENT_DELIMITER);
|
|
1030
|
+
if (!walk.truncated) {
|
|
1031
|
+
return { pk, sk: consumedPart, skTruncated: false };
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
pk,
|
|
1035
|
+
sk: composeTruncated(consumedPart, walk.boundaryLiteral),
|
|
1036
|
+
skTruncated: true
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// src/capture/registry.ts
|
|
1041
|
+
var ChangeCaptureRegistryImpl = class {
|
|
1042
|
+
subscribers = /* @__PURE__ */ new Set();
|
|
1043
|
+
/**
|
|
1044
|
+
* Stream-enable opt-in, keyed by the model class. A model is only eligible to
|
|
1045
|
+
* have its pre-write image fetched (`ReturnValues: ALL_OLD`) when it has been
|
|
1046
|
+
* explicitly stream-enabled here. Default is disabled.
|
|
1047
|
+
*/
|
|
1048
|
+
streamEnabled = /* @__PURE__ */ new Set();
|
|
1049
|
+
/** Register an internal subscriber. Returns an idempotent unsubscribe. */
|
|
1050
|
+
register(subscriber) {
|
|
1051
|
+
this.subscribers.add(subscriber);
|
|
1052
|
+
let active = true;
|
|
1053
|
+
return () => {
|
|
1054
|
+
if (!active) return;
|
|
1055
|
+
active = false;
|
|
1056
|
+
this.subscribers.delete(subscriber);
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
/** True when at least one subscriber is live. Cheap fast-path gate. */
|
|
1060
|
+
hasSubscribers() {
|
|
1061
|
+
return this.subscribers.size > 0;
|
|
1062
|
+
}
|
|
1063
|
+
/** Mark a model class stream-enabled (opt-in). */
|
|
1064
|
+
enableStream(modelClass) {
|
|
1065
|
+
this.streamEnabled.add(modelClass);
|
|
1066
|
+
}
|
|
1067
|
+
/** Disable streaming for a model class. */
|
|
1068
|
+
disableStream(modelClass) {
|
|
1069
|
+
this.streamEnabled.delete(modelClass);
|
|
1070
|
+
}
|
|
1071
|
+
/** True when the model class has been stream-enabled. */
|
|
1072
|
+
isStreamEnabled(modelClass) {
|
|
1073
|
+
return this.streamEnabled.has(modelClass);
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Whether the originating write should request a pre-write image
|
|
1077
|
+
* (`ReturnValues: ALL_OLD`). Per spec §5 this is added **only when** the model
|
|
1078
|
+
* is stream-enabled AND a subscriber is live, so the OldImage cost is never
|
|
1079
|
+
* paid when nobody is listening.
|
|
1080
|
+
*/
|
|
1081
|
+
wantsOldImage(modelClass) {
|
|
1082
|
+
return this.hasSubscribers() && this.streamEnabled.has(modelClass);
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Deliver one raw write to every subscriber. Each subscriber is isolated:
|
|
1086
|
+
* a throw is swallowed so it neither fails the originating write nor blocks
|
|
1087
|
+
* other subscribers (spec §14 default).
|
|
1088
|
+
*/
|
|
1089
|
+
emit(record) {
|
|
1090
|
+
if (this.subscribers.size === 0) return;
|
|
1091
|
+
for (const subscriber of [...this.subscribers]) {
|
|
1092
|
+
try {
|
|
1093
|
+
subscriber(record);
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
/** @internal — test-only full reset. */
|
|
1099
|
+
reset() {
|
|
1100
|
+
this.subscribers.clear();
|
|
1101
|
+
this.streamEnabled.clear();
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
var ChangeCaptureRegistry = new ChangeCaptureRegistryImpl();
|
|
1105
|
+
function captureWrite(record) {
|
|
1106
|
+
ChangeCaptureRegistry.emit(record);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// src/operations/put.ts
|
|
1110
|
+
function buildPutInput(modelClass, item, options) {
|
|
1111
|
+
const meta = MetadataRegistry.get(modelClass);
|
|
1112
|
+
if (!meta.primaryKey) {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
`No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
const tableName = TableMapping.resolve(meta.tableName);
|
|
1118
|
+
const serialized = {};
|
|
1119
|
+
for (const fieldMeta of meta.fields) {
|
|
1120
|
+
const value = item[fieldMeta.propertyName];
|
|
1121
|
+
if (value !== void 0) {
|
|
1122
|
+
serialized[fieldMeta.propertyName] = serializeFieldValue(value, fieldMeta);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const keyInput = {};
|
|
1126
|
+
for (const fieldName of meta.primaryKey.inputFieldNames) {
|
|
1127
|
+
keyInput[fieldName] = serialized[fieldName] ?? item[fieldName];
|
|
1128
|
+
}
|
|
1129
|
+
const { pk, sk } = resolveSegmentedKey(
|
|
1130
|
+
meta.primaryKey.segmented,
|
|
1131
|
+
keyInput,
|
|
1132
|
+
`${modelClass.name} primary key`
|
|
1133
|
+
);
|
|
1134
|
+
const dynamoItem = {
|
|
1135
|
+
PK: pk,
|
|
1136
|
+
SK: sk ?? "",
|
|
1137
|
+
...serialized
|
|
1138
|
+
};
|
|
1139
|
+
for (const emb of meta.embeddedFields) {
|
|
1140
|
+
const value = item[emb.propertyName];
|
|
1141
|
+
if (value !== void 0) {
|
|
1142
|
+
dynamoItem[emb.propertyName] = value;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
for (const gsi2 of meta.gsiDefinitions) {
|
|
1146
|
+
const gsiInput = {};
|
|
1147
|
+
for (const fieldName of gsi2.inputFieldNames) {
|
|
1148
|
+
gsiInput[fieldName] = serialized[fieldName] ?? item[fieldName];
|
|
1149
|
+
}
|
|
1150
|
+
const { pk: gsiPk, sk: gsiSk } = resolveSegmentedKey(
|
|
1151
|
+
gsi2.segmented,
|
|
1152
|
+
gsiInput,
|
|
1153
|
+
`${modelClass.name} GSI '${gsi2.indexName}'`
|
|
1154
|
+
);
|
|
1155
|
+
dynamoItem[`${gsi2.indexName}PK`] = gsiPk;
|
|
1156
|
+
dynamoItem[`${gsi2.indexName}SK`] = gsiSk ?? "";
|
|
1157
|
+
}
|
|
1158
|
+
const putInput = {
|
|
1159
|
+
TableName: tableName,
|
|
1160
|
+
Item: dynamoItem
|
|
1161
|
+
};
|
|
1162
|
+
if (options?.condition) {
|
|
1163
|
+
const condResult = buildConditionExpression(options.condition);
|
|
1164
|
+
putInput.ConditionExpression = condResult.expression;
|
|
1165
|
+
if (Object.keys(condResult.names).length > 0) {
|
|
1166
|
+
putInput.ExpressionAttributeNames = condResult.names;
|
|
1167
|
+
}
|
|
1168
|
+
if (Object.keys(condResult.values).length > 0) {
|
|
1169
|
+
putInput.ExpressionAttributeValues = condResult.values;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return putInput;
|
|
1173
|
+
}
|
|
1174
|
+
async function executePut(modelClass, item, options) {
|
|
1175
|
+
const putInput = buildPutInput(modelClass, item, options);
|
|
1176
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
1177
|
+
const result = await ClientManager.getExecutor().put(
|
|
1178
|
+
putInput,
|
|
1179
|
+
wantsOld ? { returnOldImage: true } : void 0
|
|
1180
|
+
);
|
|
1181
|
+
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
1182
|
+
const oldItem = result.oldItem;
|
|
1183
|
+
captureWrite({
|
|
1184
|
+
table: putInput.TableName,
|
|
1185
|
+
model: modelClass.name,
|
|
1186
|
+
op: "put",
|
|
1187
|
+
keys: { pk: String(putInput.Item.PK), sk: String(putInput.Item.SK ?? "") },
|
|
1188
|
+
newItem: putInput.Item,
|
|
1189
|
+
...oldItem ? { oldItem } : {}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// src/expression/update-expression.ts
|
|
1195
|
+
function isPlainObject(value) {
|
|
1196
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
|
|
1197
|
+
}
|
|
1198
|
+
function flattenChanges(obj, parentPath, embeddedFieldNames) {
|
|
1199
|
+
const sets = [];
|
|
1200
|
+
const removes = [];
|
|
1201
|
+
for (const [fieldName, value] of Object.entries(obj)) {
|
|
1202
|
+
const currentPath = [...parentPath, fieldName];
|
|
1203
|
+
if (value === void 0) {
|
|
1204
|
+
removes.push({ path: currentPath });
|
|
1205
|
+
} else if (embeddedFieldNames.has(currentPath[0]) && isPlainObject(value)) {
|
|
1206
|
+
const nested = flattenChanges(value, currentPath, embeddedFieldNames);
|
|
1207
|
+
sets.push(...nested.sets);
|
|
1208
|
+
removes.push(...nested.removes);
|
|
1209
|
+
} else {
|
|
1210
|
+
sets.push({ path: currentPath, value });
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return { sets, removes };
|
|
1214
|
+
}
|
|
1215
|
+
function buildUpdateExpression(changes, embeddedFieldNames) {
|
|
1216
|
+
const { sets, removes } = flattenChanges(changes, [], embeddedFieldNames);
|
|
1217
|
+
if (sets.length === 0 && removes.length === 0) {
|
|
1218
|
+
throw new Error("No changes provided for update.");
|
|
1219
|
+
}
|
|
1220
|
+
const names = {};
|
|
1221
|
+
const values = {};
|
|
1222
|
+
let valueCounter = 0;
|
|
1223
|
+
const setClauses = [];
|
|
1224
|
+
for (const op of sets) {
|
|
1225
|
+
const pathExpr = op.path.map((segment) => {
|
|
1226
|
+
const nameKey = `#${segment}`;
|
|
1227
|
+
names[nameKey] = segment;
|
|
1228
|
+
return nameKey;
|
|
1229
|
+
}).join(".");
|
|
1230
|
+
const valueKey = `:val${valueCounter++}`;
|
|
1231
|
+
values[valueKey] = op.value;
|
|
1232
|
+
setClauses.push(`${pathExpr} = ${valueKey}`);
|
|
1233
|
+
}
|
|
1234
|
+
const removeClauses = [];
|
|
1235
|
+
for (const op of removes) {
|
|
1236
|
+
const pathExpr = op.path.map((segment) => {
|
|
1237
|
+
const nameKey = `#${segment}`;
|
|
1238
|
+
names[nameKey] = segment;
|
|
1239
|
+
return nameKey;
|
|
1240
|
+
}).join(".");
|
|
1241
|
+
removeClauses.push(pathExpr);
|
|
1242
|
+
}
|
|
1243
|
+
const parts = [];
|
|
1244
|
+
if (setClauses.length > 0) {
|
|
1245
|
+
parts.push(`SET ${setClauses.join(", ")}`);
|
|
1246
|
+
}
|
|
1247
|
+
if (removeClauses.length > 0) {
|
|
1248
|
+
parts.push(`REMOVE ${removeClauses.join(", ")}`);
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
expression: parts.join(" "),
|
|
1252
|
+
names,
|
|
1253
|
+
values
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/hydrator/hidden-key.ts
|
|
1258
|
+
var GRAPHDDB_KEY = /* @__PURE__ */ Symbol("graphddb:key");
|
|
1259
|
+
function attachHiddenKey(result, raw) {
|
|
1260
|
+
const pk = raw.PK;
|
|
1261
|
+
if (typeof pk !== "string") {
|
|
1262
|
+
throw new Error(
|
|
1263
|
+
"Cannot attach updatable key: raw item is missing a string `PK` attribute."
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
const sk = raw.SK;
|
|
1267
|
+
const value = {
|
|
1268
|
+
PK: pk,
|
|
1269
|
+
SK: typeof sk === "string" ? sk : ""
|
|
1270
|
+
};
|
|
1271
|
+
Object.defineProperty(result, GRAPHDDB_KEY, {
|
|
1272
|
+
value,
|
|
1273
|
+
enumerable: false,
|
|
1274
|
+
writable: false,
|
|
1275
|
+
configurable: false
|
|
1276
|
+
});
|
|
1277
|
+
return result;
|
|
1278
|
+
}
|
|
1279
|
+
function readHiddenKey(entity) {
|
|
1280
|
+
const value = entity[GRAPHDDB_KEY];
|
|
1281
|
+
if (value === void 0) return void 0;
|
|
1282
|
+
return value;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/operations/update.ts
|
|
1286
|
+
function resolveUpdateKey(modelClass, primaryKey, entity, fieldMap) {
|
|
1287
|
+
const hidden = readHiddenKey(entity);
|
|
1288
|
+
if (hidden) {
|
|
1289
|
+
return { pk: hidden.PK, sk: hidden.SK };
|
|
1290
|
+
}
|
|
1291
|
+
const missing = primaryKey.inputFieldNames.filter(
|
|
1292
|
+
(f) => entity[f] === void 0
|
|
1293
|
+
);
|
|
1294
|
+
if (missing.length > 0) {
|
|
1295
|
+
const fieldList = missing.join(", ");
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`Cannot resolve update key for '${modelClass.name}': missing key field(s) [${fieldList}]. Provide an explicit key (update({ ${fieldList} }, changes)), select all key fields, or query with { updatable: true }.`
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
const keyInput = {};
|
|
1301
|
+
for (const fieldName of primaryKey.inputFieldNames) {
|
|
1302
|
+
let value = entity[fieldName];
|
|
1303
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
1304
|
+
if (fieldMeta && value !== void 0) {
|
|
1305
|
+
value = serializeFieldValue(value, fieldMeta);
|
|
1306
|
+
}
|
|
1307
|
+
keyInput[fieldName] = value;
|
|
1308
|
+
}
|
|
1309
|
+
const { pk, sk } = resolveSegmentedKey(
|
|
1310
|
+
primaryKey.segmented,
|
|
1311
|
+
keyInput,
|
|
1312
|
+
`${modelClass.name} primary key`
|
|
1313
|
+
);
|
|
1314
|
+
return { pk, sk: sk ?? "" };
|
|
1315
|
+
}
|
|
1316
|
+
function buildUpdateInput(modelClass, entity, changes, options) {
|
|
1317
|
+
const meta = MetadataRegistry.get(modelClass);
|
|
1318
|
+
if (!meta.primaryKey) {
|
|
1319
|
+
throw new Error(
|
|
1320
|
+
`No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
const tableName = TableMapping.resolve(meta.tableName);
|
|
1324
|
+
const fieldMap = new Map(
|
|
1325
|
+
meta.fields.map((f) => [f.propertyName, f])
|
|
1326
|
+
);
|
|
1327
|
+
for (const fieldName of Object.keys(changes)) {
|
|
1328
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
1329
|
+
if (fieldMeta?.options?.readonly) {
|
|
1330
|
+
throw new Error(
|
|
1331
|
+
`Cannot update readonly field '${fieldName}' on '${modelClass.name}'. Readonly fields can only be set during put.`
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
const { pk, sk } = resolveUpdateKey(
|
|
1336
|
+
modelClass,
|
|
1337
|
+
meta.primaryKey,
|
|
1338
|
+
entity,
|
|
1339
|
+
fieldMap
|
|
1340
|
+
);
|
|
1341
|
+
const serializedChanges = {};
|
|
1342
|
+
for (const [fieldName, value] of Object.entries(changes)) {
|
|
1343
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
1344
|
+
if (fieldMeta && value !== void 0) {
|
|
1345
|
+
serializedChanges[fieldName] = serializeFieldValue(value, fieldMeta);
|
|
1346
|
+
} else {
|
|
1347
|
+
serializedChanges[fieldName] = value;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const embeddedFieldNames = new Set(
|
|
1351
|
+
meta.embeddedFields.map((e) => e.propertyName)
|
|
1352
|
+
);
|
|
1353
|
+
const updateResult = buildUpdateExpression(serializedChanges, embeddedFieldNames);
|
|
1354
|
+
const names = { ...updateResult.names };
|
|
1355
|
+
const values = { ...updateResult.values };
|
|
1356
|
+
let conditionExpression;
|
|
1357
|
+
if (options?.condition) {
|
|
1358
|
+
const condResult = buildConditionExpression(options.condition);
|
|
1359
|
+
conditionExpression = condResult.expression;
|
|
1360
|
+
Object.assign(names, condResult.names);
|
|
1361
|
+
Object.assign(values, condResult.values);
|
|
1362
|
+
}
|
|
1363
|
+
const updateInput = {
|
|
1364
|
+
TableName: tableName,
|
|
1365
|
+
Key: {
|
|
1366
|
+
PK: pk,
|
|
1367
|
+
SK: sk
|
|
1368
|
+
},
|
|
1369
|
+
UpdateExpression: updateResult.expression,
|
|
1370
|
+
ExpressionAttributeNames: names
|
|
1371
|
+
};
|
|
1372
|
+
if (Object.keys(values).length > 0) {
|
|
1373
|
+
updateInput.ExpressionAttributeValues = values;
|
|
1374
|
+
}
|
|
1375
|
+
if (conditionExpression) {
|
|
1376
|
+
updateInput.ConditionExpression = conditionExpression;
|
|
1377
|
+
}
|
|
1378
|
+
return updateInput;
|
|
1379
|
+
}
|
|
1380
|
+
function serializeChanges(modelClass, changes) {
|
|
1381
|
+
const meta = MetadataRegistry.get(modelClass);
|
|
1382
|
+
const fieldMap = new Map(meta.fields.map((f) => [f.propertyName, f]));
|
|
1383
|
+
const out = {};
|
|
1384
|
+
for (const [name, value] of Object.entries(changes)) {
|
|
1385
|
+
const fieldMeta = fieldMap.get(name);
|
|
1386
|
+
out[name] = fieldMeta && value !== void 0 ? serializeFieldValue(value, fieldMeta) : value;
|
|
1387
|
+
}
|
|
1388
|
+
return out;
|
|
1389
|
+
}
|
|
1390
|
+
async function executeUpdate(modelClass, entity, changes, options) {
|
|
1391
|
+
const updateInput = buildUpdateInput(modelClass, entity, changes, options);
|
|
1392
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
1393
|
+
const result = await ClientManager.getExecutor().update(
|
|
1394
|
+
updateInput,
|
|
1395
|
+
wantsOld ? { returnOldImage: true } : void 0
|
|
1396
|
+
);
|
|
1397
|
+
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
1398
|
+
const oldItem = result.oldItem;
|
|
1399
|
+
const keys = { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") };
|
|
1400
|
+
const changedAttrs = serializeChanges(modelClass, changes);
|
|
1401
|
+
const newItem = {
|
|
1402
|
+
...oldItem ?? { PK: keys.pk, SK: keys.sk },
|
|
1403
|
+
...changedAttrs
|
|
1404
|
+
};
|
|
1405
|
+
captureWrite({
|
|
1406
|
+
table: updateInput.TableName,
|
|
1407
|
+
model: modelClass.name,
|
|
1408
|
+
op: "update",
|
|
1409
|
+
keys,
|
|
1410
|
+
newItem,
|
|
1411
|
+
...oldItem ? { oldItem } : {}
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// src/operations/delete.ts
|
|
1417
|
+
function buildDeleteInput(modelClass, keyObj, options) {
|
|
1418
|
+
const meta = MetadataRegistry.get(modelClass);
|
|
1419
|
+
if (!meta.primaryKey) {
|
|
1420
|
+
throw new Error(
|
|
1421
|
+
`No primary key defined for '${modelClass.name}'. Ensure the model has a static \`keys = key(...)\` definition.`
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
const tableName = TableMapping.resolve(meta.tableName);
|
|
1425
|
+
const fieldMap = new Map(
|
|
1426
|
+
meta.fields.map((f) => [f.propertyName, f])
|
|
1427
|
+
);
|
|
1428
|
+
const keyInput = {};
|
|
1429
|
+
for (const fieldName of meta.primaryKey.inputFieldNames) {
|
|
1430
|
+
let value = keyObj[fieldName];
|
|
1431
|
+
const fieldMeta = fieldMap.get(fieldName);
|
|
1432
|
+
if (fieldMeta && value !== void 0) {
|
|
1433
|
+
value = serializeFieldValue(value, fieldMeta);
|
|
1434
|
+
}
|
|
1435
|
+
keyInput[fieldName] = value;
|
|
1436
|
+
}
|
|
1437
|
+
const { pk, sk } = resolveSegmentedKey(
|
|
1438
|
+
meta.primaryKey.segmented,
|
|
1439
|
+
keyInput,
|
|
1440
|
+
`${modelClass.name} primary key`
|
|
1441
|
+
);
|
|
1442
|
+
const deleteInput = {
|
|
1443
|
+
TableName: tableName,
|
|
1444
|
+
Key: {
|
|
1445
|
+
PK: pk,
|
|
1446
|
+
SK: sk ?? ""
|
|
1447
|
+
}
|
|
1448
|
+
};
|
|
1449
|
+
if (options?.condition) {
|
|
1450
|
+
const condResult = buildConditionExpression(options.condition);
|
|
1451
|
+
deleteInput.ConditionExpression = condResult.expression;
|
|
1452
|
+
if (Object.keys(condResult.names).length > 0) {
|
|
1453
|
+
deleteInput.ExpressionAttributeNames = condResult.names;
|
|
1454
|
+
}
|
|
1455
|
+
if (Object.keys(condResult.values).length > 0) {
|
|
1456
|
+
deleteInput.ExpressionAttributeValues = condResult.values;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
return deleteInput;
|
|
1460
|
+
}
|
|
1461
|
+
async function executeDelete(modelClass, keyObj, options) {
|
|
1462
|
+
const deleteInput = buildDeleteInput(modelClass, keyObj, options);
|
|
1463
|
+
const wantsOld = ChangeCaptureRegistry.wantsOldImage(modelClass);
|
|
1464
|
+
const result = await ClientManager.getExecutor().delete(
|
|
1465
|
+
deleteInput,
|
|
1466
|
+
wantsOld ? { returnOldImage: true } : void 0
|
|
1467
|
+
);
|
|
1468
|
+
if (ChangeCaptureRegistry.hasSubscribers()) {
|
|
1469
|
+
const oldItem = result.oldItem;
|
|
1470
|
+
captureWrite({
|
|
1471
|
+
table: deleteInput.TableName,
|
|
1472
|
+
model: modelClass.name,
|
|
1473
|
+
op: "delete",
|
|
1474
|
+
keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") },
|
|
1475
|
+
...oldItem ? { oldItem } : {}
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/runtime/same-key-collapse.ts
|
|
1481
|
+
function collapseSameKey(items, view) {
|
|
1482
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1483
|
+
for (const item of items) {
|
|
1484
|
+
const sig = view.keySignature(item);
|
|
1485
|
+
const bucket = groups.get(sig);
|
|
1486
|
+
if (bucket === void 0) groups.set(sig, [item]);
|
|
1487
|
+
else bucket.push(item);
|
|
1488
|
+
}
|
|
1489
|
+
const replacement = /* @__PURE__ */ new Map();
|
|
1490
|
+
const dropped = /* @__PURE__ */ new Set();
|
|
1491
|
+
for (const bucket of groups.values()) {
|
|
1492
|
+
if (bucket.length === 1) continue;
|
|
1493
|
+
const kinds = new Set(bucket.map((it) => view.kind(it)));
|
|
1494
|
+
if (kinds.has("Delete") && kinds.has("Put")) {
|
|
1495
|
+
for (const it of bucket) dropped.add(it);
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
if (kinds.has("Put") && kinds.has("Update")) {
|
|
1499
|
+
throw new Error(
|
|
1500
|
+
`Contract runtime: a derived counter (\`increment\`) resolves to the SAME item as the entity write at runtime (the call-time key values made an aliased target coincide with the written row). A Put+Update on one transaction item is unsupported \u2014 DynamoDB rejects touching one key twice, and collapsing it would silently drop the increment. A \`w.increment(...)\` must target a DIFFERENT row (e.g. a parent aggregate's counter); to maintain a counter ON the written entity, write the field as part of the entity itself, not via a derived counter onto its own row.`
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
if (kinds.size === 1 && kinds.has("Update")) {
|
|
1504
|
+
replacement.set(bucket[0], view.mergeAdds(bucket));
|
|
1505
|
+
for (const it of bucket.slice(1)) dropped.add(it);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (kinds.size === 1 && (kinds.has("Put") || kinds.has("Delete"))) {
|
|
1509
|
+
for (const it of bucket.slice(1)) dropped.add(it);
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
`Contract runtime: ${bucket.length} transaction items resolve to the same physical row with an unsupported op combination (${[...kinds].sort().join("+")}). A TransactWriteItems may not touch one key twice; this collision is not a known collapse shape (self-row Put/Delete de-dup, swap no-op, or symmetric counter ADDs), so it is rejected rather than silently collapsed.`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
const out = [];
|
|
1517
|
+
for (const item of items) {
|
|
1518
|
+
if (dropped.has(item)) continue;
|
|
1519
|
+
out.push(replacement.get(item) ?? item);
|
|
1520
|
+
}
|
|
1521
|
+
return out;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// src/runtime/transaction-commit.ts
|
|
1525
|
+
var MAX_TRANSACT_ITEMS = 25;
|
|
1526
|
+
var execItemCollapseView = {
|
|
1527
|
+
kind: execItemKind,
|
|
1528
|
+
keySignature: execItemKeySignature,
|
|
1529
|
+
mergeAdds: mergeAddUpdates
|
|
1530
|
+
};
|
|
1531
|
+
function collapseSameKeyItems(items) {
|
|
1532
|
+
return collapseSameKey(items, execItemCollapseView);
|
|
1533
|
+
}
|
|
1534
|
+
async function commitTransaction(items, options = {}) {
|
|
1535
|
+
const collapsed = collapseSameKeyItems(items);
|
|
1536
|
+
if (collapsed.length === 0) return collapsed;
|
|
1537
|
+
if (collapsed.length > MAX_TRANSACT_ITEMS) {
|
|
1538
|
+
throw options.limitError ? options.limitError(collapsed.length) : new Error(
|
|
1539
|
+
`Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
await ClientManager.getExecutor().transactWrite(collapsed);
|
|
1543
|
+
captureTransactItems(collapsed, options.modelBySignature ?? EMPTY_MODEL_MAP);
|
|
1544
|
+
return collapsed;
|
|
1545
|
+
}
|
|
1546
|
+
var EMPTY_MODEL_MAP = /* @__PURE__ */ new Map();
|
|
1547
|
+
function captureTransactItems(items, modelBySignature) {
|
|
1548
|
+
if (!ChangeCaptureRegistry.hasSubscribers()) return;
|
|
1549
|
+
for (const item of items) {
|
|
1550
|
+
if ("ConditionCheck" in item) continue;
|
|
1551
|
+
captureWrite(captureMetaForItem(item, modelBySignature));
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
function captureMetaForItem(item, modelBySignature) {
|
|
1555
|
+
const model = modelBySignature.get(execItemKeySignature(item));
|
|
1556
|
+
const labelled = model !== void 0 ? { model } : {};
|
|
1557
|
+
if ("Put" in item) {
|
|
1558
|
+
const Item = item.Put.Item;
|
|
1559
|
+
return {
|
|
1560
|
+
table: item.Put.TableName,
|
|
1561
|
+
...labelled,
|
|
1562
|
+
op: "put",
|
|
1563
|
+
keys: { pk: String(Item.PK), sk: String(Item.SK ?? "") },
|
|
1564
|
+
newItem: Item
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
if ("Update" in item) {
|
|
1568
|
+
return {
|
|
1569
|
+
table: item.Update.TableName,
|
|
1570
|
+
...labelled,
|
|
1571
|
+
op: "update",
|
|
1572
|
+
keys: { pk: String(item.Update.Key.PK), sk: String(item.Update.Key.SK ?? "") }
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
table: item.Delete.TableName,
|
|
1577
|
+
...labelled,
|
|
1578
|
+
op: "delete",
|
|
1579
|
+
keys: { pk: String(item.Delete.Key.PK), sk: String(item.Delete.Key.SK ?? "") }
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function execItemKeySignature(item) {
|
|
1583
|
+
const part = (rec) => `${String(rec?.PK ?? "")}#${String(rec?.SK ?? "")}`;
|
|
1584
|
+
if ("Put" in item) return `${item.Put.TableName}#${part(item.Put.Item)}`;
|
|
1585
|
+
if ("Update" in item) return `${item.Update.TableName}#${part(item.Update.Key)}`;
|
|
1586
|
+
if ("Delete" in item) return `${item.Delete.TableName}#${part(item.Delete.Key)}`;
|
|
1587
|
+
return `${item.ConditionCheck.TableName}#${part(item.ConditionCheck.Key)}`;
|
|
1588
|
+
}
|
|
1589
|
+
function execItemKind(item) {
|
|
1590
|
+
if ("Put" in item) return "Put";
|
|
1591
|
+
if ("Update" in item) return "Update";
|
|
1592
|
+
if ("Delete" in item) return "Delete";
|
|
1593
|
+
return "ConditionCheck";
|
|
1594
|
+
}
|
|
1595
|
+
function mergeAddUpdates(updates) {
|
|
1596
|
+
const first = updates[0];
|
|
1597
|
+
if (!("Update" in first)) {
|
|
1598
|
+
throw new Error("Contract runtime: mergeAddUpdates received a non-Update item.");
|
|
1599
|
+
}
|
|
1600
|
+
const deltas = /* @__PURE__ */ new Map();
|
|
1601
|
+
for (const it of updates) {
|
|
1602
|
+
if (!("Update" in it)) {
|
|
1603
|
+
throw new Error("Contract runtime: cannot merge a non-Update item into an ADD.");
|
|
1604
|
+
}
|
|
1605
|
+
const names2 = it.Update.ExpressionAttributeNames ?? {};
|
|
1606
|
+
const values2 = it.Update.ExpressionAttributeValues ?? {};
|
|
1607
|
+
for (const [namePlaceholder, attr] of Object.entries(names2)) {
|
|
1608
|
+
const valuePlaceholder = `:${namePlaceholder.slice(1)}`;
|
|
1609
|
+
const delta = values2[valuePlaceholder];
|
|
1610
|
+
if (typeof delta !== "number") {
|
|
1611
|
+
throw new Error(
|
|
1612
|
+
`Contract runtime: cannot merge same-key counter updates \u2014 an ADD delta on '${attr}' is not numeric.`
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
deltas.set(attr, (deltas.get(attr) ?? 0) + delta);
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
const names = {};
|
|
1619
|
+
const values = {};
|
|
1620
|
+
const clauses = [];
|
|
1621
|
+
let i = 0;
|
|
1622
|
+
for (const [attr, delta] of deltas) {
|
|
1623
|
+
names[`#a${i}`] = attr;
|
|
1624
|
+
values[`:a${i}`] = delta;
|
|
1625
|
+
clauses.push(`#a${i} :a${i}`);
|
|
1626
|
+
i++;
|
|
1627
|
+
}
|
|
1628
|
+
const merged = {
|
|
1629
|
+
TableName: first.Update.TableName,
|
|
1630
|
+
Key: first.Update.Key,
|
|
1631
|
+
UpdateExpression: `ADD ${clauses.join(", ")}`,
|
|
1632
|
+
ExpressionAttributeNames: names,
|
|
1633
|
+
ExpressionAttributeValues: values
|
|
1634
|
+
};
|
|
1635
|
+
return { Update: merged };
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// src/operations/transaction.ts
|
|
1639
|
+
var MODEL_CLASS_KEY = /* @__PURE__ */ Symbol.for("graphddb.modelClass");
|
|
1640
|
+
function attachModelClass(modelStatic, modelClass) {
|
|
1641
|
+
Object.defineProperty(modelStatic, MODEL_CLASS_KEY, {
|
|
1642
|
+
value: modelClass,
|
|
1643
|
+
enumerable: false,
|
|
1644
|
+
configurable: false,
|
|
1645
|
+
writable: false
|
|
1646
|
+
});
|
|
1647
|
+
return modelStatic;
|
|
1648
|
+
}
|
|
1649
|
+
function resolveModelClass(model) {
|
|
1650
|
+
const modelClass = model[MODEL_CLASS_KEY];
|
|
1651
|
+
if (!modelClass) {
|
|
1652
|
+
throw new Error(
|
|
1653
|
+
"Invalid model reference. Use ModelStatic from DDBModel.asModel()."
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
return modelClass;
|
|
1657
|
+
}
|
|
1658
|
+
var TransactionContext = class {
|
|
1659
|
+
items = [];
|
|
1660
|
+
captureMeta = [];
|
|
1661
|
+
get itemCount() {
|
|
1662
|
+
return this.items.length;
|
|
1663
|
+
}
|
|
1664
|
+
put(model, item, options) {
|
|
1665
|
+
this.assertWithinLimit();
|
|
1666
|
+
const modelClass = resolveModelClass(model);
|
|
1667
|
+
const putInput = buildPutInput(modelClass, item, options);
|
|
1668
|
+
this.items.push({ Put: putInput });
|
|
1669
|
+
this.captureMeta.push({
|
|
1670
|
+
modelName: modelClass.name,
|
|
1671
|
+
op: "put",
|
|
1672
|
+
table: putInput.TableName,
|
|
1673
|
+
keys: { pk: String(putInput.Item.PK), sk: String(putInput.Item.SK ?? "") },
|
|
1674
|
+
newItem: putInput.Item
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
update(model, entity, changes, options) {
|
|
1678
|
+
this.assertWithinLimit();
|
|
1679
|
+
const modelClass = resolveModelClass(model);
|
|
1680
|
+
const updateInput = buildUpdateInput(modelClass, entity, changes, options);
|
|
1681
|
+
this.items.push({ Update: updateInput });
|
|
1682
|
+
this.captureMeta.push({
|
|
1683
|
+
modelName: modelClass.name,
|
|
1684
|
+
op: "update",
|
|
1685
|
+
table: updateInput.TableName,
|
|
1686
|
+
keys: { pk: String(updateInput.Key.PK), sk: String(updateInput.Key.SK ?? "") }
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
delete(model, key2, options) {
|
|
1690
|
+
this.assertWithinLimit();
|
|
1691
|
+
const modelClass = resolveModelClass(model);
|
|
1692
|
+
const deleteInput = buildDeleteInput(modelClass, key2, options);
|
|
1693
|
+
this.items.push({ Delete: deleteInput });
|
|
1694
|
+
this.captureMeta.push({
|
|
1695
|
+
modelName: modelClass.name,
|
|
1696
|
+
op: "delete",
|
|
1697
|
+
table: deleteInput.TableName,
|
|
1698
|
+
keys: { pk: String(deleteInput.Key.PK), sk: String(deleteInput.Key.SK ?? "") }
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Add a read-only `ConditionCheck` assertion on a keyed item (issue #81). The
|
|
1703
|
+
* item is **not** mutated; the `options.condition` (e.g.
|
|
1704
|
+
* `{ attributeExists: 'PK' }` to require the row exists) is asserted, and a
|
|
1705
|
+
* failed assertion cancels the **whole** `TransactWriteItems` atomically. This
|
|
1706
|
+
* is the foundation for referential-integrity derivation (proposal: `requires
|
|
1707
|
+
* <Entity> exists`). A ConditionCheck emits no change-capture record (it writes
|
|
1708
|
+
* nothing).
|
|
1709
|
+
*
|
|
1710
|
+
* @throws if `options.condition` is missing — a ConditionCheck without an
|
|
1711
|
+
* assertion is meaningless.
|
|
1712
|
+
*/
|
|
1713
|
+
conditionCheck(model, key2, options) {
|
|
1714
|
+
if (!options || !options.condition) {
|
|
1715
|
+
throw new Error(
|
|
1716
|
+
"TransactionContext.conditionCheck requires a `condition` (the read-only assertion it makes); e.g. `{ condition: { attributeExists: 'PK' } }`."
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
this.assertWithinLimit();
|
|
1720
|
+
const modelClass = resolveModelClass(model);
|
|
1721
|
+
const { TableName, Key } = buildDeleteInput(modelClass, key2);
|
|
1722
|
+
const cond = buildConditionExpression(options.condition);
|
|
1723
|
+
const check = {
|
|
1724
|
+
TableName,
|
|
1725
|
+
Key,
|
|
1726
|
+
ConditionExpression: cond.expression
|
|
1727
|
+
};
|
|
1728
|
+
if (Object.keys(cond.names).length > 0) {
|
|
1729
|
+
check.ExpressionAttributeNames = cond.names;
|
|
1730
|
+
}
|
|
1731
|
+
if (Object.keys(cond.values).length > 0) {
|
|
1732
|
+
check.ExpressionAttributeValues = cond.values;
|
|
1733
|
+
}
|
|
1734
|
+
this.items.push({ ConditionCheck: check });
|
|
1735
|
+
}
|
|
1736
|
+
/** @internal */
|
|
1737
|
+
getTransactItems() {
|
|
1738
|
+
return this.items;
|
|
1739
|
+
}
|
|
1740
|
+
/** @internal — capture descriptors for the write-capture seam (issue #72). */
|
|
1741
|
+
getCaptureMeta() {
|
|
1742
|
+
return this.captureMeta;
|
|
1743
|
+
}
|
|
1744
|
+
assertWithinLimit() {
|
|
1745
|
+
if (this.items.length >= MAX_TRANSACT_ITEMS) {
|
|
1746
|
+
throw new Error(
|
|
1747
|
+
`Transaction exceeds DynamoDB limit of ${MAX_TRANSACT_ITEMS} items`
|
|
1748
|
+
);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
async function executeTransaction(fn) {
|
|
1753
|
+
const tx = new TransactionContext();
|
|
1754
|
+
await fn(tx);
|
|
1755
|
+
const transactItems = tx.getTransactItems();
|
|
1756
|
+
if (transactItems.length === 0) {
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const modelBySignature = /* @__PURE__ */ new Map();
|
|
1760
|
+
for (const meta of tx.getCaptureMeta()) {
|
|
1761
|
+
if (meta.modelName) {
|
|
1762
|
+
modelBySignature.set(
|
|
1763
|
+
`${meta.table}#${meta.keys.pk}#${meta.keys.sk}`,
|
|
1764
|
+
meta.modelName
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
await commitTransaction(transactItems, {
|
|
1769
|
+
modelBySignature
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
export {
|
|
1774
|
+
isColumn,
|
|
1775
|
+
createColumnMap,
|
|
1776
|
+
isKeySegment,
|
|
1777
|
+
k,
|
|
1778
|
+
segmentFieldNames,
|
|
1779
|
+
key,
|
|
1780
|
+
gsi,
|
|
1781
|
+
Linter,
|
|
1782
|
+
noScanRule,
|
|
1783
|
+
requireLimitRule,
|
|
1784
|
+
gsiAmbiguityRule,
|
|
1785
|
+
resolveKey,
|
|
1786
|
+
missingGsiRule,
|
|
1787
|
+
relationDepthRule,
|
|
1788
|
+
createDefaultLinter,
|
|
1789
|
+
MetadataRegistry,
|
|
1790
|
+
TableMapping,
|
|
1791
|
+
pkTemplate,
|
|
1792
|
+
skTemplate,
|
|
1793
|
+
resolveSegmentedKey,
|
|
1794
|
+
BATCH_GET_MAX_KEYS,
|
|
1795
|
+
BATCH_WRITE_MAX_ITEMS,
|
|
1796
|
+
DynamoExecutor,
|
|
1797
|
+
ClientManager,
|
|
1798
|
+
buildConditionExpression,
|
|
1799
|
+
serializeFieldValue,
|
|
1800
|
+
ChangeCaptureRegistry,
|
|
1801
|
+
captureWrite,
|
|
1802
|
+
buildPutInput,
|
|
1803
|
+
executePut,
|
|
1804
|
+
buildUpdateExpression,
|
|
1805
|
+
attachHiddenKey,
|
|
1806
|
+
buildUpdateInput,
|
|
1807
|
+
executeUpdate,
|
|
1808
|
+
buildDeleteInput,
|
|
1809
|
+
executeDelete,
|
|
1810
|
+
MAX_TRANSACT_ITEMS,
|
|
1811
|
+
collapseSameKeyItems,
|
|
1812
|
+
commitTransaction,
|
|
1813
|
+
execItemKeySignature,
|
|
1814
|
+
attachModelClass,
|
|
1815
|
+
resolveModelClass,
|
|
1816
|
+
TransactionContext,
|
|
1817
|
+
executeTransaction
|
|
1818
|
+
};
|