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

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 +341 -29
  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
@@ -33,6 +33,23 @@ function generateUlid() {
33
33
  }
34
34
  return id;
35
35
  }
36
+ /**
37
+ * Generates a monotonically unique numeric ID for DynamoDB tables with numeric keys.
38
+ * Uses timestamp-based generation with a sub-millisecond counter to ensure uniqueness.
39
+ */
40
+ let _numericIdCounter = 0;
41
+ let _numericIdLastMs = 0;
42
+ function generateNumericId() {
43
+ const now = Date.now();
44
+ if (now === _numericIdLastMs) {
45
+ _numericIdCounter++;
46
+ }
47
+ else {
48
+ _numericIdLastMs = now;
49
+ _numericIdCounter = 0;
50
+ }
51
+ return now * 1000 + _numericIdCounter;
52
+ }
36
53
  // ---------------------------------------------------------------------------
37
54
  // SDK Command factories (injectable for testing without real AWS SDK)
38
55
  // ---------------------------------------------------------------------------
@@ -93,6 +110,9 @@ export default class DynamoDBDB {
93
110
  throw new Error('DynamoDBDB client not initialized — call init() first');
94
111
  return this.client;
95
112
  }
113
+ _resolveTableName(modelName) {
114
+ return (this.dbConfig.tablePrefix ?? '') + sanitizeTableName(this.deps.getPluralName(modelName));
115
+ }
96
116
  /** Resolve Orm singleton — falls back to real import in production. */
