@stonyx/orm 0.3.2-beta.2 → 0.3.2-beta.200

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 (52) hide show
  1. package/README.md +72 -2
  2. package/config/environment.js +8 -0
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +7 -2
  14. package/dist/main.d.ts +16 -0
  15. package/dist/main.js +36 -0
  16. package/dist/manage-record.d.ts +1 -0
  17. package/dist/manage-record.js +66 -4
  18. package/dist/mysql/connection.d.ts +1 -0
  19. package/dist/mysql/mysql-db.d.ts +9 -0
  20. package/dist/mysql/mysql-db.js +48 -12
  21. package/dist/orm-request.d.ts +1 -0
  22. package/dist/orm-request.js +32 -14
  23. package/dist/postgres/connection.d.ts +1 -0
  24. package/dist/postgres/connection.js +8 -6
  25. package/dist/postgres/postgres-db.d.ts +9 -0
  26. package/dist/postgres/postgres-db.js +64 -18
  27. package/dist/record.js +7 -5
  28. package/dist/relationships.js +1 -1
  29. package/dist/serializer.js +38 -2
  30. package/dist/store.d.ts +13 -1
  31. package/dist/store.js +74 -4
  32. package/dist/types/orm-types.d.ts +11 -0
  33. package/package.json +18 -7
  34. package/src/commands.ts +43 -0
  35. package/src/dynamodb/connection.ts +50 -0
  36. package/src/dynamodb/dynamodb-db.ts +811 -0
  37. package/src/dynamodb/operation-builder.ts +202 -0
  38. package/src/dynamodb/type-map.ts +54 -0
  39. package/src/index.ts +8 -2
  40. package/src/main.ts +45 -0
  41. package/src/manage-record.ts +72 -4
  42. package/src/mysql/connection.ts +1 -0
  43. package/src/mysql/mysql-db.ts +49 -14
  44. package/src/orm-request.ts +39 -13
  45. package/src/postgres/connection.ts +10 -6
  46. package/src/postgres/postgres-db.ts +63 -20
  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 +78 -4
  51. package/src/types/orm-types.ts +12 -0
  52. package/src/types/stonyx.d.ts +7 -1
@@ -10,6 +10,10 @@ import { isOrmRecord } from './utils.js';
10
10
 
