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