@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
@@ -4,7 +4,7 @@ import { ensureMigrationsTable, getAppliedMigrations, getMigrationFiles, applyMi
4
4
  import { introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot } from './schema-introspector.js';
5
5
  import type { ModelSchema, ViewSchema, SnapshotEntry } from './schema-introspector.js';
6
6
  import { loadLatestSnapshot, detectSchemaDrift } from './migration-generator.js';
7
- import { buildInsert, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
7
+ import { buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect } from './query-builder.js';
8
8
  import { store } from '@stonyx/orm';
9
9
  import { createRecord } from '../manage-record.js';
10
10
  import { confirm } from '@stonyx/utils/prompt';
@@ -32,6 +32,43 @@ interface ExecuteResult {
32
32
  insertId?: number;
33
33
  }
34
34
 
35
+ interface QueuedWrite {
36
+ operation: string;
37
+ modelName: string;
38
+ context: PersistContext;
39
+ response: PersistResponse;
40
+ resolve: () => void;
41
+ reject: (error: unknown) => void;
42
+ }
43
+
44
+ interface PreparedCreate {
45
+ write: QueuedWrite;
46
+ modelName: string;
47
+ record: OrmRecord;
48
+ row: Record<string, unknown>;
49
+ isPendingId: boolean;
50
+ }
51
+
52
+ type ExitEmitter = Pick<NodeJS.Process, 'once' | 'removeListener'>;
53
+
54
+ const MYSQL_MAX_PLACEHOLDERS = 65535;
55
+ const DEFAULT_WRITE_BATCH_SIZE = 500;
56
+ const DEFAULT_WRITE_QUEUE_LIMIT = 10000;
57
+
58
+ export class WriteQueueFullError extends Error {
59
+ constructor(limit: number) {
60
+ super(`MysqlDB write queue is full (${limit} queued writes, writeQueueLimit); the write was rejected`);
61
+ this.name = 'WriteQueueFullError';
62
+ }
63
+ }
64
+
65
+ export class WriteQueueClosedError extends Error {
66
+ constructor() {
67
+ super('MysqlDB is shutting down; the write was rejected');
68
+ this.name = 'WriteQueueClosedError';
69
+ }
70
+ }
71
+
35
72
  interface OrmStore {
36
73
  get(key: string): Map<number | string, unknown> | undefined;
37
74
  get(key: string, id: number | string): unknown;
@@ -54,6 +91,7 @@ interface MysqlDBDeps {
54
91
  loadLatestSnapshot: typeof loadLatestSnapshot;
55
92
  detectSchemaDrift: typeof detectSchemaDrift;
56
93
  buildInsert: typeof buildInsert;
94
+ buildInsertMany: typeof buildInsertMany;
57
95
  buildUpdate: typeof buildUpdate;
58
96
  buildDelete: typeof buildDelete;
59
97
  buildSelect: typeof buildSelect;
@@ -65,6 +103,7 @@ interface MysqlDBDeps {
65
103
  config: typeof config;
66
104
  log: Record<string, ((...args: unknown[]) => void) | undefined>;
67
105
  path: typeof path;
106
+ process: ExitEmitter;
68
107
  }
69
108
 
70
109
  const defaultDeps: MysqlDBDeps = {
@@ -72,9 +111,9 @@ const defaultDeps: MysqlDBDeps = {
72
111
  getMigrationFiles, applyMigration, parseMigrationFile,
73
112
  introspectModels, introspectViews, getTopologicalOrder, schemasToSnapshot,
74
113
  loadLatestSnapshot, detectSchemaDrift,
75
- buildInsert, buildUpdate, buildDelete, buildSelect,
114
+ buildInsert, buildInsertMany, buildUpdate, buildDelete, buildSelect,
76
115
  createRecord, store: store as OrmStore, confirm, readFile, getPluralName,
77
- config, log, path
116
+ config, log, path, process
78
117
  };
79
118
 
80
119
  export default class MysqlDB {
@@ -84,6 +123,13 @@ export default class MysqlDB {
84
123
  pool!: Pool | null;
85
124
  mysqlConfig!: MysqlConfig;
86
125
 
126
+ readonly persistResolvesAfterWrite = true;
127
+ private _queuedWrites: QueuedWrite[] = [];
128
+ private _inFlightWrites = new Set<QueuedWrite>();
129
+ private _drainingWrites: Promise<void> | null = null;
130
+ private _acceptingWrites = true;
131
+ private _exitReport: (() => void) | null = null;
132
+
87
133
  constructor(deps: Partial<MysqlDBDeps> = {}) {
88
134
  if (MysqlDB.instance) return MysqlDB.instance;
89
135
  MysqlDB.instance = this;
@@ -104,6 +150,20 @@ export default class MysqlDB {
104
150
  this.pool = await this.deps.getPool(this.mysqlConfig);
105
151
  await this.deps.ensureMigrationsTable(this.pool, this.mysqlConfig.migrationsTable);
106
152
  await this.loadMemoryRecords();
153
+ this._acceptingWrites = true;
154
+ this._registerExitReport();
155
+ }
156
+
157
+ get writeBatchSize(): number {
158
+ return Number(this.mysqlConfig?.writeBatchSize) || DEFAULT_WRITE_BATCH_SIZE;
159
+ }
160
+
161
+ get writeQueueLimit(): number {
162
+ return Number(this.mysqlConfig?.writeQueueLimit) || DEFAULT_WRITE_QUEUE_LIMIT;
163
+ }
164
+
165
+ get pendingWriteCount(): number {
166
+ return this._inFlightWrites.size + this._queuedWrites.length;
107
167
  }
108
168
 
109
169
  async startup(): Promise<void> {
@@ -118,7 +178,15 @@ export default class MysqlDB {
118
178
  if (pending.length > 0) {
119
179
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
120
180
 
121
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
181
+ let shouldApply: boolean;
182
+ if (this.mysqlConfig.autoMigrate === true) {
183
+ shouldApply = true;
184
+ } else if (this.mysqlConfig.autoMigrate === false) {
185
+ shouldApply = false;
186
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
187
+ } else {
188
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
189
+ }
122
190
 
123
191
  if (shouldApply) {
124
192
  for (const filename of pending) {
@@ -139,9 +207,17 @@ export default class MysqlDB {
139
207
  const modelCount = Object.keys(schemas).length;
140
208
 
141
209
  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
- );
210
+ let shouldGenerate: boolean;
211
+ if (this.mysqlConfig.autoMigrate === true) {
212
+ shouldGenerate = true;
213
+ } else if (this.mysqlConfig.autoMigrate === false) {
214
+ shouldGenerate = false;
215
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
216
+ } else {
217
+ shouldGenerate = await this.deps.confirm(
218
+ `No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`
219
+ );
220
+ }
145
221
 
146
222
  if (shouldGenerate) {
147
223
  const { generateMigration } = await import('./migration-generator.js');
@@ -175,10 +251,36 @@ export default class MysqlDB {
175
251
  }
176
252
 
177
253
  async shutdown(): Promise<void> {
254
+ this._acceptingWrites = false;
255
+ while (this._drainingWrites) await this._drainingWrites;
256
+ this._unregisterExitReport();
178
257
  await this.deps.closePool();
179
258
  this.pool = null;
180
259
  }
181
260
 
261
+ private _registerExitReport(): void {
262
+ if (this._exitReport) return;
263
+ this._exitReport = () => this._reportUnpersistedWrites();
264
+ this.deps.process.once('exit', this._exitReport);
265
+ }
266
+
267
+ private _unregisterExitReport(): void {
268
+ if (!this._exitReport) return;
269
+ this.deps.process.removeListener('exit', this._exitReport);
270
+ this._exitReport = null;
271
+ }
272
+
273
+ private _reportUnpersistedWrites(): void {
274
+ const lostWrites = [...this._inFlightWrites, ...this._queuedWrites];
275
+ if (lostWrites.length === 0) return;
276
+
277
+ const lostByModel = new Map<string, number>();
278
+ for (const { modelName } of lostWrites) lostByModel.set(modelName, (lostByModel.get(modelName) ?? 0) + 1);
279
+ const breakdown = [...lostByModel].map(([modelName, count]) => `${modelName}: ${count}`).join(', ');
280
+
281
+ this.deps.log.error?.(`[MysqlDB] Process exiting with ${lostWrites.length} queued write(s) not persisted; they are lost (${breakdown})`);
282
+ }
283
+
182
284
  async save(): Promise<void> {
183
285
  // No-op: MySQL persists data immediately via persist()
184
286
  }
@@ -394,69 +496,185 @@ export default class MysqlDB {
394
496
  }
395
497
 
396
498
  async persist(operation: string, modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
397
- // Views are read-only — no-op for all write operations
398
499
  const Orm = (await import('@stonyx/orm')).default;
399
500
  if ((Orm as unknown as { instance?: { isView?: (name: string) => boolean } }).instance?.isView?.(modelName)) return;
400
501
 
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);
502
+ if (!this._acceptingWrites) throw new WriteQueueClosedError();
503
+ if (this._queuedWrites.length >= this.writeQueueLimit) throw new WriteQueueFullError(this.writeQueueLimit);
504
+
505
+ return new Promise<void>((resolve, reject) => {
506
+ const write: QueuedWrite = {
507
+ operation, modelName, context, response,
508
+ resolve: () => {
509
+ this._inFlightWrites.delete(write);
510
+ resolve();
511
+ },
512
+ reject: (error: unknown) => {
513
+ this._inFlightWrites.delete(write);
514
+ reject(error);
515
+ },
516
+ };
517
+ this._queuedWrites.push(write);
518
+ this._drainWritesInBackground();
519
+ });
520
+ }
521
+
522
+ private _drainWritesInBackground(): void {
523
+ if (this._drainingWrites) return;
524
+ this._drainingWrites = this._drainQueuedWrites().finally(() => {
525
+ this._drainingWrites = null;
526
+ });
527
+ }
528
+
529
+ private async _drainQueuedWrites(): Promise<void> {
530
+ while (this._queuedWrites.length > 0) {
531
+ const writes = this._takeNextWriteGroup();
532
+ writes.forEach(write => this._inFlightWrites.add(write));
533
+ try {
534
+ await this._executeWriteGroup(writes);
535
+ } catch (error) {
536
+ writes.filter(write => this._inFlightWrites.has(write)).forEach(write => write.reject(error));
537
+ }
408
538
  }
409
539
  }
410
540
 
411
- private async _persistCreate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
412
- const schemas = this.deps.introspectModels();
413
- const schema = schemas[modelName];
541
+ private _takeNextWriteGroup(): QueuedWrite[] {
542
+ const [first] = this._queuedWrites;
543
+ let groupSize = 1;
544
+
545
+ if (first.operation === 'create') {
546
+ while (
547
+ groupSize < this.writeBatchSize
548
+ && groupSize < this._queuedWrites.length
549
+ && this._queuedWrites[groupSize].operation === 'create'
550
+ && this._queuedWrites[groupSize].modelName === first.modelName
551
+ ) groupSize++;
552
+ }
414
553
 
415
- if (!schema) return;
554
+ return this._queuedWrites.splice(0, groupSize);
555
+ }
556
+
557
+ private async _executeWriteGroup(writes: QueuedWrite[]): Promise<void> {
558
+ const [first] = writes;
559
+ if (first.operation === 'create') return this._persistCreates(first.modelName, writes);
416
560
 
561
+ try {
562
+ if (first.operation === 'update') await this._persistUpdate(first.modelName, first.context, first.response);
563
+ if (first.operation === 'delete') await this._persistDelete(first.modelName, first.context);
564
+ first.resolve();
565
+ } catch (error) {
566
+ first.reject(error);
567
+ }
568
+ }
569
+
570
+ private async _persistCreates(modelName: string, writes: QueuedWrite[]): Promise<void> {
571
+ const schema = this.deps.introspectModels()[modelName];
572
+ const prepared: PreparedCreate[] = [];
573
+
574
+ for (const write of writes) {
575
+ try {
576
+ const create = schema ? this._prepareCreate(modelName, schema, write) : null;
577
+ if (create) prepared.push(create);
578
+ else write.resolve();
579
+ } catch (error) {
580
+ write.reject(error);
581
+ }
582
+ }
583
+
584
+ for (const chunk of this._chunkForSingleInsert(prepared)) {
585
+ await this._insertCreates(schema, chunk);
586
+ }
587
+ }
588
+
589
+ private _prepareCreate(modelName: string, schema: ModelSchema, write: QueuedWrite): PreparedCreate | null {
590
+ const { context, response } = write;
417
591
  const recordId = response?.data?.id;
418
592
  const record = recordId != null ? this.deps.store.get(modelName, (isNaN(recordId as number) ? recordId : parseInt(recordId as string)) as number | string) as OrmRecord | null : null;
419
593
 
420
- if (!record) return;
421
-
422
- const insertData = this._recordToRow(record, schema);
594
+ if (!record) return null;
423
595
 
424
- // For auto-increment models, remove the pending ID.
425
- // Check context.rawData (not record.__data) because __pendingSqlId is not a model
426
- // attribute and gets lost during serialization.
596
+ const row = this._recordToRow(record, schema);
427
597
  const isPendingId = context.rawData?.__pendingSqlId === true;
428
598
 
429
- if (isPendingId) {
430
- delete insertData.id;
431
- } else if (insertData.id !== undefined) {
432
- // Keep user-provided ID (string IDs or explicit numeric IDs)
599
+ if (isPendingId) delete row.id;
600
+
601
+ return { write, modelName, record, row, isPendingId };
602
+ }
603
+
604
+ private _chunkForSingleInsert(prepared: PreparedCreate[]): PreparedCreate[][] {
605
+ const chunks: PreparedCreate[][] = [];
606
+ let chunk: PreparedCreate[] = [];
607
+ let chunkColumns = new Set<string>();
608
+
609
+ for (const create of prepared) {
610
+ const columnsWithCreate = new Set([...chunkColumns, ...Object.keys(create.row)]);
611
+ const mixesIdSources = chunk.length > 0 && chunk[0].isPendingId !== create.isPendingId;
612
+ const exceedsPlaceholders = (chunk.length + 1) * columnsWithCreate.size > MYSQL_MAX_PLACEHOLDERS;
613
+
614
+ if (chunk.length > 0 && (mixesIdSources || exceedsPlaceholders)) {
615
+ chunks.push(chunk);
616
+ chunk = [];
617
+ chunkColumns = new Set(Object.keys(create.row));
618
+ } else {
619
+ chunkColumns = columnsWithCreate;
620
+ }
621
+
622
+ chunk.push(create);
433
623
  }
434
624
 
435
- const { sql, values } = this.deps.buildInsert(schema.table, insertData);
625
+ if (chunk.length > 0) chunks.push(chunk);
626
+ return chunks;
627
+ }
436
628
 
437
- const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
629
+ private async _insertCreates(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
630
+ if (creates.length === 1) return this._insertCreatesOneByOne(schema, creates);
438
631
 
439
- // Re-key the record in the store if MySQL generated the ID
440
- if (isPendingId && result.insertId) {
441
- const pendingId = record.id;
442
- const realId = result.insertId;
443
- const modelStore = this.deps.store.get(modelName);
444
- if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
632
+ let firstInsertId: number | undefined;
445
633
 
446
- modelStore.delete(pendingId as number | string);
447
- record.__data.id = realId;
448
- record.id = realId;
449
- modelStore.set(realId as number | string, record);
634
+ try {
635
+ const { sql, values } = this.deps.buildInsertMany(schema.table, creates.map(({ row }) => row));
636
+ const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
637
+ firstInsertId = result.insertId;
638
+ } catch {
639
+ return this._insertCreatesOneByOne(schema, creates);
640
+ }
450
641
 
451
- // Update the response data with the real ID
452
- if (response?.data) {
453
- response.data.id = realId;
454
- }
642
+ creates.forEach((create, index) => {
643
+ if (firstInsertId) this._adoptGeneratedId(create, firstInsertId + index);
644
+ create.write.resolve();
645
+ });
646
+ }
455
647
 
456
- delete record.__data.__pendingSqlId;
648
+ private async _insertCreatesOneByOne(schema: ModelSchema, creates: PreparedCreate[]): Promise<void> {
649
+ for (const create of creates) {
650
+ try {
651
+ const { sql, values } = this.deps.buildInsert(schema.table, create.row);
652
+ const [result] = await this.requirePool().execute(sql, values) as [ExecuteResult, unknown];
653
+ if (result.insertId) this._adoptGeneratedId(create, result.insertId);
654
+ create.write.resolve();
655
+ } catch (error) {
656
+ create.write.reject(error);
657
+ }
457
658
  }
458
659
  }
459
660
 
661
+ private _adoptGeneratedId({ modelName, record, isPendingId, write }: PreparedCreate, realId: number): void {
662
+ if (!isPendingId) return;
663
+
664
+ const pendingId = record.id;
665
+ const modelStore = this.deps.store.get(modelName);
666
+ if (!modelStore) throw new Error(`Model "${modelName}" not found in store during ID re-key`);
667
+
668
+ modelStore.delete(pendingId as number | string);
669
+ record.__data.id = realId;
670
+ record.id = realId;
671
+ modelStore.set(realId, record);
672
+
673
+ if (write.response?.data) write.response.data.id = realId;
674
+
675
+ delete record.__data.__pendingSqlId;
676
+ }
677
+
460
678
  private async _persistUpdate(modelName: string, context: PersistContext, response: PersistResponse): Promise<void> {
461
679
  const schemas = this.deps.introspectModels();
462
680
  const schema = schemas[modelName];
@@ -27,6 +27,27 @@ export function buildInsert(table: string, data: Record<string, unknown>): Query
27
27
  return { sql, values };
28
28
  }
29
29
 
30
+ export function buildInsertMany(table: string, rows: Record<string, unknown>[]): QueryResult {
31
+ validateIdentifier(table, 'table name');
32
+
33
+ const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];
34
+ columns.forEach(column => validateIdentifier(column, 'column name'));
35
+
36
+ const values: unknown[] = [];
37
+ const tuples = rows.map(row => {
38
+ const cells = columns.map(column => {
39
+ if (!Object.hasOwn(row, column)) return 'DEFAULT';
40
+ values.push(row[column]);
41
+ return '?';
42
+ });
43
+ return `(${cells.join(', ')})`;
44
+ });
45
+
46
+ const sql = `INSERT INTO \`${table}\` (${columns.map(column => `\`${column}\``).join(', ')}) VALUES ${tuples.join(', ')}`;
47
+
48
+ return { sql, values };
49
+ }
50
+
30
51
  export function buildUpdate(table: string, id: unknown, data: Record<string, unknown>): QueryResult {
31
52
  validateIdentifier(table, 'table name');
32
53
 
@@ -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
+ /**
18
+ * The ONE place a URL id is turned into the value a record is resolved by —
19
+ * abofs/stonyx-orm#270.
20
+ *
21
+ * Before this existed this coercion was written out seven times: twice in
22
+ * README.md, once in docs/usage-patterns.md, and four times inside the
23
+ * framework. Seven copies, not seven identical copies — the three persistence
24
+ * ones (abofs/stonyx-orm#282) omit the `if (!id) return ''` guard below, which
25
+ * is the point: nothing held them together, so they had already drifted.
26
+ * The framework's copy — `getId()`, module-private in
27
+ * orm-request.ts, unreachable through the package `exports` map — was the one
28
+ * that decided which record a request addressed, and a consumer's `access()`
29
+ * predicate had no way to obtain it. So the framework resolved the record by
30
+ * one value and asked the consumer to authorize on a different one.
31
+ *
32
+ * Measured consequence at the time of filing: with the documented predicate
33
+ * applied verbatim to a numeric-id model, `GET /animals/007`, `/7.9`, `/7e0`,
34
+ * `/0x7`, `/%207`, `/%2B7` and `/7%0A` each served the protected record, and
35
+ * `DELETE /animals/007` destroyed it — because `parseInt` folds every one of
36
+ * those onto `7` while `'007' === '7'` is false.
37
+ *
38
+ * Two things follow from this being a single exported function, and both are
39
+ * the point:
40
+ *
41
+ * 1. A permissive change here is HARMLESS, because both sides move together.
42
+ * Lowercasing string ids used to disclose and destroy `owner:angela` with
43
+ * the whole suite green; with one implementation the predicate simply sees
44
+ * the same lowercased value and still refuses.
45
+ * 2. A divergence — a second, private normaliser at the resolution site — is
46
+ * what is now dangerous, and that is what the tests pin, by observing the
47
+ * key the resolution path actually used.
48
+ *
49
+ * SEMANTICS ARE UNCHANGED FROM `getId()`, deliberately and byte-for-byte.
50
+ * Whether `7.9` / `0x7` / `%0A` *should* resolve to record 7 at all is a real
51
+ * question, and it is a behaviour change on the record-resolution path for
52
+ * every consumer rather than an authorization fix — it belongs in its own
53
+ * issue with its own compatibility argument (issue body scope item 4). #270
54
+ * preserves today's semantics exactly and makes both sides agree on them.
55
+ *
56
+ * Two details are load-bearing and are pinned by
57
+ * test/unit/normalize-record-id-test.ts:
58
+ *
59
+ * - `parseInt` is called with NO RADIX. `parseInt('0x7')` is 7;
60
+ * `parseInt('0x7', 10)` is 0. Adding the radix looks like a cleanup and
61
+ * silently changes which record every hex-spelled URL addresses.
62
+ * - The coercion applies only when the id LOOKS numeric, so a model with
63
+ * string ids is passed through untouched, case included.
64
+ *
65
+ * Must stay synchronous: `auth()` is invoked without `await` by
66
+ * @stonyx/rest-server, so a promise here would be handed to `access()` as the
67
+ * record id.
68
+ *
69
+ * FALSY AND `NaN` RETURNS ARE LOAD-BEARING ELSEWHERE — abofs/stonyx-orm#287.
70
+ * `''` is returned for any falsy id because `store.get(key, undefined)` returns
71
+ * the whole model Map rather than a record (abofs/stonyx-orm#167). But `''` is
72
+ * not the only falsy return: `'0'`, `'00'`, `'-0'` and `'0x0'` all normalise to
73
+ * `0`, and `' '`, `'\t'`, `'\n'`, `'\u00a0'` all normalise to `NaN` (their
74
+ * `Number()` is `0`, so the `isNaN` guard does not fire and `parseInt` runs).
75
+ * `store.remove(key, id)` branches on truthiness, so those spellings reach a
76
+ * fall-through this function does not own. Tracked as #287; every row is pinned
77
+ * in test/unit/normalize-record-id-test.ts so a cleanup here cannot move the
78
+ * boundary #287 is measured against.
79
+ *
80
+ * @param id the raw, already-URL-decoded id text from `request.params.id`
81
+ * @returns the value the ORM resolves the record by
82
+ */
83
+ export default function normalizeRecordId(id?: string | null): string | number {
84
+ if (!id) return '';
85
+ if (isNaN(id as unknown as number)) return id;
86
+
87
+ return parseInt(id);
88
+ }