@model-ts/dynamodb 4.1.0 → 4.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cjs/__test__/client-env-guard.test.d.ts +1 -0
  3. package/dist/cjs/__test__/client-env-guard.test.js +28 -0
  4. package/dist/cjs/__test__/client-env-guard.test.js.map +1 -0
  5. package/dist/cjs/__test__/conformance.test.d.ts +1 -0
  6. package/dist/cjs/__test__/conformance.test.js +1835 -0
  7. package/dist/cjs/__test__/conformance.test.js.map +1 -0
  8. package/dist/cjs/__test__/in-memory.spec.test.d.ts +1 -0
  9. package/dist/cjs/__test__/in-memory.spec.test.js +234 -0
  10. package/dist/cjs/__test__/in-memory.spec.test.js.map +1 -0
  11. package/dist/cjs/client.d.ts +2 -2
  12. package/dist/cjs/client.js +14 -6
  13. package/dist/cjs/client.js.map +1 -1
  14. package/dist/cjs/errors.d.ts +13 -1
  15. package/dist/cjs/errors.js +12 -1
  16. package/dist/cjs/errors.js.map +1 -1
  17. package/dist/cjs/in-memory/document-client.d.ts +45 -0
  18. package/dist/cjs/in-memory/document-client.js +795 -0
  19. package/dist/cjs/in-memory/document-client.js.map +1 -0
  20. package/dist/cjs/in-memory/expression.d.ts +42 -0
  21. package/dist/cjs/in-memory/expression.js +665 -0
  22. package/dist/cjs/in-memory/expression.js.map +1 -0
  23. package/dist/cjs/in-memory/index.d.ts +2 -0
  24. package/dist/cjs/in-memory/index.js +6 -0
  25. package/dist/cjs/in-memory/index.js.map +1 -0
  26. package/dist/cjs/in-memory/spec.d.ts +23 -0
  27. package/dist/cjs/in-memory/spec.js +141 -0
  28. package/dist/cjs/in-memory/spec.js.map +1 -0
  29. package/dist/cjs/in-memory/store.d.ts +73 -0
  30. package/dist/cjs/in-memory/store.js +267 -0
  31. package/dist/cjs/in-memory/store.js.map +1 -0
  32. package/dist/cjs/in-memory/treap.d.ts +31 -0
  33. package/dist/cjs/in-memory/treap.js +187 -0
  34. package/dist/cjs/in-memory/treap.js.map +1 -0
  35. package/dist/cjs/in-memory/utils.d.ts +10 -0
  36. package/dist/cjs/in-memory/utils.js +38 -0
  37. package/dist/cjs/in-memory/utils.js.map +1 -0
  38. package/dist/cjs/sandbox.js +44 -0
  39. package/dist/cjs/sandbox.js.map +1 -1
  40. package/dist/esm/__test__/client-env-guard.test.d.ts +1 -0
  41. package/dist/esm/__test__/client-env-guard.test.js +26 -0
  42. package/dist/esm/__test__/client-env-guard.test.js.map +1 -0
  43. package/dist/esm/__test__/conformance.test.d.ts +1 -0
  44. package/dist/esm/__test__/conformance.test.js +1833 -0
  45. package/dist/esm/__test__/conformance.test.js.map +1 -0
  46. package/dist/esm/__test__/in-memory.spec.test.d.ts +1 -0
  47. package/dist/esm/__test__/in-memory.spec.test.js +232 -0
  48. package/dist/esm/__test__/in-memory.spec.test.js.map +1 -0
  49. package/dist/esm/client.d.ts +2 -2
  50. package/dist/esm/client.js +14 -6
  51. package/dist/esm/client.js.map +1 -1
  52. package/dist/esm/errors.d.ts +13 -1
  53. package/dist/esm/errors.js +10 -0
  54. package/dist/esm/errors.js.map +1 -1
  55. package/dist/esm/in-memory/document-client.d.ts +45 -0
  56. package/dist/esm/in-memory/document-client.js +791 -0
  57. package/dist/esm/in-memory/document-client.js.map +1 -0
  58. package/dist/esm/in-memory/expression.d.ts +42 -0
  59. package/dist/esm/in-memory/expression.js +657 -0
  60. package/dist/esm/in-memory/expression.js.map +1 -0
  61. package/dist/esm/in-memory/index.d.ts +2 -0
  62. package/dist/esm/in-memory/index.js +3 -0
  63. package/dist/esm/in-memory/index.js.map +1 -0
  64. package/dist/esm/in-memory/spec.d.ts +23 -0
  65. package/dist/esm/in-memory/spec.js +138 -0
  66. package/dist/esm/in-memory/spec.js.map +1 -0
  67. package/dist/esm/in-memory/store.d.ts +73 -0
  68. package/dist/esm/in-memory/store.js +258 -0
  69. package/dist/esm/in-memory/store.js.map +1 -0
  70. package/dist/esm/in-memory/treap.d.ts +31 -0
  71. package/dist/esm/in-memory/treap.js +183 -0
  72. package/dist/esm/in-memory/treap.js.map +1 -0
  73. package/dist/esm/in-memory/utils.d.ts +10 -0
  74. package/dist/esm/in-memory/utils.js +28 -0
  75. package/dist/esm/in-memory/utils.js.map +1 -0
  76. package/dist/esm/sandbox.js +44 -0
  77. package/dist/esm/sandbox.js.map +1 -1
  78. package/package.json +2 -1
  79. package/src/__test__/client-env-guard.test.ts +31 -0
  80. package/src/__test__/conformance.test.ts +2042 -0
  81. package/src/__test__/in-memory.spec.test.ts +283 -0
  82. package/src/client.ts +17 -4
  83. package/src/errors.ts +24 -0
  84. package/src/in-memory/document-client.ts +1140 -0
  85. package/src/in-memory/expression.ts +830 -0
  86. package/src/in-memory/index.ts +2 -0
  87. package/src/in-memory/spec.ts +159 -0
  88. package/src/in-memory/store.ts +360 -0
  89. package/src/in-memory/treap.ts +239 -0
  90. package/src/in-memory/utils.ts +45 -0
  91. package/src/sandbox.ts +56 -0