11
11
  interface OrmRequest$ extends Request {
12
12
  protocol?: string;
13
+ // Express sets this to the path the router was mounted at, e.g. '/api/animals'
14
+ // when orm.restServer.route is '/api'. Optional because non-Express callers
15
+ // (unit tests, programmatic handler invocation) do not supply it.
16
+ baseUrl?: string;
13
17
  method: string;
14
18
  params: { [key: string]: string };
15
19
  body?: { [key: string]: unknown };
@@ -77,11 +81,30 @@ function getModelRelationships(modelName: string): { [key: string]: Relationship
77
81
  return relationships;
78
82
  }
79
83
 
80
- // Helper to build base URL from request
81
- function getBaseUrl(request: OrmRequest$): string {
84
+ /**
85
+ * Build the absolute base URL that every advertised link hangs off — origin
86
+ * plus the prefix the ORM's routes are actually mounted at.
87
+ *
88
+ * The prefix is derived from the *request*, not from
89
+ * `config.orm.restServer.route`. Express sets `request.baseUrl` to the real
90
+ * mountpath registered by `RestServer.mountRoute`, which for this module is
91
+ * always `<prefix>/<pluralizedModel>` (see setup-rest-server.ts). Stripping the
92
+ * trailing model segment therefore yields the prefix by construction, and the
93
+ * link builder cannot drift from the mount registrar the way a second,
94
+ * independent normalisation of `route` would.
95
+ *
96
+ * When `request.baseUrl` is absent or does not end in the model segment the
97
+ * prefix is empty, which reproduces the previous origin-only behaviour.
98
+ */
99
+ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
82
100
  const protocol = request.protocol || 'http';
83
101
  const host = request.get('host');
84
- return `${protocol}://${host}`;
102
+
103
+ const modelSegment = `/${pluralizedModel}`;
104
+ const mountPath = request.baseUrl ?? '';
105
+ const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
106
+
107
+ return `${protocol}://${host}${prefix}`;
85
108
  }
86
109
 
87
110
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
@@ -278,7 +301,7 @@ export default class OrmRequest extends Request {
278
301
  if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter as (record: OrmRecord) => boolean);
279
302
  if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
280
303
 
281
- const baseUrl = getBaseUrl(request);
304
+ const baseUrl = getBaseUrl(request, pluralizedModel);
282
305
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
283
306
 
284
307
  return buildResponse(data, request.query?.include, recordsToReturn, {
@@ -294,7 +317,7 @@ export default class OrmRequest extends Request {
294
317
  const fieldsMap = parseFields(request.query);
295
318
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
296
319
 
297
- const baseUrl = getBaseUrl(request);
320
+ const baseUrl = getBaseUrl(request, pluralizedModel);
298
321
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
299
322
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
300
323
  baseUrl
@@ -330,7 +353,7 @@ export default class OrmRequest extends Request {
330
353
  }
331
354
 
332
355
  const recordAttributes = id !== undefined ? { id, ...sanitizedAttributes } : sanitizedAttributes;
333
- const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false });
356
+ const created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
334
357
  const record = isOrmRecord(created) ? created : null;
335
358
  if (!record) return 500;
336
359
 
@@ -368,7 +391,7 @@ export default class OrmRequest extends Request {
368
391
  }
369
392
  }
370
393
  if (Object.keys(relUpdates).length > 0) {
371
- updateRecord(record as never, relUpdates);
394
+ updateRecord(record as never, relUpdates, { _skipAutoPersist: true });
372
395
  }
373
396
  }
374
397
 
@@ -376,7 +399,7 @@ export default class OrmRequest extends Request {
376
399
  };
377
400
 
378
401
  const deleteHandler: HandlerFn = ({ params }) => {
379
- store.remove(model, getId(params));
402
+ store.remove(model, getId(params), { _skipAutoPersist: true });
380
403
  return 204;
381
404
  };
382
405
 
