@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.
@@ -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/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;
@@ -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> {
@@ -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
@@ -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,9 +466,14 @@ 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 create/update (delete is handled by store.remove auto-persist)
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
- if (sqlDb && (operation === 'create' || operation === 'update')) {
476
+ if (sqlDb && WRITE_OPERATIONS.has(operation)) {
449
477
  await sqlDb.persist(operation, this.model, context, response);
450
478
  }
451
479
 
@@ -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