@oino-ts/nosql-aws 1.0.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/dist/cjs/OINONoSqlAwsDynamo.js +605 -0
- package/dist/cjs/index.js +10 -0
- package/dist/esm/OINONoSqlAwsDynamo.js +601 -0
- package/dist/esm/index.js +6 -0
- package/dist/types/OINONoSqlAwsDynamo.d.ts +223 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +41 -0
- package/src/OINONoSqlAwsDynamo.ts +652 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
import { DynamoDBClient, DescribeTableCommand } from "@aws-sdk/client-dynamodb";
|
|
7
|
+
import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand, ScanCommand, QueryCommand, BatchWriteCommand } from "@aws-sdk/lib-dynamodb";
|
|
8
|
+
import { OINOResult, OINOQueryFilter, OINOQueryBooleanOperation, OINOQueryComparison, OINOQueryNullCheck, OINOStringDataField, OINODatetimeDataField } from "@oino-ts/common";
|
|
9
|
+
import { OINONoSql, OINONoSqlDataModel } from "@oino-ts/nosql";
|
|
10
|
+
/**
|
|
11
|
+
* Internal attribute names used in the DynamoDB item for the system fields
|
|
12
|
+
* that are not natively present in DynamoDB (unlike AWS DynamoDb Storage).
|
|
13
|
+
*
|
|
14
|
+
* - `timestamp` is stored as `_timestamp` (ISO-8601 string, managed on upsert)
|
|
15
|
+
* - `etag` is stored as `_etag` (UUID v4 string, regenerated on each upsert)
|
|
16
|
+
*/
|
|
17
|
+
const DYNAMO_TIMESTAMP_ATTR = "_timestamp";
|
|
18
|
+
const DYNAMO_ETAG_ATTR = "_etag";
|
|
19
|
+
/**
|
|
20
|
+
* Map from `OINONoSqlEntry` logical field names to DynamoDB item attribute names
|
|
21
|
+
* for the system fields that have non-trivial mappings. Primary key attributes
|
|
22
|
+
* (`partitionKey` / `rowKey`) are NOT listed here because their DynamoDB
|
|
23
|
+
* attribute names are discovered at runtime from the table schema and stored as
|
|
24
|
+
* instance fields; the `?? field_name` fallback in `buildFilterExpression`
|
|
25
|
+
* handles them correctly once the OINO field names equal the DynamoDB names.
|
|
26
|
+
*/
|
|
27
|
+
const ENTRY_TO_ATTR = {
|
|
28
|
+
timestamp: DYNAMO_TIMESTAMP_ATTR,
|
|
29
|
+
etag: DYNAMO_ETAG_ATTR
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Map from OINOQueryComparison / OINOQueryBooleanOperation operator tokens
|
|
33
|
+
* to DynamoDB FilterExpression / KeyConditionExpression operators.
|
|
34
|
+
* `like` is intentionally absent – it has no DynamoDB equivalent.
|
|
35
|
+
*/
|
|
36
|
+
const OINO_TO_DYNAMO_OP = {
|
|
37
|
+
eq: "=",
|
|
38
|
+
ne: "<>",
|
|
39
|
+
lt: "<",
|
|
40
|
+
le: "<=",
|
|
41
|
+
gt: ">",
|
|
42
|
+
ge: ">="
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* AWS DynamoDB implementation of `OINONoSql`.
|
|
46
|
+
*
|
|
47
|
+
* Authenticates using static IAM credentials supplied as a JSON-encoded
|
|
48
|
+
* connection string. Connection parameters map as:
|
|
49
|
+
* - `params.url` → optional custom endpoint URL (e.g. for DynamoDB Local:
|
|
50
|
+
* `http://localhost:8000`)
|
|
51
|
+
* - `params.table` → DynamoDB table name
|
|
52
|
+
* - `params.connectionStr` → JSON string:
|
|
53
|
+
* `{"region":"…","accessKeyId":"…","secretAccessKey":"…"}`
|
|
54
|
+
* - `params.staticPartitionKey` → scope all operations to a fixed partition key
|
|
55
|
+
*
|
|
56
|
+
* Register and use via the factory:
|
|
57
|
+
* ```ts
|
|
58
|
+
* import { OINONoSqlFactory } from "@oino-ts/nosql"
|
|
59
|
+
* import { OINONoSqlAwsDynamoDB } from "@oino-ts/nosql-aws"
|
|
60
|
+
*
|
|
61
|
+
* OINONoSqlFactory.registerNoSql("OINONoSqlAwsDynamoDB", OINONoSqlAwsDynamoDB)
|
|
62
|
+
*
|
|
63
|
+
* const nosql = await OINONoSqlFactory.createNoSql({
|
|
64
|
+
* type: "OINONoSqlAwsDynamoDB",
|
|
65
|
+
* url: "",
|
|
66
|
+
* table: "myTable",
|
|
67
|
+
* connectionStr: JSON.stringify({
|
|
68
|
+
* region: "us-east-1",
|
|
69
|
+
* accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
70
|
+
* secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
|
|
71
|
+
* })
|
|
72
|
+
* })
|
|
73
|
+
* const api = await OINONoSqlFactory.createApi(nosql, {
|
|
74
|
+
* apiName: "entities",
|
|
75
|
+
* tableName: "myTable"
|
|
76
|
+
* })
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* ## DynamoDB table schema
|
|
80
|
+
*
|
|
81
|
+
* The target table must have:
|
|
82
|
+
* - A HASH key of type **String** (any attribute name is accepted)
|
|
83
|
+
* - A RANGE key of type **String** (any attribute name is accepted)
|
|
84
|
+
*
|
|
85
|
+
* The actual attribute names are read from the table during `validate()` and
|
|
86
|
+
* stored on the instance. The OINO API field names (`partitionKey`, `rowKey`)
|
|
87
|
+
* will reflect the real DynamoDB attribute names once the backend is validated.
|
|
88
|
+
*
|
|
89
|
+
* Two additional system attributes are managed automatically:
|
|
90
|
+
* - `_timestamp` – ISO-8601 timestamp, set on every upsert
|
|
91
|
+
* - `_etag` – UUID v4, regenerated on every upsert
|
|
92
|
+
*
|
|
93
|
+
* All custom entity data is stored as top-level item attributes and
|
|
94
|
+
* serialised into `OINONoSqlEntry.properties` on read.
|
|
95
|
+
*
|
|
96
|
+
* ## Static partition key
|
|
97
|
+
*
|
|
98
|
+
* Set `staticPartitionKey` to scope all operations to a fixed partition key,
|
|
99
|
+
* allowing multiple logical tables to share one physical DynamoDB table:
|
|
100
|
+
* ```ts
|
|
101
|
+
* const nosql = await OINONoSqlFactory.createNoSql({
|
|
102
|
+
* type: "OINONoSqlAwsDynamoDB",
|
|
103
|
+
* url: "",
|
|
104
|
+
* table: "sharedTable",
|
|
105
|
+
* connectionStr: process.env.DYNAMO_CONNECTION_STR,
|
|
106
|
+
* staticPartitionKey: "myLogicalTable"
|
|
107
|
+
* })
|
|
108
|
+
* ```
|
|
109
|
+
*
|
|
110
|
+
* ## Filter support
|
|
111
|
+
*
|
|
112
|
+
* Filters on `partitionKey` are used to choose between `Query` (cheap, when
|
|
113
|
+
* an equality predicate on `partitionKey` is detected) and `Scan` (full-table).
|
|
114
|
+
*
|
|
115
|
+
* All predicates except `like` are translated to DynamoDB `FilterExpression`
|
|
116
|
+
* (evaluated server-side, but billed at scan cost). `like` predicates are
|
|
117
|
+
* evaluated in-memory after the DynamoDB response is received.
|
|
118
|
+
*
|
|
119
|
+
* DynamoDB operators supported: `=`, `<>`, `<`, `<=`, `>`, `>=`,
|
|
120
|
+
* `attribute_exists`, `attribute_not_exists`, `AND`, `OR`, `NOT`.
|
|
121
|
+
*/
|
|
122
|
+
export class OINONoSqlAwsDynamo extends OINONoSql {
|
|
123
|
+
_rawClient = null;
|
|
124
|
+
_docClient = null;
|
|
125
|
+
/** Actual DynamoDB HASH key attribute name, discovered during validate(). */
|
|
126
|
+
_hashKeyAttr = "partitionKey";
|
|
127
|
+
/** Actual DynamoDB RANGE key attribute name, discovered during validate(). */
|
|
128
|
+
_rangeKeyAttr = "rowKey";
|
|
129
|
+
// ── FilterExpression translation ──────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Walk the `OINOQueryFilter` tree and attempt to build a DynamoDB
|
|
132
|
+
* `FilterExpression` string, accumulating placeholder names and values
|
|
133
|
+
* into `builder`.
|
|
134
|
+
*
|
|
135
|
+
* Returns `undefined` for sub-trees that contain untranslatable predicates
|
|
136
|
+
* (currently only the `like` operator). For `OR` nodes, if either child
|
|
137
|
+
* cannot be expressed, the entire `OR` returns `undefined` because omitting
|
|
138
|
+
* one branch would change the semantics.
|
|
139
|
+
*
|
|
140
|
+
* @param filter filter node to translate
|
|
141
|
+
* @param builder mutable accumulator for placeholder names / values
|
|
142
|
+
*/
|
|
143
|
+
static buildFilterExpression(filter, builder) {
|
|
144
|
+
if (filter.isEmpty())
|
|
145
|
+
return undefined;
|
|
146
|
+
const op = filter.operator;
|
|
147
|
+
if (op === OINOQueryBooleanOperation.and) {
|
|
148
|
+
const left = OINONoSqlAwsDynamo.buildFilterExpression(filter.leftSide, builder);
|
|
149
|
+
const right = OINONoSqlAwsDynamo.buildFilterExpression(filter.rightSide, builder);
|
|
150
|
+
if (left && right)
|
|
151
|
+
return `(${left}) AND (${right})`;
|
|
152
|
+
return left ?? right;
|
|
153
|
+
}
|
|
154
|
+
if (op === OINOQueryBooleanOperation.or) {
|
|
155
|
+
const left = OINONoSqlAwsDynamo.buildFilterExpression(filter.leftSide, builder);
|
|
156
|
+
const right = OINONoSqlAwsDynamo.buildFilterExpression(filter.rightSide, builder);
|
|
157
|
+
if (left && right)
|
|
158
|
+
return `(${left}) OR (${right})`;
|
|
159
|
+
return undefined; // cannot partially push down an OR
|
|
160
|
+
}
|
|
161
|
+
if (op === OINOQueryBooleanOperation.not) {
|
|
162
|
+
const inner = OINONoSqlAwsDynamo.buildFilterExpression(filter.rightSide, builder);
|
|
163
|
+
if (inner)
|
|
164
|
+
return `NOT (${inner})`;
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
// Leaf predicate
|
|
168
|
+
if (op === OINOQueryComparison.like)
|
|
169
|
+
return undefined; // not supported by DynamoDB
|
|
170
|
+
const field_name = filter.leftSide;
|
|
171
|
+
const dyn_attr = ENTRY_TO_ATTR[field_name] ?? field_name;
|
|
172
|
+
const idx = builder.counter++;
|
|
173
|
+
const name_key = `#n${idx}`;
|
|
174
|
+
builder.names[name_key] = dyn_attr;
|
|
175
|
+
if (op === OINOQueryNullCheck.isnull)
|
|
176
|
+
return `attribute_not_exists(${name_key})`;
|
|
177
|
+
if (op === OINOQueryNullCheck.isNotNull)
|
|
178
|
+
return `attribute_exists(${name_key})`;
|
|
179
|
+
const dyn_op = OINO_TO_DYNAMO_OP[op];
|
|
180
|
+
if (!dyn_op)
|
|
181
|
+
return undefined;
|
|
182
|
+
const val_key = `:v${idx}`;
|
|
183
|
+
const raw = filter.rightSide;
|
|
184
|
+
// Timestamp comparisons are stored as ISO-8601 strings; compare as strings.
|
|
185
|
+
builder.values[val_key] = raw;
|
|
186
|
+
return `${name_key} ${dyn_op} ${val_key}`;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Recursively search an AND-branch filter tree for a top-level
|
|
190
|
+
* hash-key `eq <value>` leaf. Returns the value string when found,
|
|
191
|
+
* or `undefined` if no such leaf exists at the AND level.
|
|
192
|
+
*
|
|
193
|
+
* The search deliberately does not descend into OR or NOT branches because
|
|
194
|
+
* extracting a partition key equality from inside an OR would change the
|
|
195
|
+
* scan semantics.
|
|
196
|
+
*
|
|
197
|
+
* @param filter filter tree to search
|
|
198
|
+
*/
|
|
199
|
+
extractPartitionKeyEq(filter) {
|
|
200
|
+
if (filter.isEmpty())
|
|
201
|
+
return undefined;
|
|
202
|
+
const op = filter.operator;
|
|
203
|
+
if (op === OINOQueryComparison.eq && filter.leftSide === this._hashKeyAttr) {
|
|
204
|
+
return filter.rightSide;
|
|
205
|
+
}
|
|
206
|
+
if (op === OINOQueryBooleanOperation.and) {
|
|
207
|
+
return this.extractPartitionKeyEq(filter.leftSide)
|
|
208
|
+
?? this.extractPartitionKeyEq(filter.rightSide);
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Recursively rebuild the filter tree, removing any top-level (AND-branch)
|
|
214
|
+
* hash-key `eq <value>` leaf. Used to avoid passing the partition key
|
|
215
|
+
* predicate in both `KeyConditionExpression` and `FilterExpression`.
|
|
216
|
+
*
|
|
217
|
+
* Returns `undefined` when the entire tree reduces to nothing after removal.
|
|
218
|
+
*
|
|
219
|
+
* @param filter filter tree to process
|
|
220
|
+
*/
|
|
221
|
+
stripPartitionKeyEq(filter) {
|
|
222
|
+
if (filter.isEmpty())
|
|
223
|
+
return undefined;
|
|
224
|
+
const op = filter.operator;
|
|
225
|
+
if (op === OINOQueryComparison.eq && filter.leftSide === this._hashKeyAttr) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
if (op === OINOQueryBooleanOperation.and) {
|
|
229
|
+
const left = this.stripPartitionKeyEq(filter.leftSide);
|
|
230
|
+
const right = this.stripPartitionKeyEq(filter.rightSide);
|
|
231
|
+
if (!left && !right)
|
|
232
|
+
return undefined;
|
|
233
|
+
if (!left)
|
|
234
|
+
return right;
|
|
235
|
+
if (!right)
|
|
236
|
+
return left;
|
|
237
|
+
// Both sides survive: rebuild the AND node.
|
|
238
|
+
return new OINOQueryFilter(left, OINOQueryBooleanOperation.and, right);
|
|
239
|
+
}
|
|
240
|
+
return filter;
|
|
241
|
+
}
|
|
242
|
+
// ── OINODataSource lifecycle ──────────────────────────────────────────
|
|
243
|
+
/**
|
|
244
|
+
* Initialise the AWS SDK DynamoDB Document Client from the JSON-encoded
|
|
245
|
+
* `connectionStr`. Does not perform any network call.
|
|
246
|
+
*/
|
|
247
|
+
async connect() {
|
|
248
|
+
if (!this.nosqlParams.connectionStr) {
|
|
249
|
+
return new OINOResult({
|
|
250
|
+
success: false,
|
|
251
|
+
status: 400,
|
|
252
|
+
statusText: "OINONoSqlAwsDynamoDB: params.connectionStr is required (JSON with region, accessKeyId, secretAccessKey)"
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
let creds;
|
|
256
|
+
try {
|
|
257
|
+
creds = JSON.parse(this.nosqlParams.connectionStr);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return new OINOResult({
|
|
261
|
+
success: false,
|
|
262
|
+
status: 400,
|
|
263
|
+
statusText: "OINONoSqlAwsDynamoDB: params.connectionStr must be valid JSON"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (!creds.region || !creds.accessKeyId || !creds.secretAccessKey) {
|
|
267
|
+
return new OINOResult({
|
|
268
|
+
success: false,
|
|
269
|
+
status: 400,
|
|
270
|
+
statusText: "OINONoSqlAwsDynamoDB: connectionStr must contain region, accessKeyId, and secretAccessKey"
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const client_config = {
|
|
275
|
+
region: creds.region,
|
|
276
|
+
credentials: {
|
|
277
|
+
accessKeyId: creds.accessKeyId,
|
|
278
|
+
secretAccessKey: creds.secretAccessKey
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
if (this.nosqlParams.url) {
|
|
282
|
+
client_config.endpoint = this.nosqlParams.url;
|
|
283
|
+
}
|
|
284
|
+
const raw_client = new DynamoDBClient(client_config);
|
|
285
|
+
this._rawClient = raw_client;
|
|
286
|
+
this._docClient = DynamoDBDocumentClient.from(raw_client, {
|
|
287
|
+
marshallOptions: { removeUndefinedValues: true },
|
|
288
|
+
unmarshallOptions: { wrapNumbers: false }
|
|
289
|
+
});
|
|
290
|
+
this.isConnected = true;
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
294
|
+
return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAwsDynamoDB connect failed: " + msg });
|
|
295
|
+
}
|
|
296
|
+
return new OINOResult();
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Verify that the target table exists, read its key schema, and store the
|
|
300
|
+
* HASH and RANGE attribute names for use by all subsequent operations.
|
|
301
|
+
* Both key attributes must be of type String (`S`).
|
|
302
|
+
*/
|
|
303
|
+
async validate() {
|
|
304
|
+
if (!this._rawClient) {
|
|
305
|
+
return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAwsDynamo: not connected" });
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const desc = await this._rawClient.send(new DescribeTableCommand({ TableName: this.nosqlParams.table }));
|
|
309
|
+
const key_schema = desc.Table?.KeySchema ?? [];
|
|
310
|
+
const attr_defs = desc.Table?.AttributeDefinitions ?? [];
|
|
311
|
+
const type_of = (attrName) => {
|
|
312
|
+
const def = attr_defs.find((a) => a.AttributeName === attrName);
|
|
313
|
+
return def?.AttributeType ?? "?";
|
|
314
|
+
};
|
|
315
|
+
const hash_key = key_schema.find((k) => k.KeyType === "HASH");
|
|
316
|
+
const range_key = key_schema.find((k) => k.KeyType === "RANGE");
|
|
317
|
+
const hash_name = hash_key?.AttributeName ?? "";
|
|
318
|
+
const range_name = range_key?.AttributeName ?? "";
|
|
319
|
+
const hash_type = type_of(hash_name);
|
|
320
|
+
const range_type = type_of(range_name);
|
|
321
|
+
if (!hash_name || hash_type !== "S") {
|
|
322
|
+
return new OINOResult({
|
|
323
|
+
success: false,
|
|
324
|
+
status: 500,
|
|
325
|
+
statusText: `OINONoSqlAwsDynamo: table '${this.nosqlParams.table}' HASH key must be of type String but found '${hash_name}' (${hash_type})`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (!range_name || range_type !== "S") {
|
|
329
|
+
return new OINOResult({
|
|
330
|
+
success: false,
|
|
331
|
+
status: 500,
|
|
332
|
+
statusText: `OINONoSqlAwsDynamo: table '${this.nosqlParams.table}' RANGE key must be of type String but found '${range_name}' (${range_type})`
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
this._hashKeyAttr = hash_name;
|
|
336
|
+
this._rangeKeyAttr = range_name;
|
|
337
|
+
this.isValidated = true;
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
// console.log("OINONoSqlAwsDynamo validate error", e, (e as any)["$response"])
|
|
341
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
342
|
+
if (msg.includes("ResourceNotFoundException") || msg.toLowerCase().includes("not found")) {
|
|
343
|
+
return new OINOResult({
|
|
344
|
+
success: false,
|
|
345
|
+
status: 404,
|
|
346
|
+
statusText: "OINONoSqlAwsDynamo: table '" + this.nosqlParams.table + "' not found"
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return new OINOResult({ success: false, status: 500, statusText: "OINONoSqlAwsDynamo validate failed: " + msg });
|
|
350
|
+
}
|
|
351
|
+
return new OINOResult();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Release the client reference.
|
|
355
|
+
*/
|
|
356
|
+
async disconnect() {
|
|
357
|
+
this._rawClient = null;
|
|
358
|
+
this._docClient = null;
|
|
359
|
+
this._hashKeyAttr = "partitionKey";
|
|
360
|
+
this._rangeKeyAttr = "rowKey";
|
|
361
|
+
this.isConnected = false;
|
|
362
|
+
this.isValidated = false;
|
|
363
|
+
}
|
|
364
|
+
// ── OINONoSql operations ──────────────────────────────────────────────
|
|
365
|
+
/**
|
|
366
|
+
* List entities from the table.
|
|
367
|
+
*
|
|
368
|
+
* Uses `Query` when an equality predicate on `partitionKey` can be
|
|
369
|
+
* extracted from the filter (or when `staticPartitionKey` is set);
|
|
370
|
+
* otherwise falls back to `Scan`.
|
|
371
|
+
*
|
|
372
|
+
* All predicates except `like` are translated to a DynamoDB
|
|
373
|
+
* `FilterExpression` and evaluated server-side. `like` predicates are
|
|
374
|
+
* evaluated in-memory after the response is received.
|
|
375
|
+
*
|
|
376
|
+
* @param filter optional query filter to apply
|
|
377
|
+
*/
|
|
378
|
+
async listEntries(filter) {
|
|
379
|
+
if (!this._docClient) {
|
|
380
|
+
throw new Error("OINONoSqlAwsDynamoDB: not connected");
|
|
381
|
+
}
|
|
382
|
+
// Determine effective partition key for Query vs Scan decision.
|
|
383
|
+
const pk_eq_from_filter = filter && !filter.isEmpty()
|
|
384
|
+
? this.extractPartitionKeyEq(filter)
|
|
385
|
+
: undefined;
|
|
386
|
+
const effective_pk = this.nosqlParams.staticPartitionKey ?? pk_eq_from_filter;
|
|
387
|
+
// Build FilterExpression, excluding the pkEq leaf when using Query
|
|
388
|
+
// so it does not appear in both KeyConditionExpression and FilterExpression.
|
|
389
|
+
const builder = { names: {}, values: {}, counter: 0 };
|
|
390
|
+
let filter_expr;
|
|
391
|
+
if (filter && !filter.isEmpty()) {
|
|
392
|
+
const filter_for_expr = effective_pk && pk_eq_from_filter
|
|
393
|
+
? this.stripPartitionKeyEq(filter)
|
|
394
|
+
: filter;
|
|
395
|
+
if (filter_for_expr && !filter_for_expr.isEmpty()) {
|
|
396
|
+
filter_expr = OINONoSqlAwsDynamo.buildFilterExpression(filter_for_expr, builder);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const has_names = Object.keys(builder.names).length > 0;
|
|
400
|
+
const has_values = Object.keys(builder.values).length > 0;
|
|
401
|
+
let items;
|
|
402
|
+
if (effective_pk) {
|
|
403
|
+
// ── Query path ──────────────────────────────────────────────────
|
|
404
|
+
// Reserve slots :pk and #pk before the filter builder runs so
|
|
405
|
+
// counter indices never collide.
|
|
406
|
+
const query_input = {
|
|
407
|
+
TableName: this.nosqlParams.table,
|
|
408
|
+
KeyConditionExpression: "#pk = :pk",
|
|
409
|
+
ExpressionAttributeNames: { "#pk": this._hashKeyAttr, ...(has_names ? builder.names : {}) },
|
|
410
|
+
ExpressionAttributeValues: { ":pk": effective_pk, ...(has_values ? builder.values : {}) }
|
|
411
|
+
};
|
|
412
|
+
if (filter_expr)
|
|
413
|
+
query_input.FilterExpression = filter_expr;
|
|
414
|
+
const result = await this._docClient.send(new QueryCommand(query_input));
|
|
415
|
+
items = (result.Items ?? []);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
// ── Scan path ───────────────────────────────────────────────────
|
|
419
|
+
const scan_input = { TableName: this.nosqlParams.table };
|
|
420
|
+
if (filter_expr) {
|
|
421
|
+
scan_input.FilterExpression = filter_expr;
|
|
422
|
+
if (has_names)
|
|
423
|
+
scan_input.ExpressionAttributeNames = builder.names;
|
|
424
|
+
if (has_values)
|
|
425
|
+
scan_input.ExpressionAttributeValues = builder.values;
|
|
426
|
+
}
|
|
427
|
+
const result = await this._docClient.send(new ScanCommand(scan_input));
|
|
428
|
+
items = (result.Items ?? []);
|
|
429
|
+
}
|
|
430
|
+
const entries = items.map(item => this.itemToEntry(item));
|
|
431
|
+
// In-memory pass for any predicates that could not be expressed
|
|
432
|
+
// (currently: `like`), and to ensure correctness after server-side
|
|
433
|
+
// partial pushdown.
|
|
434
|
+
if (!filter || filter.isEmpty())
|
|
435
|
+
return entries;
|
|
436
|
+
return entries.filter(e => OINONoSql.matchesEntry(e, filter));
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Fetch a single entity by its primary key values.
|
|
440
|
+
*
|
|
441
|
+
* @param primaryKey [partitionKey, rowKey]
|
|
442
|
+
*/
|
|
443
|
+
async getEntry(primaryKey) {
|
|
444
|
+
if (!this._docClient) {
|
|
445
|
+
throw new Error("OINONoSqlAwsDynamoDB: not connected");
|
|
446
|
+
}
|
|
447
|
+
const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
|
|
448
|
+
const rk = primaryKey[1] ?? "";
|
|
449
|
+
const result = await this._docClient.send(new GetCommand({
|
|
450
|
+
TableName: this.nosqlParams.table,
|
|
451
|
+
Key: { [this._hashKeyAttr]: pk, [this._rangeKeyAttr]: rk }
|
|
452
|
+
}));
|
|
453
|
+
if (!result.Item)
|
|
454
|
+
return null;
|
|
455
|
+
return this.itemToEntry(result.Item);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Upsert (insert or replace) an entity.
|
|
459
|
+
*
|
|
460
|
+
* `_timestamp` is set to the current UTC time on every upsert.
|
|
461
|
+
* `_etag` is set to a new UUID v4 on every upsert.
|
|
462
|
+
*
|
|
463
|
+
* All fields in `entry.properties` are written as top-level DynamoDB item
|
|
464
|
+
* attributes alongside the key and system fields.
|
|
465
|
+
*
|
|
466
|
+
* @param entry entity to upsert
|
|
467
|
+
*/
|
|
468
|
+
async upsertEntry(entry) {
|
|
469
|
+
if (!this._docClient) {
|
|
470
|
+
throw new Error("OINONoSqlAwsDynamoDB: not connected");
|
|
471
|
+
}
|
|
472
|
+
const item = {
|
|
473
|
+
[this._hashKeyAttr]: this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "",
|
|
474
|
+
[this._rangeKeyAttr]: entry.primaryKey[1] ?? "",
|
|
475
|
+
[DYNAMO_TIMESTAMP_ATTR]: new Date().toISOString(),
|
|
476
|
+
[DYNAMO_ETAG_ATTR]: crypto.randomUUID(),
|
|
477
|
+
...entry.properties
|
|
478
|
+
};
|
|
479
|
+
await this._docClient.send(new PutCommand({
|
|
480
|
+
TableName: this.nosqlParams.table,
|
|
481
|
+
Item: item
|
|
482
|
+
}));
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Batch-upsert using DynamoDB `BatchWriteCommand`. DynamoDB limits each
|
|
486
|
+
* call to 25 items; entries are chunked accordingly. Any items returned
|
|
487
|
+
* in `UnprocessedItems` (capacity exceeded) are retried once before
|
|
488
|
+
* throwing.
|
|
489
|
+
*/
|
|
490
|
+
async upsertEntries(entries) {
|
|
491
|
+
if (!this._docClient) {
|
|
492
|
+
throw new Error("OINONoSqlAwsDynamoDB: not connected");
|
|
493
|
+
}
|
|
494
|
+
const to_requests = (batch) => batch.map(entry => ({
|
|
495
|
+
PutRequest: {
|
|
496
|
+
Item: {
|
|
497
|
+
[this._hashKeyAttr]: this.nosqlParams.staticPartitionKey ?? entry.primaryKey[0] ?? "",
|
|
498
|
+
[this._rangeKeyAttr]: entry.primaryKey[1] ?? "",
|
|
499
|
+
[DYNAMO_TIMESTAMP_ATTR]: new Date().toISOString(),
|
|
500
|
+
[DYNAMO_ETAG_ATTR]: crypto.randomUUID(),
|
|
501
|
+
...entry.properties
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}));
|
|
505
|
+
for (let i = 0; i < entries.length; i += 25) {
|
|
506
|
+
const chunk = entries.slice(i, i + 25);
|
|
507
|
+
// console.log(`Batch upsert of chunk ${i / 25 + 1}`, chunk)
|
|
508
|
+
const res = await this._docClient.send(new BatchWriteCommand({
|
|
509
|
+
RequestItems: { [this.nosqlParams.table]: to_requests(chunk) }
|
|
510
|
+
}));
|
|
511
|
+
const unprocessed = res.UnprocessedItems?.[this.nosqlParams.table];
|
|
512
|
+
if (unprocessed && unprocessed.length > 0) {
|
|
513
|
+
// Single retry for throttled items
|
|
514
|
+
const retry = await this._docClient.send(new BatchWriteCommand({
|
|
515
|
+
RequestItems: { [this.nosqlParams.table]: unprocessed }
|
|
516
|
+
}));
|
|
517
|
+
const still_unprocessed = retry.UnprocessedItems?.[this.nosqlParams.table];
|
|
518
|
+
if (still_unprocessed && still_unprocessed.length > 0) {
|
|
519
|
+
throw new Error(`DynamoDB BatchWrite: ${still_unprocessed.length} item(s) unprocessed after retry`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Delete an entity by its primary key values.
|
|
526
|
+
*
|
|
527
|
+
* @param primaryKey [partitionKey, rowKey]
|
|
528
|
+
*/
|
|
529
|
+
async deleteEntry(primaryKey) {
|
|
530
|
+
if (!this._docClient) {
|
|
531
|
+
throw new Error("OINONoSqlAwsDynamoDB: not connected");
|
|
532
|
+
}
|
|
533
|
+
const pk = this.nosqlParams.staticPartitionKey ?? primaryKey[0] ?? "";
|
|
534
|
+
await this._docClient.send(new DeleteCommand({
|
|
535
|
+
TableName: this.nosqlParams.table,
|
|
536
|
+
Key: { [this._hashKeyAttr]: pk, [this._rangeKeyAttr]: primaryKey[1] ?? "" }
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
// ── OINODataSource datamodel initialisation ───────────────────────────
|
|
540
|
+
/**
|
|
541
|
+
* Attach a static `OINONoSqlDataModel` to the given API, adding the five
|
|
542
|
+
* standard fields that mirror the `OINONoSqlEntry` structure.
|
|
543
|
+
*
|
|
544
|
+
* Field mapping to DynamoDB item attributes:
|
|
545
|
+
* | OINO field | DynamoDB attribute | Key role |
|
|
546
|
+
* |-------------------|-------------------------|----------------|
|
|
547
|
+
* | `_hashKeyAttr` | discovered HASH attr | Partition key |
|
|
548
|
+
* | `_rangeKeyAttr` | discovered RANGE attr | Sort key |
|
|
549
|
+
* | `timestamp` | `_timestamp` | Managed, string|
|
|
550
|
+
* | `etag` | `_etag` | Managed, string|
|
|
551
|
+
* | `properties` | (all other attrs) | JSON-serialised|
|
|
552
|
+
*
|
|
553
|
+
* @param api the `OINONoSqlApi` whose data model is to be initialised
|
|
554
|
+
*/
|
|
555
|
+
async initializeApiDatamodel(api) {
|
|
556
|
+
const no_sql_api = api;
|
|
557
|
+
const datamodel = new OINONoSqlDataModel(no_sql_api);
|
|
558
|
+
const ds = this;
|
|
559
|
+
const FIELD = { isPrimaryKey: false, isForeignKey: false, isAutoInc: false, isNotNull: false };
|
|
560
|
+
const PK = { isPrimaryKey: true, isForeignKey: false, isAutoInc: false, isNotNull: true };
|
|
561
|
+
datamodel.addField(new OINOStringDataField(ds, this._hashKeyAttr, "TEXT", PK, 1024));
|
|
562
|
+
datamodel.addField(new OINOStringDataField(ds, this._rangeKeyAttr, "TEXT", PK, 1024));
|
|
563
|
+
datamodel.addField(new OINODatetimeDataField(ds, "timestamp", "DATETIME", FIELD));
|
|
564
|
+
datamodel.addField(new OINOStringDataField(ds, "etag", "TEXT", FIELD, 256));
|
|
565
|
+
datamodel.addField(new OINOStringDataField(ds, "properties", "TEXT", FIELD, 65536));
|
|
566
|
+
no_sql_api.initializeDatamodel(datamodel);
|
|
567
|
+
}
|
|
568
|
+
// ── Private helpers ───────────────────────────────────────────────────
|
|
569
|
+
/**
|
|
570
|
+
* Convert a raw DynamoDB item (as returned by `DynamoDBDocumentClient`)
|
|
571
|
+
* to an `OINONoSqlEntry`.
|
|
572
|
+
*
|
|
573
|
+
* The four system attributes (the HASH key, the RANGE key, `_timestamp`,
|
|
574
|
+
* `_etag`) are extracted using the attribute names discovered during
|
|
575
|
+
* `validate()`; every other attribute is collected into `properties`.
|
|
576
|
+
*
|
|
577
|
+
* @param item raw item from DynamoDB
|
|
578
|
+
*/
|
|
579
|
+
itemToEntry(item) {
|
|
580
|
+
const pk_val = item[this._hashKeyAttr];
|
|
581
|
+
const rk_val = item[this._rangeKeyAttr];
|
|
582
|
+
const ts = item[DYNAMO_TIMESTAMP_ATTR];
|
|
583
|
+
const etag_val = item[DYNAMO_ETAG_ATTR];
|
|
584
|
+
const reserved = new Set([this._hashKeyAttr, this._rangeKeyAttr, DYNAMO_TIMESTAMP_ATTR, DYNAMO_ETAG_ATTR]);
|
|
585
|
+
const rest = {};
|
|
586
|
+
for (const key of Object.keys(item)) {
|
|
587
|
+
if (!reserved.has(key)) {
|
|
588
|
+
rest[key] = item[key];
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const timestamp = typeof ts === "string" && ts !== ""
|
|
592
|
+
? new Date(ts)
|
|
593
|
+
: new Date(0);
|
|
594
|
+
return {
|
|
595
|
+
primaryKey: [String(pk_val ?? ""), String(rk_val ?? "")],
|
|
596
|
+
timestamp,
|
|
597
|
+
etag: String(etag_val ?? ""),
|
|
598
|
+
properties: rest
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
5
|
+
*/
|
|
6
|
+
export { OINONoSqlAwsDynamo } from "./OINONoSqlAwsDynamo.js";
|