@ftschopp/dynatable-core 2.2.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # @ftschopp/dynatable-core [2.3.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@2.2.1...@ftschopp/dynatable-core@2.3.0) (2026-06-03)
2
+
3
+
4
+ ### Features
5
+
6
+ * **core:** add setDefined to UpdateBuilder ([#58](https://github.com/ftschopp/dynatable/issues/58)) ([851c0dc](https://github.com/ftschopp/dynatable/commit/851c0dc8ab6c6eed0555c874c6230d8997530808)), closes [#56](https://github.com/ftschopp/dynatable/issues/56) [#57](https://github.com/ftschopp/dynatable/issues/57)
7
+
8
+ ## @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)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+
1
15
  # @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)
2
16
 
3
17
 
package/README.md CHANGED
@@ -265,6 +265,27 @@ await table.entities.User.update({ username: 'alice' })
265
265
  .where((attr, op) => op.gt(attr.followerCount, 0))
266
266
  .execute();
267
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
+
275
+ // UPSERT via setDefined - sync from an external system where undefined
276
+ // means "this attribute no longer applies". Defined values go to SET,
277
+ // undefined keys go to REMOVE. Internally composes `.set()` + `.remove()`,
278
+ // so all guards (PK template, GSI templates, dedup, GSI key recomputation)
279
+ // apply identically.
280
+ await table.entities.User.update({ username: 'alice' })
281
+ .setDefined({
282
+ name: tamsRecord.name, // SET
283
+ email: tamsRecord.email, // may be undefined → REMOVE
284
+ lastSeenAt: new Date().toISOString(),
285
+ })
286
+ .setIfNotExists('createdAt', new Date().toISOString())
287
+ .execute();
288
+
268
289
  // DELETE - Remove item
269
290
  await table.entities.User.delete({ username: 'alice' })
270
291
  .returning('ALL_OLD')
@@ -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,EAO/C,oBAAoB,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAC7C,aAAa,CAAC,KAAK,CAAC,CA4jBtB"}
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,CA6lBtB"}
@@ -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
  *
@@ -72,6 +103,7 @@ setIfNotExistsInputs = {}) {
72
103
  attrOrUpdates !== null) {
73
104
  // Multiple updates case
74
105
  const updates = attrOrUpdates;
106
+ assertNoUndefined('set', updates);
75
107
  const newActions = [];
76
108
  const newSetInputs = { ...setInputs };
77
109
  for (const [attr, val] of Object.entries(updates)) {
@@ -88,6 +120,7 @@ setIfNotExistsInputs = {}) {
88
120
  }
89
121
  // Single update case
90
122
  const attrName = normalizeAttr(attrOrUpdates);
123
+ assertNoUndefined('set', { attr: attrName, value });
91
124
  const valueName = getUniqueValueName(attrName);
92
125
  const action = {
93
126
  expression: `#${attrName} = :${valueName}`,
@@ -103,6 +136,7 @@ setIfNotExistsInputs = {}) {
103
136
  typeof attrOrUpdates === 'object' &&
104
137
  attrOrUpdates !== null) {
105
138
  const updates = attrOrUpdates;
139
+ assertNoUndefined('setIfNotExists', updates);
106
140
  const newActions = [];
107
141
  const newSetIfNotExistsInputs = { ...setIfNotExistsInputs };
108
142
  for (const [attr, val] of Object.entries(updates)) {
@@ -119,6 +153,7 @@ setIfNotExistsInputs = {}) {
119
153
  }
120
154
  // Single update case
121
155
  const attrName = normalizeAttr(attrOrUpdates);
156
+ assertNoUndefined('setIfNotExists', { attr: attrName, value });
122
157
  const valueName = getUniqueValueName(attrName);
123
158
  const action = {
124
159
  expression: `#${attrName} = if_not_exists(#${attrName}, :${valueName})`,
@@ -127,6 +162,33 @@ setIfNotExistsInputs = {}) {
127
162
  };
128
163
  return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, { ...setIfNotExistsInputs, [attrName]: value });
129
164
  },
165
+ setDefined(updates) {
166
+ // Split the payload, then route through the public `.set()` and
167
+ // `.remove()` paths so all of their guards apply unchanged: PK
168
+ // template immutability, GSI template guard 2 (which will reject
169
+ // any `undefined` targeting a GSI-template field), dedup, and the
170
+ // index-key recomputation triggered by `setInputs`. Composing the
171
+ // builder instead of re-emitting actions keeps the surface area
172
+ // of this method as close to zero as possible.
173
+ const defined = {};
174
+ const toRemove = [];
175
+ for (const [attr, val] of Object.entries(updates)) {
176
+ if (val === undefined) {
177
+ toRemove.push(attr);
178
+ }
179
+ else {
180
+ defined[attr] = val;
181
+ }
182
+ }
183
+ let next = build();
184
+ if (Object.keys(defined).length > 0) {
185
+ next = next.set(defined);
186
+ }
187
+ for (const attr of toRemove) {
188
+ next = next.remove(attr);
189
+ }
190
+ return next;
191
+ },
130
192
  remove(attr) {
131
193
  const attrName = normalizeAttr(attr);
132
194
  const action = {
@@ -137,6 +199,7 @@ setIfNotExistsInputs = {}) {
137
199
  },
138
200
  add(attr, value) {
139
201
  const attrName = normalizeAttr(attr);
202
+ assertNoUndefined('add', { attr: attrName, value });
140
203
  const valueName = getUniqueValueName(attrName);
141
204
  const action = {
142
205
  expression: `#${attrName} :${valueName}`,
@@ -147,6 +210,7 @@ setIfNotExistsInputs = {}) {
147
210
  },
148
211
  delete(attr, value) {
149
212
  const attrName = normalizeAttr(attr);
213
+ assertNoUndefined('delete', { attr: attrName, value });
150
214
  const valueName = getUniqueValueName(attrName);
151
215
  const action = {
152
216
  expression: `#${attrName} :${valueName}`,
@@ -59,6 +59,28 @@ export interface UpdateBuilder<Model> extends Omit<OperationBuilder<Model>, 'dbP
59
59
  */
60
60
  setIfNotExists(attr: keyof Model | AttrRef, value: any): UpdateBuilder<Model>;
61
61
  setIfNotExists(updates: Partial<Model>): UpdateBuilder<Model>;
62
+ /**
63
+ * Upsert helper: writes attributes whose value is defined with SET, and
64
+ * removes attributes whose value is `undefined`. Equivalent to splitting
65
+ * the payload between `.set()` and `.remove()` at the call site, but
66
+ * makes the intent explicit at the API.
67
+ *
68
+ * Typical use case: syncing from an external system where a missing field
69
+ * in the source payload means "this attribute no longer applies" rather
70
+ * than "leave the existing value alone" — the latter is what `.set()`
71
+ * with `undefined` would imply, and it's a destructive footgun the strict
72
+ * `.set()` rightly rejects.
73
+ *
74
+ * Internally routes through `.set()` and `.remove()`, so every existing
75
+ * guard (primary-key template, secondary-index templates, dedup,
76
+ * GSI-key recomputation) applies identically — including the rejection
77
+ * of `.remove()` on fields used in a GSI template, which will surface as
78
+ * an error here whenever an `undefined` targets such a field.
79
+ *
80
+ * `null` is treated as a defined value and writes the DynamoDB `NULL`
81
+ * type — only `undefined` triggers the REMOVE branch.
82
+ */
83
+ setDefined(updates: Partial<Model>): UpdateBuilder<Model>;
62
84
  /**
63
85
  * Removes an attribute from the item
64
86
  */
@@ -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;;;;;;;;;;;;;;;;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"}
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;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAE1D;;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.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",