@stonyx/orm 0.3.2-alpha.11 → 0.3.2-alpha.111

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 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 ?? 'us-east-1',
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
- enabled: ORM_USE_REST_SERVER ?? 'true',
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 GlobalAccess {
315
- models = ['owner', 'animal'];
369
+ export default class OwnerAccess {
370
+ models = ['owner'];
316
371
 
317
372
  access(request) {
318
- if (request.url.endsWith('/owner/angela')) return false;
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 [project-structure.md](project-structure.md).
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 DocumentClientConstructor = new (options: {
16
- client: unknown;
17
- }) => DocumentClient;
18
- export type DynamoDBClientConstructor = new (options: unknown) => unknown;
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 { DynamoDB } = await import('@aws-sdk/client-dynamodb');
13
- const { DynamoDBDocument } = await import('@aws-sdk/lib-dynamodb');
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 DynamoDB(clientOptions);
20
- return new DynamoDBDocument({ client: rawClient });
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
312
- // For numeric-ID models with a pending ID, generate a ULID
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
- finalId = generateUlid();
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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
- changedData[col] = currentData[col] ?? null;
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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 = sanitizeTableName(this.deps.getPluralName(modelName));
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
- item[col] = data[col];
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 names = {};
68
- const values = {};
69
- const clauses = [];
70
- for (const [attr, val] of Object.entries(conditions)) {
71
- const nameAlias = `#${attr}`;
72
- const valAlias = `:${attr}`;
73
- names[nameAlias] = attr;
74
- values[valAlias] = val;
75
- clauses.push(`${nameAlias} = ${valAlias}`);
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 Object.entries(keyConditions)) {
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
@@ -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 hasManyReg = getHasManyRegistry();
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 = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
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).catch((err) => {
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>;
@@ -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>;