@stonyx/orm 0.3.2-alpha.12 → 0.3.2-alpha.120

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.
Files changed (51) hide show
  1. package/README.md +337 -28
  2. package/dist/column-conversion.d.ts +34 -0
  3. package/dist/column-conversion.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +7 -4
  5. package/dist/dynamodb/connection.js +4 -4
  6. package/dist/dynamodb/dynamodb-db.d.ts +11 -0
  7. package/dist/dynamodb/dynamodb-db.js +54 -14
  8. package/dist/dynamodb/operation-builder.js +20 -13
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +1 -0
  11. package/dist/main.js +19 -1
  12. package/dist/manage-record.js +34 -3
  13. package/dist/mysql/connection.d.ts +1 -0
  14. package/dist/mysql/mysql-db.d.ts +8 -0
  15. package/dist/mysql/mysql-db.js +44 -10
  16. package/dist/normalize-record-id.d.ts +67 -0
  17. package/dist/normalize-record-id.js +88 -0
  18. package/dist/orm-request.d.ts +17 -1
  19. package/dist/orm-request.js +77 -19
  20. package/dist/postgres/column-rules.d.ts +33 -0
  21. package/dist/postgres/column-rules.js +34 -0
  22. package/dist/postgres/connection.d.ts +1 -0
  23. package/dist/postgres/connection.js +8 -6
  24. package/dist/postgres/postgres-db.d.ts +12 -0
  25. package/dist/postgres/postgres-db.js +60 -19
  26. package/dist/record.js +7 -5
  27. package/dist/relationships.js +1 -1
  28. package/dist/serializer.js +38 -2
  29. package/dist/store.d.ts +13 -1
  30. package/dist/store.js +65 -6
  31. package/dist/types/orm-types.d.ts +3 -0
  32. package/package.json +11 -7
  33. package/src/column-conversion.ts +53 -0
  34. package/src/dynamodb/connection.ts +9 -8
  35. package/src/dynamodb/dynamodb-db.ts +56 -13
  36. package/src/dynamodb/operation-builder.ts +29 -15
  37. package/src/index.ts +2 -0
  38. package/src/main.ts +19 -1
  39. package/src/manage-record.ts +41 -9
  40. package/src/mysql/connection.ts +1 -0
  41. package/src/mysql/mysql-db.ts +44 -12
  42. package/src/normalize-record-id.ts +88 -0
  43. package/src/orm-request.ts +109 -18
  44. package/src/postgres/column-rules.ts +37 -0
  45. package/src/postgres/connection.ts +10 -6
  46. package/src/postgres/postgres-db.ts +64 -21
  47. package/src/record.ts +8 -5
  48. package/src/relationships.ts +1 -1
  49. package/src/serializer.ts +39 -2
  50. package/src/store.ts +68 -6
  51. package/src/types/orm-types.ts +3 -0
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Orm, { relationships } from '@stonyx/orm';
2
- import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
2
+ import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
3
3
  import ViewResolver from './view-resolver.js';
