@ftschopp/dynatable-core 2.0.0 → 2.2.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.2.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@2.1.0...@ftschopp/dynatable-core@2.2.0) (2026-06-02)
2
+
3
+
4
+ ### Features
5
+
6
+ * **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))
7
+
8
+ # @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)
9
+
10
+
11
+ ### Features
12
+
13
+ * **core:** auto-inject equality conditions for literal-template keys in query builder ([5ecb781](https://github.com/ftschopp/dynatable/commit/5ecb78130a393a63eb255a49116177bff1caa496))
14
+
1
15
  # @ftschopp/dynatable-core [2.0.0](https://github.com/ftschopp/dynatable/compare/@ftschopp/dynatable-core@1.6.1...@ftschopp/dynatable-core@2.0.0) (2026-05-11)
2
16
 
3
17
 
package/README.md CHANGED
@@ -258,6 +258,7 @@ 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')
@@ -1 +1 @@
1
- {"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAM1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAiB,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAElE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAid7D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,cAAc,EACvB,UAAU,CAAC,EAAE,MAAM,GAClB,YAAY,CAAC,KAAK,CAAC,CA0BrB"}
1
+ {"version":3,"file":"create-query-builder.d.ts","sourceRoot":"","sources":["../../../src/builders/query/create-query-builder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAM1D,OAAO,EAAE,YAAY,EAA8B,MAAM,SAAS,CAAC;AACnE,OAAO,EAAiB,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAElE,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AA4hB7D;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EACtC,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,cAAc,EACtB,KAAK,CAAC,EAAE,eAAe,EACvB,MAAM,CAAC,EAAE,cAAc,EACvB,UAAU,CAAC,EAAE,MAAM,GAClB,YAAY,CAAC,KAAK,CAAC,CA0BrB"}
@@ -85,6 +85,43 @@ function applyKeyTemplate(template, fieldName, fieldValue, keyOperator) {
85
85
  `with only "${fieldName}" provided — the template still contains unfilled ` +
86
86
  `variable(s). Use beginsWith for prefix queries on composite keys.`);
87
87
  }
88
+ /**
89
+ * Heuristic for identifying a hash (partition) key by name. Used only to
90
+ * decide whether a literal-template auto-injection covers DynamoDB's
91
+ * "Query needs a partition key" requirement — for actual key resolution
92
+ * we go through {@link keyBelongsToIndex} instead.
93
+ *
94
+ * Convention: primary is literally `PK`; index keys are `${indexName}PK`
95
+ * (conventional) or any `…PK`-suffixed name (non-conventional, opted in
96
+ * via `KeyDefinition.indexName`). The legitimate non-conventional case
97
+ * `BySpotifyId` / `lookupPK` is captured by the `endsWith('PK')` check.
98
+ */
99
+ function isHashKeyName(keyName, indexName) {
100
+ return indexName ? keyName.endsWith('PK') : keyName === 'PK';
101
+ }
102
+ /**
103
+ * Collects every key on the active index (or primary) whose template is
104
+ * fully literal — no `${...}` variables. These keys can't be referenced
105
+ * by attribute name in `where()` (there is no template var to bind to),
106
+ * so the query builder synthesizes the equality condition itself.
107
+ *
108
+ * Two real-world patterns this enables:
109
+ * 1. Entity-type partition: `GSI1PK: 'AIRPORT'` with a variable SK —
110
+ * "give me every Airport, filtered/range-scoped by SK".
111
+ * 2. Singleton sort-key marker: `SK: 'PROFILE'` paired with a variable
112
+ * PK like `'USER#${userId}'` — auto-injecting SK turns a wasteful
113
+ * "scan the whole user partition then filter" into a direct lookup.
114
+ */
115
+ function getLiteralKeys(model, indexName) {
116
+ if (!model)
117
+ return [];
118
+ const entries = indexName
119
+ ? Object.entries(model.index ?? {}).filter(([keyName, keyDef]) => keyBelongsToIndex(keyName, keyDef, indexName))
120
+ : Object.entries(model.key);
121
+ return entries
122
+ .filter(([, keyDef]) => (0, model_utils_1.extractTemplateVars)(keyDef.value).length === 0)
123
+ .map(([keyName, keyDef]) => ({ keyName, value: keyDef.value }));
124
+ }
88
125
  /**
89
126
  * Separates a condition tree into key conditions and filter conditions
90
127
  * Key conditions are rewritten to use actual DynamoDB key names (PK/SK)
@@ -236,12 +273,38 @@ function createQueryExecutor(state) {
236
273
  }
237
274
  // Separate key conditions from filter conditions
238
275
  const { keyConditions, filterConditions } = separateConditions(state.condition, state.model, state.indexName);
239
- // DynamoDB Query requires at least a partition-key condition. If
240
- // separation didn't pick anything as a key condition, the caller is
241
- // either filtering on non-key attributes (which means they want
242
- // scan()) or referencing the wrong attribute name. Fail loudly here
243
- // instead of letting the SDK reject the request at execute time.
244
- if (keyConditions.length === 0) {
276
+ // Auto-inject equality conditions for keys whose template is fully
277
+ // literal they have no `${vars}`, so the user can't reference
278
+ // them via `attr.*` in `where()`. Two real patterns this enables:
279
+ // 1. Entity-type GSI partition: `GSI1PK: 'AIRPORT'` without
280
+ // injection DynamoDB rejects the Query for missing the PK.
281
+ // 2. Sort-key marker: `SK: 'PROFILE'` paired with a variable PK
282
+ // like `'USER#${userId}'` — without injection the query reads
283
+ // every item under that PK and post-filters by `_type`.
284
+ // The `covered` check is defensive — a literal-template key can't
285
+ // appear in user conditions (no template var to bind) — but cheap
286
+ // protection against future schema shapes.
287
+ const literalKeys = getLiteralKeys(state.model, state.indexName);
288
+ const userKeyConditionCount = keyConditions.length;
289
+ for (const { keyName, value } of literalKeys) {
290
+ const covered = keyConditions.some((cond) => Object.values(cond.names ?? {}).includes(keyName));
291
+ if (covered)
292
+ continue;
293
+ keyConditions.push({
294
+ expression: `#${keyName} = :${keyName}_literal`,
295
+ names: { [`#${keyName}`]: keyName },
296
+ values: { [`:${keyName}_literal`]: value },
297
+ });
298
+ }
299
+ // DynamoDB Query requires at least a partition-key condition. The
300
+ // friendly error fires when:
301
+ // - the user's where() contributed zero key conditions, AND
302
+ // - no auto-injected literal covers the hash key.
303
+ // We can't blindly check `keyConditions.length === 0` after
304
+ // injection because a literal SK alone would silently slip past
305
+ // and produce a less helpful DynamoDB error.
306
+ const literalHashInjected = literalKeys.some(({ keyName }) => isHashKeyName(keyName, state.indexName));
307
+ if (userKeyConditionCount === 0 && !literalHashInjected) {
245
308
  const keyTemplates = state.indexName
246
309
  ? Object.entries(state.model?.index ?? {})
247
310
  .filter(([keyName, def]) => keyBelongsToIndex(keyName, def, state.indexName))
@@ -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;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"}
@@ -34,7 +34,14 @@ function actionAttrName(action) {
34
34
  * cannot be fully resolved from the primary-key vars plus the updates, building
35
35
  * the params throws — the caller must include the missing fields in `.set()`.
36
36
  */
37
- function createUpdateBuilder(tableName, key, client, prevConditions = [], updateActions = { set: [], remove: [], add: [], delete: [] }, returnMode = 'NONE', valueCounter = 0, enableTimestamps = false, logger, indexContext, setInputs = {}, consumedCapacity) {
37
+ function createUpdateBuilder(tableName, key, client, prevConditions = [], updateActions = { set: [], remove: [], add: [], delete: [] }, returnMode = 'NONE', valueCounter = 0, enableTimestamps = false, logger, indexContext, setInputs = {}, consumedCapacity,
38
+ // Tracks attributes targeted by `.setIfNotExists()` separately from
39
+ // `setInputs`. Index recomputation reads `setInputs` to resolve template
40
+ // values; conditional writes can't supply a static value (DynamoDB picks
41
+ // current vs `:v` at write time), so they must NOT participate in template
42
+ // resolution — but the PK-template / index-template guards still need to
43
+ // see them to reject schema-incompatible usage.
44
+ setIfNotExistsInputs = {}) {
38
45
  const conditions = [...prevConditions];
39
46
  const getUniqueValueName = (baseName) => {
40
47
  return `${baseName}_${valueCounter++}`;
@@ -54,7 +61,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
54
61
  });
55
62
  const opBuilder = (0, shared_1.createOpBuilder)();
56
63
  const condition = fn(attrs, opBuilder);
57
- return createUpdateBuilder(tableName, key, client, [...conditions, condition], updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
64
+ return createUpdateBuilder(tableName, key, client, [...conditions, condition], updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
58
65
  },
59
66
  set(attrOrUpdates, value) {
60
67
  // When no value is provided and the first arg is a plain object,
@@ -77,7 +84,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
77
84
  });
78
85
  newSetInputs[attrName] = val;
79
86
  }
80
- return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, newSetInputs, consumedCapacity);
87
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, newSetInputs, consumedCapacity, setIfNotExistsInputs);
81
88
  }
