@stonyx/orm 0.3.2-alpha.1 → 0.3.2-alpha.100
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/README.md +156 -5
- package/config/environment.js +8 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -4
- package/dist/main.d.ts +12 -12
- package/dist/main.js +28 -42
- package/dist/manage-record.d.ts +1 -0
- package/dist/manage-record.js +66 -4
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +9 -0
- package/dist/mysql/mysql-db.js +48 -12
- package/dist/orm-request.d.ts +1 -0
- package/dist/orm-request.js +32 -14
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +9 -0
- package/dist/postgres/postgres-db.js +64 -18
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +13 -1
- package/dist/store.js +74 -4
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +20 -7
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +5 -4
- package/src/main.ts +36 -50
- package/src/manage-record.ts +72 -4
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +49 -14
- package/src/orm-request.ts +39 -13
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +63 -20
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +78 -4
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
|
|
|
13
13
|
- **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
|
|
14
14
|
- **Serializers**: Map raw data into model-friendly structures, including nested properties.
|
|
15
15
|
- **Transforms**: Apply custom transformations on data values automatically.
|
|
16
|
-
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL for production workloads.
|
|
16
|
+
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL/PostgreSQL/TimescaleDB/DynamoDB for production workloads.
|
|
17
17
|
- **REST Server Integration**: Automatic route setup with customizable access control.
|
|
18
18
|
- **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
|
|
19
19
|
|
|
@@ -65,13 +65,16 @@ const {
|
|
|
65
65
|
MYSQL_DATABASE,
|
|
66
66
|
MYSQL_CONNECTION_LIMIT,
|
|
67
67
|
MYSQL_MIGRATIONS_DIR,
|
|
68
|
+
DYNAMODB_REGION,
|
|
69
|
+
DYNAMODB_ENDPOINT,
|
|
70
|
+
DYNAMODB_TABLE_PREFIX,
|
|
68
71
|
} = process.env;
|
|
69
72
|
|
|
70
73
|
export default {
|
|
71
74
|
orm: {
|
|
72
75
|
logColor: 'white',
|
|
73
76
|
logMethod: 'db',
|
|
74
|
-
|
|
77
|
+
|
|
75
78
|
db: {
|
|
76
79
|
autosave: DB_AUTO_SAVE ?? 'false',
|
|
77
80
|
file: DB_FILE ?? 'db.json',
|
|
@@ -95,6 +98,12 @@ export default {
|
|
|
95
98
|
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
96
99
|
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
97
100
|
migrationsTable: '__migrations',
|
|
101
|
+
autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
|
|
102
|
+
} : undefined,
|
|
103
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
104
|
+
region: DYNAMODB_REGION,
|
|
105
|
+
endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
|
|
106
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
98
107
|
} : undefined,
|
|
99
108
|
restServer: {
|
|
100
109
|
enabled: ORM_USE_REST_SERVER ?? 'true',
|
|
@@ -104,6 +113,12 @@ export default {
|
|
|
104
113
|
};
|
|
105
114
|
```
|
|
106
115
|
|
|
116
|
+
> **`route` and JSON:API `links`.** Generated endpoints are mounted under `route`, and every
|
|
117
|
+
> `links.self` / `links.related` the ORM emits is an absolute URL that includes it. With
|
|
118
|
+
> `ORM_REST_ROUTE='/api'` the animal collection is served at `/api/animals` and advertises
|
|
119
|
+
> `http://host/api/animals`, so published links are followable as-is — do not prepend the
|
|
120
|
+
> mount yourself.
|
|
121
|
+
|
|
107
122
|
Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
|
|
108
123
|
|
|
109
124
|
```bash
|
|
@@ -243,6 +258,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
|
|
|
243
258
|
| `stonyx db:migrate` | Apply pending migrations |
|
|
244
259
|
| `stonyx db:migrate:rollback` | Rollback the most recent migration |
|
|
245
260
|
| `stonyx db:migrate:status` | Show migration status |
|
|
261
|
+
| `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
|
|
262
|
+
|
|
263
|
+
### DynamoDB Mode
|
|
264
|
+
|
|
265
|
+
Set the `DYNAMODB_REGION` environment variable to enable DynamoDB persistence. Tables are created with PAY_PER_REQUEST (on-demand) billing. Global Secondary Indexes (GSIs) are auto-provisioned at startup based on model `belongsTo` relationships — each FK column gets a GSI. `findAll()` with conditions routes to a GSI Query when the condition key matches a GSI partition key; non-indexed attribute conditions fall back to Scan + FilterExpression (expensive for large tables). ULID generation replaces auto-increment for numeric-ID models.
|
|
266
|
+
|
|
267
|
+
```javascript
|
|
268
|
+
dynamodb: {
|
|
269
|
+
region: 'us-east-1',
|
|
270
|
+
endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
|
|
271
|
+
tablePrefix: 'myapp-', // optional table name prefix
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Environment variables:
|
|
276
|
+
|
|
277
|
+
* `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
|
|
278
|
+
* `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
|
|
279
|
+
* `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
|
|
280
|
+
|
|
281
|
+
**Peer dependencies:** `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` must be installed when using the DynamoDB driver. The AWS SDK is dynamically imported and only loaded when the DynamoDB driver is selected.
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
|
285
|
+
```
|
|
246
286
|
|
|
247
287
|
### Running MySQL Tests
|
|
248
288
|
|
|
@@ -278,16 +318,127 @@ await setupRestServer('/', './access');
|
|
|
278
318
|
Access classes define models and provide custom filtering/authorization logic:
|
|
279
319
|
|
|
280
320
|
```js
|
|
281
|
-
export default class
|
|
282
|
-
models = ['owner'
|
|
321
|
+
export default class OwnerAccess {
|
|
322
|
+
models = ['owner'];
|
|
323
|
+
|
|
324
|
+
access(request) {
|
|
325
|
+
// `access` runs after route matching, so `request.params` is populated and
|
|
326
|
+
// `id` has already been URL-decoded. Authorize on it, never on a URL.
|
|
327
|
+
const { id } = request.params;
|
|
328
|
+
|
|
329
|
+
// `id` is still raw client text. Normalise it the way the record lookup
|
|
330
|
+
// does, or your predicate and the lookup disagree — see "Numeric ids" below.
|
|
331
|
+
// No radix on parseInt: that is deliberate, and it must stay that way.
|
|
332
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
333
|
+
|
|
334
|
+
// Returning false explicitly denies access to this record
|
|
335
|
+
if (recordId === 'angela') return false;
|
|
336
|
+
|
|
337
|
+
// No `id` means the collection route. Returning a function plugs it in to
|
|
338
|
+
// the response object as a filter. NOTE: a function return authorizes the
|
|
339
|
+
// request outright — the operations list below is not consulted — so this
|
|
340
|
+
// branch permits POST /owners as well as reads.
|
|
341
|
+
if (recordId === undefined) return record => record.id !== 'angela';
|
|
342
|
+
|
|
343
|
+
// Returning a list of operations allows full access to everything else
|
|
344
|
+
return ['read', 'create', 'update', 'delete'];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Do not authorize on the request URL.** `request.url` is rewritten relative to
|
|
350
|
+
the mount point, so inside the REST server it is `/angela`, not `/owners/angela`
|
|
351
|
+
— a suffix comparison against it never matches and the request falls through to
|
|
352
|
+
whatever the method returns next. `request.originalUrl` keeps the full path but
|
|
353
|
+
is still the raw text the client sent, so it varies with query strings
|
|
354
|
+
(`/owners?x=1`), trailing slashes (`/owners/angela/`), casing (`/OwNeRs/angela`)
|
|
355
|
+
and percent-encoding (`/owners/%61ngela`). Each of those is a plain address-bar
|
|
356
|
+
request. Which of them reach your handler at all depends on how the REST server
|
|
357
|
+
configures Express route matching — that is a deployment detail you should not
|
|
358
|
+
be building an authorization decision on top of. `request.params.id` is
|
|
359
|
+
identical for every spelling that does reach you.
|
|
360
|
+
|
|
361
|
+
**One access class per model when the rules are model-specific.** `access()`
|
|
362
|
+
receives only the request, and the request does not carry the model name
|
|
363
|
+
directly — `request.baseUrl` is the *mount text as the client spelled it*
|
|
364
|
+
(`/OWNERS`), so it must be case-normalised before it is compared, and it is
|
|
365
|
+
still the mount rather than the model. Deriving a model from it means every
|
|
366
|
+
unrecognised spelling falls through to whatever your method returns next, so
|
|
367
|
+
prefer one class per model. A class may still list several models in `models`
|
|
368
|
+
when they share one rule.
|
|
369
|
+
|
|
370
|
+
**Numeric ids: normalise before you compare.** `request.params.id` is raw text
|
|
371
|
+
from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
|
|
372
|
+
parseInt(id)` — *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
|
|
373
|
+
`7e0`, `0x7`, `+7`, `%207` (a leading space), `%09 7` (a tab) and `7%0A` (a
|
|
374
|
+
trailing newline) all address record `7`, while a `===` against the raw text
|
|
375
|
+
matches only the one spelling you wrote down. Every other spelling falls through
|
|
376
|
+
to whatever your method returns next — which, in the shape above, is a full CRUD
|
|
377
|
+
grant. All of them are plain address-bar requests.
|
|
378
|
+
|
|
379
|
+
Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
|
|
380
|
+
is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
|
|
381
|
+
hex spelling. And the coercion applies only when the id looks numeric, so a
|
|
382
|
+
model with string ids (like `owner` above) is unaffected — which is exactly why
|
|
383
|
+
this is easy to miss. Normalise the same way the lookup does:
|
|
384
|
+
|
|
385
|
+
```javascript
|
|
386
|
+
export default class AnimalAccess {
|
|
387
|
+
models = ['animal'];
|
|
283
388
|
|
|
284
389
|
access(request) {
|
|
285
|
-
|
|
390
|
+
const { id } = request.params;
|
|
391
|
+
|
|
392
|
+
// Agrees with the lookup for every spelling of 7 above. Compare the
|
|
393
|
+
// coerced value, which for a numeric-id model is a number, not a string.
|
|
394
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
395
|
+
|
|
396
|
+
if (recordId === 7) return false;
|
|
397
|
+
|
|
398
|
+
if (recordId === undefined) return record => record.id !== 7;
|
|
399
|
+
|
|
286
400
|
return ['read', 'create', 'update', 'delete'];
|
|
287
401
|
}
|
|
288
402
|
}
|
|
289
403
|
```
|
|
290
404
|
|
|
405
|
+
Both samples above are extracted from this file and executed verbatim against a
|
|
406
|
+
live server on every CI run — see
|
|
407
|
+
[`test/integration/readme-access/`](https://github.com/abofs/stonyx-orm/tree/dev/test/integration/readme-access).
|
|
408
|
+
`DELETE /owners/angela` is asserted to be refused with the record intact, and
|
|
409
|
+
every spelling named above is measured individually, the numeric ones over a raw
|
|
410
|
+
socket. Nothing in this section is prose that was never run.
|
|
411
|
+
|
|
412
|
+
### Upgrading: behaviour changes
|
|
413
|
+
|
|
414
|
+
**Advertised `links.self` / `links.related` now carry the REST mount route.**
|
|
415
|
+
Consumer-visible for any deployment where `orm.restServer.route` is not the
|
|
416
|
+
default `'/'`.
|
|
417
|
+
|
|
418
|
+
Measured on this repo's mounted-route harness at `ORM_REST_ROUTE='/api'`, resource
|
|
419
|
+
`links.self` in the response to `GET /api/animals/1`:
|
|
420
|
+
|
|
421
|
+
| | published `links.self` | fetching that URL |
|
|
422
|
+
|---|---|---|
|
|
423
|
+
| before | `http://host/animals/1` | **404** |
|
|
424
|
+
| after | `http://host/api/animals/1` | **200** |
|
|
425
|
+
|
|
426
|
+
The ORM previously built links from the request origin alone, so at any non-default
|
|
427
|
+
mount every URL it advertised pointed at a route that did not exist
|
|
428
|
+
(abofs/stonyx-orm#254). Links are now built from the path the routes are actually
|
|
429
|
+
mounted at, and are followable verbatim.
|
|
430
|
+
|
|
431
|
+
**⚠️ Breaking if you carry a prepending workaround.** The usual workaround for #254
|
|
432
|
+
was for the client to prepend the mount to every link the ORM published. That now
|
|
433
|
+
double-prefixes: prepending `/api` to the new `http://host/api/animals/1` yields
|
|
434
|
+
`http://host/api/api/animals/1`, measured **404**. Remove the prepending. There is no
|
|
435
|
+
configuration flag that restores the old link shape.
|
|
436
|
+
|
|
437
|
+
**Unaffected.** Deployments on the default `ORM_REST_ROUTE='/'` see byte-identical
|
|
438
|
+
output — the prefix is empty, and this is pinned by a byte-identity test against a
|
|
439
|
+
golden fixture captured *before* the fix. Response structure, field names and the
|
|
440
|
+
public API are unchanged; only the URL value inside `links` changes.
|
|
441
|
+
|
|
291
442
|
### Include Parameter (Sideloading Relationships)
|
|
292
443
|
|
|
293
444
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
package/config/environment.js
CHANGED
|
@@ -33,6 +33,9 @@ const {
|
|
|
33
33
|
TIMESCALE_DATABASE,
|
|
34
34
|
TIMESCALE_CONNECTION_LIMIT,
|
|
35
35
|
TIMESCALE_MIGRATIONS_DIR,
|
|
36
|
+
DYNAMODB_REGION,
|
|
37
|
+
DYNAMODB_ENDPOINT,
|
|
38
|
+
DYNAMODB_TABLE_PREFIX,
|
|
36
39
|
} = process.env;
|
|
37
40
|
|
|
38
41
|
export default {
|
|
@@ -84,6 +87,11 @@ export default {
|
|
|
84
87
|
migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
|
|
85
88
|
migrationsTable: '__migrations',
|
|
86
89
|
} : undefined,
|
|
90
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
91
|
+
region: DYNAMODB_REGION,
|
|
92
|
+
endpoint: DYNAMODB_ENDPOINT || undefined,
|
|
93
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX || '',
|
|
94
|
+
} : undefined,
|
|
87
95
|
restServer: {
|
|
88
96
|
enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
|
|
89
97
|
route: ORM_REST_ROUTE ?? '/',
|
package/dist/commands.js
CHANGED
|
@@ -20,6 +20,11 @@ const commands = {
|
|
|
20
20
|
description: 'Generate a MySQL migration from current model schemas',
|
|
21
21
|
bootstrap: true,
|
|
22
22
|
run: async (args) => {
|
|
23
|
+
const config = (await import('stonyx/config')).default;
|
|
24
|
+
if (config.orm.dynamodb) {
|
|
25
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
23
28
|
const description = args?.join(' ') || 'migration';
|
|
24
29
|
const { generateMigration } = await import('./mysql/migration-generator.js');
|
|
25
30
|
const result = await generateMigration(description);
|
|
@@ -31,12 +36,33 @@ const commands = {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
},
|
|
39
|
+
'db:sync': {
|
|
40
|
+
description: 'Provision DynamoDB tables and GSIs from current model schemas',
|
|
41
|
+
bootstrap: true,
|
|
42
|
+
run: async () => {
|
|
43
|
+
const config = (await import('stonyx/config')).default;
|
|
44
|
+
if (!config.orm.dynamodb) {
|
|
45
|
+
console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
|
|
49
|
+
const db = new DynamoDBDB();
|
|
50
|
+
await db.init();
|
|
51
|
+
await db.startup();
|
|
52
|
+
await db.shutdown();
|
|
53
|
+
console.log('DynamoDB tables synced successfully.');
|
|
54
|
+
}
|
|
55
|
+
},
|
|
34
56
|
'db:migrate': {
|
|
35
57
|
description: 'Apply pending MySQL migrations',
|
|
36
58
|
bootstrap: true,
|
|
37
59
|
run: async () => {
|
|
38
60
|
const config = (await import('stonyx/config')).default;
|
|
39
61
|
const mysqlConfig = config.orm.mysql;
|
|
62
|
+
if (config.orm.dynamodb) {
|
|
63
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
40
66
|
if (!mysqlConfig) {
|
|
41
67
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
42
68
|
process.exit(1);
|
|
@@ -75,6 +101,10 @@ const commands = {
|
|
|
75
101
|
bootstrap: true,
|
|
76
102
|
run: async () => {
|
|
77
103
|
const config = (await import('stonyx/config')).default;
|
|
104
|
+
if (config.orm.dynamodb) {
|
|
105
|
+
console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
78
108
|
const mysqlConfig = config.orm.mysql;
|
|
79
109
|
if (!mysqlConfig) {
|
|
80
110
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -113,6 +143,10 @@ const commands = {
|
|
|
113
143
|
bootstrap: true,
|
|
114
144
|
run: async () => {
|
|
115
145
|
const config = (await import('stonyx/config')).default;
|
|
146
|
+
if (config.orm.dynamodb) {
|
|
147
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
116
150
|
const mysqlConfig = config.orm.mysql;
|
|
117
151
|
if (!mysqlConfig) {
|
|
118
152
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
export interface DynamoDBConfig {
|
|
8
|
+
region?: string;
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
tablePrefix?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export type DocumentClient = {
|
|
14
|
+
send(command: unknown): Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
export type DynamoDBClientConstructor = new (options: unknown) => {
|
|
17
|
+
config: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type DocumentClientFromFn = {
|
|
20
|
+
from(client: unknown): DocumentClient;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
24
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
29
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
30
|
+
*/
|
|
31
|
+
export declare function destroyDocumentClient(_client: DocumentClient | null): null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
9
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
10
|
+
*/
|
|
11
|
+
export async function createDocumentClient(dbConfig) {
|
|
12
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
|
|
13
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb');
|
|
14
|
+
const clientOptions = {};
|
|
15
|
+
if (dbConfig.region)
|
|
16
|
+
clientOptions.region = dbConfig.region;
|
|
17
|
+
if (dbConfig.endpoint)
|
|
18
|
+
clientOptions.endpoint = dbConfig.endpoint;
|
|
19
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
20
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
24
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
25
|
+
*/
|
|
26
|
+
export function destroyDocumentClient(_client) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB driver implementing the SqlDb PAL contract.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in replacement for PostgresDB / MysqlDB — zero ORM core changes.
|
|
5
|
+
* Selected via config.orm.dynamodb.
|
|
6
|
+
*/
|
|
7
|
+
import { createDocumentClient, destroyDocumentClient } from './connection.js';
|
|
8
|
+
import type { DocumentClient, DynamoDBConfig } from './connection.js';
|
|
9
|
+
import { buildPutItem, buildGetItem, buildUpdateItem, buildDeleteItem, buildScan, buildQuery } from './operation-builder.js';
|
|
10
|
+
import { introspectModels, getTopologicalOrder } from '../postgres/schema-introspector.js';
|
|
11
|
+
import { getDynamoKeyType } from './type-map.js';
|
|
12
|
+
import { store } from '@stonyx/orm';
|
|
13
|
+
import { createRecord } from '../manage-record.js';
|
|
14
|
+
import { getPluralName } from '../plural-registry.js';
|
|
15
|
+
import config from 'stonyx/config';
|
|
16
|
+
import log from 'stonyx/log';
|
|
17
|
+
import type { OrmRecord } from '../types/orm-types.js';
|
|
18
|
+
/**
|
|
19
|
+
* Load the DynamoDB DocumentClient command constructors via dynamic import.
|
|
20
|
+
* Returns a frozen object so it can be cached in deps.
|
|
21
|
+
*/
|
|
22
|
+
export declare function loadDocClientCommands(): Promise<{
|
|
23
|
+
PutCommand: new (params: unknown) => unknown;
|
|
24
|
+
GetCommand: new (params: unknown) => unknown;
|
|
25
|
+
UpdateCommand: new (params: unknown) => unknown;
|
|
26
|
+
DeleteCommand: new (params: unknown) => unknown;
|
|
27
|
+
ScanCommand: new (params: unknown) => unknown;
|
|
28
|
+
QueryCommand: new (params: unknown) => unknown;
|
|
29
|
+
}>;
|
|
30
|
+
export declare function loadTableCommands(): Promise<{
|
|
31
|
+
DynamoDBClient: new (opts: unknown) => {
|
|
32
|
+
send(cmd: unknown): Promise<unknown>;
|
|
33
|
+
};
|
|
34
|
+
DescribeTableCommand: new (params: unknown) => unknown;
|
|
35
|
+
CreateTableCommand: new (params: unknown) => unknown;
|
|
36
|
+
UpdateTableCommand: new (params: unknown) => unknown;
|
|
37
|
+
}>;
|
|
38
|
+
interface PersistContext {
|
|
39
|
+
record?: OrmRecord;
|
|
40
|
+
recordId?: unknown;
|
|
41
|
+
oldState?: Record<string, unknown>;
|
|
42
|
+
rawData?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
interface PersistResponse {
|
|
45
|
+
data?: {
|
|
46
|
+
id?: unknown;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Minimal Orm module shape needed at runtime — avoids circular import at top-level. */
|
|
50
|
+
interface OrmModule {
|
|
51
|
+
default: {
|
|
52
|
+
instance: {
|
|
53
|
+
getRecordClasses(name: string): {
|
|
54
|
+
modelClass: {
|
|
55
|
+
memory?: boolean;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
isView?(name: string): boolean;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export interface DynamoDBDeps {
|
|
63
|
+
createDocumentClient: typeof createDocumentClient;
|
|
64
|
+
destroyDocumentClient: typeof destroyDocumentClient;
|
|
65
|
+
loadDocClientCommands: typeof loadDocClientCommands;
|
|
66
|
+
loadTableCommands: typeof loadTableCommands;
|
|
67
|
+
buildPutItem: typeof buildPutItem;
|
|
68
|
+
buildGetItem: typeof buildGetItem;
|
|
69
|
+
buildUpdateItem: typeof buildUpdateItem;
|
|
70
|
+
buildDeleteItem: typeof buildDeleteItem;
|
|
71
|
+
buildScan: typeof buildScan;
|
|
72
|
+
buildQuery: typeof buildQuery;
|
|
73
|
+
introspectModels: typeof introspectModels;
|
|
74
|
+
getTopologicalOrder: typeof getTopologicalOrder;
|
|
75
|
+
getDynamoKeyType: typeof getDynamoKeyType;
|
|
76
|
+
createRecord: typeof createRecord;
|
|
77
|
+
store: typeof store;
|
|
78
|
+
getPluralName: typeof getPluralName;
|
|
79
|
+
config: typeof config;
|
|
80
|
+
log: typeof log;
|
|
81
|
+
/** Injected for testing — import('@stonyx/orm') replacement */
|
|
82
|
+
_importOrm?: () => Promise<OrmModule>;
|
|
83
|
+
[key: string]: unknown;
|
|
84
|
+
}
|
|
85
|
+
export default class DynamoDBDB {
|
|
86
|
+
static instance: DynamoDBDB | undefined;
|
|
87
|
+
deps: DynamoDBDeps;
|
|
88
|
+
client: DocumentClient | null;
|
|
89
|
+
dbConfig: DynamoDBConfig;
|
|
90
|
+
/** GSI registry built during init from model introspection. */
|
|
91
|
+
private _gsiRegistry;
|
|
92
|
+
constructor(deps?: Partial<DynamoDBDeps>);
|
|
93
|
+
private requireClient;
|
|
94
|
+
private _resolveTableName;
|
|
95
|
+
/** Resolve Orm singleton — falls back to real import in production. */
|
|
96
|
+
private _getOrm;
|
|
97
|
+
init(): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* For each model, DescribeTable — CreateTable if missing (with GSIs, PAY_PER_REQUEST).
|
|
100
|
+
* For existing tables, check for missing GSIs and UpdateTable + poll for ACTIVE.
|
|
101
|
+
*/
|
|
102
|
+
startup(): Promise<void>;
|
|
103
|
+
shutdown(): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* DynamoDB does NOT use write serialization (#156).
|
|
106
|
+
*
|
|
107
|
+
* Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
|
|
108
|
+
* constraints and no multi-row transactions in standard single-item
|
|
109
|
+
* operations (PutItem, UpdateItem, DeleteItem). Each operation is
|
|
110
|
+
* atomic at the item level and cannot deadlock against other items.
|
|
111
|
+
* Concurrent fire-and-forget writes therefore cannot produce the
|
|
112
|
+
* cross-row lock contention that causes InnoDB/PG deadlocks.
|
|
113
|
+
*/
|
|
114
|
+
persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void>;
|
|
115
|
+
findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined>;
|
|
116
|
+
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]>;
|
|
117
|
+
loadMemoryRecords(): Promise<void>;
|
|
118
|
+
private _persistCreate;
|
|
119
|
+
private _persistUpdate;
|
|
120
|
+
private _persistDelete;
|
|
121
|
+
private _paginatedScan;
|
|
122
|
+
private _paginatedQuery;
|
|
123
|
+
/**
|
|
124
|
+
* Build the GSI registry from model introspection.
|
|
125
|
+
* Registry: modelName → attrName → gsiName
|
|
126
|
+
*
|
|
127
|
+
* FK columns (belonging to belongsTo relationships) get a GSI automatically.
|
|
128
|
+
*/
|
|
129
|
+
private _buildGsiRegistry;
|
|
130
|
+
/**
|
|
131
|
+
* Find a GSI that can serve the given conditions.
|
|
132
|
+
*/
|
|
133
|
+
private _findGsiMatch;
|
|
134
|
+
private _buildAttributeDefinitions;
|
|
135
|
+
private _buildGsiDefinitions;
|
|
136
|
+
private _waitForTableActive;
|
|
137
|
+
private _itemToRawData;
|
|
138
|
+
private _recordToItem;
|
|
139
|
+
private _evictIfNotMemory;
|
|
140
|
+
loadAllRecords(): Promise<void>;
|
|
141
|
+
}
|
|
142
|
+
export {};
|