Skip to content

Commit 215c582

Browse files
authored
[Fix] Fix divergencies between documentation and implementation (#580)
* Addresses divergencies between documentation and implementation discovered by Claude Fable. * Clean up * 3.9.1 * Clean up * Clean up
1 parent fc1ffd0 commit 215c582

18 files changed

Lines changed: 566 additions & 47 deletions

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -664,4 +664,15 @@ All notable changes to this project will be documented in this file. Breaking ch
664664

665665
## [3.9.0]
666666
### Added
667-
- Added a new `returnOnConditionCheckFailure` execution option for write operations. Instead of throwing when a condition expression fails, the call resolves with `{ rejected: true }`. Pass `true` to get just the rejection flag, or `"all_old"` to also receive the existing item under `data`. The `"all_old"` mode requires AWS SDK v3, or AWS SDK v2 >= `2.1408.0`.
667+
- Added a new `returnOnConditionCheckFailure` execution option for write operations. Instead of throwing when a condition expression fails, the call resolves with `{ rejected: true }`. Pass `true` to get just the rejection flag, or `"all_old"` to also receive the existing item under `data`. The `"all_old"` mode requires AWS SDK v3, or AWS SDK v2 >= `2.1408.0`.
668+
669+
## [3.9.1]
670+
### Fixed
671+
- The `unprocessed: "raw"` execution option now works as documented. Previously the option was silently ignored, and unprocessed keys returned from batch operations were always deconstructed into composite attributes; passing `"raw"` now returns them in their raw key format.
672+
- Set attributes flagged `hidden: true` are no longer returned when nested inside a `map` attribute. Hidden attributes of every type and depth now stay hidden — if your application was reading these values on parent map attributes despite the flag, they will no longer be present in responses.
673+
- Set attributes defined with enum items now produce a valid, typed DynamoDB Set when generating parameters without an attached client. Previously the generated set was malformed (missing its type) and could be rejected by DynamoDB if sent as-is. Relatedly, defining a set with an unsupported member type now fails immediately with an `Invalid Set type` error instead of continuing with an invalid set.
674+
- Defining an attribute with an invalid `watch` value now fails with a validation error explaining the accepted values (an array of attribute names or `"*"`). Previously entity construction crashed with an unhelpful `ReferenceError`.
675+
- Transaction results are now reliable in several edge cases: `transactGet` results keep their expected order and count even when a returned item cannot be matched to one of your entities (such slots resolve to `null`); canceled transactions containing such items no longer throw; each result in a transaction response is now a distinct object, so modifying one no longer silently modifies the others; and passing the same options object to multiple `.go()` calls no longer causes loggers and listeners to fire additional times with each call.
676+
677+
### Changed
678+
- Corrected documentation that had drifted from actual behavior. Most notably, the descriptions of `find` and `match` were swapped: `match` is the method that applies your provided values as query filters, while `find` only uses them to seek an index. If you chose between these methods based on the documentation, double-check you are using the one you intended.

index.d.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,26 @@ type TransactGetCommandInput = {
1919
TransactItems: TransactGetItem[];
2020
};
2121

22+
export type V2DocumentClient = {
23+
get: DocumentClientMethod;
24+
put: DocumentClientMethod;
25+
delete: DocumentClientMethod;
26+
update: DocumentClientMethod;
27+
batchWrite: DocumentClientMethod;
28+
batchGet: DocumentClientMethod;
29+
scan: DocumentClientMethod;
30+
transactGet: DocumentClientMethod;
31+
transactWrite: DocumentClientMethod;
32+
query: DocumentClientMethod;
33+
};
34+
35+
export type V3DocumentClient = {
36+
send: (command: any) => Promise<any>;
37+
};
38+
2239
export type DocumentClient =
23-
| {
24-
get: DocumentClientMethod;
25-
put: DocumentClientMethod;
26-
delete: DocumentClientMethod;
27-
update: DocumentClientMethod;
28-
batchWrite: DocumentClientMethod;
29-
batchGet: DocumentClientMethod;
30-
scan: DocumentClientMethod;
31-
transactGet: DocumentClientMethod;
32-
transactWrite: DocumentClientMethod;
33-
query: DocumentClientMethod;
34-
}
35-
| {
36-
send: (command: any) => Promise<any>;
37-
};
40+
| V2DocumentClient
41+
| V3DocumentClient;
3842

3943
export type AllCollectionNames<
4044
E extends { [name: string]: Entity<any, any, any, any> },

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "electrodb",
3-
"version": "3.9.0",
3+
"version": "3.9.1",
44
"description": "A library to more easily create and interact with multiple entities and heretical relationships in dynamodb",
55
"main": "index.js",
66
"scripts": {

src/entity.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1960,7 +1960,7 @@ class Entity {
19601960

19611961
if (typeof option.unprocessed === "string") {
19621962
if (typeof UnprocessedTypes[option.unprocessed] === "string") {
1963-
config.unproessed = UnprocessedTypes[option.unprocessed];
1963+
config.unprocessed = UnprocessedTypes[option.unprocessed];
19641964
} else {
19651965
throw new e.ElectroError(
19661966
e.ErrorCodes.InvalidOptions,

src/schema.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,9 @@ class SetAttribute extends Attribute {
11251125
? (value, getSiblings) => get(value, getSiblings())
11261126
: (attr) => attr;
11271127
return (values, getSiblings) => {
1128+
if (this.hidden) {
1129+
return;
1130+
}
11281131
if (values !== undefined) {
11291132
const data = this.fromDDBSet(values);
11301133
return getter(data, getSiblings);
@@ -1423,7 +1426,7 @@ class Schema {
14231426
} else {
14241427
throw new e.ElectroError(
14251428
e.ErrorCodes.InvalidAttributeWatchDefinition,
1426-
`Attribute Validation Error. The attribute '${name}' is defined to "watch" an invalid value of: '${attribute.watch}'. The watch property must either be a an array of attribute names, or the single string value of "${WatchAll}".`,
1429+
`Attribute Validation Error. The attribute '${name}' is defined to "watch" an invalid value of: '${attribute.watch}'. The watch property must either be a an array of attribute names, or the single string value of "${AttributeWildCard}".`,
14271430
);
14281431
}
14291432
} else {

src/set.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@ const memberTypeToSetType = {
55
Binary: "Binary",
66
string: "String",
77
number: "Number",
8+
enum: "String",
89
};
910

1011
class DynamoDBSet {
1112
constructor(list, type) {
1213
this.wrapperName = "Set";
1314
this.type = memberTypeToSetType[type];
1415
if (this.type === undefined) {
15-
new Error(`Invalid Set type: ${type}`);
16+
throw new Error(`Invalid Set type: ${type}`);
1617
}
1718
this.values = Array.from(new Set([].concat(list)));
1819
}

src/transaction.js

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@ function cleanseCanceledData(
3131
paramItem,
3232
identifiers,
3333
});
34-
result.item = entities[entityAlias].formatResponse({ Item }, index, {
35-
...config,
36-
pager: false,
37-
parse: undefined,
38-
}).data;
34+
if (entityAlias) {
35+
result.item = entities[entityAlias].formatResponse({ Item }, index, {
36+
...config,
37+
pager: false,
38+
parse: undefined,
39+
}).data;
40+
} else {
41+
result.item = null;
42+
}
3943
} else {
4044
result.item = null;
4145
}
@@ -69,6 +73,7 @@ function cleanseTransactionData(
6973
const paramItem = paramItems[i];
7074
const entityAlias = matchToEntityAlias({ paramItem, identifiers, record });
7175
if (!entityAlias) {
76+
results.push(null);
7277
continue;
7378
}
7479

@@ -130,14 +135,14 @@ function createTransaction(options) {
130135
data: [],
131136
};
132137
}
138+
let listeners =
139+
options && options.listeners ? [...options.listeners] : [];
133140
if (options && options.logger) {
134-
if (!options.listeners) {
135-
options.listeners = [];
136-
}
137-
options.listeners.push(options.logger);
141+
listeners = [...listeners, options.logger];
138142
}
139143
const response = await driver.go(method, params, {
140144
...options,
145+
listeners,
141146
parse: (options, data) => {
142147
if (options.data === DataOptions.raw) {
143148
return data;
@@ -162,12 +167,15 @@ function createTransaction(options) {
162167
},
163168
);
164169
} else {
165-
return new Array(paramItems ? paramItems.length : 0).fill({
166-
item: null,
167-
code: "None",
168-
rejected: false,
169-
message: undefined,
170-
});
170+
return Array.from(
171+
{ length: paramItems ? paramItems.length : 0 },
172+
() => ({
173+
item: null,
174+
code: "None",
175+
rejected: false,
176+
message: undefined,
177+
}),
178+
);
171179
}
172180
},
173181
});
@@ -202,4 +210,6 @@ module.exports = {
202210
createTransaction,
203211
createWriteTransaction,
204212
createGetTransaction,
213+
cleanseTransactionData,
214+
cleanseCanceledData,
205215
};

test/fixtures/mock-client.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import type { DocumentClient } from "aws-sdk/clients/dynamodb";
2+
import type { V2DocumentClient } from "../../index";
3+
4+
export type StoredItem = Record<string, any>;
5+
6+
export type MockableMethod = keyof V2DocumentClient;
7+
8+
const MockableMethods: Record<MockableMethod, MockableMethod> = {
9+
get: "get",
10+
put: "put",
11+
update: "update",
12+
delete: "delete",
13+
batchWrite: "batchWrite",
14+
batchGet: "batchGet",
15+
scan: "scan",
16+
query: "query",
17+
transactWrite: "transactWrite",
18+
transactGet: "transactGet",
19+
}
20+
21+
export type CanceledTransactionSimulation = {
22+
cancellationReasons: Array<{
23+
Code?: string;
24+
Item?: StoredItem;
25+
Message?: string;
26+
}>;
27+
};
28+
29+
type MethodInput = {
30+
get: DocumentClient.GetItemInput;
31+
put: DocumentClient.PutItemInput;
32+
update: DocumentClient.UpdateItemInput;
33+
delete: DocumentClient.DeleteItemInput;
34+
batchWrite: DocumentClient.BatchWriteItemInput;
35+
batchGet: DocumentClient.BatchGetItemInput;
36+
scan: DocumentClient.ScanInput;
37+
query: DocumentClient.QueryInput;
38+
transactWrite: DocumentClient.TransactWriteItemsInput;
39+
transactGet: DocumentClient.TransactGetItemsInput;
40+
};
41+
42+
type MethodOutput = {
43+
get: DocumentClient.GetItemOutput;
44+
put: DocumentClient.PutItemOutput;
45+
update: DocumentClient.UpdateItemOutput;
46+
delete: DocumentClient.DeleteItemOutput;
47+
batchWrite: DocumentClient.BatchWriteItemOutput;
48+
batchGet: DocumentClient.BatchGetItemOutput;
49+
scan: DocumentClient.ScanOutput;
50+
query: DocumentClient.QueryOutput;
51+
transactWrite:
52+
| DocumentClient.TransactWriteItemsOutput
53+
| CanceledTransactionSimulation;
54+
transactGet:
55+
| DocumentClient.TransactGetItemsOutput
56+
| CanceledTransactionSimulation;
57+
};
58+
59+
export type MockHandler<Method extends MockableMethod = MockableMethod> =
60+
| MethodOutput[Method]
61+
| ((params: MethodInput[Method]) => MethodOutput[Method]);
62+
63+
/** method-name-keyed map: unknown method names are compile errors */
64+
export type MockHandlers = {
65+
[Method in MockableMethod]?: MockHandler<Method>;
66+
};
67+
68+
export type MockClientCall = {
69+
method: MockableMethod;
70+
params: Record<string, any>;
71+
};
72+
73+
export type MockedV2DocumentClient = V2DocumentClient & {
74+
createSet: (value: any) => Set<any>;
75+
};
76+
77+
export type MockV2Client = {
78+
client: MockedV2DocumentClient;
79+
calls: MockClientCall[];
80+
};
81+
82+
export function makeMockV2Client(handlers: MockHandlers = {}): MockV2Client {
83+
const calls: MockClientCall[] = [];
84+
const transactMethods = new Set<MockableMethod>([
85+
"transactWrite",
86+
"transactGet",
87+
]);
88+
const client: any = {
89+
createSet: (value: any) => new Set([].concat(value)),
90+
};
91+
for (const method of Object.values(MockableMethods)) {
92+
client[method] = (params: Record<string, any>) => {
93+
calls.push({ method, params });
94+
const handler = handlers[method];
95+
const value: any =
96+
typeof handler === "function"
97+
? (handler as (input: Record<string, any>) => unknown)(params)
98+
: handler;
99+
if (transactMethods.has(method)) {
100+
const stored: Record<string, (input: any) => void> = {};
101+
return {
102+
on: (event: string, callback: (input: any) => void) => {
103+
stored[event] = callback;
104+
},
105+
abort: () => {},
106+
promise: () => {
107+
if (value && value.cancellationReasons) {
108+
if (stored.extractError) {
109+
stored.extractError({
110+
httpResponse: {
111+
body: {
112+
toString: () =>
113+
JSON.stringify({
114+
CancellationReasons: value.cancellationReasons,
115+
}),
116+
},
117+
},
118+
});
119+
}
120+
return Promise.reject(new Error("TransactionCanceledException"));
121+
}
122+
return Promise.resolve(value === undefined ? {} : value);
123+
},
124+
};
125+
}
126+
return {
127+
promise: () => Promise.resolve(value === undefined ? {} : value),
128+
};
129+
};
130+
}
131+
132+
return { client, calls };
133+
}
134+
135+
export type MakePagingQueryHandlerOptions = {
136+
pages: number;
137+
perPage: number;
138+
/** produces the i-th stored item (from-DynamoDB shape, including key fields) */
139+
makeItem: (i: number) => StoredItem;
140+
};
141+
142+
export type PagingQueryResponse = {
143+
Items: StoredItem[];
144+
Count: number;
145+
LastEvaluatedKey?: { pk: any; sk: any };
146+
};
147+
148+
export type MakePagingQueryHandlerResponse = (
149+
params: Record<string, any>,
150+
) => PagingQueryResponse;
151+
152+
// Builds a `query`/`scan` handler that pages through `pages` responses of
153+
// `perPage` items each, emitting a `LastEvaluatedKey` on every page but the
154+
// last. Sort keys must be unique across items. Like DynamoDB, the handler
155+
// resumes from the params' `ExclusiveStartKey` by locating the item whose
156+
// pk/sk match, so it also honors cursors ElectroDB synthesizes mid-page
157+
// (e.g. when a `count` limit lands inside a page).
158+
export function makePagingQueryHandler(
159+
options: MakePagingQueryHandlerOptions,
160+
): MakePagingQueryHandlerResponse {
161+
const { pages, perPage, makeItem } = options;
162+
const items: StoredItem[] = [];
163+
const indexBySortKey = new Map<string, number>();
164+
for (let i = 0; i < pages * perPage; i++) {
165+
const item = makeItem(i);
166+
items.push(item);
167+
indexBySortKey.set(`${item.pk}|${item.sk}`, i);
168+
}
169+
return (params: Record<string, any>) => {
170+
let start = 0;
171+
const exclusiveStartKey = params.ExclusiveStartKey;
172+
if (exclusiveStartKey !== undefined) {
173+
const index = indexBySortKey.get(
174+
`${exclusiveStartKey.pk}|${exclusiveStartKey.sk}`,
175+
);
176+
if (index === undefined) {
177+
throw new Error("Unknown ExclusiveStartKey provided to paging mock");
178+
}
179+
start = index + 1;
180+
}
181+
const Items = items.slice(start, start + perPage);
182+
const response: PagingQueryResponse = { Items, Count: Items.length };
183+
const lastReturned = start + Items.length - 1;
184+
if (lastReturned < items.length - 1 && Items.length > 0) {
185+
const lastItem = Items[Items.length - 1];
186+
response.LastEvaluatedKey = { pk: lastItem.pk, sk: lastItem.sk };
187+
}
188+
return response;
189+
};
190+
}

0 commit comments

Comments
 (0)