97
117
  async _getOrm() {
98
118
  if (this.deps._importOrm)
@@ -124,7 +144,7 @@ export default class DynamoDBDB {
124
144
  clientOptions.endpoint = this.dbConfig.endpoint;
125
145
  const rawClient = new DynamoDBClient(clientOptions);
126
146
  for (const [modelName, schema] of Object.entries(schemas)) {
127
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
147
+ const tableName = this._resolveTableName(modelName);
128
148
  const gsis = this._buildGsiDefinitions(modelName, schema);
129
149
  try {
130
150
  const desc = await rawClient.send(new DescribeTableCommand({ TableName: tableName }));
@@ -171,6 +191,16 @@ export default class DynamoDBDB {
171
191
  // -------------------------------------------------------------------------
172
192
  // SqlDb contract — persist
173
193
  // -------------------------------------------------------------------------
194
+ /**
195
+ * DynamoDB does NOT use write serialization (#156).
196
+ *
197
+ * Unlike MySQL/PostgreSQL, DynamoDB has no server-side foreign key
198
+ * constraints and no multi-row transactions in standard single-item
199
+ * operations (PutItem, UpdateItem, DeleteItem). Each operation is
200
+ * atomic at the item level and cannot deadlock against other items.
201
+ * Concurrent fire-and-forget writes therefore cannot produce the
202
+ * cross-row lock contention that causes InnoDB/PG deadlocks.
203
+ */
174
204
  async persist(operation, modelName, context, response) {
175
205
  const OrmModule = await this._getOrm();
176
206
  if (OrmModule.default?.instance?.isView?.(modelName))
@@ -192,7 +222,7 @@ export default class DynamoDBDB {
192
222
  const schema = schemas[modelName];
193
223
  if (!schema)
194
224
  return undefined;
195
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
225
+ const tableName = this._resolveTableName(modelName);
196
226
  const { GetCommand } = await this.deps.loadDocClientCommands();
197
227
  const params = this.deps.buildGetItem(tableName, { id });
198
228
  try {
@@ -220,7 +250,7 @@ export default class DynamoDBDB {
220
250
  const schema = schemas[modelName];
221
251
  if (!schema)
222
252
  return [];
223
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
253
+ const tableName = this._resolveTableName(modelName);
224
254
  try {
225
255
  let items;
226
256
  if (!conditions || Object.keys(conditions).length === 0) {
@@ -275,7 +305,7 @@ export default class DynamoDBDB {
275
305
  continue;
276
306
  }
277
307
  const schema = schemas[modelName];
278
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
308
+ const tableName = this._resolveTableName(modelName);
279
309
  try {
280
310
  const items = await this._paginatedScan(tableName);
281
311
  for (const item of items) {
@@ -308,11 +338,12 @@ export default class DynamoDBDB {
308
338
  if (!record)
309
339
  return;
310
340
  const isPendingId = context.rawData?.__pendingSqlId === true;
311
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
312
- // For numeric-ID models with a pending ID, generate a ULID
341
+ const tableName = this._resolveTableName(modelName);
342
+ // For models with a pending ID, generate a unique replacement ID
313
343
  let finalId = record.id;
314
344
  if (isPendingId) {
315
- finalId = generateUlid();
345
+ const keyType = this.deps.getDynamoKeyType(schema.idType);
346
+ finalId = keyType === 'N' ? generateNumericId() : generateUlid();
316
347
  }
317
348
  const item = this._recordToItem(record, schema, context.rawData);
318
349
  item.id = finalId;
@@ -340,7 +371,7 @@ export default class DynamoDBDB {
340
371
  const record = context.record;
341
372
  if (!record)
342
373
  return;
343
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
374
+ const tableName = this._resolveTableName(modelName);
344
375
  const id = record.id;
345
376
  const oldState = context.oldState || {};
346
377
  const currentData = record.__data;
@@ -348,7 +379,11 @@ export default class DynamoDBDB {
348
379
  const changedData = {};
349
380
  for (const col of Object.keys(schema.columns)) {
350
381
  if (currentData[col] !== oldState[col]) {
351
- changedData[col] = currentData[col] ?? null;
382
+ const value = currentData[col] ?? null;
383
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
384
+ changedData[col] = (value instanceof Date)
385
+ ? value.toISOString()
386
+ : value;
352
387
  }
353
388
  }
354
389
  // FK changes
@@ -376,7 +411,7 @@ export default class DynamoDBDB {
376
411
  const id = context.recordId;
377
412
  if (id == null)
378
413
  return;
379
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
414
+ const tableName = this._resolveTableName(modelName);
380
415
  const { DeleteCommand } = await this.deps.loadDocClientCommands();
381
416
  const params = this.deps.buildDeleteItem(tableName, { id });
382
417
  await this.requireClient().send(new DeleteCommand(params));
@@ -422,7 +457,7 @@ export default class DynamoDBDB {
422
457
  _buildGsiRegistry() {
423
458
  const schemas = this.deps.introspectModels();
424
459
  for (const [modelName, schema] of Object.entries(schemas)) {
425
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
460
+ const tableName = this._resolveTableName(modelName);
426
461
  const modelGsis = new Map();
427
462
  for (const fkCol of Object.keys(schema.foreignKeys)) {
428
463
  const gsiName = `${tableName}-${fkCol}-index`;
@@ -471,7 +506,7 @@ export default class DynamoDBDB {
471
506
  });
472
507
  }
473
508
  _buildGsiDefinitions(modelName, schema) {
474
- const tableName = sanitizeTableName(this.deps.getPluralName(modelName));
509
+ const tableName = this._resolveTableName(modelName);
475
510
  const gsis = [];
476
511
  for (const fkCol of Object.keys(schema.foreignKeys)) {
477
512
  const gsiName = `${tableName}-${fkCol}-index`;
@@ -515,8 +550,13 @@ export default class DynamoDBDB {
515
550
  if (data.id !== undefined)
516
551
  item.id = data.id;
517
552
  for (const col of Object.keys(schema.columns)) {
518
- if (data[col] !== undefined)
519
- item[col] = data[col];
553
+ if (data[col] !== undefined) {
554
+ const value = data[col];
555
+ // Date objects must be serialized to ISO-8601 strings for DynamoDB 'S' storage
556
+ item[col] = (value instanceof Date)
557
+ ? value.toISOString()
558
+ : value;
559
+ }
520
560
  }
521
561
  for (const fkCol of Object.keys(schema.foreignKeys)) {
522
562
  const relName = fkCol.replace(/_id$/, '');
@@ -64,19 +64,22 @@ export function buildScan(tableName, conditions, exclusiveStartKey) {
64
64
  if (exclusiveStartKey)
65
65
  params.ExclusiveStartKey = exclusiveStartKey;
66
66
  if (conditions && Object.keys(conditions).length > 0) {
67
- const names = {};
68
- const values = {};
69
- const clauses = [];
70
- for (const [attr, val] of Object.entries(conditions)) {
71
- const nameAlias = `#${attr}`;
72
- const valAlias = `:${attr}`;
73
- names[nameAlias] = attr;
74
- values[valAlias] = val;
75
- clauses.push(`${nameAlias} = ${valAlias}`);
67
+ const validEntries = Object.entries(conditions).filter(([, val]) => val !== undefined && val !== null);
68
+ if (validEntries.length > 0) {
69
+ const names = {};
70
+ const values = {};
71
+ const clauses = [];
72
+ for (const [attr, val] of validEntries) {
73
+ const nameAlias = `#${attr}`;
74
+ const valAlias = `:${attr}`;
75
+ names[nameAlias] = attr;
76
+ values[valAlias] = val;
77
+ clauses.push(`${nameAlias} = ${valAlias}`);
78
+ }
79
+ params.FilterExpression = clauses.join(' AND ');
80
+ params.ExpressionAttributeNames = names;
81
+ params.ExpressionAttributeValues = values;
76
82
  }
77
- params.FilterExpression = clauses.join(' AND ');
78
- params.ExpressionAttributeNames = names;
79
- params.ExpressionAttributeValues = values;
80
83
  }
81
84
  return params;
82
85
  }
@@ -86,10 +89,14 @@ export function buildScan(tableName, conditions, exclusiveStartKey) {
86
89
  * as equality expressions joined by AND.
87
90
  */
88
91
  export function buildQuery(tableName, indexName, keyConditions, exclusiveStartKey) {
92
+ const validEntries = Object.entries(keyConditions).filter(([, val]) => val !== undefined && val !== null);
93
+ if (validEntries.length === 0) {
94
+ throw new Error('buildQuery: all keyCondition values are undefined/null');
95
+ }
89
96
  const names = {};
90
97
  const values = {};
91
98
  const clauses = [];
92
- for (const [attr, val] of Object.entries(keyConditions)) {
99
+ for (const [attr, val] of validEntries) {
93
100
  const nameAlias = `#${attr}`;
94
101
  const valAlias = `:${attr}`;
95
102
  names[nameAlias] = attr;
package/dist/index.d.ts CHANGED
@@ -12,4 +12,6 @@ export type { PersistErrorDetail } from './main.js';
12
12
  export { Model, View, Serializer };
13
13
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
14
14
  export { count, avg, sum, min, max };
15
+ export { default as normalizeRecordId } from './normalize-record-id.js';
16
+ export type { OrmRequest$ as OrmAccessRequest } from './orm-request.js';
15
17
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js';
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@ export { store, relationships } from './main.js';
26
26
  export { Model, View, Serializer }; // base classes
27
27
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
28
28
  export { count, avg, sum, min, max }; // aggregate helpers
29
+ export { default as normalizeRecordId } from './normalize-record-id.js'; // canonical URL-id -> record-id resolution (#270)
29
30
  export { beforeHook, afterHook, clearHook, clearAllHooks } from './hooks.js'; // middleware hooks
30
31
  // Store API:
31
32
  // store.get(model, id) -- sync, memory-only
package/dist/main.js CHANGED
@@ -19,7 +19,6 @@ import log from 'stonyx/log';
19
19
  import { forEachFileImport } from '@stonyx/utils/file';
20
20
  import { kebabCaseToPascalCase, pluralize } from '@stonyx/utils/string';
21
21
  import { registerPluralName } from './plural-registry.js';
22
- import setupRestServer from './setup-rest-server.js';
23
22
  import baseTransforms from './transforms.js';
24
23
  import Store from './store.js';
25
24
  import Serializer from './serializer.js';
@@ -131,6 +130,25 @@ export default class Orm {
131
130
  promises.push(db.init());
132
131
  }
133
132
  if (restServer.enabled === 'true') {
133
+ // MUST stay dynamic. setup-rest-server.js names the optional
134
+ // '@stonyx/rest-server' peer in its own static graph — directly, and
135
+ // through orm-request.ts / meta-request.ts, which import `Request` at
136
+ // module scope because they extend it (correctly: an `extends` base
137
+ // class cannot be awaited). Node links a module's entire static graph
138
+ // before evaluating any of it, so a static import here puts that
139
+ // specifier on the entry graph and `import('@stonyx/orm')` throws
140
+ // ERR_MODULE_NOT_FOUND for an ORM-only consumer that never installed the
141
+ // optional peer.
142
+ //
143
+ // NOT the same reason the SQL/DynamoDB drivers above are lazy: those
144
+ // modules carry no static peer specifier that survives to `dist/`
145
+ // (postgres-db.ts:15 and mysql-db.ts:17 are `import type`, erased by
146
+ // tsc), so the `await import()` there is not what isolates pg / mysql2 /
147
+ // @aws-sdk — that happens one layer down, in src/*/connection.ts (and
148
+ // src/dynamodb/dynamodb-db.ts).
149
+ // setup-rest-server.js is the only dist module whose laziness is
150
+ // load-bearing for peer resolution. (#280)
151
+ const { default: setupRestServer } = await import('./setup-rest-server.js');
134
152
  promises.push(setupRestServer(restServer.route, paths.access, restServer.metaRoute));
135
153
  }
136
154
  // Wire up memory resolver so store.find() can check model memory flags
@@ -52,13 +52,34 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
52
52
  relationship.push(record);
53
53
  pendingHasMany.splice(0);
54
54
  }
55
+ // FK-based inverse hasMany wiring — when a child record is created with a
56
+ // foreign-key field (e.g. `owner: 'owner-1'` on an animal), find any parent
57
+ // whose hasMany registry targets this model and push the child into the
58
+ // parent's shared array. This covers edge cases where the child is created
59
+ // in a separate async frame without a belongsTo handler firing.
60
+ const hasManyReg = getHasManyRegistry();
61
+ if (hasManyReg) {
62
+ for (const [parentModelName, targetMap] of hasManyReg) {
63
+ const childArrayMap = targetMap.get(modelName);
64
+ if (!childArrayMap)
65
+ continue;
66
+ // Check if rawData contains a FK field matching the parent model name
67
+ const fkValue = rawData[parentModelName];
68
+ if (fkValue === undefined || fkValue === null)
69
+ continue;
70
+ const parentArray = childArrayMap.get(fkValue);
71
+ if (parentArray && !parentArray.includes(record)) {
72
+ parentArray.push(record);
73
+ }
74
+ }
75
+ }
55
76
  // Fulfill pending belongsTo relationships
56
77
  const pendingBelongsToQueue = getPendingBelongsToRegistry();
57
78
  const pendingBelongsToRaw = pendingBelongsToQueue.get(modelName)?.get(record.id);
58
79
  const pendingBelongsTo = Array.isArray(pendingBelongsToRaw) ? pendingBelongsToRaw : undefined;
59
80
  if (pendingBelongsTo) {
60
81
  const belongsToReg = getBelongsToRegistry();
61
- const hasManyReg = getHasManyRegistry();
82
+ const pendingHasManyReg = getHasManyRegistry();
62
83
  for (const { sourceRecord, sourceModelName, relationshipKey, relationshipId } of pendingBelongsTo) {
63
84
  // Update the belongsTo relationship on the source record
64
85
  sourceRecord.__relationships[relationshipKey] = record;
@@ -72,7 +93,7 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
72
93
  }
73
94
  }
74
95
  // Wire inverse hasMany if it exists
75
- const inverseHasMany = hasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
96
+ const inverseHasMany = pendingHasManyReg.get(modelName)?.get(sourceModelName)?.get(record.id);
76
97
  if (inverseHasMany && !inverseHasMany.includes(sourceRecord)) {
77
98
  inverseHasMany.push(sourceRecord);
78
99
  }
@@ -83,14 +104,24 @@ export function createRecord(modelName, rawData = {}, userOptions = {}) {
83
104
  // Auto-persist to SQL — skip for DB loads (isDbRecord) and relationship resolution (_relationshipKey)
84
105
  const shouldPersist = orm?.sqlDb && !options.isDbRecord && !userOptions._relationshipKey && !options._skipAutoPersist;
85
106
  if (shouldPersist) {
107
+ // Capture ID before persist — SQL adapters re-key pending IDs to real DB IDs,
108
+ // but relationship registries were keyed with this original ID
109
+ const registryId = record.id;
86
110
  const response = { data: { id: record.id } };
87
- orm.sqlDb.persist('create', modelName, { rawData }, response).catch((err) => {
111
+ orm.sqlDb.persist('create', modelName, { rawData }, response)
112
+ .catch((err) => {
88
113
  orm.emitPersistError({
89
114
  operation: 'create',
90
115
  modelName,
91
116
  recordId: record.id,
92
117
  error: err instanceof Error ? err : new Error(String(err)),
93
118
  });
119
+ })
120
+ .finally(() => {
121
+ // Evict non-memory records after persist to prevent unbounded heap growth (stonyx#81)
122
+ if (store._memoryResolver && !store._memoryResolver(modelName)) {
123
+ store.evictRecord(modelName, record.id, registryId);
124
+ }
94
125
  });
95
126
  }
96
127
  return record;
@@ -8,6 +8,7 @@ interface MysqlConfig {
8
8
  connectionLimit?: number;
9
9
  migrationsTable?: string;
10
10
  migrationsDir?: string;
11
+ autoMigrate?: boolean;
11
12
  }
12
13
  export declare function getPool(mysqlConfig: MysqlConfig): Promise<Pool>;
13
14
  export declare function closePool(): Promise<void>;
@@ -61,6 +61,14 @@ export default class MysqlDB {
61
61
  deps: MysqlDBDeps;
62
62
  pool: Pool | null;
63
63
  mysqlConfig: MysqlConfig;
64
+ /**
65
+ * Promise-chain mutex for write serialization (#156).
66
+ * All persist() calls chain through this single queue so concurrent
67
+ * fire-and-forget writes never produce parallel InnoDB transactions
68
+ * on FK-linked rows (which cause deadlocks).
69
+ * Reads are NOT affected — only persist() serializes.
70
+ */
71
+ private _writeQueue;
64
72
  constructor(deps?: Partial<MysqlDBDeps>);
65
73
  private requirePool;
66
74
  init(): Promise<void>;
@@ -26,6 +26,14 @@ export default class MysqlDB {
26
26
  deps;
27
27
  pool;
28
28
  mysqlConfig;
29
+ /**
30
+ * Promise-chain mutex for write serialization (#156).
31
+ * All persist() calls chain through this single queue so concurrent
32
+ * fire-and-forget writes never produce parallel InnoDB transactions
33
+ * on FK-linked rows (which cause deadlocks).
34
+ * Reads are NOT affected — only persist() serializes.
35
+ */
36
+ _writeQueue = Promise.resolve();
29
37
  constructor(deps = {}) {
30
38
  if (MysqlDB.instance)
31
39
  return MysqlDB.instance;
@@ -57,7 +65,17 @@ export default class MysqlDB {
57
65
  const pending = files.filter(f => !applied.includes(f));
58
66
  if (pending.length > 0) {
59
67
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
60
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
68
+ let shouldApply;
69
+ if (this.mysqlConfig.autoMigrate === true) {
70
+ shouldApply = true;
71
+ }
72
+ else if (this.mysqlConfig.autoMigrate === false) {
73
+ shouldApply = false;
74
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
75
+ }
76
+ else {
77
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
78
+ }
61
79
  if (shouldApply) {
62
80
  for (const filename of pending) {
63
81
  const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
@@ -76,7 +94,17 @@ export default class MysqlDB {
76
94
  const schemas = this.deps.introspectModels();
77
95
  const modelCount = Object.keys(schemas).length;
78
96
  if (modelCount > 0) {
79
- const shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
97
+ let shouldGenerate;
98
+ if (this.mysqlConfig.autoMigrate === true) {
99
+ shouldGenerate = true;
100
+ }
101
+ else if (this.mysqlConfig.autoMigrate === false) {
102
+ shouldGenerate = false;
103
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
104
+ }
105
+ else {
106
+ shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
107
+ }
80
108
  if (shouldGenerate) {
81
109
  const { generateMigration } = await import('./migration-generator.js');
82
110
  const result = await generateMigration('initial_setup');
@@ -302,14 +330,20 @@ export default class MysqlDB {
302
330
  const Orm = (await import('@stonyx/orm')).default;
303
331
  if (Orm.instance?.isView?.(modelName))
304
332
  return;
305
- switch (operation) {
306
- case 'create':
307
- return this._persistCreate(modelName, context, response);
308
- case 'update':
309
- return this._persistUpdate(modelName, context, response);
310
- case 'delete':
311
- return this._persistDelete(modelName, context);
312
- }
333
+ const work = async () => {
334
+ switch (operation) {
335
+ case 'create':
336
+ return this._persistCreate(modelName, context, response);
337
+ case 'update':
338
+ return this._persistUpdate(modelName, context, response);
339
+ case 'delete':
340
+ return this._persistDelete(modelName, context);
341
+ }
342
+ };
343
+ // Chain through the write queue — .then(work, work) ensures the queue
344
+ // advances even when a previous persist rejects (#156).
345
+ this._writeQueue = this._writeQueue.then(work, work);
346
+ return this._writeQueue;
313
347
  }
314
348
  async _persistCreate(modelName, context, response) {
315
349
  const schemas = this.deps.introspectModels();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The ONE place a URL id is turned into the value a record is resolved by —
3
+ * abofs/stonyx-orm#270.
4
+ *
5
+ * Before this existed this coercion was written out seven times: twice in
6
+ * README.md, once in docs/usage-patterns.md, and four times inside the
7
+ * framework. Seven copies, not seven identical copies — the three persistence
8
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
9
+ * is the point: nothing held them together, so they had already drifted.
10
+ * The framework's copy — `getId()`, module-private in
11
+ * orm-request.ts, unreachable through the package `exports` map — was the one
12
+ * that decided which record a request addressed, and a consumer's `access()`
13
+ * predicate had no way to obtain it. So the framework resolved the record by
14
+ * one value and asked the consumer to authorize on a different one.
15
+ *
16
+ * Measured consequence at the time of filing: with the documented predicate
17
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
18
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
19
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
20
+ * those onto `7` while `'007' === '7'` is false.
21
+ *
22
+ * Two things follow from this being a single exported function, and both are
23
+ * the point:
24
+ *
25
+ * 1. A permissive change here is HARMLESS, because both sides move together.
26
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
27
+ * the whole suite green; with one implementation the predicate simply sees
28
+ * the same lowercased value and still refuses.
29
+ * 2. A divergence — a second, private normaliser at the resolution site — is
30
+ * what is now dangerous, and that is what the tests pin, by observing the
31
+ * key the resolution path actually used.
32
+ *
33
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
34
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
35
+ * question, and it is a behaviour change on the record-resolution path for
36
+ * every consumer rather than an authorization fix — it belongs in its own
37
+ * issue with its own compatibility argument (issue body scope item 4). #270
38
+ * preserves today's semantics exactly and makes both sides agree on them.
39
+ *
40
+ * Two details are load-bearing and are pinned by
41
+ * test/unit/normalize-record-id-test.ts:
42
+ *
43
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
44
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
45
+ * silently changes which record every hex-spelled URL addresses.
46
+ * - The coercion applies only when the id LOOKS numeric, so a model with
47
+ * string ids is passed through untouched, case included.
48
+ *
49
+ * Must stay synchronous: `auth()` is invoked without `await` by
50
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
51
+ * record id.
52
+ *
53
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
54
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
55
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
56
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
57
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
58
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
59
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
60
+ * fall-through this function does not own. Tracked as #287; every row is pinned
61
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
62
+ * boundary #287 is measured against.
63
+ *
64
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
65
+ * @returns the value the ORM resolves the record by
66
+ */
67
+ export default function normalizeRecordId(id?: string | null): string | number;
@@ -0,0 +1,88 @@
1
+ /*
2
+ * Copyright 2025 Stone Costa
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the 'License');
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an 'AS IS' BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ /**
17
+ * The ONE place a URL id is turned into the value a record is resolved by —
18
+ * abofs/stonyx-orm#270.
19
+ *
20
+ * Before this existed this coercion was written out seven times: twice in
21
+ * README.md, once in docs/usage-patterns.md, and four times inside the
22
+ * framework. Seven copies, not seven identical copies — the three persistence
23
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
24
+ * is the point: nothing held them together, so they had already drifted.
25
+ * The framework's copy — `getId()`, module-private in
26
+ * orm-request.ts, unreachable through the package `exports` map — was the one
27
+ * that decided which record a request addressed, and a consumer's `access()`
28
+ * predicate had no way to obtain it. So the framework resolved the record by
29
+ * one value and asked the consumer to authorize on a different one.
30
+ *
31
+ * Measured consequence at the time of filing: with the documented predicate
32
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
33
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
34
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
35
+ * those onto `7` while `'007' === '7'` is false.
36
+ *
37
+ * Two things follow from this being a single exported function, and both are
38
+ * the point:
39
+ *
40
+ * 1. A permissive change here is HARMLESS, because both sides move together.
41
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
42
+ * the whole suite green; with one implementation the predicate simply sees
43
+ * the same lowercased value and still refuses.
44
+ * 2. A divergence — a second, private normaliser at the resolution site — is
45
+ * what is now dangerous, and that is what the tests pin, by observing the
46
+ * key the resolution path actually used.
47
+ *
48
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
49
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
50
+ * question, and it is a behaviour change on the record-resolution path for
51
+ * every consumer rather than an authorization fix — it belongs in its own
52
+ * issue with its own compatibility argument (issue body scope item 4). #270
53
+ * preserves today's semantics exactly and makes both sides agree on them.
54
+ *
55
+ * Two details are load-bearing and are pinned by
56
+ * test/unit/normalize-record-id-test.ts:
57
+ *
58
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
59
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
60
+ * silently changes which record every hex-spelled URL addresses.
61
+ * - The coercion applies only when the id LOOKS numeric, so a model with
62
+ * string ids is passed through untouched, case included.
63
+ *
64
+ * Must stay synchronous: `auth()` is invoked without `await` by
65
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
66
+ * record id.
67
+ *
68
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
69
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
70
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
71
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
72
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
73
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
74
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
75
+ * fall-through this function does not own. Tracked as #287; every row is pinned
76
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
77
+ * boundary #287 is measured against.
78
+ *
79
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
80
+ * @returns the value the ORM resolves the record by
81
+ */
82
+ export default function normalizeRecordId(id) {
83
+ if (!id)
84
+ return '';
85
+ if (isNaN(id))
86
+ return id;
87
+ return parseInt(id);
88
+ }
@@ -1,10 +1,26 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
- interface OrmRequest$ extends Request {
2
+ /**
3
+ * The request object a consumer's `access()` predicate receives.
4
+ *
5
+ * Exported because `recordId` is public API — README's `access()` samples
6
+ * destructure it — and a public runtime field with an unreachable type asks the
7
+ * consumer to re-declare something the framework already knows, which is
8
+ * abofs/stonyx-orm#270's own defect shape one layer over into the type surface.
9
+ * `HookContext` (src/hooks.ts) is this repo's precedent for exporting the
10
+ * interface a consumer is handed. Re-exported from the root barrel as
11
+ * `OrmAccessRequest` (src/index.ts) — NOT as `OrmRequest`, which is already the
12
+ * default-exported CLASS in this file and means something else. The
13
+ * `./orm-request` subpath is not in the `exports` map, so the barrel is the
14
+ * only reachable spelling.
15
+ */
16
+ export interface OrmRequest$ extends Request {
3
17
  protocol?: string;
18
+ baseUrl?: string;
4
19
  method: string;
5
20
  params: {
6
21
  [key: string]: string;
7
22
  };
23
+ recordId?: string | number | undefined;
8
24
  body?: {
9
25
  [key: string]: unknown;
10
26
  };