@stonyx/orm 0.3.2-beta.33 → 0.3.2-beta.330

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 (71) hide show
  1. package/README.md +377 -30
  2. package/config/environment.js +111 -12
  3. package/dist/column-conversion.d.ts +34 -0
  4. package/dist/column-conversion.js +34 -0
  5. package/dist/commands.js +34 -0
  6. package/dist/dynamodb/connection.d.ts +31 -0
  7. package/dist/dynamodb/connection.js +28 -0
  8. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  9. package/dist/dynamodb/dynamodb-db.js +596 -0
  10. package/dist/dynamodb/operation-builder.d.ts +76 -0
  11. package/dist/dynamodb/operation-builder.js +116 -0
  12. package/dist/dynamodb/type-map.d.ts +31 -0
  13. package/dist/dynamodb/type-map.js +48 -0
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.js +3 -2
  16. package/dist/main.d.ts +1 -0
  17. package/dist/main.js +29 -1
  18. package/dist/manage-record.d.ts +4 -0
  19. package/dist/manage-record.js +95 -34
  20. package/dist/mysql/connection.d.ts +3 -0
  21. package/dist/mysql/mysql-db.d.ts +32 -2
  22. package/dist/mysql/mysql-db.js +239 -45
  23. package/dist/mysql/query-builder.d.ts +1 -0
  24. package/dist/mysql/query-builder.js +17 -0
  25. package/dist/normalize-record-id.d.ts +67 -0
  26. package/dist/normalize-record-id.js +88 -0
  27. package/dist/orm-request.d.ts +17 -1
  28. package/dist/orm-request.js +77 -19
  29. package/dist/postgres/column-rules.d.ts +33 -0
  30. package/dist/postgres/column-rules.js +34 -0
  31. package/dist/postgres/connection.d.ts +1 -0
  32. package/dist/postgres/connection.js +8 -6
  33. package/dist/postgres/postgres-db.d.ts +36 -2
  34. package/dist/postgres/postgres-db.js +244 -45
  35. package/dist/postgres/query-builder.d.ts +1 -0
  36. package/dist/postgres/query-builder.js +17 -0
  37. package/dist/record.js +7 -5
  38. package/dist/relationships.js +1 -1
  39. package/dist/serializer.js +38 -2
  40. package/dist/store.d.ts +13 -1
  41. package/dist/store.js +65 -6
  42. package/dist/timescale/query-builder.d.ts +1 -1
  43. package/dist/timescale/query-builder.js +1 -2
  44. package/dist/types/orm-types.d.ts +11 -0
  45. package/package.json +19 -7
  46. package/src/column-conversion.ts +53 -0
  47. package/src/commands.ts +43 -0
  48. package/src/dynamodb/connection.ts +50 -0
  49. package/src/dynamodb/dynamodb-db.ts +811 -0
  50. package/src/dynamodb/operation-builder.ts +202 -0
  51. package/src/dynamodb/type-map.ts +54 -0
  52. package/src/index.ts +4 -2
  53. package/src/main.ts +30 -1
  54. package/src/manage-record.ts +112 -33
  55. package/src/mysql/connection.ts +3 -0
  56. package/src/mysql/mysql-db.ts +264 -46
  57. package/src/mysql/query-builder.ts +21 -0
  58. package/src/normalize-record-id.ts +88 -0
  59. package/src/orm-request.ts +109 -18
  60. package/src/postgres/column-rules.ts +37 -0
  61. package/src/postgres/connection.ts +10 -6
  62. package/src/postgres/postgres-db.ts +277 -49
  63. package/src/postgres/query-builder.ts +21 -0
  64. package/src/record.ts +8 -5
  65. package/src/relationships.ts +1 -1
  66. package/src/serializer.ts +39 -2
  67. package/src/store.ts +68 -6
  68. package/src/timescale/query-builder.ts +1 -2
  69. package/src/types/orm-types.ts +12 -0
  70. package/src/types/stonyx.d.ts +7 -1
  71. package/config/environment.ts +0 -91
