@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.
- package/README.md +377 -30
- package/config/environment.js +111 -12
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/main.d.ts +1 -0
- package/dist/main.js +29 -1
- package/dist/manage-record.d.ts +4 -0
- package/dist/manage-record.js +95 -34
- package/dist/mysql/connection.d.ts +3 -0
- package/dist/mysql/mysql-db.d.ts +32 -2
- package/dist/mysql/mysql-db.js +239 -45
- package/dist/mysql/query-builder.d.ts +1 -0
- package/dist/mysql/query-builder.js +17 -0
- package/dist/normalize-record-id.d.ts +67 -0
- package/dist/normalize-record-id.js +88 -0
- package/dist/orm-request.d.ts +17 -1
- package/dist/orm-request.js +77 -19
- package/dist/postgres/column-rules.d.ts +33 -0
- package/dist/postgres/column-rules.js +34 -0
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +36 -2
- package/dist/postgres/postgres-db.js +244 -45
- package/dist/postgres/query-builder.d.ts +1 -0
- package/dist/postgres/query-builder.js +17 -0
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/timescale/query-builder.d.ts +1 -1
- package/dist/timescale/query-builder.js +1 -2
- package/dist/types/orm-types.d.ts +11 -0
- package/package.json +19 -7
- package/src/column-conversion.ts +53 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/index.ts +4 -2
- package/src/main.ts +30 -1
- package/src/manage-record.ts +112 -33
- package/src/mysql/connection.ts +3 -0
- package/src/mysql/mysql-db.ts +264 -46
- package/src/mysql/query-builder.ts +21 -0
- package/src/normalize-record-id.ts +88 -0
- package/src/orm-request.ts +109 -18
- package/src/postgres/column-rules.ts +37 -0
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +277 -49
- package/src/postgres/query-builder.ts +21 -0
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/store.ts +68 -6
- package/src/timescale/query-builder.ts +1 -2
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
- package/config/environment.ts +0 -91
package/dist/mysql/mysql-db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
+
}
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { Request } from '@stonyx/rest-server';
|
|
2
|
-
|
|
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
|
};
|