@stonyx/orm 0.3.2-alpha.11 → 0.3.2-alpha.110
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 +174 -6
- package/dist/dynamodb/connection.d.ts +7 -4
- package/dist/dynamodb/connection.js +4 -4
- package/dist/dynamodb/dynamodb-db.d.ts +11 -0
- package/dist/dynamodb/dynamodb-db.js +54 -14
- package/dist/dynamodb/operation-builder.js +20 -13
- package/dist/main.js +19 -1
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/orm-request.d.ts +1 -0
- package/dist/orm-request.js +31 -13
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- 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 +65 -6
- package/dist/types/orm-types.d.ts +3 -0
- package/package.json +11 -7
- package/src/dynamodb/connection.ts +9 -8
- package/src/dynamodb/dynamodb-db.ts +56 -13
- package/src/dynamodb/operation-builder.ts +29 -15
- package/src/main.ts +19 -1
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +38 -12
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +3 -0
package/README.md
CHANGED
|
@@ -40,6 +40,12 @@ All properties prefixed with `__` (`__data`, `__relationships`, `__model`, `__se
|
|
|
40
40
|
npm install @stonyx/orm
|
|
41
41
|
````
|
|
42
42
|
|
|
43
|
+
That is the whole install for an ORM-only app. The database drivers and
|
|
44
|
+
`@stonyx/rest-server` are **optional peer dependencies**: a default install does
|
|
45
|
+
not put them on disk, and none of them is loaded unless your configuration asks
|
|
46
|
+
for it. Add only the ones you actually use — see
|
|
47
|
+
[Optional peer dependencies](#optional-peer-dependencies).
|
|
48
|
+
|
|
43
49
|
## Usage example
|
|
44
50
|
|
|
45
51
|
This module is part of the **Stonyx framework**. To use it, first configure the `restServer` key in your `environment.js` file:
|
|
@@ -98,20 +104,29 @@ export default {
|
|
|
98
104
|
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
99
105
|
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
100
106
|
migrationsTable: '__migrations',
|
|
107
|
+
autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
|
|
101
108
|
} : undefined,
|
|
102
109
|
dynamodb: DYNAMODB_REGION ? {
|
|
103
|
-
region: DYNAMODB_REGION
|
|
110
|
+
region: DYNAMODB_REGION,
|
|
104
111
|
endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
|
|
105
112
|
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
106
113
|
} : undefined,
|
|
107
114
|
restServer: {
|
|
108
|
-
|
|
115
|
+
// 'true' requires @stonyx/rest-server to be installed — see
|
|
116
|
+
// "Optional peer dependencies" below.
|
|
117
|
+
enabled: ORM_USE_REST_SERVER ?? 'false',
|
|
109
118
|
route: ORM_REST_ROUTE ?? '/'
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
};
|
|
113
122
|
```
|
|
114
123
|
|
|
124
|
+
> **`route` and JSON:API `links`.** Generated endpoints are mounted under `route`, and every
|
|
125
|
+
> `links.self` / `links.related` the ORM emits is an absolute URL that includes it. With
|
|
126
|
+
> `ORM_REST_ROUTE='/api'` the animal collection is served at `/api/animals` and advertises
|
|
127
|
+
> `http://host/api/animals`, so published links are followable as-is — do not prepend the
|
|
128
|
+
> mount yourself.
|
|
129
|
+
|
|
115
130
|
Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
|
|
116
131
|
|
|
117
132
|
```bash
|
|
@@ -120,6 +135,46 @@ stonyx serve
|
|
|
120
135
|
|
|
121
136
|
For further framework instructions, see the [Stonyx repository](https://github.com/abofs/stonyx).
|
|
122
137
|
|
|
138
|
+
## Optional peer dependencies
|
|
139
|
+
|
|
140
|
+
`@stonyx/orm` boots with **none** of its optional peers installed. Each one is
|
|
141
|
+
imported lazily, and only when your configuration selects it:
|
|
142
|
+
|
|
143
|
+
| Configuration | Package you must install |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `orm.restServer.enabled: 'true'` | `@stonyx/rest-server` |
|
|
146
|
+
| `orm.postgres` or `orm.timescale` | `pg` |
|
|
147
|
+
| `orm.mysql` | `mysql2` |
|
|
148
|
+
| `orm.dynamodb` | `@aws-sdk/client-dynamodb`, `@aws-sdk/lib-dynamodb` |
|
|
149
|
+
|
|
150
|
+
If a configuration key selects one that is not on disk, the failure surfaces
|
|
151
|
+
from `Orm.init()` while the framework boots:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
Cannot find package '@stonyx/rest-server' imported from .../@stonyx/orm/dist/orm-request.js
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**ORM-only, no REST server.** This is the shape the `environment.js` above is
|
|
158
|
+
written for: keep `orm.restServer.enabled` at `'false'` and install nothing
|
|
159
|
+
beyond `@stonyx/orm`. Models, relationships, serializers, transforms and hooks
|
|
160
|
+
work unchanged; only the generated REST routes are absent.
|
|
161
|
+
|
|
162
|
+
> **The module's own default is the other way round.** The `config/environment.js`
|
|
163
|
+
> that ships inside `@stonyx/orm` defaults `restServer.enabled` to `'true'`, so
|
|
164
|
+
> an app that omits the `restServer` key *entirely* gets REST switched on and
|
|
165
|
+
> needs `@stonyx/rest-server` installed. Set the key explicitly, whichever way
|
|
166
|
+
> you want it.
|
|
167
|
+
|
|
168
|
+
**Turning REST on.**
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npm install @stonyx/rest-server
|
|
172
|
+
ORM_USE_REST_SERVER=true stonyx serve
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
See [REST Server Integration](#rest-server-integration) for access classes and
|
|
176
|
+
route configuration.
|
|
177
|
+
|
|
123
178
|
## Models
|
|
124
179
|
|
|
125
180
|
Define a model with attributes and relationships:
|
|
@@ -311,16 +366,127 @@ await setupRestServer('/', './access');
|
|
|
311
366
|
Access classes define models and provide custom filtering/authorization logic:
|
|
312
367
|
|
|
313
368
|
```js
|
|
314
|
-
export default class
|
|
315
|
-
models = ['owner'
|
|
369
|
+
export default class OwnerAccess {
|
|
370
|
+
models = ['owner'];
|
|
316
371
|
|
|
317
372
|
access(request) {
|
|
318
|
-
|
|
373
|
+
// `access` runs after route matching, so `request.params` is populated and
|
|
374
|
+
// `id` has already been URL-decoded. Authorize on it, never on a URL.
|
|
375
|
+
const { id } = request.params;
|
|
376
|
+
|
|
377
|
+
// `id` is still raw client text. Normalise it the way the record lookup
|
|
378
|
+
// does, or your predicate and the lookup disagree — see "Numeric ids" below.
|
|
379
|
+
// No radix on parseInt: that is deliberate, and it must stay that way.
|
|
380
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
381
|
+
|
|
382
|
+
// Returning false explicitly denies access to this record
|
|
383
|
+
if (recordId === 'angela') return false;
|
|
384
|
+
|
|
385
|
+
// No `id` means the collection route. Returning a function plugs it in to
|
|
386
|
+
// the response object as a filter. NOTE: a function return authorizes the
|
|
387
|
+
// request outright — the operations list below is not consulted — so this
|
|
388
|
+
// branch permits POST /owners as well as reads.
|
|
389
|
+
if (recordId === undefined) return record => record.id !== 'angela';
|
|
390
|
+
|
|
391
|
+
// Returning a list of operations allows full access to everything else
|
|
319
392
|
return ['read', 'create', 'update', 'delete'];
|
|
320
393
|
}
|
|
321
394
|
}
|
|
322
395
|
```
|
|
323
396
|
|
|
397
|
+
**Do not authorize on the request URL.** `request.url` is rewritten relative to
|
|
398
|
+
the mount point, so inside the REST server it is `/angela`, not `/owners/angela`
|
|
399
|
+
— a suffix comparison against it never matches and the request falls through to
|
|
400
|
+
whatever the method returns next. `request.originalUrl` keeps the full path but
|
|
401
|
+
is still the raw text the client sent, so it varies with query strings
|
|
402
|
+
(`/owners?x=1`), trailing slashes (`/owners/angela/`), casing (`/OwNeRs/angela`)
|
|
403
|
+
and percent-encoding (`/owners/%61ngela`). Each of those is a plain address-bar
|
|
404
|
+
request. Which of them reach your handler at all depends on how the REST server
|
|
405
|
+
configures Express route matching — that is a deployment detail you should not
|
|
406
|
+
be building an authorization decision on top of. `request.params.id` is
|
|
407
|
+
identical for every spelling that does reach you.
|
|
408
|
+
|
|
409
|
+
**One access class per model when the rules are model-specific.** `access()`
|
|
410
|
+
receives only the request, and the request does not carry the model name
|
|
411
|
+
directly — `request.baseUrl` is the *mount text as the client spelled it*
|
|
412
|
+
(`/OWNERS`), so it must be case-normalised before it is compared, and it is
|
|
413
|
+
still the mount rather than the model. Deriving a model from it means every
|
|
414
|
+
unrecognised spelling falls through to whatever your method returns next, so
|
|
415
|
+
prefer one class per model. A class may still list several models in `models`
|
|
416
|
+
when they share one rule.
|
|
417
|
+
|
|
418
|
+
**Numeric ids: normalise before you compare.** `request.params.id` is raw text
|
|
419
|
+
from the client. When it looks numeric the ORM coerces it — `isNaN(id) ? id :
|
|
420
|
+
parseInt(id)` — *before* it resolves the record, so `7`, `007`, `7.0`, `7.9`,
|
|
421
|
+
`7e0`, `0x7`, `+7`, `%207` (a leading space), `%097` (a tab) and `7%0A` (a
|
|
422
|
+
trailing newline) all address record `7`, while a `===` against the raw text
|
|
423
|
+
matches only the one spelling you wrote down. Every other spelling falls through
|
|
424
|
+
to whatever your method returns next — which, in the shape above, is a full CRUD
|
|
425
|
+
grant. All of them are plain address-bar requests.
|
|
426
|
+
|
|
427
|
+
Two details are load-bearing. `parseInt` is called with **no radix**, so `0x7`
|
|
428
|
+
is `7` and not `0`; writing `parseInt(id, 10)` in your predicate re-opens the
|
|
429
|
+
hex spelling. And the coercion applies only when the id looks numeric, so a
|
|
430
|
+
model with string ids (like `owner` above) is unaffected — which is exactly why
|
|
431
|
+
this is easy to miss. Normalise the same way the lookup does:
|
|
432
|
+
|
|
433
|
+
```javascript
|
|
434
|
+
export default class AnimalAccess {
|
|
435
|
+
models = ['animal'];
|
|
436
|
+
|
|
437
|
+
access(request) {
|
|
438
|
+
const { id } = request.params;
|
|
439
|
+
|
|
440
|
+
// Agrees with the lookup for every spelling of 7 above. Compare the
|
|
441
|
+
// coerced value, which for a numeric-id model is a number, not a string.
|
|
442
|
+
const recordId = id === undefined ? undefined : (isNaN(id) ? id : parseInt(id));
|
|
443
|
+
|
|
444
|
+
if (recordId === 7) return false;
|
|
445
|
+
|
|
446
|
+
if (recordId === undefined) return record => record.id !== 7;
|
|
447
|
+
|
|
448
|
+
return ['read', 'create', 'update', 'delete'];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Both samples above are extracted from this file and executed verbatim against a
|
|
454
|
+
live server on every CI run — see
|
|
455
|
+
[`test/integration/readme-access/`](https://github.com/abofs/stonyx-orm/tree/dev/test/integration/readme-access).
|
|
456
|
+
`DELETE /owners/angela` is asserted to be refused with the record intact, and
|
|
457
|
+
every spelling named above is measured individually, the numeric ones over a raw
|
|
458
|
+
socket. Nothing in this section is prose that was never run.
|
|
459
|
+
|
|
460
|
+
### Upgrading: behaviour changes
|
|
461
|
+
|
|
462
|
+
**Advertised `links.self` / `links.related` now carry the REST mount route.**
|
|
463
|
+
Consumer-visible for any deployment where `orm.restServer.route` is not the
|
|
464
|
+
default `'/'`.
|
|
465
|
+
|
|
466
|
+
Measured on this repo's mounted-route harness at `ORM_REST_ROUTE='/api'`, resource
|
|
467
|
+
`links.self` in the response to `GET /api/animals/1`:
|
|
468
|
+
|
|
469
|
+
| | published `links.self` | fetching that URL |
|
|
470
|
+
|---|---|---|
|
|
471
|
+
| before | `http://host/animals/1` | **404** |
|
|
472
|
+
| after | `http://host/api/animals/1` | **200** |
|
|
473
|
+
|
|
474
|
+
The ORM previously built links from the request origin alone, so at any non-default
|
|
475
|
+
mount every URL it advertised pointed at a route that did not exist
|
|
476
|
+
(abofs/stonyx-orm#254). Links are now built from the path the routes are actually
|
|
477
|
+
mounted at, and are followable verbatim.
|
|
478
|
+
|
|
479
|
+
**⚠️ Breaking if you carry a prepending workaround.** The usual workaround for #254
|
|
480
|
+
was for the client to prepend the mount to every link the ORM published. That now
|
|
481
|
+
double-prefixes: prepending `/api` to the new `http://host/api/animals/1` yields
|
|
482
|
+
`http://host/api/api/animals/1`, measured **404**. Remove the prepending. There is no
|
|
483
|
+
configuration flag that restores the old link shape.
|
|
484
|
+
|
|
485
|
+
**Unaffected.** Deployments on the default `ORM_REST_ROUTE='/'` see byte-identical
|
|
486
|
+
output — the prefix is empty, and this is pinned by a byte-identity test against a
|
|
487
|
+
golden fixture captured *before* the fix. Response structure, field names and the
|
|
488
|
+
public API are unchanged; only the URL value inside `links` changes.
|
|
489
|
+
|
|
324
490
|
### Include Parameter (Sideloading Relationships)
|
|
325
491
|
|
|
326
492
|
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.
|
|
@@ -798,7 +964,9 @@ test('validation hook rejects negative age', async () => {
|
|
|
798
964
|
|
|
799
965
|
## Project Structure
|
|
800
966
|
|
|
801
|
-
For a full architectural reference, see
|
|
967
|
+
For a full architectural reference, see
|
|
968
|
+
[docs/project-structure.md](https://github.com/abofs/stonyx-orm/blob/dev/docs/project-structure.md).
|
|
969
|
+
That file is repo-only — it is not in the published tarball, so the link is absolute.
|
|
802
970
|
|
|
803
971
|
## License
|
|
804
972
|
|
|
@@ -7,15 +7,18 @@
|
|
|
7
7
|
export interface DynamoDBConfig {
|
|
8
8
|
region?: string;
|
|
9
9
|
endpoint?: string;
|
|
10
|
+
tablePrefix?: string;
|
|
10
11
|
[key: string]: unknown;
|
|
11
12
|
}
|
|
12
13
|
export type DocumentClient = {
|
|
13
14
|
send(command: unknown): Promise<unknown>;
|
|
14
15
|
};
|
|
15
|
-
export type
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
export type
|
|
16
|
+
export type DynamoDBClientConstructor = new (options: unknown) => {
|
|
17
|
+
config: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type DocumentClientFromFn = {
|
|
20
|
+
from(client: unknown): DocumentClient;
|
|
21
|
+
};
|
|
19
22
|
/**
|
|
20
23
|
* Create a DynamoDBDocumentClient from the given config.
|
|
21
24
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
@@ -9,15 +9,15 @@
|
|
|
9
9
|
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
10
10
|
*/
|
|
11
11
|
export async function createDocumentClient(dbConfig) {
|
|
12
|
-
const {
|
|
13
|
-
const {
|
|
12
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
|
|
13
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb');
|
|
14
14
|
const clientOptions = {};
|
|
15
15
|
if (dbConfig.region)
|
|
16
16
|
clientOptions.region = dbConfig.region;
|
|
17
17
|
if (dbConfig.endpoint)
|
|
18
18
|
clientOptions.endpoint = dbConfig.endpoint;
|
|
19
|
-
const rawClient = new
|
|
20
|
-
return
|
|
19
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
20
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
@@ -91,6 +91,7 @@ export default class DynamoDBDB {
|
|
|
91
91
|
private _gsiRegistry;
|
|
92
92
|
constructor(deps?: Partial<DynamoDBDeps>);
|
|
93
93
|
private requireClient;
|
|
94
|
+
private _resolveTableName;
|
|
94
95
|
/** Resolve Orm singleton — falls back to real import in production. */
|
|
95
96
|
private _getOrm;
|
|
96
97
|
init(): Promise<void>;
|
|
@@ -100,6 +101,16 @@ export default class DynamoDBDB {
|
|
|
100
101
|
*/
|
|
101
102
|
startup(): Promise<void>;
|
|
102
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
|
+
*/
|
|
103
114
|
persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void>;
|
|
104
115
|
findRecord(modelName: string, id: unknown): Promise<OrmRecord | undefined>;
|
|
105
116
|
findAll(modelName: string, conditions?: Record<string, unknown>): Promise<OrmRecord[]>;
|
|
@@ -33,6 +33,23 @@ function generateUlid() {
|
|
|
33
33
|
}
|
|
34
34
|
return id;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
|
|
38
|
+
* Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
|
|
39
|
+
*/
|
|
40
|
+
let _numericIdCounter = 0;
|
|
41
|
+
let _numericIdLastMs = 0;
|
|
42
|
+
function generateNumericId() {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
if (now === _numericIdLastMs) {
|
|
45
|
+
_numericIdCounter++;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
_numericIdLastMs = now;
|
|
49
|
+
_numericIdCounter = 0;
|
|
50
|
+
}
|
|
51
|
+
return now * 1000 + _numericIdCounter;
|
|
52
|
+
}
|
|
36
53
|
// ---------------------------------------------------------------------------
|
|
37
54
|
// SDK Command factories (injectable for testing without real AWS SDK)
|
|
38
55
|
// ---------------------------------------------------------------------------
|
|
@@ -93,6 +110,9 @@ export default class DynamoDBDB {
|
|
|
93
110
|
throw new Error('DynamoDBDB client not initialized — call init() first');
|
|
94
111
|
return this.client;
|
|
95
112
|
}
|
|
113
|
+
_resolveTableName(modelName) {
|
|
114
|
+
return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
|
|
115
|
+
}
|
|
96
116
|
/** Resolve Orm singleton — falls back to real import in production. */
|
|
97
117
|
async _getOrm() {
|
|
98
118
|
if (this.deps._importOrm)
|
|
@@ -124,7 +144,7 @@ export default class DynamoDBDB {
|
|
|
124
144
|
clientOptions.endpoint = this.dbConfig.endpoint;
|
|
125
145
|
const rawClient = new DynamoDBClient(clientOptions);
|
|
126
146
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
127
|
-
const tableName =
|
|
147
|
+
const tableName = this._resolveTableName(modelName);
|
|
128
148
|
const gsis = this._buildGsiDefinitions(modelName, schema);
|
|
129
149
|
try {
|
|
130
150
|
const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName }));
|
|
@@ -171,6 +191,16 @@ export default class DynamoDBDB {
|
|
|
171
191
|
// -------------------------------------------------------------------------
|
|
172
192
|
// SqlDb contract — persist
|
|
173
193
|
// -------------------------------------------------------------------------
|
|
194
|
+
/**
|
|
195
|
+
* DynamoDB does NOT use write serialization (#156).
|
|
196
|
+
*
|
|
197
|
+
* Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
|
|
198
|
+
* constraints and no multi-row transactions in standard single-item
|
|
199
|
+
* operations (PutItem, UpdateItem, DeleteItem). Each operation is
|
|
200
|
+
* atomic at the item level and cannot deadlock against other items.
|
|
201
|
+
* Concurrent fire-and-forget writes therefore cannot produce the
|
|
202
|
+
* cross-row lock contention that causes InnoDB/PG deadlocks.
|
|
203
|
+
*/
|
|
174
204
|
async persist(operation, modelName, context, response) {
|
|
175
205
|
const OrmModule = await this._getOrm();
|
|
176
206
|
if (OrmModule.default?.instance?.isView?.(modelName))
|
|
@@ -192,7 +222,7 @@ export default class DynamoDBDB {
|
|
|
192
222
|
const schema = schemas[modelName];
|
|
193
223
|
if (!schema)
|
|
194
224
|
return undefined;
|
|
195
|
-
const tableName =
|
|
225
|
+
const tableName = this._resolveTableName(modelName);
|
|
196
226
|
const { GetCommand } = await this.deps.loadDocClientCommands();
|
|
197
227
|
const params = this.deps.buildGetItem(tableName, { id });
|
|
198
228
|
try {
|
|
@@ -220,7 +250,7 @@ export default class DynamoDBDB {
|
|
|
220
250
|
const schema = schemas[modelName];
|
|
221
251
|
if (!schema)
|
|
222
252
|
return [];
|
|
223
|
-
const tableName =
|
|
253
|
+
const tableName = this._resolveTableName(modelName);
|
|
224
254
|
try {
|
|
225
255
|
let items;
|
|
226
256
|
if (!conditions || Object.keys(conditions).length === 0) {
|
|
@@ -275,7 +305,7 @@ export default class DynamoDBDB {
|
|
|
275
305
|
continue;
|
|
276
306
|
}
|
|
277
307
|
const schema = schemas[modelName];
|
|
278
|
-
const tableName =
|
|
308
|
+
const tableName = this._resolveTableName(modelName);
|
|
279
309
|
try {
|
|
280
310
|
const items = await this._paginatedScan(tableName);
|
|
281
311
|
for (const item of items) {
|
|
@@ -308,11 +338,12 @@ export default class DynamoDBDB {
|
|
|
308
338
|
if (!record)
|
|
309
339
|
return;
|
|
310
340
|
const isPendingId = context.rawData?.__pendingSqlId === true;
|
|
311
|
-
const tableName =
|
|
312
|
-
// For
|
|
341
|
+
const tableName = this._resolveTableName(modelName);
|
|
342
|
+
// For models with a pending ID, generate a unique replacement ID
|
|
313
343
|
let finalId = record.id;
|
|
314
344
|
if (isPendingId) {
|
|
315
|
-
|
|
345
|
+
const keyType = this.deps.getDynamoKeyType(schema.idType);
|
|
346
|
+
finalId = keyType === 'N' ? generateNumericId() : generateUlid();
|
|
316
347
|
}
|
|
317
348
|
const item = this._recordToItem(record, schema, context.rawData);
|
|
318
349
|
item.id = finalId;
|
|
@@ -340,7 +371,7 @@ export default class DynamoDBDB {
|
|
|
340
371
|
const record = context.record;
|
|
341
372
|
if (!record)
|
|
342
373
|
return;
|
|
343
|
-
const tableName =
|
|
374
|
+
const tableName = this._resolveTableName(modelName);
|
|
344
375
|
const id = record.id;
|
|
345
376
|
const oldState = context.oldState || {};
|
|
346
377
|
const currentData = record.__data;
|
|
@@ -348,7 +379,11 @@ export default class DynamoDBDB {
|
|
|
348
379
|
const changedData = {};
|
|
349
380
|
for (const col of Object.keys(schema.columns)) {
|
|
350
381
|
if (currentData[col] !== oldState[col]) {
|
|
351
|
-
|
|
382
|
+
const value = currentData[col] ?? null;
|
|
383
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
384
|
+
changedData[col] = (value instanceof Date)
|
|
385
|
+
? value.toISOString()
|
|
386
|
+
: value;
|
|
352
387
|
}
|
|
353
388
|
}
|
|
354
389
|
// FK changes
|
|
@@ -376,7 +411,7 @@ export default class DynamoDBDB {
|
|
|
376
411
|
const id = context.recordId;
|
|
377
412
|
if (id == null)
|
|
378
413
|
return;
|
|
379
|
-
const tableName =
|
|
414
|
+
const tableName = this._resolveTableName(modelName);
|
|
380
415
|
const { DeleteCommand } = await this.deps.loadDocClientCommands();
|
|
381
416
|
const params = this.deps.buildDeleteItem(tableName, { id });
|
|
382
417
|
await this.requireClient().send(new DeleteCommand(params));
|
|
@@ -422,7 +457,7 @@ export default class DynamoDBDB {
|
|
|
422
457
|
_buildGsiRegistry() {
|
|
423
458
|
const schemas = this.deps.introspectModels();
|
|
424
459
|
for (const [modelName, schema] of Object.entries(schemas)) {
|
|
425
|
-
const tableName =
|
|
460
|
+
const tableName = this._resolveTableName(modelName);
|
|
426
461
|
const modelGsis = new Map();
|
|
427
462
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
428
463
|
const gsiName = `${tableName}-${fkCol}-index`;
|
|
@@ -471,7 +506,7 @@ export default class DynamoDBDB {
|
|
|
471
506
|
});
|
|
472
507
|
}
|
|
473
508
|
_buildGsiDefinitions(modelName, schema) {
|
|
474
|
-
const tableName =
|
|
509
|
+
const tableName = this._resolveTableName(modelName);
|
|
475
510
|
const gsis = [];
|
|
476
511
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
477
512
|
const gsiName = `${tableName}-${fkCol}-index`;
|
|
@@ -515,8 +550,13 @@ export default class DynamoDBDB {
|
|
|
515
550
|
if (data.id !== undefined)
|
|
516
551
|
item.id = data.id;
|
|
517
552
|
for (const col of Object.keys(schema.columns)) {
|
|
518
|
-
if (data[col] !== undefined)
|
|
519
|
-
|
|
553
|
+
if (data[col] !== undefined) {
|
|
554
|
+
const value = data[col];
|
|
555
|
+
// Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
|
|
556
|
+
item[col] = (value instanceof Date)
|
|
557
|
+
? value.toISOString()
|
|
558
|
+
: value;
|
|
559
|
+
}
|
|
520
560
|
}
|
|
521
561
|
for (const fkCol of Object.keys(schema.foreignKeys)) {
|
|
522
562
|
const relName = fkCol.replace(/_id$/, '');
|
|
@@ -64,19 +64,22 @@ export function buildScan(tableName, conditions, exclusiveStartKey) {
|
|
|
64
64
|
if (exclusiveStartKey)
|
|
65
65
|
params.ExclusiveStartKey = exclusiveStartKey;
|
|
66
66
|
if (conditions && Object.keys(conditions).length > 0) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
const validEntries = Object.entries(conditions).filter(([, val]) => val !== undefined && val !== null);
|
|
68
|
+
if (validEntries.length > 0) {
|
|
69
|
+
const names = {};
|
|
70
|
+
const values = {};
|
|
71
|
+
const clauses = [];
|
|
72
|
+
for (const [attr, val] of validEntries) {
|
|
73
|
+
const nameAlias = `#${attr}`;
|
|
74
|
+
const valAlias = `:${attr}`;
|
|
75
|
+
names[nameAlias] = attr;
|
|
76
|
+
values[valAlias] = val;
|
|
77
|
+
clauses.push(`${nameAlias} = ${valAlias}`);
|
|
78
|
+
}
|
|
79
|
+
params.FilterExpression = clauses.join(' AND ');
|
|
80
|
+
params.ExpressionAttributeNames = names;
|
|
81
|
+
params.ExpressionAttributeValues = values;
|
|
76
82
|
}
|
|
77
|
-
params.FilterExpression = clauses.join(' AND ');
|
|
78
|
-
params.ExpressionAttributeNames = names;
|
|
79
|
-
params.ExpressionAttributeValues = values;
|
|
80
83
|
}
|
|
81
84
|
return params;
|
|
82
85
|
}
|
|
@@ -86,10 +89,14 @@ export function buildScan(tableName, conditions, exclusiveStartKey) {
|
|
|
86
89
|
* as equality expressions joined by AND.
|
|
87
90
|
*/
|
|
88
91
|
export function buildQuery(tableName, indexName, keyConditions, exclusiveStartKey) {
|
|
92
|
+
const validEntries = Object.entries(keyConditions).filter(([, val]) => val !== undefined && val !== null);
|
|
93
|
+
if (validEntries.length === 0) {
|
|
94
|
+
throw new Error('buildQuery: all keyCondition values are undefined/null');
|
|
95
|
+
}
|
|
89
96
|
const names = {};
|
|
90
97
|
const values = {};
|
|
91
98
|
const clauses = [];
|
|
92
|
-
for (const [attr, val] of
|
|
99
|
+
for (const [attr, val] of validEntries) {
|
|
93
100
|
const nameAlias = `#${attr}`;
|
|
94
101
|
const valAlias = `:${attr}`;
|
|
95
102
|
names[nameAlias] = attr;
|
package/dist/main.js
CHANGED
|
@@ -19,7 +19,6 @@ import log from 'stonyx/log';
|
|
|
19
19
|
import { forEachFileImport } from '@stonyx/utils/file';
|
|
20
20
|
import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
|
|
21
21
|
import { registerPluralName } from './plural-registry.js';
|
|
22
|
-
import setupRestServer from './setup-rest-server.js';
|
|
23
22
|
import baseTransforms from './transforms.js';
|
|
24
23
|
import Store from './store.js';
|
|
25
24
|
import Serializer from './serializer.js';
|
|
@@ -131,6 +130,25 @@ export default class Orm {
|
|
|
131
130
|
promises.push(db.init());
|
|
132
131
|
}
|
|
133
132
|
if (restServer.enabled === 'true') {
|
|
133
|
+
// MUST stay dynamic. setup-rest-server.js names the optional
|
|
134
|
+
// '@stonyx/rest-server' peer in its own static graph — directly, and
|
|
135
|
+
// through orm-request.ts / meta-request.ts, which import `Request` at
|
|
136
|
+
// module scope because they extend it (correctly: an `extends` base
|
|
137
|
+
// class cannot be awaited). Node links a module's entire static graph
|
|
138
|
+
// before evaluating any of it, so a static import here puts that
|
|
139
|
+
// specifier on the entry graph and `import('@stonyx/orm')` throws
|
|
140
|
+
// ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
|
|
141
|
+
// optional peer.
|
|
142
|
+
//
|
|
143
|
+
// NOT the same reason the SQL/DynamoDB drivers above are lazy: those
|
|
144
|
+
// modules carry no static peer specifier that survives to `dist/`
|
|
145
|
+
// (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
|
|
146
|
+
// tsc), so the `await import()` there is not what isolates pg / mysql2 /
|
|
147
|
+
// @aws-sdk — that happens one layer down, in src/*/connection.ts (and
|
|
148
|
+
// src/dynamodb/dynamodb-db.ts).
|
|
149
|
+
// setup-rest-server.js is the only dist module whose laziness is
|
|
150
|
+
// load-bearing for peer resolution. (#280)
|
|
151
|
+
const { default: setupRestServer } = await import('./setup-rest-server.js');
|
|
134
152
|
promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
|
|
135
153
|
}
|
|
136
154
|
// Wire up memory resolver so store.find() can check model memory flags
|
package/dist/manage-record.js
CHANGED
|
@@ -52,13 +52,34 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
52
52
|
relationship.push(record);
|
|
53
53
|
pendingHasMany.splice(0);
|
|
54
54
|
}
|
|
55
|
+
// FK-based inverse hasMany wiring — when a child record is created with a
|
|
56
|
+
// foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
|
|
57
|
+
// whose hasMany registry targets this model and push the child into the
|
|
58
|
+
// parent's shared array. This covers edge cases where the child is created
|
|
59
|
+
// in a separate async frame without a belongsTo handler firing.
|
|
60
|
+
const hasManyReg = getHasManyRegistry();
|
|
61
|
+
if (hasManyReg) {
|
|
62
|
+
for (const [parentModelName, targetMap] of hasManyReg) {
|
|
63
|
+
const childArrayMap = targetMap.get(modelName);
|
|
64
|
+
if (!childArrayMap)
|
|
65
|
+
continue;
|
|
66
|
+
// Check if rawData contains a FK field matching the parent model name
|
|
67
|
+
const fkValue = rawData[parentModelName];
|
|
68
|
+
if (fkValue === undefined || fkValue === null)
|
|
69
|
+
continue;
|
|
70
|
+
const parentArray = childArrayMap.get(fkValue);
|
|
71
|
+
if (parentArray && !parentArray.includes(record)) {
|
|
72
|
+
parentArray.push(record);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
// Fulfill pending belongsTo relationships
|
|
56
77
|
const pendingBelongsToQueue = getPendingBelongsToRegistry();
|
|
57
78
|
const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
|
|
58
79
|
const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw : undefined;
|
|
59
80
|
if (pendingBelongsTo) {
|
|
60
81
|
const belongsToReg = getBelongsToRegistry();
|
|
61
|
-
const
|
|
82
|
+
const pendingHasManyReg = getHasManyRegistry();
|
|
62
83
|
for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
|
|
63
84
|
// Update the belongsTo relationship on the source record
|
|
64
85
|
sourceRecord.__relationships[relationshipKey] = record;
|
|
@@ -72,7 +93,7 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
72
93
|
}
|
|
73
94
|
}
|
|
74
95
|
// Wire inverse hasMany if it exists
|
|
75
|
-
const inverseHasMany =
|
|
96
|
+
const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
|
|
76
97
|
if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
|
|
77
98
|
inverseHasMany.push(sourceRecord);
|
|
78
99
|
}
|
|
@@ -83,14 +104,24 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
|
|
|
83
104
|
// Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
|
|
84
105
|
const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
|
|
85
106
|
if (shouldPersist) {
|
|
107
|
+
// Capture ID before persist — SQL adapters re-key pending IDs to real DB IDs,
|
|
108
|
+
// but relationship registries were keyed with this original ID
|
|
109
|
+
const registryId = record.id;
|
|
86
110
|
const response = { data: { id: record.id } };
|
|
87
|
-
orm.sqlDb.persist('create', modelName, { rawData }, response)
|
|
111
|
+
orm.sqlDb.persist('create', modelName, { rawData }, response)
|
|
112
|
+
.catch((err) => {
|
|
88
113
|
orm.emitPersistError({
|
|
89
114
|
operation: 'create',
|
|
90
115
|
modelName,
|
|
91
116
|
recordId: record.id,
|
|
92
117
|
error: err instanceof Error ? err : new Error(String(err)),
|
|
93
118
|
});
|
|
119
|
+
})
|
|
120
|
+
.finally(() => {
|
|
121
|
+
// Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
|
|
122
|
+
if (store._memoryResolver && !store._memoryResolver(modelName)) {
|
|
123
|
+
store.evictRecord(modelName, record.id, registryId);
|
|
124
|
+
}
|
|
94
125
|
});
|
|
95
126
|
}
|
|
96
127
|
return record;
|
|
@@ -8,6 +8,7 @@ interface MysqlConfig {
|
|
|
8
8
|
connectionLimit?: number;
|
|
9
9
|
migrationsTable?: string;
|
|
10
10
|
migrationsDir?: string;
|
|
11
|
+
autoMigrate?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export declare function getPool(mysqlConfig: MysqlConfig): Promise<Pool>;
|
|
13
14
|
export declare function closePool(): Promise<void>;
|
package/dist/mysql/mysql-db.d.ts
CHANGED
|
@@ -61,6 +61,14 @@ export default class MysqlDB {
|
|
|
61
61
|
deps: MysqlDBDeps;
|
|
62
62
|
pool: Pool | null;
|
|
63
63
|
mysqlConfig: MysqlConfig;
|
|
64
|
+
/**
|
|
65
|
+
* Promise-chain mutex for write serialization (#156).
|
|
66
|
+
* All persist() calls chain through this single queue so concurrent
|
|
67
|
+
* fire-and-forget writes never produce parallel InnoDB transactions
|
|
68
|
+
* on FK-linked rows (which cause deadlocks).
|
|
69
|
+
* Reads are NOT affected — only persist() serializes.
|
|
70
|
+
*/
|
|
71
|
+
private _writeQueue;
|
|
64
72
|
constructor(deps?: Partial<MysqlDBDeps>);
|
|
65
73
|
private requirePool;
|
|
66
74
|
init(): Promise<void>;
|