@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 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;AAyB7D;;;;;;;;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,GAC9C,aAAa,CAAC,KAAK,CAAC,CAubtB"}
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 / .remove / .add / .delete)
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 = [...setFields, ...removeFields, ...addFields, ...deleteFields];
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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ftschopp/dynatable-core",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",