@@ -0,0 +1,283 @@
1
+ import * as t from "io-ts"
2
+ import { model } from "@model-ts/core"
3
+ import { Client } from "../client"
4
+ import { getProvider } from "../provider"
5
+ import { createSandbox, Sandbox } from "../sandbox"
6
+ import { NotSupportedError } from "../errors"
7
+ import { IN_MEMORY_SPEC } from "../in-memory/spec"
8
+
9
+ const CODEC = t.type({
10
+ id: t.string,
11
+ group: t.string,
12
+ value: t.number,
13
+ })
14
+
15
+ const withInMemory = async <T>(
16
+ run: (ctx: {
17
+ client: Client
18
+ sandbox: Sandbox
19
+ Item: any
20
+ }) => Promise<T>
21
+ ): Promise<T> => {
22
+ const previous = process.env.EXPERIMENTAL_DYNAMODB_IN_MEMORY
23
+ process.env.EXPERIMENTAL_DYNAMODB_IN_MEMORY = "1"
24
+
25
+ const client = new Client({ tableName: "table" })
26
+ const provider = getProvider(client)
27
+
28
+ class Item extends model("Item", CODEC, provider) {
29
+ get PK() {
30
+ return `PK#${this.group}`
31
+ }
32
+
33
+ get SK() {
34
+ return `SK#${String(this.value).padStart(3, "0")}`
35
+ }
36
+
37
+ get GSI2PK() {
38
+ return `GSI2PK#${this.group}`
39
+ }
40
+
41
+ get GSI2SK() {
42
+ return `GSI2SK#${this.id}`
43
+ }
44
+ }
45
+
46
+ const sandbox = await createSandbox(client)
47
+
48
+ try {
49
+ return await run({ client, sandbox, Item })
50
+ } finally {
51
+ await sandbox.destroy()
52
+
53
+ if (typeof previous === "undefined")
54
+ delete process.env.EXPERIMENTAL_DYNAMODB_IN_MEMORY
55
+ else process.env.EXPERIMENTAL_DYNAMODB_IN_MEMORY = previous
56
+ }
57
+ }
58
+
59
+ describe("in-memory spec", () => {
60
+ test("locks projection and index scope", () => {
61
+ expect(IN_MEMORY_SPEC.projection).toBe("ALL")
62
+ expect(IN_MEMORY_SPEC.excludedIndexes).toEqual(["GSI1"])
63
+ expect(IN_MEMORY_SPEC.indexes.includes("primary")).toBe(true)
64
+ expect(IN_MEMORY_SPEC.indexes.includes("GSI2")).toBe(true)
65
+ expect(IN_MEMORY_SPEC.indexes.includes("GSI19")).toBe(true)
66
+ expect(IN_MEMORY_SPEC.indexes.includes("GSI1" as any)).toBe(false)
67
+ })
68
+
69
+ test("throws deterministic NotSupportedError for unsupported method", async () => {
70
+ await withInMemory(async ({ client }) => {
71
+ await expect(
72
+ (client.documentClient as any).transactGet({}).promise()
73
+ ).rejects.toMatchObject({
74
+ name: "NotSupportedError",
75
+ code: "NotSupportedError",
76
+ method: "transactGet",
77
+ featurePath: "transactGet",
78
+ })
79
+ })
80
+ })
81
+
82
+ test("rejects GSI1 with deterministic NotSupportedError", async () => {
83
+ await withInMemory(async ({ client, Item }) => {
84
+ await new Item({ id: "1", group: "g", value: 1 }).put()
85
+
86
+ await expect(
87
+ client.documentClient
88
+ .query({
89
+ TableName: client.tableName,
90
+ IndexName: "GSI1",
91
+ KeyConditionExpression: "PK = :pk",
92
+ ExpressionAttributeValues: { ":pk": "PK#g" },
93
+ })
94
+ .promise()
95
+ ).rejects.toMatchObject({
96
+ name: "NotSupportedError",
97
+ code: "NotSupportedError",
98
+ method: "query",
99
+ featurePath: "query.IndexName",
100
+ })
101
+ })
102
+ })
103
+
104
+ test("keeps GSI membership in sync after update/removal", async () => {
105
+ await withInMemory(async ({ client, Item }) => {
106
+ const item = await new Item({ id: "42", group: "test", value: 42 }).put()
107
+
108
+ const before = await client.documentClient
109
+ .query({
110
+ TableName: client.tableName,
111
+ IndexName: "GSI2",
112
+ KeyConditionExpression: "GSI2PK = :pk",
113
+ ExpressionAttributeValues: { ":pk": "GSI2PK#test" },
114
+ })
115
+ .promise()
116
+
117
+ expect(before.Count).toBe(1)
118
+
119
+ await Item.updateRaw(
120
+ { PK: item.PK, SK: item.SK },
121
+ { GSI2PK: null, GSI2SK: null } as any
122
+ )
123
+
124
+ const after = await client.documentClient
125
+ .query({
126
+ TableName: client.tableName,
127
+ IndexName: "GSI2",
128
+ KeyConditionExpression: "GSI2PK = :pk",
129
+ ExpressionAttributeValues: { ":pk": "GSI2PK#test" },
130
+ })
131
+ .promise()
132
+
133
+ expect(after.Count).toBe(0)
134
+ })
135
+ })
136
+
137
+ test("supports if_not_exists in update expressions", async () => {
138
+ await withInMemory(async ({ client, Item }) => {
139
+ const item = await new Item({ id: "100", group: "expr", value: 1 }).put()
140
+
141
+ const first = await client.documentClient
142
+ .update({
143
+ TableName: client.tableName,
144
+ Key: { PK: item.PK, SK: item.SK },
145
+ UpdateExpression: "SET #count = if_not_exists(#count, :zero) + :inc",
146
+ ExpressionAttributeNames: { "#count": "count" },
147
+ ExpressionAttributeValues: { ":zero": 0, ":inc": 2 },
148
+ ReturnValues: "ALL_NEW",
149
+ })
150
+ .promise()
151
+
152
+ expect(first.Attributes?.count).toBe(2)
153
+
154
+ const second = await client.documentClient
155
+ .update({
156
+ TableName: client.tableName,
157
+ Key: { PK: item.PK, SK: item.SK },
158
+ UpdateExpression: "SET #count = if_not_exists(#count, :zero) + :inc",
159
+ ExpressionAttributeNames: { "#count": "count" },
160
+ ExpressionAttributeValues: { ":zero": 0, ":inc": 3 },
161
+ ReturnValues: "ALL_NEW",
162
+ })
163
+ .promise()
164
+
165
+ expect(second.Attributes?.count).toBe(5)
166
+ })
167
+ })
168
+
169
+ test("supports list_append and filter functions", async () => {
170
+ await withInMemory(async ({ client, Item }) => {
171
+ const item = await new Item({ id: "200", group: "expr", value: 2 }).put()
172
+
173
+ await client.documentClient
174
+ .update({
175
+ TableName: client.tableName,
176
+ Key: { PK: item.PK, SK: item.SK },
177
+ UpdateExpression:
178
+ "SET #tags = list_append(if_not_exists(#tags, :empty), :more)",
179
+ ExpressionAttributeNames: { "#tags": "tags" },
180
+ ExpressionAttributeValues: { ":empty": [], ":more": ["a", "b"] },
181
+ ReturnValues: "ALL_NEW",
182
+ })
183
+ .promise()
184
+
185
+ const filtered = await client.documentClient
186
+ .scan({
187
+ TableName: client.tableName,
188
+ FilterExpression:
189
+ "contains(#tags, :tag) and attribute_type(#id, :t) and size(#id) > :min",
190
+ ExpressionAttributeNames: { "#tags": "tags", "#id": "id" },
191
+ ExpressionAttributeValues: { ":tag": "a", ":t": "S", ":min": 1 },
192
+ })
193
+ .promise()
194
+
195
+ expect(filtered.Count).toBe(1)
196
+ expect(filtered.Items?.[0]?.id).toBe("200")
197
+ })
198
+ })
199
+
200
+ test("supports document paths with list indexes in condition expressions", async () => {
201
+ await withInMemory(async ({ client }) => {
202
+ await client.documentClient
203
+ .put({
204
+ TableName: client.tableName,
205
+ Item: {
206
+ PK: "PK#expr",
207
+ SK: "SK#300",
208
+ id: "300",
209
+ group: "expr",
210
+ value: 3,
211
+ tenancy: {
212
+ mailboxes: [{ inboxId: "inbox-1" }],
213
+ },
214
+ },
215
+ })
216
+ .promise()
217
+
218
+ const updated = await client.documentClient
219
+ .update({
220
+ TableName: client.tableName,
221
+ Key: { PK: "PK#expr", SK: "SK#300" },
222
+ ConditionExpression: "tenancy.mailboxes[0].inboxId = :inboxId",
223
+ UpdateExpression: "SET #value = :next",
224
+ ExpressionAttributeNames: { "#value": "value" },
225
+ ExpressionAttributeValues: { ":inboxId": "inbox-1", ":next": 4 },
226
+ ReturnValues: "ALL_NEW",
227
+ })
228
+ .promise()
229
+
230
+ expect(updated.Attributes?.value).toBe(4)
231
+
232
+ const updatedWithNames = await client.documentClient
233
+ .update({
234
+ TableName: client.tableName,
235
+ Key: { PK: "PK#expr", SK: "SK#300" },
236
+ ConditionExpression: "#tenancy.#mailboxes[0].#inboxId = :inboxId",
237
+ UpdateExpression: "SET #value = :next",
238
+ ExpressionAttributeNames: {
239
+ "#value": "value",
240
+ "#tenancy": "tenancy",
241
+ "#mailboxes": "mailboxes",
242
+ "#inboxId": "inboxId",
243
+ },
244
+ ExpressionAttributeValues: { ":inboxId": "inbox-1", ":next": 5 },
245
+ ReturnValues: "ALL_NEW",
246
+ })
247
+ .promise()
248
+
249
+ expect(updatedWithNames.Attributes?.value).toBe(5)
250
+ })
251
+ })
252
+
253
+ test("is deterministic for repeated seeded runs", async () => {
254
+ const execute = async () =>
255
+ withInMemory(async ({ client, sandbox, Item }) => {
256
+ const order = [7, 1, 9, 2, 8, 3, 6, 4, 5, 0]
257
+
258
+ await Promise.all(
259
+ order.map((value) =>
260
+ new Item({ id: String(value), group: "det", value }).put()
261
+ )
262
+ )
263
+
264
+ const page = await client.documentClient
265
+ .query({
266
+ TableName: client.tableName,
267
+ KeyConditionExpression: "PK = :pk",
268
+ ExpressionAttributeValues: { ":pk": "PK#det" },
269
+ })
270
+ .promise()
271
+
272
+ return {
273
+ snapshot: await sandbox.snapshot(),
274
+ queryKeys: (page.Items ?? []).map((entry) => `${entry.PK}::${entry.SK}`),
275
+ }
276
+ })
277
+
278
+ const first = await execute()
279
+ const second = await execute()
280
+
281
+ expect(second).toEqual(first)
282
+ })
283
+ })
package/src/client.ts CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  PaginationResult,
45
45
  } from "./pagination"
