@stonyx/orm 0.3.2-alpha.4 → 0.3.2-alpha.41

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 (49) hide show
  1. package/README.md +36 -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/main.d.ts +16 -0
  14. package/dist/main.js +36 -0
  15. package/dist/manage-record.js +46 -6
  16. package/dist/mysql/connection.d.ts +1 -0
  17. package/dist/mysql/mysql-db.d.ts +8 -0
  18. package/dist/mysql/mysql-db.js +44 -10
  19. package/dist/orm-request.js +7 -6
  20. package/dist/postgres/connection.d.ts +1 -0
  21. package/dist/postgres/connection.js +8 -6
  22. package/dist/postgres/postgres-db.d.ts +8 -0
  23. package/dist/postgres/postgres-db.js +44 -10
  24. package/dist/record.js +7 -5
  25. package/dist/relationships.js +1 -1
  26. package/dist/serializer.js +38 -2
  27. package/dist/store.d.ts +13 -1
  28. package/dist/store.js +71 -7
  29. package/dist/types/orm-types.d.ts +11 -0
  30. package/package.json +17 -7
  31. package/src/commands.ts +43 -0
  32. package/src/dynamodb/connection.ts +50 -0
  33. package/src/dynamodb/dynamodb-db.ts +811 -0
  34. package/src/dynamodb/operation-builder.ts +202 -0
  35. package/src/dynamodb/type-map.ts +54 -0
  36. package/src/index.ts +1 -0
  37. package/src/main.ts +45 -0
  38. package/src/manage-record.ts +48 -7
  39. package/src/mysql/connection.ts +1 -0
  40. package/src/mysql/mysql-db.ts +44 -12
  41. package/src/orm-request.ts +8 -5
  42. package/src/postgres/connection.ts +10 -6
  43. package/src/postgres/postgres-db.ts +44 -12
  44. package/src/record.ts +8 -5
  45. package/src/relationships.ts +1 -1
  46. package/src/serializer.ts +39 -2
  47. package/src/store.ts +74 -7
  48. package/src/types/orm-types.ts +12 -0
  49. package/src/types/stonyx.d.ts +7 -1