82
89
  // Single update case
83
90
  const attrName = normalizeAttr(attrOrUpdates);
@@ -87,7 +94,38 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
87
94
  names: { [`#${attrName}`]: attrName },
88
95
  values: { [`:${valueName}`]: value },
89
96
  };
90
- return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, { ...setInputs, [attrName]: value }, consumedCapacity);
97
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, { ...setInputs, [attrName]: value }, consumedCapacity, setIfNotExistsInputs);
98
+ },
99
+ setIfNotExists(attrOrUpdates, value) {
100
+ // Object form: treat as Partial<Model>. The AttrRef overload always
101
+ // passes a value, so it routes through the single-update path below.
102
+ if (value === undefined &&
103
+ typeof attrOrUpdates === 'object' &&
104
+ attrOrUpdates !== null) {
105
+ const updates = attrOrUpdates;
106
+ const newActions = [];
107
+ const newSetIfNotExistsInputs = { ...setIfNotExistsInputs };
108
+ for (const [attr, val] of Object.entries(updates)) {
109
+ const attrName = attr;
110
+ const valueName = getUniqueValueName(attrName);
111
+ newActions.push({
112
+ expression: `#${attrName} = if_not_exists(#${attrName}, :${valueName})`,
113
+ names: { [`#${attrName}`]: attrName },
114
+ values: { [`:${valueName}`]: val },
115
+ });
116
+ newSetIfNotExistsInputs[attrName] = val;
117
+ }
118
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, ...newActions] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, newSetIfNotExistsInputs);
119
+ }
120
+ // Single update case
121
+ const attrName = normalizeAttr(attrOrUpdates);
122
+ const valueName = getUniqueValueName(attrName);
123
+ const action = {
124
+ expression: `#${attrName} = if_not_exists(#${attrName}, :${valueName})`,
125
+ names: { [`#${attrName}`]: attrName },
126
+ values: { [`:${valueName}`]: value },
127
+ };
128
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, set: [...updateActions.set, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, { ...setIfNotExistsInputs, [attrName]: value });
91
129
  },