46
46
  import { GSI, GSI_NAMES, GSIPK, GSISK } from "./gsi"
47
+ import { createInMemoryDocumentClient } from "./in-memory"
47
48
 
48
49
  export type QueryParams = Omit<
49
50
  DocumentClient.QueryInput,
@@ -110,7 +111,18 @@ export class Client {
110
111
  this.tableName = props?.tableName
111
112
  this.cursorEncryptionKey = props?.cursorEncryptionKey
112
113
  this.paginationOptions = props?.paginationOptions
113
- this.documentClient = new DocumentClient(props)
114
+
115
+ const inMemoryRequested = process.env.EXPERIMENTAL_DYNAMODB_IN_MEMORY === "1"
116
+ if (inMemoryRequested && process.env.NODE_ENV !== "test") {
117
+ throw new Error(
118
+ `EXPERIMENTAL_DYNAMODB_IN_MEMORY=1 is only allowed when NODE_ENV is "test" (received "${process.env.NODE_ENV ?? "undefined"}").`
119
+ )
120
+ }
121
+
122
+ this.documentClient =
123
+ inMemoryRequested
124
+ ? (createInMemoryDocumentClient() as any as DocumentClient)
125
+ : new DocumentClient(props)
114
126
  this.dataLoader = new DataLoader<
115
127
  GetOperation<Decodable>,
116
128
  DynamoDBModelInstance,
@@ -160,6 +172,7 @@ export class Client {
160
172
  M extends DynamoDBModelConstructor<T>
161
173
  >({
162
174
  _model,
175
+ _operation,
163
176
  _deleted,
164
177
  item,
165
178
  IgnoreExistence,
@@ -200,6 +213,7 @@ export class Client {
200
213
 
201
214
  async get<M extends Decodable>({
202
215
  _model,
216
+ _operation,
203
217
  key,
204
218
  ...params
205
219
  }: GetOperation<M>): Promise<DecodableInstance<M>> {
@@ -272,7 +286,7 @@ export class Client {
272
286
  async updateRaw<M extends DynamoDBModelConstructor<any>>(
273
287
  operation: UpdateRawOperation<M>
274
288
  ): Promise<InstanceType<M>> {
275
- const { _model, key, attributes, ...params } = operation
289
+ const { _model, _operation, key, attributes, ...params } = operation
276
290
 
277
291
  const {
278
292
  UpdateExpression,
@@ -831,8 +845,7 @@ export class Client {
831
845
  ): DocumentClient.TransactWriteItem => {
832
846
  switch (operation._operation) {
833
847
  case "put": {
834
- const { _model, _deleted, item, IgnoreExistence, ...params } = operation
835
- debugger
848
+ const { _model, _operation, _deleted, item, IgnoreExistence, ...params } = operation
836
849
  const encoded = (_model as M & DynamoDBInternals<M>).__dynamoDBEncode(
837
850
  item
838
851
  )
package/src/errors.ts CHANGED
@@ -45,6 +45,29 @@ export class PaginationError extends Error {
45
45
  name = "PaginationError"
46
46
  }
47
47
 
48
+ export class NotSupportedError extends Error {
49
+ name = "NotSupportedError"
50
+ code = "NotSupportedError"
51
+ method: string
52
+ featurePath: string
53
+ reason: string
54
+
55
+ constructor({
56
+ method,
57
+ featurePath,
58
+ reason,
59
+ }: {
60
+ method: string
61
+ featurePath: string
62
+ reason: string
63
+ }) {
64
+ super(reason)
65
+ this.method = method
66
+ this.featurePath = featurePath
67
+ this.reason = reason
68
+ }
69
+ }
70
+
48
71
  export type DynamoDBError =
49
72
  | KeyExistsError
50
73
  | ItemNotFoundError
@@ -52,3 +75,4 @@ export type DynamoDBError =
52
75
  | BulkWriteTransactionError
53
76
  | BulkWriteRollbackError
54
77
  | PaginationError
78
+ | NotSupportedError