@ftschopp/dynatable-core 2.1.0 → 2.2.1
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/CHANGELOG.md +14 -0
- package/README.md +8 -0
- package/dist/builders/update/create-update-builder.d.ts +1 -1
- package/dist/builders/update/create-update-builder.d.ts.map +1 -1
- package/dist/builders/update/create-update-builder.js +134 -12
- package/dist/builders/update/types.d.ts +19 -0
- package/dist/builders/update/types.d.ts.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## @ftschopp/dynatable-core [2.2.1](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@2.2.0...@ftschopp/dynatable-core@2.2.1) (2026-06-03)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **core:** reject undefined in update actions with actionable error ([#56](https://github.com/ftschopp/dynatable/issues/56)) ([5b8bee9](https://github.com/ftschopp/dynatable/commit/5b8bee9e9c041abdbff1b2b97d1eaba16730f257)), closes [#55](https://github.com/ftschopp/dynatable/issues/55)
|
|
7
|
+
|
|
8
|
+
# @ftschopp/dynatable-core [2.2.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@2.1.0...@ftschopp/dynatable-core@2.2.0) (2026-06-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **core:** add setIfNotExists to UpdateBuilder for immutable upsert fields ([#54](https://github.com/ftschopp/dynatable/issues/54)) ([a35d208](https://github.com/ftschopp/dynatable/commit/a35d2087f7b28c46c95a6da98dca910b4a8ee97c))
|
|
14
|
+
|
|
1
15
|
# @ftschopp/dynatable-core [2.1.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@2.0.0...@ftschopp/dynatable-core@2.1.0) (2026-06-01)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -258,12 +258,20 @@ await table.entities.User.put({
|
|
|
258
258
|
// UPDATE - Modify attributes
|
|
259
259
|
await table.entities.User.update({ username: 'alice' })
|
|
260
260
|
.set('name', 'Alice Johnson')
|
|
261
|
+
.setIfNotExists('createdAt', new Date().toISOString()) // write only on first insert
|
|
261
262
|
.add('followerCount', 1)
|
|
262
263
|
.remove('email')
|
|
263
264
|
.returning('ALL_NEW')
|
|
264
265
|
.where((attr, op) => op.gt(attr.followerCount, 0))
|
|
265
266
|
.execute();
|
|
266
267
|
|
|
268
|
+
// Note: `.set()`, `.setIfNotExists()`, `.add()` and `.delete()` reject
|
|
269
|
+
// `undefined` values up front. DynamoDB can't encode `undefined` in
|
|
270
|
+
// `ExpressionAttributeValues`, so the builder throws with the offending
|
|
271
|
+
// keys instead of letting the request fail server-side. Use `.remove(attr)`
|
|
272
|
+
// to clear an attribute, or filter `undefined` out of your payload before
|
|
273
|
+
// calling `.set()`. `null` is allowed — it writes the DynamoDB `NULL` type.
|
|
274
|
+
|
|
267
275
|
// DELETE - Remove item
|
|
268
276
|
await table.entities.User.delete({ username: 'alice' })
|
|
269
277
|
.returning('ALL_OLD')
|
|
@@ -16,5 +16,5 @@ export declare function createUpdateBuilder<Model>(tableName: string, key: Parti
|
|
|
16
16
|
remove: UpdateAction[];
|
|
17
17
|
add: UpdateAction[];
|
|
18
18
|
delete: UpdateAction[];
|
|
19
|
-
}, returnMode?: 'NONE' | 'ALL_OLD' | 'ALL_NEW' | 'UPDATED_OLD' | 'UPDATED_NEW', valueCounter?: number, enableTimestamps?: boolean, logger?: DynamoDBLogger, indexContext?: IndexContext, setInputs?: Record<string, any>, consumedCapacity?: 'INDEXES' | 'TOTAL' | 'NONE'): UpdateBuilder<Model>;
|
|
19
|
+
}, returnMode?: 'NONE' | 'ALL_OLD' | 'ALL_NEW' | 'UPDATED_OLD' | 'UPDATED_NEW', valueCounter?: number, enableTimestamps?: boolean, logger?: DynamoDBLogger, indexContext?: IndexContext, setInputs?: Record<string, any>, consumedCapacity?: 'INDEXES' | 'TOTAL' | 'NONE', setIfNotExistsInputs?: Record<string, any>): UpdateBuilder<Model>;
|
|
20
20
|
//# sourceMappingURL=create-update-builder.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"create-update-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/update/create-update-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAAgC,SAAS,EAA4B,MAAM,WAAW,CAAC;AAC9F,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"create-update-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/update/create-update-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE1D,OAAO,EAAgC,SAAS,EAA4B,MAAM,WAAW,CAAC;AAC9F,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAkE7D;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EACvC,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC,EACnB,MAAM,EAAE,cAAc,EACtB,cAAc,GAAE,SAAS,EAAO,EAChC,aAAa,GAAE;IACb,GAAG,EAAE,YAAY,EAAE,CAAC;IACpB,MAAM,EAAE,YAAY,EAAE,CAAC;IACvB,GAAG,EAAE,YAAY,EAAE,CAAC;IACpB,MAAM,EAAE,YAAY,EAAE,CAAC;CACuB,EAChD,UAAU,GAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,aAAsB,EACnF,YAAY,SAAI,EAChB,gBAAgB,UAAQ,EACxB,MAAM,CAAC,EAAE,cAAc,EACvB,YAAY,CAAC,EAAE,YAAY,EAC3B,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,EACnC,gBAAgB,CAAC,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,EAO/C,oBAAoB,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAC7C,aAAa,CAAC,KAAK,CAAC,CAkkBtB"}
|
|
@@ -25,6 +25,37 @@ function actionAttrName(action) {
|
|
|
25
25
|
const values = Object.values(action.names);
|
|
26
26
|
return values[0];
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Reject `undefined` before it reaches DynamoDB. SET / ADD / DELETE /
|
|
30
|
+
* setIfNotExists all encode the value into `ExpressionAttributeValues` — an
|
|
31
|
+
* `undefined` there is either silently dropped by the marshaller (when
|
|
32
|
+
* `removeUndefinedValues` is on, which then fails server-side with an unused
|
|
33
|
+
* expression value error) or rejected outright. Catching it at the call site
|
|
34
|
+
* gives a useful error and a pointer to the right primitive.
|
|
35
|
+
*
|
|
36
|
+
* `null` is intentionally allowed — DynamoDB has a NULL attribute type and
|
|
37
|
+
* the caller may legitimately want to write it.
|
|
38
|
+
*/
|
|
39
|
+
function assertNoUndefined(method, payload) {
|
|
40
|
+
const undefinedKeys = 'attr' in payload
|
|
41
|
+
? payload.value === undefined
|
|
42
|
+
? [payload.attr]
|
|
43
|
+
: []
|
|
44
|
+
: Object.entries(payload)
|
|
45
|
+
.filter(([, v]) => v === undefined)
|
|
46
|
+
.map(([k]) => k);
|
|
47
|
+
if (undefinedKeys.length === 0)
|
|
48
|
+
return;
|
|
49
|
+
const guidance = method === 'set'
|
|
50
|
+
? `Use .remove(attr) to clear an attribute, or filter undefined out ` +
|
|
51
|
+
`before calling .set().`
|
|
52
|
+
: method === 'setIfNotExists'
|
|
53
|
+
? `setIfNotExists requires a concrete value; omit the key or filter ` +
|
|
54
|
+
`it out.`
|
|
55
|
+
: `.${method}() requires a concrete value for each attribute.`;
|
|
56
|
+
throw new Error(`.${method}() received undefined for key(s) [${undefinedKeys.join(', ')}]. ` +
|
|
57
|
+
`DynamoDB cannot encode undefined in ExpressionAttributeValues. ${guidance}`);
|
|
58
|
+
}
|
|
28
59
|
/**
|
|
29
60
|
* Creates an UpdateBuilder for an item key and table.
|
|
30
61
|
*
|
|
@@ -34,7 +65,14 @@ function actionAttrName(action) {
|
|
|
34
65
|
* cannot be fully resolved from the primary-key vars plus the updates, building
|
|
35
66
|
* the params throws — the caller must include the missing fields in `.set()`.
|
|
36
67
|
*/
|
|
37
|
-
function createUpdateBuilder(tableName, key, client, prevConditions = [], updateActions = { set: [], remove: [], add: [], delete: [] }, returnMode = 'NONE', valueCounter = 0, enableTimestamps = false, logger, indexContext, setInputs = {}, consumedCapacity
|
|
68
|
+
function createUpdateBuilder(tableName, key, client, prevConditions = [], updateActions = { set: [], remove: [], add: [], delete: [] }, returnMode = 'NONE', valueCounter = 0, enableTimestamps = false, logger, indexContext, setInputs = {}, consumedCapacity,
|
|
69
|
+
// Tracks attributes targeted by `.setIfNotExists()` separately from
|
|
70
|
+
// `setInputs`. Index recomputation reads `setInputs` to resolve template
|
|
71
|
+
// values; conditional writes can't supply a static value (DynamoDB picks
|
|
72
|
+
// current vs `:v` at write time), so they must NOT participate in template
|
|
73
|
+
// resolution — but the PK-template / index-template guards still need to
|
|
74
|
+
// see them to reject schema-incompatible usage.
|
|
75
|
+
setIfNotExistsInputs = {}) {
|
|
38
76
|
const conditions = [...prevConditions];
|
|
39
77
|
const getUniqueValueName = (baseName) => {
|
|
40
78
|
return `${baseName}_${valueCounter++}`;
|
|
@@ -54,7 +92,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
54
92
|
});
|
|
55
93
|
const opBuilder = (0, shared_1.createOpBuilder)();
|
|
56
94
|
const condition = fn(attrs, opBuilder);
|
|
57
|
-
return createUpdateBuilder(tableName, key, client, [...conditions, condition], updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
|
|
95
|
+
return createUpdateBuilder(tableName, key, client, [...conditions, condition], updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
|
|
58
96
|
},
|
|
59
97
|
set(attrOrUpdates, value) {
|
|
60
98
|
// When no value is provided and the first arg is a plain object,
|
|
@@ -65,6 +103,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
65
103
|
attrOrUpdates !== null) {
|
|
66
104
|
// Multiple updates case
|
|
67
105
|
const updates = attrOrUpdates;
|
|
106
|
+
assertNoUndefined('set', updates);
|
|
68
107
|
const newActions = [];
|
|
69
108
|
const newSetInputs = { ...setInputs };
|
|
70
109
|
for (const [attr, val] of Object.entries(updates)) {
|
|
@@ -77,17 +116,51 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
77
116
|
});
|
|
78
117
|
newSetInputs[attrName] = val;
|
|
79
118
|
}
|
|
80
|
-
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, newSetInputs, consumedCapacity);
|
|
119
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, newSetInputs, consumedCapacity, setIfNotExistsInputs);
|
|
81
120
|
}
|
|
82
121
|
// Single update case
|
|
83
122
|
const attrName = normalizeAttr(attrOrUpdates);
|
|
123
|
+
assertNoUndefined('set', { attr: attrName, value });
|
|
84
124
|
const valueName = getUniqueValueName(attrName);
|
|
85
125
|
const action = {
|
|
86
126
|
expression: `#${attrName} = :${valueName}`,
|
|
87
127
|
names: { [`#${attrName}`]: attrName },
|
|
88
128
|
values: { [`:${valueName}`]: value },
|
|
89
129
|
};
|
|
90
|
-
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, { ...setInputs, [attrName]: value }, consumedCapacity);
|
|
130
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, { ...setInputs, [attrName]: value }, consumedCapacity, setIfNotExistsInputs);
|
|
131
|
+
},
|
|
132
|
+
setIfNotExists(attrOrUpdates, value) {
|
|
133
|
+
// Object form: treat as Partial<Model>. The AttrRef overload always
|
|
134
|
+
// passes a value, so it routes through the single-update path below.
|
|
135
|
+
if (value === undefined &&
|
|
136
|
+
typeof attrOrUpdates === 'object' &&
|
|
137
|
+
attrOrUpdates !== null) {
|
|
138
|
+
const updates = attrOrUpdates;
|
|
139
|
+
assertNoUndefined('setIfNotExists', updates);
|
|
140
|
+
const newActions = [];
|
|
141
|
+
const newSetIfNotExistsInputs = { ...setIfNotExistsInputs };
|
|
142
|
+
for (const [attr, val] of Object.entries(updates)) {
|
|
143
|
+
const attrName = attr;
|
|
144
|
+
const valueName = getUniqueValueName(attrName);
|
|
145
|
+
newActions.push({
|
|
146
|
+
expression: `#${attrName} = if_not_exists(#${attrName}, :${valueName})`,
|
|
147
|
+
names: { [`#${attrName}`]: attrName },
|
|
148
|
+
values: { [`:${valueName}`]: val },
|
|
149
|
+
});
|
|
150
|
+
newSetIfNotExistsInputs[attrName] = val;
|
|
151
|
+
}
|
|
152
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, newSetIfNotExistsInputs);
|
|
153
|
+
}
|
|
154
|
+
// Single update case
|
|
155
|
+
const attrName = normalizeAttr(attrOrUpdates);
|
|
156
|
+
assertNoUndefined('setIfNotExists', { attr: attrName, value });
|
|
157
|
+
const valueName = getUniqueValueName(attrName);
|
|
158
|
+
const action = {
|
|
159
|
+
expression: `#${attrName} = if_not_exists(#${attrName}, :${valueName})`,
|
|
160
|
+
names: { [`#${attrName}`]: attrName },
|
|
161
|
+
values: { [`:${valueName}`]: value },
|
|
162
|
+
};
|
|
163
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, { ...setIfNotExistsInputs, [attrName]: value });
|
|
91
164
|
},
|
|
92
165
|
remove(attr) {
|
|
93
166
|
const attrName = normalizeAttr(attr);
|
|
@@ -95,33 +168,35 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
95
168
|
expression: `#${attrName}`,
|
|
96
169
|
names: { [`#${attrName}`]: attrName },
|
|
97
170
|
};
|
|
98
|
-
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, remove: [...updateActions.remove, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
|
|
171
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, remove: [...updateActions.remove, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
|
|
99
172
|
},
|
|
100
173
|
add(attr, value) {
|
|
101
174
|
const attrName = normalizeAttr(attr);
|
|
175
|
+
assertNoUndefined('add', { attr: attrName, value });
|
|
102
176
|
const valueName = getUniqueValueName(attrName);
|
|
103
177
|
const action = {
|
|
104
178
|
expression: `#${attrName} :${valueName}`,
|
|
105
179
|
names: { [`#${attrName}`]: attrName },
|
|
106
180
|
values: { [`:${valueName}`]: value },
|
|
107
181
|
};
|
|
108
|
-
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, add: [...updateActions.add, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
|
|
182
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, add: [...updateActions.add, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
|
|
109
183
|
},
|
|
110
184
|
delete(attr, value) {
|
|
111
185
|
const attrName = normalizeAttr(attr);
|
|
186
|
+
assertNoUndefined('delete', { attr: attrName, value });
|
|
112
187
|
const valueName = getUniqueValueName(attrName);
|
|
113
188
|
const action = {
|
|
114
189
|
expression: `#${attrName} :${valueName}`,
|
|
115
190
|
names: { [`#${attrName}`]: attrName },
|
|
116
191
|
values: { [`:${valueName}`]: value },
|
|
117
192
|
};
|
|
118
|
-
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, delete: [...updateActions.delete, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
|
|
193
|
+
return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, delete: [...updateActions.delete, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
|
|
119
194
|
},
|
|
120
195
|
returning(mode) {
|
|
121
|
-
return createUpdateBuilder(tableName, key, client, conditions, updateActions, mode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
|
|
196
|
+
return createUpdateBuilder(tableName, key, client, conditions, updateActions, mode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
|
|
122
197
|
},
|
|
123
198
|
returnConsumedCapacity(mode) {
|
|
124
|
-
return createUpdateBuilder(tableName, key, client, conditions, updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, mode);
|
|
199
|
+
return createUpdateBuilder(tableName, key, client, conditions, updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, mode, setIfNotExistsInputs);
|
|
125
200
|
},
|
|
126
201
|
dbParams() {
|
|
127
202
|
// Clone updateActions to avoid mutation
|
|
@@ -136,6 +211,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
136
211
|
// structured) — for remove/add/delete we read from the action's
|
|
137
212
|
// attribute-name map.
|
|
138
213
|
const setFields = Object.keys(setInputs);
|
|
214
|
+
const setIfNotExistsFields = Object.keys(setIfNotExistsInputs);
|
|
139
215
|
const removeFields = updateActions.remove
|
|
140
216
|
.map(actionAttrName)
|
|
141
217
|
.filter((n) => !!n);
|
|
@@ -150,15 +226,21 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
150
226
|
// the user changes a field that participates in the PK/SK template,
|
|
151
227
|
// the row's PK doesn't move (DynamoDB doesn't allow that) but the
|
|
152
228
|
// attribute does — leaving an inconsistent row whose PK encodes the
|
|
153
|
-
// old value. Catch this on every op (.set / .
|
|
154
|
-
// before it leaves the process.
|
|
229
|
+
// old value. Catch this on every op (.set / .setIfNotExists /
|
|
230
|
+
// .remove / .add / .delete) before it leaves the process.
|
|
155
231
|
const primaryKeyTemplateVars = new Set();
|
|
156
232
|
for (const keyDef of Object.values(indexContext.model.key)) {
|
|
157
233
|
for (const v of (0, model_utils_1.extractTemplateVars)(keyDef.value)) {
|
|
158
234
|
primaryKeyTemplateVars.add(v);
|
|
159
235
|
}
|
|
160
236
|
}
|
|
161
|
-
const allTouched = [
|
|
237
|
+
const allTouched = [
|
|
238
|
+
...setFields,
|
|
239
|
+
...setIfNotExistsFields,
|
|
240
|
+
...removeFields,
|
|
241
|
+
...addFields,
|
|
242
|
+
...deleteFields,
|
|
243
|
+
];
|
|
162
244
|
const pkConflicts = [
|
|
163
245
|
...new Set(allTouched.filter((f) => primaryKeyTemplateVars.has(f))),
|
|
164
246
|
];
|
|
@@ -174,6 +256,13 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
174
256
|
// the new value, REMOVE strips the field entirely, and DELETE
|
|
175
257
|
// mutates a Set without naming a scalar. Switching to .set(field,
|
|
176
258
|
// newValue) lets the recompute path handle it correctly.
|
|
259
|
+
//
|
|
260
|
+
// Guard 3: .setIfNotExists() against a field used in a SECONDARY-index
|
|
261
|
+
// template. The resolved value is decided by DynamoDB at write time
|
|
262
|
+
// (current attr vs `:v`), so the recompute path cannot statically
|
|
263
|
+
// produce a value that's guaranteed consistent with the stored
|
|
264
|
+
// attribute. Allowing it would silently corrupt the index whenever
|
|
265
|
+
// the conditional write keeps the existing value.
|
|
177
266
|
if (indexContext.model.index) {
|
|
178
267
|
const indexTemplateVars = new Set();
|
|
179
268
|
for (const indexDef of Object.values(indexContext.model.index)) {
|
|
@@ -192,6 +281,18 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
192
281
|
`recomputed without an explicit new value. Use .set(field, ` +
|
|
193
282
|
`newValue) instead so the index key is recomputed atomically.`);
|
|
194
283
|
}
|
|
284
|
+
const ifNotExistsGsiConflicts = [
|
|
285
|
+
...new Set(setIfNotExistsFields.filter((f) => indexTemplateVars.has(f))),
|
|
286
|
+
];
|
|
287
|
+
if (ifNotExistsGsiConflicts.length > 0) {
|
|
288
|
+
throw new Error(`Cannot use .setIfNotExists() on field(s) ` +
|
|
289
|
+
`[${ifNotExistsGsiConflicts.join(', ')}] — they participate in a ` +
|
|
290
|
+
`secondary-index template, and if_not_exists() may keep the ` +
|
|
291
|
+
`existing value at write time, which would leave the index key ` +
|
|
292
|
+
`inconsistent with the stored attribute. Either restructure the ` +
|
|
293
|
+
`schema so this field is not part of any GSI template, or perform ` +
|
|
294
|
+
`a get + conditional .set() in two steps.`);
|
|
295
|
+
}
|
|
195
296
|
}
|
|
196
297
|
const { actions: idxActions, missing } = (0, model_utils_1.computeIndexUpdates)(indexContext.model, indexContext.keyVars, setInputs);
|
|
197
298
|
if (missing.length > 0) {
|
|
@@ -237,6 +338,27 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
|
|
|
237
338
|
};
|
|
238
339
|
actionsToProcess.set = [...actionsToProcess.set, timestampAction];
|
|
239
340
|
}
|
|
341
|
+
// Dedup guard: DynamoDB rejects overlapping document paths inside a
|
|
342
|
+
// SET expression (e.g. `SET #foo = :a, #foo = :b` → ValidationException
|
|
343
|
+
// "Two document paths overlap"). Catch the common footguns —
|
|
344
|
+
// .set().set() on the same key, .set() + .setIfNotExists() on the
|
|
345
|
+
// same key, and enableTimestamps + .set/.setIfNotExists('updatedAt')
|
|
346
|
+
// — before the network round-trip. Runs AFTER the timestamp injection
|
|
347
|
+
// so #updatedAt collisions are also reported.
|
|
348
|
+
const setActionNames = actionsToProcess.set
|
|
349
|
+
.map(actionAttrName)
|
|
350
|
+
.filter((n) => !!n);
|
|
351
|
+
const duplicateSetAttrs = [
|
|
352
|
+
...new Set(setActionNames.filter((n, i) => setActionNames.indexOf(n) !== i)),
|
|
353
|
+
];
|
|
354
|
+
if (duplicateSetAttrs.length > 0) {
|
|
355
|
+
throw new Error(`Update would emit multiple SET actions targeting the same ` +
|
|
356
|
+
`attribute(s) [${duplicateSetAttrs.join(', ')}]. DynamoDB rejects ` +
|
|
357
|
+
`overlapping document paths. Check that you're not combining ` +
|
|
358
|
+
`.set() and .setIfNotExists() on the same field, calling .set() ` +
|
|
359
|
+
`twice for the same key, or targeting an attribute that ` +
|
|
360
|
+
`enableTimestamps already manages (updatedAt).`);
|
|
361
|
+
}
|
|
240
362
|
const buildSection = (label, actions, includeValues) => actions.length === 0
|
|
241
363
|
? null
|
|
242
364
|
: {
|
|
@@ -40,6 +40,25 @@ export interface UpdateBuilder<Model> extends Omit<OperationBuilder<Model>, 'dbP
|
|
|
40
40
|
*/
|
|
41
41
|
set(attr: keyof Model | AttrRef, value: any): UpdateBuilder<Model>;
|
|
42
42
|
set(updates: Partial<Model>): UpdateBuilder<Model>;
|
|
43
|
+
/**
|
|
44
|
+
* Conditionally sets an attribute only if it does not already exist on the
|
|
45
|
+
* item, using DynamoDB's `if_not_exists()` SET function. Emits
|
|
46
|
+
* `#attr = if_not_exists(#attr, :v)`.
|
|
47
|
+
*
|
|
48
|
+
* Primary use case: upsert patterns where some fields must be written only
|
|
49
|
+
* on first insert (immutable `createdAt`, `firstSeenAt`, initial counters).
|
|
50
|
+
*
|
|
51
|
+
* Restrictions enforced at `dbParams()` time:
|
|
52
|
+
* - The field must not participate in the primary-key template (PK is
|
|
53
|
+
* immutable; the conditional write is meaningless).
|
|
54
|
+
* - The field must not participate in any secondary-index template,
|
|
55
|
+
* because the resolved value is decided by DynamoDB at write time and
|
|
56
|
+
* the index key cannot be recomputed safely.
|
|
57
|
+
* - The same attribute cannot also be targeted by `.set()` in the same
|
|
58
|
+
* update — DynamoDB rejects overlapping document paths.
|
|
59
|
+
*/
|
|
60
|
+
setIfNotExists(attr: keyof Model | AttrRef, value: any): UpdateBuilder<Model>;
|
|
61
|
+
setIfNotExists(updates: Partial<Model>): UpdateBuilder<Model>;
|
|
43
62
|
/**
|
|
44
63
|
* Removes an attribute from the item
|
|
45
64
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/builders/update/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEzF;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC9B,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC9B,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,aAAa,CAAC,KAAK,CAAE,SAAQ,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC;IAC/F;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,SAAS,KAAK,SAAS,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAExF;;OAEG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IACnE,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEnD;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAE1D;;OAEG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEnE;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEtE;;OAEG;IACH,SAAS,CACP,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,GACnE,aAAa,CAAC,KAAK,CAAC,CAAC;IAExB;;;;;;;;OAQG;IACH,sBAAsB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEjF;;OAEG;IACH,QAAQ,IAAI,kBAAkB,CAAC;CAChC"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/builders/update/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,gBAAgB,EAAE,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAEzF;;GAEG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC9B,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,KAAK,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;CAC9B,CAAC;AAEF;;GAEG;AACH,MAAM,WAAW,aAAa,CAAC,KAAK,CAAE,SAAQ,IAAI,CAAC,gBAAgB,CAAC,KAAK,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC;IAC/F;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,EAAE,CAAC,IAAI,EAAE,WAAW,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,SAAS,KAAK,SAAS,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAExF;;OAEG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IACnE,GAAG,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEnD;;;;;;;;;;;;;;;;OAgBG;IACH,cAAc,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAC9E,cAAc,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAE9D;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAE1D;;OAEG;IACH,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEnE;;OAEG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,EAAE,GAAG,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEtE;;OAEG;IACH,SAAS,CACP,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,GAAG,aAAa,GACnE,aAAa,CAAC,KAAK,CAAC,CAAC;IAExB;;;;;;;;OAQG;IACH,sBAAsB,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,MAAM,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAEjF;;OAEG;IACH,QAAQ,IAAI,kBAAkB,CAAC;CAChC"}
|