@stonyx/orm 0.3.2-alpha.6 → 0.3.2-alpha.61
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 +580 -10
- package/config/{environment.ts → environment.js} +8 -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 +1 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +137 -11
- 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/orm-request.d.ts +181 -3
- package/dist/orm-request.js +794 -47
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- package/dist/record.js +7 -5
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +112 -0
- package/package.json +16 -7
- 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 +1 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +154 -17
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +809 -50
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +8 -5
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +118 -0
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
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;
|
|
@@ -122,17 +153,52 @@ export function updateRecord(record, rawData, userOptions = {}) {
|
|
|
122
153
|
}
|
|
123
154
|
}
|
|
124
155
|
/**
|
|
125
|
-
* gets the next available id based on
|
|
156
|
+
* gets the next available id, based on the HIGHEST id present.
|
|
126
157
|
*
|
|
127
158
|
* In MySQL mode with numeric IDs, assigns a temporary pending ID.
|
|
128
159
|
* MySQL's AUTO_INCREMENT provides the real ID after INSERT.
|
|
160
|
+
*
|
|
161
|
+
* ---------------------------------------------------------------------------
|
|
162
|
+
* WAS: `Array.from(storeMap.values()).at(-1).id + 1` — the LAST INSERTED id,
|
|
163
|
+
* not the maximum (abofs/stonyx-orm#203). The store is a Map, so insertion
|
|
164
|
+
* order stops being ascending the moment a record is deleted and recreated, a
|
|
165
|
+
* db.json is written out of order, a directory-mode store is read back in file
|
|
166
|
+
* order, or a caller POSTs a high id and then a low one. After that, every
|
|
167
|
+
* server-assigned id is one that is ALREADY TAKEN — and `createRecord`'s
|
|
168
|
+
* last-entry-wins branch then overwrites that record IN PLACE and answers 200.
|
|
169
|
+
* No error, no 409, and the store's size does not change. That is the whole
|
|
170
|
+
* defect, and it is reachable from a create with NO id at all, which is the
|
|
171
|
+
* most ordinary write a consumer performs.
|
|
172
|
+
*
|
|
173
|
+
* Covered by test/unit/assign-record-id-test.ts. Note for anyone changing this
|
|
174
|
+
* function: before that file existed, the whole suite scored 951/0 both on the
|
|
175
|
+
* defect and on a naive `Math.max` fix that introduced a second one. A green
|
|
176
|
+
* suite is not evidence here; those assertions are.
|
|
177
|
+
* ---------------------------------------------------------------------------
|
|
129
178
|
*/
|
|
130
179
|
function assignRecordId(modelName, rawData) {
|
|
131
|
-
|
|
180
|
+
// PRESENCE, not truthiness. `0` is a legal value for an `attr('number')` id,
|
|
181
|
+
// and `if (rawData.id) return` silently reassigned it, handing the caller back
|
|
182
|
+
// a different record than the one it named (#203).
|
|
183
|
+
//
|
|
184
|
+
// `''` is deliberately NOT honoured here and this is not an oversight: it is
|
|
185
|
+
// the one string that means "no id". `parseInt('')` is `NaN`, a record CAN be
|
|
186
|
+
// held under the key `NaN`, and orm-request.ts's body-id normalisation relies
|
|
187
|
+
// on `''` staying absent — otherwise `POST {"id":""}` answers 409 against a
|
|
188
|
+
// record it never named. Pinned by test/unit/assign-record-id-test.ts (AC6's
|
|
189
|
+
// BOUNDARY assertions) and by access-filter-enforcement-test.ts assertion 44.
|
|
190
|
+
// Widening this to `!== undefined` breaks both.
|
|
191
|
+
if (rawData.id || rawData.id === 0)
|
|
132
192
|
return;
|
|
133
193
|
// In SQL mode with numeric IDs, defer to database auto-increment.
|
|
134
194
|
// Use unique negative integers — they survive the number transform (parseInt preserves negatives)
|
|
135
195
|
// and avoid NaN store-key collisions that string pending IDs caused.
|
|
196
|
+
//
|
|
197
|
+
// This early return is ABOVE the max computation on purpose: a pending
|
|
198
|
+
// negative must never be a candidate for, or be perturbed by, the max path.
|
|
199
|
+
// Pinned directly (AC5.3) rather than by asserting the max is unaffected —
|
|
200
|
+
// that assertion could not have failed, because nothing negative ever reaches
|
|
201
|
+
// the code below.
|
|
136
202
|
if (Orm.instance?.sqlDb && !isStringIdModel(modelName)) {
|
|
137
203
|
rawData.id = -(++pendingIdCounter);
|
|
138
204
|
rawData.__pendingSqlId = true;
|
|
@@ -142,13 +208,73 @@ function assignRecordId(modelName, rawData) {
|
|
|
142
208
|
if (!storeMap)
|
|
143
209
|
throw new Error(`Cannot assign record ID: model "${modelName}" not found in store`);
|
|
144
210
|
const modelStore = Array.from(storeMap.values()).filter(isOrmRecord);
|
|
145
|
-
|
|
146
|
-
|
|
211
|
+
// The shape of src/standalone-db.ts:134-137, and it is chosen over
|
|
212
|
+
// `Math.max(...ids)` for a reason that is measurable rather than stylistic:
|
|
213
|
+
// a store CAN hold a record under the key `NaN` (`{id: ' '}` is truthy, so
|
|
214
|
+
// it survives the guard above and NaNs in the number transform — that is the
|
|
215
|
+
// state access-filter-enforcement-test.ts assertion 44 constructs). `Math.max`
|
|
216
|
+
// returns `NaN` if any operand is `NaN`, so it would assign `NaN`, land on
|
|
217
|
+
// that slot and overwrite it — exactly the defect being fixed, in a new
|
|
218
|
+
// disguise. This reduce cannot: non-numbers are skipped, and `NaN > max` is
|
|
219
|
+
// `false`. Pinned by AC2.
|
|
220
|
+
const maxId = modelStore.reduce((max, record) => {
|
|
221
|
+
const recordId = record.id;
|
|
222
|
+
return typeof recordId === 'number' && recordId > max ? recordId : max;
|
|
223
|
+
}, 0);
|
|
224
|
+
// THE OCCUPANCY CHECK RUNS ON THE LANDING KEY, NOT ON THE RAW CANDIDATE, and
|
|
225
|
+
// the difference is a silent data loss rather than a nicety.
|
|
226
|
+
//
|
|
227
|
+
// `createRecord` looks the record up under `rawData.id` (:50) but WRITES it
|
|
228
|
+
// under `record.id` (:69) — the value after the model's declared id transform
|
|
229
|
+
// has run inside `serialize`. On a string-id model those two differ: the
|
|
230
|
+
// number `1` is looked up, the record lands under the string `'1'`. A guard
|
|
231
|
+
// written as `storeMap.has(rawData.id)` therefore checks a key the record will
|
|
232
|
+
// never occupy, misses an occupied slot and overwrites it — measured: owner
|
|
233
|
+
// '1' age 55 -> 9, store size unchanged, no error. That is abofs/stonyx-orm
|
|
234
|
+
// #205's lookup-key/landing-key divergence reappearing inside #203's own fix,
|
|
235
|
+
// which is why AC4 exists and why `rawData.id` is set to the LANDING key
|
|
236
|
+
// below: it makes :50 and :69 agree by construction.
|
|
237
|
+
//
|
|
238
|
+
// Termination: with an injective id transform at most `storeMap.size`
|
|
239
|
+
// candidates can be occupied. A NON-injective id type would otherwise spin
|
|
240
|
+
// forever, so the loop is bounded and exits with a defined error the route can
|
|
241
|
+
// report instead of hanging the request.
|
|
242
|
+
//
|
|
243
|
+
// Resolved ONCE, outside the loop: `getIdType` instantiates the model class,
|
|
244
|
+
// so resolving it per candidate would put a model construction on every
|
|
245
|
+
// iteration of a loop that exists to walk past occupied slots.
|
|
246
|
+
const toStoreKey = storeKeyDeriver(modelName);
|
|
247
|
+
let candidate = maxId + 1;
|
|
248
|
+
let landingKey = toStoreKey(candidate);
|
|
249
|
+
let attempts = 0;
|
|
250
|
+
while (storeMap.has(landingKey)) {
|
|
251
|
+
if (++attempts > storeMap.size) {
|
|
252
|
+
throw new Error(`Cannot assign record ID: no free id available for model "${modelName}"`);
|
|
253
|
+
}
|
|
254
|
+
candidate += 1;
|
|
255
|
+
landingKey = toStoreKey(candidate);
|
|
256
|
+
}
|
|
257
|
+
rawData.id = landingKey;
|
|
147
258
|
}
|
|
148
|
-
|
|
149
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Returns the derivation that maps an id VALUE to the store KEY a record
|
|
261
|
+
* carrying it will actually be filed under — the model's declared id transform,
|
|
262
|
+
* the same one `serialize` runs at createRecord:68 before the `.set` at :69.
|
|
263
|
+
*/
|
|
264
|
+
function storeKeyDeriver(modelName) {
|
|
265
|
+
const idType = getIdType(modelName);
|
|
266
|
+
const transform = idType ? Orm.instance?.transforms?.[idType] : undefined;
|
|
267
|
+
if (typeof transform !== 'function')
|
|
268
|
+
return value => value;
|
|
269
|
+
return value => transform(value);
|
|
270
|
+
}
|
|
271
|
+
function getIdType(modelName) {
|
|
272
|
+
const modelClass = Orm.instance?.getRecordClasses(modelName).modelClass;
|
|
150
273
|
if (!modelClass)
|
|
151
|
-
return
|
|
274
|
+
return undefined;
|
|
152
275
|
const model = new modelClass(modelName);
|
|
153
|
-
return model.id?.type
|
|
276
|
+
return model.id?.type;
|
|
277
|
+
}
|
|
278
|
+
function isStringIdModel(modelName) {
|
|
279
|
+
return getIdType(modelName) === 'string';
|
|
154
280
|
}
|
|
@@ -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();
|
package/dist/orm-request.d.ts
CHANGED
|
@@ -1,4 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST request handling and access enforcement for @stonyx/orm.
|
|
3
|
+
*
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* THE `access()` CONTRACT: `access(request, { model, operation })`
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* `auth()` calls your predicate with TWO arguments. The second is the access
|
|
8
|
+
* CONTEXT -- the structural facts about the request, which the framework
|
|
9
|
+
* already holds and which you should read INSTEAD of parsing anything:
|
|
10
|
+
*
|
|
11
|
+
* context.model The model this route was mounted for, as a model name:
|
|
12
|
+
* kebab-case, exactly as declared under
|
|
13
|
+
* `config.orm.paths.model` and keyed in the store --
|
|
14
|
+
* `'owner'`, `'animal'`, `'phone-number'`. NOT the
|
|
15
|
+
* pluralised, dasherized, mount-prefixed ROUTE name. It is
|
|
16
|
+
* read from the OrmRequest instance, fixed at mount time,
|
|
17
|
+
* and no request can influence it.
|
|
18
|
+
*
|
|
19
|
+
* context.operation The operation being authorised. Exactly one of the four
|
|
20
|
+
* verbs `'read'`, `'create'`, `'update'`, `'delete'` --
|
|
21
|
+
* no second vocabulary ON THIS PATH, and never an HTTP
|
|
22
|
+
* method name like `'GET'`. These are the same four
|
|
23
|
+
* strings the permission-array return shape is written in
|
|
24
|
+
* (`['read', 'create']`), because both come from the one
|
|
25
|
+
* `methodAccessMap` below.
|
|
26
|
+
*
|
|
27
|
+
* NOT the hook vocabulary. `HookContext.operation`
|
|
28
|
+
* (`src/hooks.ts`, documented under "Hook Context Object"
|
|
29
|
+
* in the README) carries `'list' | 'get' | 'create' |
|
|
30
|
+
* 'update' | 'delete'` on an identically-named key of an
|
|
31
|
+
* identically-shaped context object, and the access
|
|
32
|
+
* vocabulary collapses `list` and `get` into `'read'`. For
|
|
33
|
+
* one `GET /animals/1` a hook sees `'get'` and `access()`
|
|
34
|
+
* sees `'read'`, so a predicate cannot tell a collection
|
|
35
|
+
* read from a record read. `AccessOperation` makes
|
|
36
|
+
* `operation === 'get'` a compile error for a TypeScript
|
|
37
|
+
* consumer, because a predicate that stops matching falls
|
|
38
|
+
* through to the permission array -- the misreading is
|
|
39
|
+
* fail-open shaped.
|
|
40
|
+
*
|
|
41
|
+
* `undefined` when the dispatched method has no entry in
|
|
42
|
+
* that map. Express delivers `HEAD` to the `GET` handler,
|
|
43
|
+
* so this is reachable. It is left undefined rather than
|
|
44
|
+
* defaulted on purpose -- a fabricated `'read'` would turn
|
|
45
|
+
* an unclassified request into an authorised one. Treat
|
|
46
|
+
* `undefined` as "not classified" and deny.
|
|
47
|
+
*
|
|
48
|
+
* So a consumer writes `if (model === 'owner' && operation === 'read')`. There
|
|
49
|
+
* is no string to parse, no variant to miss, and no way to fail open through a
|
|
50
|
+
* URL shape nobody anticipated.
|
|
51
|
+
*
|
|
52
|
+
* WHAT THE CONTEXT DOES NOT TELL YOU: WHICH SURFACE. It names the model and
|
|
53
|
+
* the verb, not the route. Measured over the live router, six surfaces produce
|
|
54
|
+
* one identical context:
|
|
55
|
+
*
|
|
56
|
+
* GET /owners { model: 'owner', operation: 'read' }
|
|
57
|
+
* GET /owners/gina { model: 'owner', operation: 'read' }
|
|
58
|
+
* GET /owners/gina/pets { model: 'owner', operation: 'read' }
|
|
59
|
+
* GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
|
|
60
|
+
* GET /owners/archived { model: 'owner', operation: 'read' }
|
|
61
|
+
* GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
|
|
62
|
+
*
|
|
63
|
+
* So a rule that depends on the SUB-PATH still needs `request.path` -- which is
|
|
64
|
+
* mount-relative and query-free, and is the one read of argument one the
|
|
65
|
+
* warning below sanctions. This repo's own fixture has such a rule: its
|
|
66
|
+
* `/archived` deny cannot be expressed from the context alone, and a predicate
|
|
67
|
+
* migrated to context-only would silently drop it, turning a deny into an
|
|
68
|
+
* allow. The related-resource and `?include=` surfaces serve ANOTHER model's
|
|
69
|
+
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
|
+
* (abofs/stonyx-orm#196).
|
|
71
|
+
*
|
|
72
|
+
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
|
+
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
|
+
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
75
|
+
* record would force a pre-fetch on every request, a second store hit and an
|
|
76
|
+
* ordering change in the middle of an authorization path. It is also
|
|
77
|
+
* unnecessary: the FUNCTION return shape already is the per-record hook. Return
|
|
78
|
+
* `(record) => boolean` and the handlers apply it to every record the request
|
|
79
|
+
* touches. Auth-time and record-time are separate decision points.
|
|
80
|
+
*
|
|
81
|
+
* THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
|
|
82
|
+
* existing `access(request)` predicate keeps working exactly as before. The
|
|
83
|
+
* warning immediately below is therefore still live: `request` is still
|
|
84
|
+
* argument ONE, and reading it is still how predicates fail open.
|
|
85
|
+
*
|
|
86
|
+
* To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
|
|
87
|
+
* an owners route -- use the boot-time registry:
|
|
88
|
+
*
|
|
89
|
+
* const predicate = Orm.instance.getAccess('animal');
|
|
90
|
+
* if (!predicate) return deny;
|
|
91
|
+
* const verdict = predicate(request, { model: 'animal', operation: 'read' });
|
|
92
|
+
*
|
|
93
|
+
* `undefined` means NO PREDICATE COULD BE RESOLVED for that name -- which
|
|
94
|
+
* includes the case where the model has an access class that failed to load,
|
|
95
|
+
* because `setup-rest-server.ts` catches a load failure, warns, and publishes
|
|
96
|
+
* whatever partial map it had. It does NOT mean the model is unrestricted.
|
|
97
|
+
* Treat it as DENY, the same way `operation === undefined` is treated above.
|
|
98
|
+
*
|
|
99
|
+
* PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
|
|
100
|
+
* the answer model-correct on its own -- the resolved predicate has to READ it.
|
|
101
|
+
* Measured against this repo's own shipped access class, on a request express
|
|
102
|
+
* dispatched to `GET /owners/angela`, asked about ANIMALS:
|
|
103
|
+
*
|
|
104
|
+
* getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
105
|
+
* -> record => record.id !== 'angela' && record.id !== 'restricted'
|
|
106
|
+
*
|
|
107
|
+
* That is the OWNERS filter, and it returns `true` for animal 21 -- the record
|
|
108
|
+
* hidden on every animal surface. Under a mount that predicate recognises
|
|
109
|
+
* neither way it is worse: it falls through to
|
|
110
|
+
* `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
|
|
111
|
+
* context was supplied and the answer is not the animal answer, and it is wrong
|
|
112
|
+
* in the GRANTING direction, because that predicate is arity-1 and identifies
|
|
113
|
+
* its collection from the request. (The first of these is asserted on a live
|
|
114
|
+
* dispatch by AC9 in test/integration/orm-test.ts.)
|
|
115
|
+
*
|
|
116
|
+
* Every predicate in this repo and in every consumer tree is arity-1 on the day
|
|
117
|
+
* this ships, and the caller has no supported way to tell which kind it got --
|
|
118
|
+
* the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
|
|
119
|
+
* So: pass the context, and do not treat a resolved predicate's answer as
|
|
120
|
+
* model-specific until that predicate has been migrated to read the context.
|
|
121
|
+
*
|
|
122
|
+
* ---------------------------------------------------------------------------
|
|
123
|
+
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
124
|
+
* ---------------------------------------------------------------------------
|
|
125
|
+
* `auth()` below hands your `access(request)` a raw transport artifact and asks
|
|
126
|
+
* you to work out which collection it addresses. Every attempt to do that by
|
|
127
|
+
* parsing the request target has failed OPEN. Five distinct variants of the
|
|
128
|
+
* same three-line example have now been found, each after the previous was
|
|
129
|
+
* fixed, by five different people:
|
|
130
|
+
*
|
|
131
|
+
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
132
|
+
* prefix match against it is ALWAYS false.
|
|
133
|
+
* 2. `request.originalUrl` carries the query string, so an anchored equality
|
|
134
|
+
* check misses `/owners?filter[age]=30`.
|
|
135
|
+
* 3. The router is a bare `express()` (`caseSensitive: false`) while a
|
|
136
|
+
* hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
|
|
137
|
+
* past it. Router-side: abofs/stonyx-rest-server#47.
|
|
138
|
+
* 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
|
|
139
|
+
* nothing -- environment-specifically, which is worse.
|
|
140
|
+
* 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
|
|
141
|
+
* `parseurl(req).pathname`, but `originalUrl` is the raw target, so
|
|
142
|
+
* `GET http://anything.example/owners/angela` reaches the handler with
|
|
143
|
+
* `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
|
|
144
|
+
* prefix match is false, `access()` falls through to whatever it returns
|
|
145
|
+
* last, and the record comes back in full. It walks past a hard
|
|
146
|
+
* `return false` deny the same way.
|
|
147
|
+
*
|
|
148
|
+
* The fix is not a sixth rule. It is to stop parsing:
|
|
149
|
+
*
|
|
150
|
+
* `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
|
|
151
|
+
* the request. It carries no query string, it is not mount-relative, it is
|
|
152
|
+
* unaffected by absolute-form, and it already includes the configured
|
|
153
|
+
* `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
|
|
154
|
+
* join. Compare it lower-cased (the router matched case-insensitively) and
|
|
155
|
+
* fail CLOSED when it is absent. Use `request.path` -- mount-relative and
|
|
156
|
+
* query-free -- if you need to distinguish sub-paths.
|
|
157
|
+
*
|
|
158
|
+
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
|
+
* string, which matches no collection, which falls through to the permission
|
|
160
|
+
* array -- a total grant. An input you cannot identify must DENY.
|
|
161
|
+
*
|
|
162
|
+
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
163
|
+
* transport artifact being asked to stand in for a structural fact.
|
|
164
|
+
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
|
+
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
|
+
* until #202 lands; the function shape is what requires any matching at all.
|
|
167
|
+
*
|
|
168
|
+
* The enforcement gates in this file (GATE 0/1/2, the create rollback, the
|
|
169
|
+
* per-handler `isDenied` re-checks) are correct independently of that -- they
|
|
170
|
+
* enforce whatever predicate you return. The stopgap is the part where YOU have
|
|
171
|
+
* to work out which predicate to return.
|
|
172
|
+
*
|
|
173
|
+
* AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
|
|
174
|
+
* It is evaluated against the record the route is ADDRESSED TO, on that model
|
|
175
|
+
* only. A write to a DIFFERENT collection can still re-parent a hidden record
|
|
176
|
+
* and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
|
|
177
|
+
* See `### Known limitations` in README.
|
|
178
|
+
*/
|
|
1
179
|
import { Request } from '@stonyx/rest-server';
|
|
180
|
+
import type { AccessFunction } from './types/orm-types.js';
|
|
2
181
|
interface OrmRequest$ extends Request {
|
|
3
182
|
protocol?: string;
|
|
4
183
|
method: string;
|
|
@@ -13,13 +192,12 @@ interface OrmRequest$ extends Request {
|
|
|
13
192
|
};
|
|
14
193
|
get(header: string): string;
|
|
15
194
|
}
|
|
16
|
-
type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
17
195
|
type HandlerFn = (request: OrmRequest$, state: {
|
|
18
196
|
[key: string]: unknown;
|
|
19
197
|
}) => unknown | Promise<unknown>;
|
|
20
198
|
export default class OrmRequest extends Request {
|
|
21
199
|
model: string;
|
|
22
|
-
access:
|
|
200
|
+
access: AccessFunction;
|
|
23
201
|
handlers: {
|
|
24
202
|
[key: string]: {
|
|
25
203
|
[key: string]: HandlerFn;
|
|
@@ -27,7 +205,7 @@ export default class OrmRequest extends Request {
|
|
|
27
205
|
};
|
|
28
206
|
constructor({ model, access }: {
|
|
29
207
|
model: string;
|
|
30
|
-
access:
|
|
208
|
+
access: AccessFunction;
|
|
31
209
|
});
|
|
32
210
|
private _withHooks;
|
|
33
211
|
private _generateRelationshipRoutes;
|