@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.
- package/README.md +341 -29
- package/dist/column-conversion.d.ts +34 -0
- package/dist/column-conversion.js +34 -0
- package/dist/dynamodb/connection.d.ts +7 -4
- package/dist/dynamodb/connection.js +4 -4
- package/dist/dynamodb/dynamodb-db.d.ts +11 -0
- package/dist/dynamodb/dynamodb-db.js +54 -14
- package/dist/dynamodb/operation-builder.js +20 -13
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/main.js +19 -1
- package/dist/manage-record.js +34 -3
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- 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 +12 -0
- package/dist/postgres/postgres-db.js +60 -19
- 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/types/orm-types.d.ts +3 -0
- package/package.json +11 -7
- package/src/column-conversion.ts +53 -0
- package/src/dynamodb/connection.ts +9 -8
- package/src/dynamodb/dynamodb-db.ts +56 -13
- package/src/dynamodb/operation-builder.ts +29 -15
- package/src/index.ts +2 -0
- package/src/main.ts +19 -1
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- 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 +64 -21
- 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/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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
312
|
-
// For
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
package/dist/manage-record.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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)
|
|
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>;
|
package/dist/mysql/mysql-db.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mysql/mysql-db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
+
}
|
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
|
};
|