92
130
  remove(attr) {
93
131
  const attrName = normalizeAttr(attr);
@@ -95,7 +133,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
95
133
  expression: `#${attrName}`,
96
134
  names: { [`#${attrName}`]: attrName },
97
135
  };
98
- return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, remove: [...updateActions.remove, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
136
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, remove: [...updateActions.remove, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
99
137
  },
100
138
  add(attr, value) {
101
139
  const attrName = normalizeAttr(attr);
@@ -105,7 +143,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
105
143
  names: { [`#${attrName}`]: attrName },
106
144
  values: { [`:${valueName}`]: value },
107
145
  };
108
- return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, add: [...updateActions.add, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
146
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, add: [...updateActions.add, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
109
147
  },
110
148
  delete(attr, value) {
111
149
  const attrName = normalizeAttr(attr);
@@ -115,13 +153,13 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
115
153
  names: { [`#${attrName}`]: attrName },
116
154
  values: { [`:${valueName}`]: value },
117
155
  };
118
- return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, delete: [...updateActions.delete, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
156
+ return createUpdateBuilder(tableName, key, client, conditions, { ...updateActions, delete: [...updateActions.delete, action] }, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
119
157
  },
120
158
  returning(mode) {
121
- return createUpdateBuilder(tableName, key, client, conditions, updateActions, mode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity);
159
+ return createUpdateBuilder(tableName, key, client, conditions, updateActions, mode, valueCounter, enableTimestamps, logger, indexContext, setInputs, consumedCapacity, setIfNotExistsInputs);
122
160
  },
123
161
  returnConsumedCapacity(mode) {
124
- return createUpdateBuilder(tableName, key, client, conditions, updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, mode);
162
+ return createUpdateBuilder(tableName, key, client, conditions, updateActions, returnMode, valueCounter, enableTimestamps, logger, indexContext, setInputs, mode, setIfNotExistsInputs);
125
163
  },
126
164
  dbParams() {
127
165
  // Clone updateActions to avoid mutation
@@ -136,6 +174,7 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
136
174
  // structured) — for remove/add/delete we read from the action's
137
175
  // attribute-name map.
138
176
  const setFields = Object.keys(setInputs);
177
+ const setIfNotExistsFields = Object.keys(setIfNotExistsInputs);
139
178
  const removeFields = updateActions.remove
140
179
  .map(actionAttrName)
141
180
  .filter((n) => !!n);
@@ -150,15 +189,21 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
150
189
  // the user changes a field that participates in the PK/SK template,
151
190
  // the row's PK doesn't move (DynamoDB doesn't allow that) but the
152
191
  // 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.
192
+ // old value. Catch this on every op (.set / .setIfNotExists /
193
+ // .remove / .add / .delete) before it leaves the process.
155
194
  const primaryKeyTemplateVars = new Set();
156
195
  for (const keyDef of Object.values(indexContext.model.key)) {
157
196
  for (const v of (0, model_utils_1.extractTemplateVars)(keyDef.value)) {
158
197
  primaryKeyTemplateVars.add(v);
159
198
  }
160
199
  }
161
- const allTouched = [...setFields, ...removeFields, ...addFields, ...deleteFields];
200
+ const allTouched = [
201
+ ...setFields,
202
+ ...setIfNotExistsFields,
203
+ ...removeFields,
204
+ ...addFields,
205
+ ...deleteFields,
206
+ ];
162
207
  const pkConflicts = [
163
208
  ...new Set(allTouched.filter((f) => primaryKeyTemplateVars.has(f))),
164
209
  ];
@@ -174,6 +219,13 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
174
219
  // the new value, REMOVE strips the field entirely, and DELETE
175
220
  // mutates a Set without naming a scalar. Switching to .set(field,
176
221
  // newValue) lets the recompute path handle it correctly.
222
+ //
223
+ // Guard 3: .setIfNotExists() against a field used in a SECONDARY-index
224
+ // template. The resolved value is decided by DynamoDB at write time
225
+ // (current attr vs `:v`), so the recompute path cannot statically
226
+ // produce a value that's guaranteed consistent with the stored
227
+ // attribute. Allowing it would silently corrupt the index whenever
228
+ // the conditional write keeps the existing value.
177
229
  if (indexContext.model.index) {
178
230
  const indexTemplateVars = new Set();
179
231
  for (const indexDef of Object.values(indexContext.model.index)) {
@@ -192,6 +244,18 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
192
244
  `recomputed without an explicit new value. Use .set(field, ` +
193
245
  `newValue) instead so the index key is recomputed atomically.`);
194
246
  }
247
+ const ifNotExistsGsiConflicts = [
248
+ ...new Set(setIfNotExistsFields.filter((f) => indexTemplateVars.has(f))),
249
+ ];
250
+ if (ifNotExistsGsiConflicts.length > 0) {
251
+ throw new Error(`Cannot use .setIfNotExists() on field(s) ` +
252
+ `[${ifNotExistsGsiConflicts.join(', ')}] — they participate in a ` +
253
+ `secondary-index template, and if_not_exists() may keep the ` +
254
+ `existing value at write time, which would leave the index key ` +
255
+ `inconsistent with the stored attribute. Either restructure the ` +
256
+ `schema so this field is not part of any GSI template, or perform ` +
257
+ `a get + conditional .set() in two steps.`);
258
+ }
195
259
  }
196
260
  const { actions: idxActions, missing } = (0, model_utils_1.computeIndexUpdates)(indexContext.model, indexContext.keyVars, setInputs);
197
261
  if (missing.length > 0) {
@@ -237,6 +301,27 @@ function createUpdateBuilder(tableName, key, client, prevConditions = [], update
237
301
  };
238
302
  actionsToProcess.set = [...actionsToProcess.set, timestampAction];
239
303
  }
304
+ // Dedup guard: DynamoDB rejects overlapping document paths inside a
305
+ // SET expression (e.g. `SET #foo = :a, #foo = :b` → ValidationException
306
+ // "Two document paths overlap"). Catch the common footguns —
307
+ // .set().set() on the same key, .set() + .setIfNotExists() on the
308
+ // same key, and enableTimestamps + .set/.setIfNotExists('updatedAt')
309
+ // — before the network round-trip. Runs AFTER the timestamp injection
310
+ // so #updatedAt collisions are also reported.
311
+ const setActionNames = actionsToProcess.set
312
+ .map(actionAttrName)
313
+ .filter((n) => !!n);
314
+ const duplicateSetAttrs = [
315
+ ...new Set(setActionNames.filter((n, i) => setActionNames.indexOf(n) !== i)),
316
+ ];
317
+ if (duplicateSetAttrs.length > 0) {
318
+ throw new Error(`Update would emit multiple SET actions targeting the same ` +
319
+ `attribute(s) [${duplicateSetAttrs.join(', ')}]. DynamoDB rejects ` +
320
+ `overlapping document paths. Check that you're not combining ` +
321
+ `.set() and .setIfNotExists() on the same field, calling .set() ` +
322
+ `twice for the same key, or targeting an attribute that ` +
323
+ `enableTimestamps already manages (updatedAt).`);
324
+ }
240
325
  const buildSection = (label, actions, includeValues) => actions.length === 0
241
326
  ? null
242
327
  : {
@@ -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.0.0",
3
+ "version": "2.2.0",
4
4
  "description": "Core library for DynamoDB single table design",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",