@@ -443,7 +466,12 @@ export default class OrmRequest extends Request {
443
466
  // Execute main handler
444
467
  const response = await handler(request, state);
445
468
 
446
- // Persist to SQL database for write operations
469
+ // Set context.record for update BEFORE persist so SQL drivers can read it
470
+ if (operation === 'update' && (response as JsonApiResponse)?.data) {
471
+ context.record = store.get(this.model, getId(request.params));
472
+ }
473
+
474
+ // Persist to SQL database for all write operations (create/update/delete)
447
475
  const sqlDb = Orm.instance.sqlDb;
448
476
  if (sqlDb && WRITE_OPERATIONS.has(operation)) {
449
477
  await sqlDb.persist(operation, this.model, context, response);
@@ -461,8 +489,6 @@ export default class OrmRequest extends Request {
461
489
  const responseData = (response as { data: { id: string | number } }).data;
462
490
  const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
463
491
  context.record = store.get(this.model, recordId);
464
- } else if (operation === 'update' && (response as JsonApiResponse)?.data) {
465
- context.record = store.get(this.model, getId(request.params));
466
492
  } else if (operation === 'delete') {
467
493
  // For delete, the record may no longer exist, but we have oldState
468
494
  context.recordId = getId(request.params);
@@ -499,7 +525,7 @@ export default class OrmRequest extends Request {
499
525
  if (!record) return 404;
500
526
 
501
527
  const relatedData = record.__relationships[relationshipName];
502
- const baseUrl = getBaseUrl(request);
528
+ const baseUrl = getBaseUrl(request, pluralizedModel);
503
529
 
504
530
  let data: unknown;
505
531
  if (info.isArray) {
@@ -523,7 +549,7 @@ export default class OrmRequest extends Request {
523
549
  if (!record) return 404;
524
550
 
525
551
  const relatedData = record.__relationships[relationshipName];
526
- const baseUrl = getBaseUrl(request);
552
+ const baseUrl = getBaseUrl(request, pluralizedModel);
527
553
 
528
554
  let data: unknown;
529
555
  if (info.isArray) {
@@ -8,6 +8,7 @@ interface PgConfig {
8
8
  password: string;
9
9
  database: string;
10
10
  connectionLimit: number;
11
+ [key: string]: unknown;
11
12
  }
12
13
 
13
14
  let pool: PgPool | null = null;
@@ -20,15 +21,18 @@ export async function getPool(pgConfig: PgConfig, extensions: string[] = ['vecto
20
21
 
21
22
  const { default: pg } = await import('pg');
22
23
 
24
+ const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
25
+
23
26
  pool = new pg.Pool({
24
- host: pgConfig.host,
25
- port: pgConfig.port,
26
- user: pgConfig.user,
27
- password: pgConfig.password,
28
- database: pgConfig.database,
29
- max: pgConfig.connectionLimit,
27
+ host,
28
+ port,
29
+ user,
30
+ password,
31
+ database,
32
+ max: connectionLimit,
30
33
  idleTimeoutMillis: 30000,
31
34
  connectionTimeoutMillis: 10000,
35
+ ...poolOpts,
32
36
  });
33
37
 
34
38
  // Enable requested PostgreSQL extensions
@@ -19,6 +19,7 @@ interface PersistContext {
19
19
  record?: OrmRecord;
20
20
  recordId?: unknown;
21
21
  oldState?: Record<string, unknown>;
22
+ rawData?: Record<string, unknown>;
22
23
  }
23
24
 
24
25
  interface PersistResponse {
@@ -89,6 +90,15 @@ export default class PostgresDB {
89
90
  pool!: Pool | null;
90
91
  pgConfig!: Record<string, unknown>;
91
92
 
93
+ /**
94
+ * Promise-chain mutex for write serialization (#156).
95
+ * All persist() calls chain through this single queue so concurrent
96
+ * fire-and-forget writes never produce parallel transactions
97
+ * on FK-linked rows (which cause deadlocks).
98
+ * Reads are NOT affected — only persist() serializes.
99
+ */
100
+ private _writeQueue: Promise<void> = Promise.resolve();
101
+
92
102
  constructor(deps: Partial<PostgresDeps> = {}) {
93
103
  const Ctor = this.constructor as typeof PostgresDB;
94
104
  if (Ctor.instance) return Ctor.instance;
@@ -124,7 +134,15 @@ export default class PostgresDB {
124
134
  if (pending.length > 0) {
125
135
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
126
136
 
127
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
137
+ let shouldApply: boolean;
138
+ if (this.pgConfig.autoMigrate === true) {
139
+ shouldApply = true;
140
+ } else if (this.pgConfig.autoMigrate === false) {
141
+ shouldApply = false;
142
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
143
+ } else {
144
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
145
+ }
128
146
 
129
147
  if (shouldApply) {
130
148
  for (const filename of pending) {
@@ -145,9 +163,17 @@ export default class PostgresDB {
145
163
  const modelCount = Object.keys(schemas).length;
146
164
 
147
165
  if (modelCount > 0) {
148
- const shouldGenerate = await this.deps.confirm(
149
- `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
150
- );
166
+ let shouldGenerate: boolean;
167
+ if (this.pgConfig.autoMigrate === true) {
168
+ shouldGenerate = true;
169
+ } else if (this.pgConfig.autoMigrate === false) {
170
+ shouldGenerate = false;
171
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
172
+ } else {
173
+ shouldGenerate = await this.deps.confirm(
174
+ `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
175
+ );
176
+ }
151
177
 
152
178
  if (shouldGenerate) {
153
179
  const { generateMigration } = await import('./migration-generator.js');
@@ -467,17 +493,24 @@ export default class PostgresDB {
467
493
  const Orm = (await import('@stonyx/orm')).default;
468
494
  if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
469
495
 
470
- switch (operation) {
471
- case 'create':
472
- return this._persistCreate(modelName, context, response);
473
- case 'update':
474
- return this._persistUpdate(modelName, context, response);
475
- case 'delete':
476
- return this._persistDelete(modelName, context);
477
- }
496
+ const work = async () => {
497
+ switch (operation) {
498
+ case 'create':
499
+ return this._persistCreate(modelName, context, response);
500
+ case 'update':
501
+ return this._persistUpdate(modelName, context, response);
502
+ case 'delete':
503
+ return this._persistDelete(modelName, context);
504
+ }
505
+ };
506
+
507
+ // Chain through the write queue — .then(work, work) ensures the queue
508
+ // advances even when a previous persist rejects (#156).
509
+ this._writeQueue = this._writeQueue.then(work, work);
510
+ return this._writeQueue;
478
511
  }
479
512
 
480
- private async _persistCreate(modelName: string, _context: PersistContext, response: PersistResponse): Promise<void> {
513
+ private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
481
514
  const schemas = this.deps.introspectModels();
482
515
  const schema = schemas[modelName];
483
516
 
@@ -491,10 +524,12 @@ export default class PostgresDB {
491
524
 
492
525
  if (!record) return;
493
526
 
494
- const insertData = this._recordToRow(record, schema);
527
+ const insertData = this._recordToRow(record, schema, context.rawData);
495
528
 
496
- // For auto-increment models, remove the pending ID
497
- const isPendingId = record.__data.__pendingSqlId;
529
+ // For auto-increment models, remove the pending ID.
530
+ // Check context.rawData (not record.__data) because __pendingSqlId is not a model
531
+ // attribute and gets lost during serialization.
532
+ const isPendingId = context.rawData?.__pendingSqlId === true;
498
533
 
499
534
  if (isPendingId) {
500
535
  delete insertData.id;
@@ -549,7 +584,10 @@ export default class PostgresDB {
549
584
  // Check FK changes too
550
585
  for (const fkCol of Object.keys(schema.foreignKeys)) {
551
586
  const relName = fkCol.replace(/_id$/, '');
552
- const currentFkValue = (record.__relationships[relName] as { id: unknown } | undefined)?.id ?? null;
587
+ const relValue = record.__relationships[relName];
588
+ const currentFkValue = (relValue && typeof relValue === 'object' && relValue !== null)
589
+ ? (relValue as { id: unknown }).id ?? null
590
+ : relValue ?? record.__data[relName] ?? null;
553
591
  const oldFkValue = oldState[relName] ?? null;
554
592
 
555
593
  if (currentFkValue !== oldFkValue) {
@@ -579,7 +617,7 @@ export default class PostgresDB {
579
617
  await this.requirePool().query(sql, values);
580
618
  }
581
619
 
582
- private _recordToRow(record: OrmRecord, schema: ModelSchema): Record<string, unknown> {
620
+ private _recordToRow(record: OrmRecord, schema: ModelSchema, rawData?: Record<string, unknown>): Record<string, unknown> {
583
621
  const row: Record<string, unknown> = {};
584
622
  const data = record.__data;
585
623
 
@@ -603,11 +641,16 @@ export default class PostgresDB {
603
641
  const relName = fkCol.replace(/_id$/, '');
604
642
  const related = record.__relationships[relName];
605
643
 
606
- if (related) {
644
+ if (related && typeof related === 'object' && related !== null) {
607
645
  row[fkCol] = (related as { id: unknown }).id;
646
+ } else if (related != null) {
647
+ // Raw FK value (e.g., string ID stored directly in __relationships)
648
+ row[fkCol] = related;
608
649
  } else if (data[relName] !== undefined) {
609
- // Raw FK value (e.g., from create payload)
610
650
  row[fkCol] = data[relName];
651
+ } else if (rawData?.[relName] !== undefined) {
652
+ // Fallback to original create payload for unresolved belongsTo FKs
653
+ row[fkCol] = rawData[relName];
611
654
  }
612
655
  }
613
656
 
package/src/record.ts CHANGED
@@ -87,12 +87,15 @@ export default class Record {
87
87
 
88
88
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
89
89
  if (Array.isArray(childRecord)) {
90
+ // Filter out cleaned records (those with no __model)
91
+ const live = childRecord.filter((r: Record) => r?.__model);
92
+
90
93
  // Deduplicate by record ID — keep last occurrence (latest data wins)
91
94
  const seen = new Set<unknown>();
92
95
  const unique: Record[] = [];
93
96
 
94
- for (let i = childRecord.length - 1; i >= 0; i--) {
95
- const r = childRecord[i] as Record;
97
+ for (let i = live.length - 1; i >= 0; i--) {
98
+ const r = live[i] as Record;
96
99
  if (!seen.has(r.id)) {
97
100
  seen.add(r.id);
98
101
  unique.push(r);
@@ -102,7 +105,7 @@ export default class Record {
102
105
  unique.reverse();
103
106
  records[key] = unique.map((r: Record) => r.serialize());
104
107
  } else {
105
- records[key] = (childRecord as Record)?.serialize() ?? null;
108
+ records[key] = (childRecord as Record)?.__model ? (childRecord as Record).serialize() : null;
106
109
  }
107
110
  }
108
111
 
@@ -136,8 +139,8 @@ export default class Record {
136
139
  if (fields && !fields.has(key)) continue;
137
140
 
138
141
  const relationshipData = Array.isArray(childRecord)
139
- ? childRecord.map((r: Record) => ({ type: r.__model.__name, id: r.id }))
140
- : childRecord ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
142
+ ? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
143
+ : (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
141
144
 
142
145
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
143
146
  const dasherizedKey = camelCaseToKebabCase(key);
@@ -51,4 +51,4 @@ export function getPendingBelongsToRegistry(): PendingBelongsToMap {
51
51
  return relationships.get('pendingBelongsTo') as PendingBelongsToMap;
52
52
  }
53
53
 
54
- export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending'];
54
+ export const TYPES: string[] = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
package/src/serializer.ts CHANGED
@@ -94,8 +94,45 @@ export default class Serializer {
94
94
  const handlerOptions = { ...options, _relationshipKey: key };
95
95
  const childRecord = handler(record, data, handlerOptions);
96
96
 
97
- rec[key] = childRecord;
98
- relatedRecords[key] = childRecord;
97
+ // hasMany relationships use a getter so format()/toJSON() always read
98
+ // the live registry array instead of a stale snapshot captured at
99
+ // serialization time. This is critical when child records are created
100
+ // in a later async frame — the belongsTo inverse wiring pushes into
101
+ // the shared registry array, and the getter ensures the parent sees it.
102
+ const isHasMany = (handler as { __relationshipType?: string }).__relationshipType === 'hasMany';
103
+
104
+ if (isHasMany) {
105
+ // `childRecord` IS the shared registry array — define a getter that
106
+ // always dereferences through the same array reference.
107
+ const registryArray = childRecord as unknown[];
108
+ Object.defineProperty(rec, key, {
109
+ enumerable: true,
110
+ configurable: true,
111
+ get: () => registryArray,
112
+ set(v: unknown) { relatedRecords[key] = v; }
113
+ });
114
+ Object.defineProperty(relatedRecords, key, {
115
+ enumerable: true,
116
+ configurable: true,
117
+ get: () => registryArray,
118
+ set(v: unknown) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
119
+ });
120
+ } else {
121
+ rec[key] = childRecord;
122
+ relatedRecords[key] = childRecord;
123
+
124
+ // Preserve the raw FK value in __data when the belongsTo handler
125
+ // couldn't resolve the target (e.g., memory:false model not loaded).
126
+ // This allows adapters to read the FK from __data as a fallback
127
+ // when __relationships[key] is null. Only store when `data` is a
128
+ // truthy non-object — i.e., a raw FK string/number that the handler
129
+ // attempted but failed to resolve. When `data` is null/undefined
130
+ // (optional empty relationship) we intentionally skip to preserve
131
+ // the existing behavior of not populating __data for empty FKs.
132
+ if (childRecord === null && data && typeof data !== 'object') {
133
+ parsedData[key] = data;
134
+ }
135
+ }
99
136
 
100
137
  continue;
101
138
  }
package/src/store.ts 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
 
5
5
  interface UnloadOptions {
@@ -64,7 +64,7 @@ export default class Store {
64
64
  get(key: string): Map<number | string, unknown> | undefined;
65
65
  get(key: string, id: number | string): unknown;
66
66
  get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
67
- if (!id) return this.data.get(key);
67
+ if (id === undefined) return this.data.get(key);
68
68
 
69
69
  return this.data.get(key)?.get(id);
70
70
  }
@@ -170,17 +170,68 @@ export default class Store {
170
170
  this.data.set(key, value);
171
171
  }
172
172
 
173
- remove(key: string, id?: number | string): void {
173
+ remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
174
174
  // Guard: read-only views cannot have records removed
175
175
  if (Orm.instance?.isView?.(key)) {
176
176
  throw new Error(`Cannot remove records from read-only view '${key}'`);
177
177
  }
178
178
 
179
+ // Auto-persist delete to SQL (fire-and-forget) — skipped when the
180
+ // request path handles persist itself to avoid double-delete.
181
+ if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
182
+ Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
183
+ Orm.instance.emitPersistError({
184
+ operation: 'delete',
185
+ modelName: key,
186
+ recordId: id,
187
+ error: err instanceof Error ? err : new Error(String(err)),
188
+ });
189
+ });
190
+ }
191
+
179
192
  if (id) return this.unloadRecord(key, id);
180
193
 
181
194
  this.unloadAllRecords(key);
182
195
  }
183
196
 
197
+ /**
198
+ * Evict a record from the store with full relationship registry cleanup.
199
+ * The caller retains its reference to the returned record, which is the
200
+ * contract memory:false post-persist eviction relies on.
201
+ *
202
+ * @param registryId - The ID used when the record's relationships were
203
+ * registered. For SQL models with pending IDs, this is the original
204
+ * negative pending ID (before the adapter re-keyed to the real DB ID).
205
+ */
206
+ evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
207
+ const modelStore = this.data.get(modelName);
208
+ if (!modelStore) return;
209
+
210
+ if (typeof id !== 'string' && typeof id !== 'number') return;
211
+ const raw = modelStore.get(id);
212
+ if (!raw || !isStoreRecord(raw)) return;
213
+
214
+ const visited = new Set([`${modelName}:${id}`]);
215
+
216
+ // Remove from hasMany arrays and nullify belongsTo references using current ID
217
+ // (the adapter updates record.id, so value-based matches need the current ID)
218
+ this._removeFromHasManyArrays(modelName, id, visited);
219
+ this._nullifyBelongsToReferences(modelName, id, visited);
220
+
221
+ // Clean up relationship registry entries using the registry key
222
+ // (belongsTo/hasMany registries were keyed by the ID at registration time,
223
+ // which may differ from the current ID if SQL persist re-keyed the record)
224
+ const cleanupId = registryId ?? id;
225
+ this._cleanupRelationshipRegistries(modelName, cleanupId);
226
+
227
+ // If registryId differs from id, also clean with current id as safety net
228
+ if (registryId !== undefined && registryId !== id) {
229
+ this._cleanupRelationshipRegistries(modelName, id);
230
+ }
231
+
232
+ modelStore.delete(id);
233
+ }
234
+
184
235
  unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
185
236
  const modelStore = this.data.get(model);
186
237
 
@@ -207,7 +258,6 @@ export default class Store {
207
258
  this._removeFromHasManyArrays(modelName, recordId, visited);
208
259
  this._nullifyBelongsToReferences(modelName, recordId, visited);
209
260
  this._cleanupRelationshipRegistries(modelName, recordId);
210
- recordToUnload.clean();
211
261
 
212
262
  this.data.get(modelName)?.delete(recordId as string | number);
213
263
  }
@@ -296,6 +346,30 @@ export default class Store {
296
346
 
297
347
  const pendingMap = getPendingRegistry().get(modelName);
298
348
  if (pendingMap) pendingMap.delete(recordId);
349
+
350
+ // Clean pendingBelongsTo entries in both directions
351
+ const pendingBelongsToMap = getPendingBelongsToRegistry();
352
+ if (pendingBelongsToMap) {
353
+ // Direction 1: evicted record was the TARGET others were waiting for
354
+ const targetEntries = pendingBelongsToMap.get(modelName);
355
+ if (targetEntries) targetEntries.delete(recordId);
356
+
357
+ // Direction 2: evicted record was the SOURCE with unresolved forward-references
358
+ for (const [, targetIdMap] of pendingBelongsToMap) {
359
+ for (const [targetId, entries] of targetIdMap) {
360
+ if (!Array.isArray(entries)) continue;
361
+ const filtered = entries.filter((e: unknown) => {
362
+ const entry = e as { sourceModelName?: string; relationshipId?: unknown };
363
+ return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
364
+ });
365
+ if (filtered.length === 0) {
366
+ targetIdMap.delete(targetId);
367
+ } else if (filtered.length < entries.length) {
368
+ targetIdMap.set(targetId, filtered);
369
+ }
370
+ }
371
+ }
372
+ }
299
373
  }
300
374
 
301
375
  /**
@@ -18,6 +18,7 @@ export interface OrmMysqlConfig {
18
18
  connectionLimit?: number;
19
19
  migrationsDir?: string;
20
20
  migrationsTable?: string;
21
+ autoMigrate?: boolean;
21
22
  [key: string]: unknown;
22
23
  }
23
24
 
@@ -30,6 +31,7 @@ export interface OrmPostgresConfig {
30
31
  connectionLimit?: number;
31
32
  migrationsDir?: string;
32
33
  migrationsTable?: string;
34
+ autoMigrate?: boolean;
33
35
  [key: string]: unknown;
34
36
  }
35
37
 
@@ -48,6 +50,13 @@ export interface OrmRestServerConfig {
48
50
  metaRoute: boolean;
49
51
  }
50
52
 
53
+ export interface OrmDynamoDBConfig {
54
+ region?: string;
55
+ endpoint?: string;
56
+ tablePrefix?: string;
57
+ [key: string]: unknown;
58
+ }
59
+
51
60
  export interface OrmSection {
52
61
  db: OrmDbConfig;
53
62
  paths: OrmPaths;
@@ -55,6 +64,9 @@ export interface OrmSection {
55
64
  mysql?: OrmMysqlConfig;
56
65
  postgres?: OrmPostgresConfig;
57
66
  timescale?: OrmPostgresConfig;
67
+ dynamodb?: OrmDynamoDBConfig;
68
+ logColor?: string;
69
+ logMethod?: string;
58
70
  [key: string]: unknown;
59
71
  }
60
72
 
@@ -5,7 +5,13 @@ declare module 'stonyx/config' {
5
5
  }
6
6
 
7
7
  declare module 'stonyx/log' {
8
- const log: Record<string, ((...args: unknown[]) => void) | undefined>;
8
+ interface Log {
9
+ db(message: string): void;
10
+ error(message: string, ...args: unknown[]): void;
11
+ defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
12
+ [key: string]: ((...args: unknown[]) => void) | undefined;
13
+ }
14
+ const log: Log;
9
15
  export default log;
10
16
  }
11
17