@@ -0,0 +1,202 @@
1
+ /**
2
+ * DynamoDB operation parameter builders.
3
+ *
4
+ * Each function returns a plain-object "params" bag that can be passed
5
+ * directly to the corresponding DocumentClient command
6
+ * (PutCommand, GetCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand).
7
+ *
8
+ * All functions are pure — no SDK imports here; the caller wraps params
9
+ * in the appropriate Command class.
10
+ */
11
+
12
+ export interface PutItemParams {
13
+ TableName: string;
14
+ Item: Record<string, unknown>;
15
+ ConditionExpression?: string;
16
+ }
17
+
18
+ export interface GetItemParams {
19
+ TableName: string;
20
+ Key: Record<string, unknown>;
21
+ }
22
+
23
+ export interface UpdateItemParams {
24
+ TableName: string;
25
+ Key: Record<string, unknown>;
26
+ UpdateExpression: string;
27
+ ExpressionAttributeNames: Record<string, string>;
28
+ ExpressionAttributeValues: Record<string, unknown>;
29
+ ReturnValues: string;
30
+ }
31
+
32
+ export interface DeleteItemParams {
33
+ TableName: string;
34
+ Key: Record<string, unknown>;
35
+ }
36
+
37
+ export interface ScanParams {
38
+ TableName: string;
39
+ FilterExpression?: string;
40
+ ExpressionAttributeNames?: Record<string, string>;
41
+ ExpressionAttributeValues?: Record<string, unknown>;
42
+ ExclusiveStartKey?: Record<string, unknown>;
43
+ }
44
+
45
+ export interface QueryParams {
46
+ TableName: string;
47
+ IndexName: string;
48
+ KeyConditionExpression: string;
49
+ ExpressionAttributeNames: Record<string, string>;
50
+ ExpressionAttributeValues: Record<string, unknown>;
51
+ ExclusiveStartKey?: Record<string, unknown>;
52
+ }
53
+
54
+ /**
55
+ * PutItem — optionally with a condition expression.
56
+ *
57
+ * Pass conditionExpression = 'attribute_not_exists(id)' to enforce uniqueness.
58
+ */
59
+ export function buildPutItem(
60
+ tableName: string,
61
+ item: Record<string, unknown>,
62
+ conditionExpression?: string,
63
+ ): PutItemParams {
64
+ const params: PutItemParams = { TableName: tableName, Item: item };
65
+ if (conditionExpression) params.ConditionExpression = conditionExpression;
66
+ return params;
67
+ }
68
+
69
+ /**
70
+ * GetItem by primary key.
71
+ */
72
+ export function buildGetItem(
73
+ tableName: string,
74
+ key: Record<string, unknown>,
75
+ ): GetItemParams {
76
+ return { TableName: tableName, Key: key };
77
+ }
78
+
79
+ /**
80
+ * UpdateItem with a SET expression built from the `updates` object.
81
+ * Only the supplied attributes are updated (diff-based call site).
82
+ */
83
+ export function buildUpdateItem(
84
+ tableName: string,
85
+ key: Record<string, unknown>,
86
+ updates: Record<string, unknown>,
87
+ ): UpdateItemParams {
88
+ const names: Record<string, string> = {};
89
+ const values: Record<string, unknown> = {};
90
+ const setClauses: string[] = [];
91
+
92
+ for (const [attr, val] of Object.entries(updates)) {
93
+ const nameAlias = `#${attr}`;
94
+ const valAlias = `:${attr}`;
95
+ names[nameAlias] = attr;
96
+ values[valAlias] = val;
97
+ setClauses.push(`${nameAlias} = ${valAlias}`);
98
+ }
99
+
100
+ return {
101
+ TableName: tableName,
102
+ Key: key,
103
+ UpdateExpression: `SET ${setClauses.join(', ')}`,
104
+ ExpressionAttributeNames: names,
105
+ ExpressionAttributeValues: values,
106
+ ReturnValues: 'NONE',
107
+ };
108
+ }
109
+
110
+ /**
111
+ * DeleteItem by primary key.
112
+ */
113
+ export function buildDeleteItem(
114
+ tableName: string,
115
+ key: Record<string, unknown>,
116
+ ): DeleteItemParams {
117
+ return { TableName: tableName, Key: key };
118
+ }
119
+
120
+ /**
121
+ * ScanCommand params.
122
+ * If conditions are supplied they are rendered as a FilterExpression using AND.
123
+ */
124
+ export function buildScan(
125
+ tableName: string,
126
+ conditions?: Record<string, unknown>,
127
+ exclusiveStartKey?: Record<string, unknown>,
128
+ ): ScanParams {
129
+ const params: ScanParams = { TableName: tableName };
130
+
131
+ if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
132
+
133
+ if (conditions && Object.keys(conditions).length > 0) {
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;
154
+ }
155
+ }
156
+
157
+ return params;
158
+ }
159
+
160
+ /**
161
+ * QueryCommand params for a GSI.
162
+ * keyConditions must be in the form { attrName: value } and will be rendered
163
+ * as equality expressions joined by AND.
164
+ */
165
+ export function buildQuery(
166
+ tableName: string,
167
+ indexName: string,
168
+ keyConditions: Record<string, unknown>,
169
+ exclusiveStartKey?: Record<string, unknown>,
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
+
179
+ const names: Record<string, string> = {};
180
+ const values: Record<string, unknown> = {};
181
+ const clauses: string[] = [];
182
+
183
+ for (const [attr, val] of validEntries) {
184
+ const nameAlias = `#${attr}`;
185
+ const valAlias = `:${attr}`;
186
+ names[nameAlias] = attr;
187
+ values[valAlias] = val;
188
+ clauses.push(`${nameAlias} = ${valAlias}`);
189
+ }
190
+
191
+ const params: QueryParams = {
192
+ TableName: tableName,
193
+ IndexName: indexName,
194
+ KeyConditionExpression: clauses.join(' AND '),
195
+ ExpressionAttributeNames: names,
196
+ ExpressionAttributeValues: values,
197
+ };
198
+
199
+ if (exclusiveStartKey) params.ExclusiveStartKey = exclusiveStartKey;
200
+
201
+ return params;
202
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Maps ORM attribute types to DynamoDB scalar attribute types.
3
+ * DynamoDB DocumentClient auto-marshalls JS objects, so most values
4
+ * are sent as their native JS types. This map is used by the
5
+ * schema-introspector and startup provisioner for table/GSI creation.
6
+ */
7
+
8
+ export type DynamoScalarType = 'S' | 'N' | 'BOOL';
9
+
10
+ /**
11
+ * DynamoDB attribute-type string for a given ORM attr type.
12
+ * - string → S
13
+ * - number / float → N (stored as Number; DocumentClient handles it)
14
+ * - boolean → BOOL
15
+ * - date → S (ISO-8601 string — enables range queries)
16
+ * - timestamp → N (milliseconds since epoch)
17
+ * - passthrough/trim/etc → S (safe default)
18
+ *
19
+ * For key schema declarations only `S` and `N` are valid; BOOL
20
+ * is legal for attributes but never for a PK/SK.
21
+ */
22
+ const typeMap: Record<string, DynamoScalarType> = {
23
+ string: 'S',
24
+ number: 'N',
25
+ float: 'N',
26
+ boolean: 'BOOL',
27
+ date: 'S',
28
+ timestamp: 'N',
29
+ passthrough: 'S',
30
+ trim: 'S',
31
+ uppercase: 'S',
32
+ ceil: 'N',
33
+ floor: 'N',
34
+ round: 'N',
35
+ };
36
+
37
+ /**
38
+ * Returns the DynamoDB attribute type for a given ORM type string.
39
+ * Defaults to 'S' for any unknown/custom type.
40
+ */
41
+ export function getDynamoType(attrType: string): DynamoScalarType {
42
+ return typeMap[attrType] ?? 'S';
43
+ }
44
+
45
+ /**
46
+ * Returns the DynamoDB key type ('S' | 'N') for use in KeySchema.
47
+ * BOOL cannot be a key attribute; anything that maps to BOOL falls back to 'S'.
48
+ */
49
+ export function getDynamoKeyType(attrType: string): 'S' | 'N' {
50
+ const t = getDynamoType(attrType);
51
+ return t === 'N' ? 'N' : 'S';
52
+ }
53
+
54
+ export default typeMap;
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ import { count, avg, sum, min, max } from './aggregates.js';
26
26
 
27
27
  export { default } from './main.js';
28
28
  export { store, relationships } from './main.js';
29
+ export type { PersistErrorDetail } from './main.js';
29
30
  export { Model, View, Serializer }; // base classes
30
31
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
31
32
  export { count, avg, sum, min, max }; // aggregate helpers
package/src/main.ts CHANGED
@@ -49,6 +49,13 @@ const defaultOptions: OrmOptions = {
49
49
  dbType: 'json'
50
50
  }
51
51
 
52
+ export interface PersistErrorDetail {
53
+ operation: 'create' | 'update' | 'delete';
54
+ modelName: string;
55
+ recordId: unknown;
56
+ error: Error;
57
+ }
58
+
52
59
  export default class Orm {
53
60
  static initialized: boolean = false;
54
61
  static relationships: Map<string, Map<string, unknown>> = new Map();
@@ -65,6 +72,8 @@ export default class Orm {
65
72
  sqlDb?: SqlDb;
66
73
  db?: OrmDB | SqlDb;
67
74
 
75
+ private _persistErrorHandler: ((detail: PersistErrorDetail) => void) | null = null;
76
+
68
77
  constructor(options: OrmOptions = {}) {
69
78
  if (Orm.instance) return Orm.instance;
70
79
 
@@ -81,6 +90,11 @@ export default class Orm {
81
90
  }
82
91
 
83
92
  async init(): Promise<void> {
93
+ // Self-register so log.db works even when @stonyx/orm is in the
94
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
95
+ const { logColor = 'white', logMethod = 'db' } = config.orm;
96
+ log.defineType(logMethod, logColor);
97
+
84
98
  const { paths, restServer } = config.orm;
85
99
 
86
100
  const promises: Promise<unknown>[] = ['Model', 'Serializer', 'Transform'].map(type => {
@@ -150,6 +164,11 @@ export default class Orm {
150
164
  this.sqlDb = new MysqlDB() as SqlDb;
151
165
  this.db = this.sqlDb;
152
166
  promises.push(this.sqlDb.init());
167
+ } else if (config.orm.dynamodb) {
168
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
169
+ this.sqlDb = new DynamoDBDB() as SqlDb;
170
+ this.db = this.sqlDb;
171
+ promises.push(this.sqlDb.init());
153
172
  } else if (this.options.dbType !== 'none') {
154
173
  const db = new DB();
155
174
  this.db = db;
@@ -214,6 +233,32 @@ export default class Orm {
214
233
  return !!this.views[`${modelClassPrefix}View`];
215
234
  }
216
235
 
236
+ /**
237
+ * Register a callback to be invoked when a fire-and-forget SQL persist fails.
238
+ * Without a handler, persist errors are logged via log.error (backwards-compatible).
239
+ */
240
+ onPersistError(handler: ((detail: PersistErrorDetail) => void) | null): void {
241
+ this._persistErrorHandler = handler;
242
+ }
243
+
244
+ /**
245
+ * Emit a persist error to the registered handler, or fall back to log.error.
246
+ */
247
+ emitPersistError(detail: PersistErrorDetail): void {
248
+ const fallbackLog = () => log.error?.(`[ORM] Failed to persist ${detail.operation} for ${detail.modelName}:${String(detail.recordId)}: ${detail.error.message}`);
249
+
250
+ if (this._persistErrorHandler) {
251
+ try {
252
+ this._persistErrorHandler(detail);
253
+ } catch (handlerError) {
254
+ fallbackLog();
255
+ log.error?.(`[ORM] onPersistError handler threw: ${handlerError instanceof Error ? handlerError.message : String(handlerError)}`);
256
+ }
257
+ } else {
258
+ fallbackLog();
259
+ }
260
+ }
261
+
217
262
  // Queue warnings to avoid the same error from being logged in the same iteration
218
263
  warn(message: string): void {
219
264
  this.warnings.add(message);
@@ -3,7 +3,6 @@ import OrmRecord from './record.js';
3
3
  import { getGlobalRegistry, getPendingRegistry, getPendingBelongsToRegistry, getBelongsToRegistry, getHasManyRegistry } from './relationships.js';
4
4
  import type Serializer from './serializer.js';
5
5
  import { isOrmRecord } from './utils.js';
6
- import log from 'stonyx/log';
7
6
 
8
7
  interface CreateRecordOptions {
9
8
  isDbRecord?: boolean;
@@ -80,6 +79,28 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
80
79
  pendingHasMany.splice(0);
81
80
  }
82
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
+
83
104
  // Fulfill pending belongsTo relationships
84
105
  const pendingBelongsToQueue = getPendingBelongsToRegistry();
85
106
  const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
@@ -87,7 +108,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
87
108
 
88
109
  if (pendingBelongsTo) {
89
110
  const belongsToReg = getBelongsToRegistry();
90
- const hasManyReg = getHasManyRegistry();
111
+ const pendingHasManyReg = getHasManyRegistry();
91
112
 
92
113
  for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
93
114
  // Update the belongsTo relationship on the source record
@@ -104,7 +125,7 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
104
125
  }
105
126
 
106
127
  // Wire inverse hasMany if it exists
107
- const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
128
+ const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
108
129
 
109
130
  if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
110
131
  inverseHasMany.push(sourceRecord);
@@ -118,10 +139,25 @@ export function createRecord(modelName: string, rawData: { [key: string]: unknow
118
139
  // Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
119
140
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
120
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;
121
145
  const response = { data: { id: record.id } };
122
- orm!.sqlDb!.persist('create', modelName, { rawData }, response).catch((err: unknown) => {
123
- log.error?.(`[ORM] Failed to persist create for ${modelName}:${String(record.id)}: ${err instanceof Error ? err.message : String(err)}`);
124
- });
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
+ }
160
+ });
125
161
  }
126
162
 
127
163
  return record;
@@ -148,7 +184,12 @@ export function updateRecord(record: OrmRecord, rawData: unknown, userOptions: C
148
184
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
149
185
  if (shouldPersist && modelName) {
150
186
  orm!.sqlDb!.persist('update', modelName, { record, oldState }, {}).catch((err: unknown) => {
151
- log.error?.(`[ORM] Failed to persist update for ${modelName}:${String(record.id)}: ${err instanceof Error ? err.message : String(err)}`);
187
+ orm!.emitPersistError({
188
+ operation: 'update',
189
+ modelName,
190
+ recordId: record.id,
191
+ error: err instanceof Error ? err : new Error(String(err)),
192
+ });
152
193
  });
153
194
  }
154
195
  }
@@ -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;
@@ -84,6 +84,15 @@ export default class MysqlDB {
84
84
  pool!: Pool | null;
85
85
  mysqlConfig!: MysqlConfig;
86
86
 
87
+ /**
88
+ * Promise-chain mutex for write serialization (#156).
89
+ * All persist() calls chain through this single queue so concurrent
90
+ * fire-and-forget writes never produce parallel InnoDB transactions
91
+ * on FK-linked rows (which cause deadlocks).
92
+ * Reads are NOT affected — only persist() serializes.
93
+ */
94
+ private _writeQueue: Promise<void> = Promise.resolve();
95
+
87
96
  constructor(deps: Partial<MysqlDBDeps> = {}) {
88
97
  if (MysqlDB.instance) return MysqlDB.instance;
89
98
  MysqlDB.instance = this;
@@ -118,7 +127,15 @@ export default class MysqlDB {
118
127
  if (pending.length > 0) {
119
128
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
120
129
 
121
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
130
+ let shouldApply: boolean;
131
+ if (this.mysqlConfig.autoMigrate === true) {
132
+ shouldApply = true;
133
+ } else if (this.mysqlConfig.autoMigrate === false) {
134
+ shouldApply = false;
135
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
136
+ } else {
137
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
138
+ }
122
139
 
123
140
  if (shouldApply) {
124
141
  for (const filename of pending) {
@@ -139,9 +156,17 @@ export default class MysqlDB {
139
156
  const modelCount = Object.keys(schemas).length;
140
157
 
141
158
  if (modelCount > 0) {
142
- const shouldGenerate = await this.deps.confirm(
143
- `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
144
- );
159
+ let shouldGenerate: boolean;
160
+ if (this.mysqlConfig.autoMigrate === true) {
161
+ shouldGenerate = true;
162
+ } else if (this.mysqlConfig.autoMigrate === false) {
163
+ shouldGenerate = false;
164
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
165
+ } else {
166
+ shouldGenerate = await this.deps.confirm(
167
+ `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
168
+ );
169
+ }
145
170
 
146
171
  if (shouldGenerate) {
147
172
  const { generateMigration } = await import('./migration-generator.js');
@@ -398,14 +423,21 @@ export default class MysqlDB {
398
423
  const Orm = (await import('@stonyx/orm')).default;
399
424
  if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
400
425
 
401
- switch (operation) {
402
- case 'create':
403
- return this._persistCreate(modelName, context, response);
404
- case 'update':
405
- return this._persistUpdate(modelName, context, response);
406
- case 'delete':
407
- return this._persistDelete(modelName, context);
408
- }
426
+ const work = async () => {
427
+ switch (operation) {
428
+ case 'create':
429
+ return this._persistCreate(modelName, context, response);
430
+ case 'update':
431
+ return this._persistUpdate(modelName, context, response);
432
+ case 'delete':
433
+ return this._persistDelete(modelName, context);
434
+ }
435
+ };
436
+
437
+ // Chain through the write queue — .then(work, work) ensures the queue
438
+ // advances even when a previous persist rejects (#156).
439
+ this._writeQueue = this._writeQueue.then(work, work);
440
+ return this._writeQueue;
409
441
  }
410
442
 
411
443
  private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
@@ -376,7 +376,7 @@ export default class OrmRequest extends Request {
376
376
  };
377
377
 
378
378
  const deleteHandler: HandlerFn = ({ params }) => {
379
- store.remove(model, getId(params));
379
+ store.remove(model, getId(params), { _skipAutoPersist: true });
380
380
  return 204;
381
381
  };
382
382
 
@@ -443,9 +443,14 @@ export default class OrmRequest extends Request {
443
443
  // Execute main handler
444
444
  const response = await handler(request, state);
445
445
 
446
- // Persist to SQL database for create/update (delete is handled by store.remove auto-persist)
446
+ // Set context.record for update BEFORE persist so SQL drivers can read it
447
+ if (operation === 'update' && (response as JsonApiResponse)?.data) {
448
+ context.record = store.get(this.model, getId(request.params));
449
+ }
450
+
451
+ // Persist to SQL database for all write operations (create/update/delete)
447
452
  const sqlDb = Orm.instance.sqlDb;
448
- if (sqlDb && (operation === 'create' || operation === 'update')) {
453
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
449
454
  await sqlDb.persist(operation, this.model, context, response);
450
455
  }
451
456
 
@@ -461,8 +466,6 @@ export default class OrmRequest extends Request {
461
466
  const responseData = (response as { data: { id: string | number } }).data;
462
467
  const recordId = isNaN(responseData.id as unknown as number) ? responseData.id : parseInt(responseData.id as string);
463
468
  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
469
  } else if (operation === 'delete') {
467
470
  // For delete, the record may no longer exist, but we have oldState
468
471
  context.recordId = getId(request.params);
@@ -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
@@ -90,6 +90,15 @@ export default class PostgresDB {
90
90
  pool!: Pool | null;
91
91
  pgConfig!: Record<string, unknown>;
92
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
+
93
102
  constructor(deps: Partial<PostgresDeps> = {}) {
94
103
  const Ctor = this.constructor as typeof PostgresDB;
95
104
  if (Ctor.instance) return Ctor.instance;
@@ -125,7 +134,15 @@ export default class PostgresDB {
125
134
  if (pending.length > 0) {
126
135
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
127
136
 
128
- 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
+ }
129
146
 
130
147
  if (shouldApply) {
131
148
  for (const filename of pending) {
@@ -146,9 +163,17 @@ export default class PostgresDB {
146
163
  const modelCount = Object.keys(schemas).length;
147
164
 
148
165
  if (modelCount > 0) {
149
- const shouldGenerate = await this.deps.confirm(
150
- `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
151
- );
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
+ }
152
177
 
153
178
  if (shouldGenerate) {
154
179
  const { generateMigration } = await import('./migration-generator.js');
@@ -468,14 +493,21 @@ export default class PostgresDB {
468
493
  const Orm = (await import('@stonyx/orm')).default;
469
494
  if ((Orm.instance as { isView?: (name: string) => boolean })?.isView?.(modelName)) return;
470
495
 
471
- switch (operation) {
472
- case 'create':
473
- return this._persistCreate(modelName, context, response);
474
- case 'update':
475
- return this._persistUpdate(modelName, context, response);
476
- case 'delete':
477
- return this._persistDelete(modelName, context);
478
- }
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;
479
511
  }
480
512
 
481
513
  private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {