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