@@ -2,7 +2,7 @@ import { getPool, closePool } from './connection.js';
2
2
  import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMigration, parseMigrationFile } from './migration-runner.js';
3
3
  import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
4
4
  import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
5
- import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
5
+ import { buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
6
6
  import { store } from '@stonyx/orm';
7
7
  import { createRecord } from '../manage-record.js';
8
8
  import { confirm } from '@stonyx/utils/prompt';
@@ -12,20 +12,41 @@ import { isDbError } from '../utils.js';
12
12
  import config from 'stonyx/config';
13
13
  import log from 'stonyx/log';
14
14
  import path from 'path';
15
+ const MYSQL_MAX_PLACEHOLDERS = 65535;
16
+ const DEFAULT_WRITE_BATCH_SIZE = 500;
17
+ const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
18
+ export class WriteQueueFullError extends Error {
19
+ constructor(limit) {
20
+ super(`MysqlDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
21
+ this.name = 'WriteQueueFullError';
22
+ }
23
+ }
24
+ export class WriteQueueClosedError extends Error {
25
+ constructor() {
26
+ super('MysqlDB is shutting down; the write was rejected');
27
+ this.name = 'WriteQueueClosedError';
28
+ }
29
+ }
15
30
  const defaultDeps = {
16
31
  getPool, closePool, ensureMigrationsTable, getAppliedMigrations,
17
32
  getMigrationFiles, applyMigration, parseMigrationFile,
18
33
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
19
34
  loadLatestSnapshot, detectSchemaDrift,
20
- buildInsert, buildUpdate, buildDelete, buildSelect,
35
+ buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect,
21
36
  createRecord, store: store, confirm, readFile, getPluralName,
22
- config, log, path
37
+ config, log, path, process
23
38
  };
24
39
  export default class MysqlDB {
25
40
  static instance;
26
41
  deps;
27
42
  pool;
28
43
  mysqlConfig;
44
+ persistResolvesAfterWrite = true;
45
+ _queuedWrites = [];
46
+ _inFlightWrites = new Set();
47
+ _drainingWrites = null;
48
+ _acceptingWrites = true;
49
+ _exitReport = null;
29
50
  constructor(deps = {}) {
30
51
  if (MysqlDB.instance)
31
52
  return MysqlDB.instance;
@@ -46,6 +67,17 @@ export default class MysqlDB {
46
67
  this.pool = await this.deps.getPool(this.mysqlConfig);
47
68
  await this.deps.ensureMigrationsTable(this.pool, this.mysqlConfig.migrationsTable);
48
69
  await this.loadMemoryRecords();
70
+ this._acceptingWrites = true;
71
+ this._registerExitReport();
72
+ }
73
+ get writeBatchSize() {
74
+ return Number(this.mysqlConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
75
+ }
76
+ get writeQueueLimit() {
77
+ return Number(this.mysqlConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
78
+ }
79
+ get pendingWriteCount() {
80
+ return this._inFlightWrites.size + this._queuedWrites.length;
49
81
  }
50
82
  async startup() {
51
83
  if (!this.mysqlConfig.migrationsDir)
@@ -57,7 +89,17 @@ export default class MysqlDB {
57
89
  const pending = files.filter(f => !applied.includes(f));
58
90
  if (pending.length > 0) {
59
91
  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?`);
92
+ let shouldApply;
93
+ if (this.mysqlConfig.autoMigrate === true) {
94
+ shouldApply = true;
95
+ }
96
+ else if (this.mysqlConfig.autoMigrate === false) {
97
+ shouldApply = false;
98
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
99
+ }
100
+ else {
101
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
102
+ }
61
103
  if (shouldApply) {
62
104
  for (const filename of pending) {
63
105
  const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
@@ -76,7 +118,17 @@ export default class MysqlDB {
76
118
  const schemas = this.deps.introspectModels();
77
119
  const modelCount = Object.keys(schemas).length;
78
120
  if (modelCount > 0) {
79
- const shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
121
+ let shouldGenerate;
122
+ if (this.mysqlConfig.autoMigrate === true) {
123
+ shouldGenerate = true;
124
+ }
125
+ else if (this.mysqlConfig.autoMigrate === false) {
126
+ shouldGenerate = false;
127
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
128
+ }
129
+ else {
130
+ shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
131
+ }
80
132
  if (shouldGenerate) {
81
133
  const { generateMigration } = await import('./migration-generator.js');
82
134
  const result = await generateMigration('initial_setup');
@@ -106,9 +158,35 @@ export default class MysqlDB {
106
158
  }
107
159
  }
108
160
  async shutdown() {
161
+ this._acceptingWrites = false;
162
+ while (this._drainingWrites)
163
+ await this._drainingWrites;
164
+ this._unregisterExitReport();
109
165
  await this.deps.closePool();
110
166
  this.pool = null;
111
167
  }
168
+ _registerExitReport() {
169
+ if (this._exitReport)
170
+ return;
171
+ this._exitReport = () => this._reportUnpersistedWrites();
172
+ this.deps.process.once('exit', this._exitReport);
173
+ }
174
+ _unregisterExitReport() {
175
+ if (!this._exitReport)
176
+ return;
177
+ this.deps.process.removeListener('exit', this._exitReport);
178
+ this._exitReport = null;
179
+ }
180
+ _reportUnpersistedWrites() {
181
+ const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
182
+ if (lostWrites.length === 0)
183
+ return;
184
+ const lostByModel = new Map();
185
+ for (const { modelName } of lostWrites)
186
+ lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
187
+ const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
188
+ this.deps.log.error?.(`[MysqlDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
189
+ }
112
190
  async save() {
113
191
  // No-op: MySQL persists data immediately via persist()
114
192
  }
@@ -298,58 +376,174 @@ export default class MysqlDB {
298
376
  return rawData;
299
377
  }
300
378
  async persist(operation, modelName, context, response) {
301
- // Views are read-only — no-op for all write operations
302
379
  const Orm = (await import('@stonyx/orm')).default;
303
380
  if (Orm.instance?.isView?.(modelName))
304
381
  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
- }
382
+ if (!this._acceptingWrites)
383
+ throw new WriteQueueClosedError();
384
+ if (this._queuedWrites.length >= this.writeQueueLimit)
385
+ throw new WriteQueueFullError(this.writeQueueLimit);
386
+ return new Promise((resolve, reject) => {
387
+ const write = {
388
+ operation, modelName, context, response,
389
+ resolve: () => {
390
+ this._inFlightWrites.delete(write);
391
+ resolve();
392
+ },
393
+ reject: (error) => {
394
+ this._inFlightWrites.delete(write);
395
+ reject(error);
396
+ },
397
+ };
398
+ this._queuedWrites.push(write);
399
+ this._drainWritesInBackground();
400
+ });
313
401
  }
314
- async _persistCreate(modelName, context, response) {
315
- const schemas = this.deps.introspectModels();
316
- const schema = schemas[modelName];
317
- if (!schema)
402
+ _drainWritesInBackground() {
403
+ if (this._drainingWrites)
318
404
  return;
405
+ this._drainingWrites = this._drainQueuedWrites().finally(() => {
406
+ this._drainingWrites = null;
407
+ });
408
+ }
409
+ async _drainQueuedWrites() {
410
+ while (this._queuedWrites.length > 0) {
411
+ const writes = this._takeNextWriteGroup();
412
+ writes.forEach(write => this._inFlightWrites.add(write));
413
+ try {
414
+ await this._executeWriteGroup(writes);
415
+ }
416
+ catch (error) {
417
+ writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
418
+ }
419
+ }
420
+ }
421
+ _takeNextWriteGroup() {
422
+ const [first] = this._queuedWrites;
423
+ let groupSize = 1;
424
+ if (first.operation === 'create') {
425
+ while (groupSize < this.writeBatchSize
426
+ && groupSize < this._queuedWrites.length
427
+ && this._queuedWrites[groupSize].operation === 'create'
428
+ && this._queuedWrites[groupSize].modelName === first.modelName)
429
+ groupSize++;
430
+ }
431
+ return this._queuedWrites.splice(0, groupSize);
432
+ }
433
+ async _executeWriteGroup(writes) {
434
+ const [first] = writes;
435
+ if (first.operation === 'create')
436
+ return this._persistCreates(first.modelName, writes);
437
+ try {
438
+ if (first.operation === 'update')
439
+ await this._persistUpdate(first.modelName, first.context, first.response);
440
+ if (first.operation === 'delete')
441
+ await this._persistDelete(first.modelName, first.context);
442
+ first.resolve();
443
+ }
444
+ catch (error) {
445
+ first.reject(error);
446
+ }
447
+ }
448
+ async _persistCreates(modelName, writes) {
449
+ const schema = this.deps.introspectModels()[modelName];
450
+ const prepared = [];
451
+ for (const write of writes) {
452
+ try {
453
+ const create = schema ? this._prepareCreate(modelName, schema, write) : null;
454
+ if (create)
455
+ prepared.push(create);
456
+ else
457
+ write.resolve();
458
+ }
459
+ catch (error) {
460
+ write.reject(error);
461
+ }
462
+ }
463
+ for (const chunk of this._chunkForSingleInsert(prepared)) {
464
+ await this._insertCreates(schema, chunk);
465
+ }
466
+ }
467
+ _prepareCreate(modelName, schema, write) {
468
+ const { context, response } = write;
319
469
  const recordId = response?.data?.id;
320
470
  const record = recordId != null ? this.deps.store.get(modelName, (isNaN(recordId) ? recordId : parseInt(recordId))) : null;
321
471
  if (!record)
322
- return;
323
- const insertData = this._recordToRow(record, schema);
324
- // For auto-increment models, remove the pending ID.
325
- // Check context.rawData (not record.__data) because __pendingSqlId is not a model
326
- // attribute and gets lost during serialization.
472
+ return null;
473
+ const row = this._recordToRow(record, schema);
327
474
  const isPendingId = context.rawData?.__pendingSqlId === true;
328
- if (isPendingId) {
329
- delete insertData.id;
475
+ if (isPendingId)
476
+ delete row.id;
477
+ return { write, modelName, record, row, isPendingId };
478
+ }
479
+ _chunkForSingleInsert(prepared) {
480
+ const chunks = [];
481
+ let chunk = [];
482
+ let chunkColumns = new Set();
483
+ for (const create of prepared) {
484
+ const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
485
+ const mixesIdSources = chunk.length > 0 && chunk[0].isPendingId !== create.isPendingId;
486
+ const exceedsPlaceholders = (chunk.length + 1) * columnsWithCreate.size > MYSQL_MAX_PLACEHOLDERS;
487
+ if (chunk.length > 0 && (mixesIdSources || exceedsPlaceholders)) {
488
+ chunks.push(chunk);
489
+ chunk = [];
490
+ chunkColumns = new Set(Object.keys(create.row));
491
+ }
492
+ else {
493
+ chunkColumns = columnsWithCreate;
494
+ }
495
+ chunk.push(create);
330
496
  }
331
- else if (insertData.id !== undefined) {
332
- // Keep user-provided ID (string IDs or explicit numeric IDs)
497
+ if (chunk.length > 0)
498
+ chunks.push(chunk);
499
+ return chunks;
500
+ }
501
+ async _insertCreates(schema, creates) {
502
+ if (creates.length === 1)
503
+ return this._insertCreatesOneByOne(schema, creates);
504
+ let firstInsertId;
505
+ try {
506
+ const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
507
+ const [result] = await this.requirePool().execute(sql, values);
508
+ firstInsertId = result.insertId;
333
509
  }
334
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
335
- const [result] = await this.requirePool().execute(sql, values);
336
- // Re-key the record in the store if MySQL generated the ID
337
- if (isPendingId && result.insertId) {
338
- const pendingId = record.id;
339
- const realId = result.insertId;
340
- const modelStore = this.deps.store.get(modelName);
341
- if (!modelStore)
342
- throw new Error(`Model "${modelName}" not found in store during ID re-key`);
343
- modelStore.delete(pendingId);
344
- record.__data.id = realId;
345
- record.id = realId;
346
- modelStore.set(realId, record);
347
- // Update the response data with the real ID
348
- if (response?.data) {
349
- response.data.id = realId;
350
- }
351
- delete record.__data.__pendingSqlId;
510
+ catch {
511
+ return this._insertCreatesOneByOne(schema, creates);
352
512
  }
513
+ creates.forEach((create, index) => {
514
+ if (firstInsertId)
515
+ this._adoptGeneratedId(create, firstInsertId + index);
516
+ create.write.resolve();
517
+ });
518
+ }
519
+ async _insertCreatesOneByOne(schema, creates) {
520
+ for (const create of creates) {
521
+ try {
522
+ const { sql, values } = this.deps.buildInsert(schema.table, create.row);
523
+ const [result] = await this.requirePool().execute(sql, values);
524
+ if (result.insertId)
525
+ this._adoptGeneratedId(create, result.insertId);
526
+ create.write.resolve();
527
+ }
528
+ catch (error) {
529
+ create.write.reject(error);
530
+ }
531
+ }
532
+ }
533
+ _adoptGeneratedId({ modelName, record, isPendingId, write }, realId) {
534
+ if (!isPendingId)
535
+ return;
536
+ const pendingId = record.id;
537
+ const modelStore = this.deps.store.get(modelName);
538
+ if (!modelStore)
539
+ throw new Error(`Model "${modelName}" not found in store during ID re-key`);
540
+ modelStore.delete(pendingId);
541
+ record.__data.id = realId;
542
+ record.id = realId;
543
+ modelStore.set(realId, record);
544
+ if (write.response?.data)
545
+ write.response.data.id = realId;
546
+ delete record.__data.__pendingSqlId;
353
547
  }
354
548
  async _persistUpdate(modelName, context, response) {
355
549
  const schemas = this.deps.introspectModels();
@@ -4,6 +4,7 @@ interface QueryResult {
4
4
  }
5
5
  export declare function validateIdentifier(name: string, context?: string): string;
6
6
  export declare function buildInsert(table: string, data: Record<string, unknown>): QueryResult;
7
+ export declare function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult;
7
8
  export declare function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult;
8
9
  export declare function buildDelete(table: string, id: unknown): QueryResult;
9
10
  export declare function buildSelect(table: string, conditions?: Record<string, unknown>): QueryResult;
@@ -14,6 +14,23 @@ export function buildInsert(table, data) {
14
14
  const sql = `INSERT INTO \`${table}\` (${keys.map(k => `\`${k}\``).join(', ')}) VALUES (${placeholders.join(', ')})`;
15
15
  return { sql, values };
16
16
  }
17
+ export function buildInsertMany(table, rows) {
18
+ validateIdentifier(table, 'table name');
19
+ const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
20
+ columns.forEach(column => validateIdentifier(column, 'column name'));
21
+ const values = [];
22
+ const tuples = rows.map(row => {
23
+ const cells = columns.map(column => {
24
+ if (!Object.hasOwn(row, column))
25
+ return 'DEFAULT';
26
+ values.push(row[column]);
27
+ return '?';
28
+ });
29
+ return `(${cells.join(', ')})`;
30
+ });
31
+ const sql = `INSERT INTO \`${table}\` (${columns.map(column => `\`${column}\``).join(', ')}) VALUES ${tuples.join(', ')}`;
32
+ return { sql, values };
33
+ }
17
34
  export function buildUpdate(table, id, data) {
18
35
  validateIdentifier(table, 'table name');
19
36
  const keys = Object.keys(data);
@@ -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
  };