4
4
  function isStoreRecord(value) {
5
5
  return typeof value === 'object' && value !== null && '__data' in value;
@@ -22,7 +22,7 @@ export default class Store {
22
22
  this.data = new Map();
23
23
  }
24
24
  get(key, id) {
25
- if (!id)
25
+ if (id === undefined)
26
26
  return this.data.get(key);
27
27
  return this.data.get(key)?.get(id);
28
28
  }
@@ -107,13 +107,14 @@ export default class Store {
107
107
  set(key, value) {
108
108
  this.data.set(key, value);
109
109
  }
110
- remove(key, id) {
110
+ remove(key, id, options) {
111
111
  // Guard: read-only views cannot have records removed
112
112
  if (Orm.instance?.isView?.(key)) {
113
113
  throw new Error(`Cannot remove records from read-only view '${key}'`);
114
114
  }
115
- // Auto-persist delete to SQL
116
- if (id && Orm.instance?.sqlDb) {
115
+ // Auto-persist delete to SQL (fire-and-forget) — skipped when the
116
+ // request path handles persist itself to avoid double-delete.
117
+ if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
117
118
  Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err) => {
118
119
  Orm.instance.emitPersistError({
119
120
  operation: 'delete',
@@ -127,6 +128,40 @@ export default class Store {
127
128
  return this.unloadRecord(key, id);
128
129
  this.unloadAllRecords(key);
129
130
  }
131
+ /**
132
+ * Evict a record from the store with full relationship registry cleanup.
133
+ * The caller retains its reference to the returned record, which is the
134
+ * contract memory:false post-persist eviction relies on.
135
+ *
136
+ * @param registryId - The ID used when the record's relationships were
137
+ * registered. For SQL models with pending IDs, this is the original
138
+ * negative pending ID (before the adapter re-keyed to the real DB ID).
139
+ */
140
+ evictRecord(modelName, id, registryId) {
141
+ const modelStore = this.data.get(modelName);
142
+ if (!modelStore)
143
+ return;
144
+ if (typeof id !== 'string' && typeof id !== 'number')
145
+ return;
146
+ const raw = modelStore.get(id);
147
+ if (!raw || !isStoreRecord(raw))
148
+ return;
149
+ const visited = new Set([`${modelName}:${id}`]);
150
+ // Remove from hasMany arrays and nullify belongsTo references using current ID
151
+ // (the adapter updates record.id, so value-based matches need the current ID)
152
+ this._removeFromHasManyArrays(modelName, id, visited);
153
+ this._nullifyBelongsToReferences(modelName, id, visited);
154
+ // Clean up relationship registry entries using the registry key
155
+ // (belongsTo/hasMany registries were keyed by the ID at registration time,
156
+ // which may differ from the current ID if SQL persist re-keyed the record)
157
+ const cleanupId = registryId ?? id;
158
+ this._cleanupRelationshipRegistries(modelName, cleanupId);
159
+ // If registryId differs from id, also clean with current id as safety net
160
+ if (registryId !== undefined && registryId !== id) {
161
+ this._cleanupRelationshipRegistries(modelName, id);
162
+ }
163
+ modelStore.delete(id);
164
+ }
130
165
  unloadRecord(model, id, options = {}) {
131
166
  const modelStore = this.data.get(model);
132
167
  if (!modelStore) {
@@ -149,7 +184,6 @@ export default class Store {
149
184
  this._removeFromHasManyArrays(modelName, recordId, visited);
150
185
  this._nullifyBelongsToReferences(modelName, recordId, visited);
151
186
  this._cleanupRelationshipRegistries(modelName, recordId);
152
- recordToUnload.clean();
153
187
  this.data.get(modelName)?.delete(recordId);
154
188
  }
155
189
  }
@@ -230,6 +264,31 @@ export default class Store {
230
264
  const pendingMap = getPendingRegistry().get(modelName);
231
265
  if (pendingMap)
232
266
  pendingMap.delete(recordId);
267
+ // Clean pendingBelongsTo entries in both directions
268
+ const pendingBelongsToMap = getPendingBelongsToRegistry();
269
+ if (pendingBelongsToMap) {
270
+ // Direction 1: evicted record was the TARGET others were waiting for
271
+ const targetEntries = pendingBelongsToMap.get(modelName);
272
+ if (targetEntries)
273
+ targetEntries.delete(recordId);
274
+ // Direction 2: evicted record was the SOURCE with unresolved forward-references
275
+ for (const [, targetIdMap] of pendingBelongsToMap) {
276
+ for (const [targetId, entries] of targetIdMap) {
277
+ if (!Array.isArray(entries))
278
+ continue;
279
+ const filtered = entries.filter((e) => {
280
+ const entry = e;
281
+ return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
282
+ });
283
+ if (filtered.length === 0) {
284
+ targetIdMap.delete(targetId);
285
+ }
286
+ else if (filtered.length < entries.length) {
287
+ targetIdMap.set(targetId, filtered);
288
+ }
289
+ }
290
+ }
291
+ }
233
292
  }
234
293
  /**
235
294
  * Extracts hasMany and non-bidirectional belongsTo children from a record
@@ -16,6 +16,7 @@ export interface OrmMysqlConfig {
16
16
  connectionLimit?: number;
17
17
  migrationsDir?: string;
18
18
  migrationsTable?: string;
19
+ autoMigrate?: boolean;
19
20
  [key: string]: unknown;
20
21
  }
21
22
  export interface OrmPostgresConfig {
@@ -27,6 +28,7 @@ export interface OrmPostgresConfig {
27
28
  connectionLimit?: number;
28
29
  migrationsDir?: string;
29
30
  migrationsTable?: string;
31
+ autoMigrate?: boolean;
30
32
  [key: string]: unknown;
31
33
  }
32
34
  export interface OrmPaths {
@@ -45,6 +47,7 @@ export interface OrmRestServerConfig {
45
47
  export interface OrmDynamoDBConfig {
46
48
  region?: string;
47
49
  endpoint?: string;
50
+ tablePrefix?: string;
48
51
  [key: string]: unknown;
49
52
  }
50
53
  export interface OrmSection {
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.12",
7
+ "version": "0.3.2-alpha.120",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,9 +61,10 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.66",
65
- "@stonyx/events": "0.1.1-beta.49",
66
- "stonyx": "0.2.3-beta.66"
64
+ "@stonyx/cron": "0.2.1-beta.149",
65
+ "@stonyx/events": "0.1.1-beta.68",
66
+ "@stonyx/utils": "0.2.3-beta.28",
67
+ "stonyx": "0.2.3-beta.101"
67
68
  },
68
69
  "peerDependencies": {
69
70
  "@aws-sdk/client-dynamodb": "^3.0.0",
@@ -90,8 +91,7 @@
90
91
  }
91
92
  },
92
93
  "devDependencies": {
93
- "@stonyx/rest-server": "0.2.1-beta.67",
94
- "@stonyx/utils": "0.2.3-beta.24",
94
+ "@stonyx/rest-server": "0.2.1-beta.151",
95
95
  "@types/node": "^25.6.0",
96
96
  "mysql2": "^3.20.0",
97
97
  "pg": "^8.20.0",
@@ -103,6 +103,10 @@
103
103
  "scripts": {
104
104
  "build": "tsc",
105
105
  "build:test": "tsc -p tsconfig.test.json",
106
- "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
106
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts' && ORM_TEST_ROUTE=/ pnpm test:mounted && ORM_TEST_ROUTE=/api pnpm test:mounted && ORM_TEST_ROUTE=api pnpm test:mounted && ORM_TEST_ROUTE=/api/v1 pnpm test:mounted && ORM_TEST_ROUTE=/api/ pnpm test:mounted && pnpm test:readme && pnpm test:reference",
107
+ "test:mounted": "node --import tsx/esm --import ./test/integration/mounted-route/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/mounted-route/links-mounted.ts' 'test/zz-exit-test.ts'",
108
+ "test:readme": "pnpm build && node --import tsx/esm --import ./test/integration/readme-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/readme-access/readme-sample.ts' 'test/zz-exit-test.ts'",
109
+ "test:reference": "pnpm build && node --import tsx/esm --import ./test/integration/reference-access/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/reference-access/reference-sample.ts' 'test/integration/reference-access/record-id-resolution.ts' 'test/zz-exit-test.ts'",
110
+ "test:dynamodb": "pnpm build && node --import tsx/esm --import ./test/integration/dynamodb/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/dynamodb/**/*-test.ts'"
107
111
  }
108
112
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared write-path column conversion (#292).
3
+ *
4
+ * This module owns the *iteration* only — the single place where a write
5
+ * payload is walked column-by-column and each value is handed to the driver's
6
+ * own conversion rule. The rules themselves are genuinely disjoint per driver
7
+ * (Postgres stringifies for JSONB; DynamoDB's DocumentClient marshals objects
8
+ * and arrays natively and must NOT be given stringified values) and therefore
9
+ * deliberately live with their drivers rather than being merged here.
10
+ *
11
+ * The duplicated create/update iteration was the drift vector that produced
12
+ * #292: `_recordToRow` converted, `_persistUpdate` did not. Routing both
13
+ * through `convertRow` removes the opportunity for the two to diverge again.
14
+ */
15
+
16
+ /**
17
+ * A driver-supplied per-column conversion rule.
18
+ *
19
+ * @param value the raw value about to be bound into the query
20
+ * @param columnType the column's declared type from `schema.columns`, or
21
+ * `undefined` when the column is not an attribute column
22
+ * (e.g. `updated_at`, FK columns and `id`, which are added
23
+ * outside the attribute loop). Rules MUST be a no-op for
24
+ * `undefined`.
25
+ */
26
+ export type ColumnRule = (value: unknown, columnType: string | undefined) => unknown;
27
+
28
+ /**
29
+ * Applies `rule` to every column of a complete write payload.
30
+ *
31
+ * Call this on the *finished* payload immediately before handing it to the
32
+ * query builder — not inside a payload-building loop. Columns appended after
33
+ * such a loop (FKs, `updated_at`) would otherwise bypass conversion, which is
34
+ * the same class of defect #292 reports.
35
+ */
36
+ export function convertRow(
37
+ row: Record<string, unknown>,
38
+ columns: Record<string, string>,
39
+ rule: ColumnRule
40
+ ): Record<string, unknown> {
41
+ const converted: Record<string, unknown> = {};
42
+
43
+ for (const [col, value] of Object.entries(row)) {
44
+ // `Object.hasOwn`, not `columns[col]`: a column named `toString` or
45
+ // `constructor` would otherwise reach the rule with a function as its
46
+ // "declared type". Inert for the shipped Postgres rule (strict equality
47
+ // against 'JSONB'), but `ColumnRule` is the extension point other drivers
48
+ // write against, and the obvious `if (columnType)` rule would invert.
49
+ converted[col] = rule(value, Object.hasOwn(columns, col) ? columns[col] : undefined);
50
+ }
51
+
52
+ return converted;
53
+ }
@@ -8,6 +8,7 @@
8
8
  export interface DynamoDBConfig {
9
9
  region?: string;
10
10
  endpoint?: string;
11
+ tablePrefix?: string;
11
12
  [key: string]: unknown;
12
13
  }
13
14
 
@@ -17,27 +18,27 @@ export type DocumentClient = {
17
18
  send(command: unknown): Promise<unknown>;
18
19
  };
19
20
 
20
- export type DocumentClientConstructor = new (options: { client: unknown }) => DocumentClient;
21
- export type DynamoDBClientConstructor = new (options: unknown) => unknown;
21
+ export type DynamoDBClientConstructor = new (options: unknown) => { config: unknown };
22
+ export type DocumentClientFromFn = { from(client: unknown): DocumentClient };
22
23
 
23
24
  /**
24
25
  * Create a DynamoDBDocumentClient from the given config.
25
26
  * Uses dynamic import so @aws-sdk/* are optional peer deps.
26
27
  */
27
28
  export async function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient> {
28
- const { DynamoDB } = await import('@aws-sdk/client-dynamodb' as string) as {
29
- DynamoDB: DynamoDBClientConstructor;
29
+ const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb' as string) as {
30
+ DynamoDBClient: DynamoDBClientConstructor;
30
31
  };
31
- const { DynamoDBDocument } = await import('@aws-sdk/lib-dynamodb' as string) as {
32
- DynamoDBDocument: DocumentClientConstructor;
32
+ const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb' as string) as {
33
+ DynamoDBDocumentClient: DocumentClientFromFn;
33
34
  };
34
35
 
35
36
  const clientOptions: Record<string, unknown> = {};
36
37
  if (dbConfig.region) clientOptions.region = dbConfig.region;
37
38
  if (dbConfig.endpoint) clientOptions.endpoint = dbConfig.endpoint;
38
39
 
39
- const rawClient = new DynamoDB(clientOptions);
40
- return new DynamoDBDocument({ client: rawClient });
40
+ const rawClient = new DynamoDBClient(clientOptions);
41
+ return DynamoDBDocumentClient.from(rawClient);
41
42
  }
42
43
 
43
44
  /**
@@ -50,6 +50,24 @@ function generateUlid(): string {
50
50
  return id;
51
51
  }
52
52
 
53
+ /**
54
+ * Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
55
+ * Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
56
+ */
57
+ let _numericIdCounter = 0;
58
+ let _numericIdLastMs = 0;
59
+
60
+ function generateNumericId(): number {
61
+ const now = Date.now();
62
+ if (now === _numericIdLastMs) {
63
+ _numericIdCounter++;
64
+ } else {
65
+ _numericIdLastMs = now;
66
+ _numericIdCounter = 0;
67
+ }
68
+ return now * 1000 + _numericIdCounter;
69
+ }
70
+
53
71
  // ---------------------------------------------------------------------------
54
72
  // SDK Command factories (injectable for testing without real AWS SDK)
55
73
  // ---------------------------------------------------------------------------
@@ -199,6 +217,10 @@ export default class DynamoDBDB {
199
217
  return this.client;
200
218
  }
201
219
 
220
+ private _resolveTableName(modelName: string): string {
221
+ return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
222
+ }
223
+
202
224
  /** Resolve Orm singleton — falls back to real import in production. */
203
225
  private async _getOrm(): Promise<OrmModule> {
204
226
  if (this.deps._importOrm) return this.deps._importOrm();
@@ -235,7 +257,7 @@ export default class DynamoDBDB {
235
257
  const rawClient = new DynamoDBClient(clientOptions);
236
258
 
237
259
  for (const [modelName, schema] of Object.entries(schemas)) {
238
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
260
+ const tableName = this._resolveTableName(modelName);
239
261
  const gsis = this._buildGsiDefinitions(modelName, schema);
240
262
 
241
263
  try {
@@ -297,6 +319,16 @@ export default class DynamoDBDB {
297
319
  // SqlDb contract — persist
298
320
  // -------------------------------------------------------------------------
299
321
 
322
+ /**
323
+ * DynamoDB does NOT use write serialization (#156).
324
+ *
325
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
326
+ * constraints and no multi-row transactions in standard single-item
327
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
328
+ * atomic at the item level and cannot deadlock against other items.
329
+ * Concurrent fire-and-forget writes therefore cannot produce the
330
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
331
+ */
300
332
  async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
301
333
  const OrmModule = await this._getOrm();
302
334
  if (OrmModule.default?.instance?.isView?.(modelName)) return;
@@ -320,7 +352,7 @@ export default class DynamoDBDB {
320
352
  const schema = schemas[modelName];
321
353
  if (!schema) return undefined;
322
354
 
323
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
355
+ const tableName = this._resolveTableName(modelName);
324
356
  const { GetCommand } = await this.deps.loadDocClientCommands();
325
357
 
326
358
  const params = this.deps.buildGetItem(tableName, { id });
@@ -354,7 +386,7 @@ export default class DynamoDBDB {
354
386
  const schema = schemas[modelName];
355
387
  if (!schema) return [];
356
388
 
357
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
389
+ const tableName = this._resolveTableName(modelName);
358
390
 
359
391
  try {
360
392
  let items: Record<string, unknown>[];
@@ -421,7 +453,7 @@ export default class DynamoDBDB {
421
453
  }
422
454
 
423
455
  const schema = schemas[modelName];
424
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
456
+ const tableName = this._resolveTableName(modelName);
425
457
 
426
458
  try {
427
459
  const items = await this._paginatedScan(tableName);
@@ -457,12 +489,13 @@ export default class DynamoDBDB {
457
489
  if (!record) return;
458
490
 
459
491
  const isPendingId = context.rawData?.__pendingSqlId === true;
460
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
492
+ const tableName = this._resolveTableName(modelName);
461
493
 
462
- // For numeric-ID models with a pending ID, generate a ULID
494
+ // For models with a pending ID, generate a unique replacement ID
463
495
  let finalId: unknown = record.id;
464
496
  if (isPendingId) {
465
- finalId = generateUlid();
497
+ const keyType = this.deps.getDynamoKeyType(schema.idType);
498
+ finalId = keyType === 'N' ? generateNumericId() : generateUlid();
466
499
  }
467
500
 
468
501
  const item = this._recordToItem(record, schema, context.rawData);
@@ -494,7 +527,7 @@ export default class DynamoDBDB {
494
527
  const record = context.record;
495
528
  if (!record) return;
496
529
 
497
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
530
+ const tableName = this._resolveTableName(modelName);
498
531
  const id = record.id;
499
532
  const oldState = context.oldState || {};
500
533
  const currentData = record.__data;
@@ -504,7 +537,11 @@ export default class DynamoDBDB {
504
537
 
505
538
  for (const col of Object.keys(schema.columns)) {
506
539
  if (currentData[col] !== oldState[col]) {
507
- changedData[col] = currentData[col] ?? null;
540
+ const value = currentData[col] ?? null;
541
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
542
+ changedData[col] = (value instanceof Date)
543
+ ? value.toISOString()
544
+ : value;
508
545
  }
509
546
  }
510
547
 
@@ -535,7 +572,7 @@ export default class DynamoDBDB {
535
572
  const id = context.recordId;
536
573
  if (id == null) return;
537
574
 
538
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
575
+ const tableName = this._resolveTableName(modelName);
539
576
  const { DeleteCommand } = await this.deps.loadDocClientCommands();
540
577
  const params = this.deps.buildDeleteItem(tableName, { id });
541
578
  await this.requireClient().send(new DeleteCommand(params));
@@ -597,7 +634,7 @@ export default class DynamoDBDB {
597
634
  const schemas = this.deps.introspectModels();
598
635
 
599
636
  for (const [modelName, schema] of Object.entries(schemas)) {
600
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
637
+ const tableName = this._resolveTableName(modelName);
601
638
  const modelGsis = new Map<string, string>();
602
639
 
603
640
  for (const fkCol of Object.keys(schema.foreignKeys)) {
@@ -659,7 +696,7 @@ export default class DynamoDBDB {
659
696
  }
660
697
 
661
698
  private _buildGsiDefinitions(modelName: string, schema: ModelSchema): unknown[] {
662
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
699
+ const tableName = this._resolveTableName(modelName);
663
700
  const gsis: unknown[] = [];
664
701
 
665
702
  for (const fkCol of Object.keys(schema.foreignKeys)) {
@@ -720,7 +757,13 @@ export default class DynamoDBDB {
720
757
  if (data.id !== undefined) item.id = data.id;
721
758
 
722
759
  for (const col of Object.keys(schema.columns)) {
723
- if (data[col] !== undefined) item[col] = data[col];
760
+ if (data[col] !== undefined) {
761
+ const value = data[col];
762
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
763
+ item[col] = (value instanceof Date)
764
+ ? value.toISOString()
765
+ : value;
766
+ }
724
767
  }
725
768
 
726
769
  for (const fkCol of Object.keys(schema.foreignKeys)) {
@@ -131,21 +131,27 @@ export function buildScan(
131
131
  if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
132
132
 
133
133
  if (conditions && Object.keys(conditions).length > 0) {
134
- const names: Record<string, string> = {};
135
- const values: Record<string, unknown> = {};
136
- const clauses: string[] = [];
137
-
138
- for (const [attr, val] of Object.entries(conditions)) {
139
- const nameAlias = `#${attr}`;
140
- const valAlias = `:${attr}`;
141
- names[nameAlias] = attr;
142
- values[valAlias] = val;
143
- clauses.push(`${nameAlias} = ${valAlias}`);
134
+ const validEntries = Object.entries(conditions).filter(
135
+ ([, val]) => val !== undefined && val !== null,
136
+ );
137
+
138
+ if (validEntries.length > 0) {
139
+ const names: Record<string, string> = {};
140
+ const values: Record<string, unknown> = {};
141
+ const clauses: string[] = [];
142
+
143
+ for (const [attr, val] of validEntries) {
144
+ const nameAlias = `#${attr}`;
145
+ const valAlias = `:${attr}`;
146
+ names[nameAlias] = attr;
147
+ values[valAlias] = val;
148
+ clauses.push(`${nameAlias} = ${valAlias}`);
149
+ }
150
+
151
+ params.FilterExpression = clauses.join(' AND ');
152
+ params.ExpressionAttributeNames = names;
153
+ params.ExpressionAttributeValues = values;
144
154
  }
145
-
146
- params.FilterExpression = clauses.join(' AND ');
147
- params.ExpressionAttributeNames = names;
148
- params.ExpressionAttributeValues = values;
149
155
  }
150
156
 
151
157
  return params;
@@ -162,11 +168,19 @@ export function buildQuery(
162
168
  keyConditions: Record<string, unknown>,
163
169
  exclusiveStartKey?: Record<string, unknown>,
164
170
  ): QueryParams {
171
+ const validEntries = Object.entries(keyConditions).filter(
172
+ ([, val]) => val !== undefined && val !== null,
173
+ );
174
+
175
+ if (validEntries.length === 0) {
176
+ throw new Error('buildQuery: all keyCondition values are undefined/null');
177
+ }
178
+
165
179
  const names: Record<string, string> = {};
166
180
  const values: Record<string, unknown> = {};
167
181
  const clauses: string[] = [];
168
182
 
169
- for (const [attr, val] of Object.entries(keyConditions)) {
183
+ for (const [attr, val] of validEntries) {
170
184
  const nameAlias = `#${attr}`;
171
185
  const valAlias = `:${attr}`;
172
186
  names[nameAlias] = attr;
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export type { PersistErrorDetail } from './main.js';
30
30
  export { Model, View, Serializer }; // base classes
31
31
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
32
32
  export { count, avg, sum, min, max }; // aggregate helpers
33
+ export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
34
+ export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js'; // the request access() is handed, incl. recordId (#270)
33
35
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
34
36
 
35
37
  // Store API:
package/src/main.ts CHANGED
@@ -20,7 +20,6 @@ import log from 'stonyx/log';
20
20
  import { forEachFileImport } from '@stonyx/utils/file';
21
21
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
22
22
  import { registerPluralName } from './plural-registry.js';
23
- import setupRestServer from './setup-rest-server.js';
24
23
  import baseTransforms from './transforms.js';
25
24
  import Store from './store.js';
26
25
  import Serializer from './serializer.js';
@@ -177,6 +176,25 @@ export default class Orm {
177
176
  }
178
177
 
179
178
  if (restServer.enabled === 'true') {
179
+ // MUST stay dynamic. setup-rest-server.js names the optional
180
+ // '@stonyx/rest-server' peer in its own static graph — directly, and
181
+ // through orm-request.ts / meta-request.ts, which import `Request` at
182
+ // module scope because they extend it (correctly: an `extends` base
183
+ // class cannot be awaited). Node links a module's entire static graph
184
+ // before evaluating any of it, so a static import here puts that
185
+ // specifier on the entry graph and `import('@stonyx/orm')` throws
186
+ // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
187
+ // optional peer.
188
+ //
189
+ // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
190
+ // modules carry no static peer specifier that survives to `dist/`
191
+ // (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
192
+ // tsc), so the `await import()` there is not what isolates pg / mysql2 /
193
+ // @aws-sdk — that happens one layer down, in src/*/connection.ts (and
194
+ // src/dynamodb/dynamodb-db.ts).
195
+ // setup-rest-server.js is the only dist module whose laziness is
196
+ // load-bearing for peer resolution. (#280)
197
+ const { default: setupRestServer } = await import('./setup-rest-server.js');
180
198
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
181
199
  }
182
200
 
@@ -79,6 +79,28 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
79
79
  pendingHasMany.splice(0);
80
80
  }
81
81
 
82
+ // FK-based inverse hasMany wiring — when a child record is created with a
83
+ // foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
84
+ // whose hasMany registry targets this model and push the child into the
85
+ // parent's shared array. This covers edge cases where the child is created
86
+ // in a separate async frame without a belongsTo handler firing.
87
+ const hasManyReg = getHasManyRegistry();
88
+ if (hasManyReg) {
89
+ for (const [parentModelName, targetMap] of hasManyReg) {
90
+ const childArrayMap = targetMap.get(modelName);
91
+ if (!childArrayMap) continue;
92
+
93
+ // Check if rawData contains a FK field matching the parent model name
94
+ const fkValue = rawData[parentModelName];
95
+ if (fkValue === undefined || fkValue === null) continue;
96
+
97
+ const parentArray = childArrayMap.get(fkValue);
98
+ if (parentArray && !parentArray.includes(record)) {
99
+ parentArray.push(record);
100
+ }
101
+ }
102
+ }
103
+
82
104
  // Fulfill pending belongsTo relationships
83
105
  const pendingBelongsToQueue = getPendingBelongsToRegistry();
84
106
  const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
@@ -86,7 +108,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
86
108
 
87
109
  if (pendingBelongsTo) {
88
110
  const belongsToReg = getBelongsToRegistry();
89
- const hasManyReg = getHasManyRegistry();
111
+ const pendingHasManyReg = getHasManyRegistry();
90
112
 
91
113
  for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
92
114
  // Update the belongsTo relationship on the source record
@@ -103,7 +125,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
103
125
  }
104
126
 
105
127
  // Wire inverse hasMany if it exists
106
- const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
128
+ const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
107
129
 
108
130
  if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
109
131
  inverseHasMany.push(sourceRecord);
@@ -117,15 +139,25 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
117
139
  // Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
118
140
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
119
141
  if (shouldPersist) {
142
+ // Capture ID before persist — SQL adapters re-key pending IDs to real DB IDs,
143
+ // but relationship registries were keyed with this original ID
144
+ const registryId = record.id;
120
145
  const response = { data: { id: record.id } };
121
- orm!.sqlDb!.persist('create', modelName, { rawData }, response).catch((err: unknown) => {
122
- orm!.emitPersistError({
123
- operation: 'create',
124
- modelName,
125
- recordId: record.id,
126
- error: err instanceof Error ? err : new Error(String(err)),
146
+ orm!.sqlDb!.persist('create', modelName, { rawData }, response)
147
+ .catch((err: unknown) => {
148
+ orm!.emitPersistError({
149
+ operation: 'create',
150
+ modelName,
151
+ recordId: record.id,
152
+ error: err instanceof Error ? err : new Error(String(err)),
153
+ });
154
+ })
155
+ .finally(() => {
156
+ // Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
157
+ if (store._memoryResolver && !store._memoryResolver(modelName)) {
158
+ store.evictRecord(modelName, record.id, registryId);
159
+ }
127
160
  });
128
- });
129
161
  }
130
162
 
131
163
  return record;
@@ -9,6 +9,7 @@ interface MysqlConfig {
9
9
  connectionLimit?: number;
10
10
  migrationsTable?: string;
11
11
  migrationsDir?: string;
12
+ autoMigrate?: boolean;
12
13
  }
13
14
 
14
15
  let pool: Pool | null = null;