@stonyx/orm 0.3.2-beta.16 → 0.3.2-beta.160
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 +1409 -11
- package/config/environment.js +99 -12
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -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/hooks.d.ts +15 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +268 -12
- 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 +274 -3
- package/dist/orm-request.js +1259 -65
- 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.d.ts +16 -0
- package/dist/record.js +154 -6
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/standalone-db.js +17 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +260 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +16 -7
- package/src/access-verdict.ts +312 -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/hooks.ts +15 -1
- package/src/index.ts +10 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +294 -18
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +1281 -67
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +182 -6
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/standalone-db.ts +17 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +268 -1
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
- package/src/utils.ts +50 -0
- package/config/environment.ts +0 -91
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Orm, { relationships } from '@stonyx/orm';
|
|
2
|
-
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry } from './relationships.js';
|
|
2
|
+
import { TYPES, getHasManyRegistry, getBelongsToRegistry, getPendingRegistry, getPendingBelongsToRegistry } from './relationships.js';
|
|
3
3
|
import ViewResolver from './view-resolver.js';
|
|
4
4
|
|
|
5
5
|
interface UnloadOptions {
|
|
@@ -64,7 +64,7 @@ export default class Store {
|
|
|
64
64
|
get(key: string): Map<number | string, unknown> | undefined;
|
|
65
65
|
get(key: string, id: number | string): unknown;
|
|
66
66
|
get(key: string, id?: number | string): Map<number | string, unknown> | unknown | undefined {
|
|
67
|
-
if (
|
|
67
|
+
if (id === undefined) return this.data.get(key);
|
|
68
68
|
|
|
69
69
|
return this.data.get(key)?.get(id);
|
|
70
70
|
}
|
|
@@ -170,14 +170,15 @@ export default class Store {
|
|
|
170
170
|
this.data.set(key, value);
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
remove(key: string, id?: number | string): void {
|
|
173
|
+
remove(key: string, id?: number | string, options?: { _skipAutoPersist?: boolean }): void {
|
|
174
174
|
// Guard: read-only views cannot have records removed
|
|
175
175
|
if (Orm.instance?.isView?.(key)) {
|
|
176
176
|
throw new Error(`Cannot remove records from read-only view '${key}'`);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
// Auto-persist delete to SQL
|
|
180
|
-
|
|
179
|
+
// Auto-persist delete to SQL (fire-and-forget) — skipped when the
|
|
180
|
+
// request path handles persist itself to avoid double-delete.
|
|
181
|
+
if (id && Orm.instance?.sqlDb && !options?._skipAutoPersist) {
|
|
181
182
|
Orm.instance.sqlDb.persist('delete', key, { recordId: id }, {}).catch((err: unknown) => {
|
|
182
183
|
Orm.instance.emitPersistError({
|
|
183
184
|
operation: 'delete',
|
|
@@ -193,6 +194,44 @@ export default class Store {
|
|
|
193
194
|
this.unloadAllRecords(key);
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Evict a record from the store with full relationship registry cleanup.
|
|
199
|
+
* The caller retains its reference to the returned record, which is the
|
|
200
|
+
* contract memory:false post-persist eviction relies on.
|
|
201
|
+
*
|
|
202
|
+
* @param registryId - The ID used when the record's relationships were
|
|
203
|
+
* registered. For SQL models with pending IDs, this is the original
|
|
204
|
+
* negative pending ID (before the adapter re-keyed to the real DB ID).
|
|
205
|
+
*/
|
|
206
|
+
evictRecord(modelName: string, id: unknown, registryId?: unknown): void {
|
|
207
|
+
const modelStore = this.data.get(modelName);
|
|
208
|
+
if (!modelStore) return;
|
|
209
|
+
|
|
210
|
+
if (typeof id !== 'string' && typeof id !== 'number') return;
|
|
211
|
+
const raw = modelStore.get(id);
|
|
212
|
+
if (!raw || !isStoreRecord(raw)) return;
|
|
213
|
+
|
|
214
|
+
const visited = new Set([`${modelName}:${id}`]);
|
|
215
|
+
|
|
216
|
+
// Remove from hasMany arrays and nullify belongsTo references using current ID
|
|
217
|
+
// (the adapter updates record.id, so value-based matches need the current ID)
|
|
218
|
+
this._removeFromHasManyArrays(modelName, id, visited);
|
|
219
|
+
this._nullifyBelongsToReferences(modelName, id, visited);
|
|
220
|
+
|
|
221
|
+
// Clean up relationship registry entries using the registry key
|
|
222
|
+
// (belongsTo/hasMany registries were keyed by the ID at registration time,
|
|
223
|
+
// which may differ from the current ID if SQL persist re-keyed the record)
|
|
224
|
+
const cleanupId = registryId ?? id;
|
|
225
|
+
this._cleanupRelationshipRegistries(modelName, cleanupId);
|
|
226
|
+
|
|
227
|
+
// If registryId differs from id, also clean with current id as safety net
|
|
228
|
+
if (registryId !== undefined && registryId !== id) {
|
|
229
|
+
this._cleanupRelationshipRegistries(modelName, id);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
modelStore.delete(id);
|
|
233
|
+
}
|
|
234
|
+
|
|
196
235
|
unloadRecord(model: string, id: unknown, options: UnloadOptions = {}): void {
|
|
197
236
|
const modelStore = this.data.get(model);
|
|
198
237
|
|
|
@@ -219,7 +258,6 @@ export default class Store {
|
|
|
219
258
|
this._removeFromHasManyArrays(modelName, recordId, visited);
|
|
220
259
|
this._nullifyBelongsToReferences(modelName, recordId, visited);
|
|
221
260
|
this._cleanupRelationshipRegistries(modelName, recordId);
|
|
222
|
-
recordToUnload.clean();
|
|
223
261
|
|
|
224
262
|
this.data.get(modelName)?.delete(recordId as string | number);
|
|
225
263
|
}
|
|
@@ -308,6 +346,30 @@ export default class Store {
|
|
|
308
346
|
|
|
309
347
|
const pendingMap = getPendingRegistry().get(modelName);
|
|
310
348
|
if (pendingMap) pendingMap.delete(recordId);
|
|
349
|
+
|
|
350
|
+
// Clean pendingBelongsTo entries in both directions
|
|
351
|
+
const pendingBelongsToMap = getPendingBelongsToRegistry();
|
|
352
|
+
if (pendingBelongsToMap) {
|
|
353
|
+
// Direction 1: evicted record was the TARGET others were waiting for
|
|
354
|
+
const targetEntries = pendingBelongsToMap.get(modelName);
|
|
355
|
+
if (targetEntries) targetEntries.delete(recordId);
|
|
356
|
+
|
|
357
|
+
// Direction 2: evicted record was the SOURCE with unresolved forward-references
|
|
358
|
+
for (const [, targetIdMap] of pendingBelongsToMap) {
|
|
359
|
+
for (const [targetId, entries] of targetIdMap) {
|
|
360
|
+
if (!Array.isArray(entries)) continue;
|
|
361
|
+
const filtered = entries.filter((e: unknown) => {
|
|
362
|
+
const entry = e as { sourceModelName?: string; relationshipId?: unknown };
|
|
363
|
+
return !(entry.sourceModelName === modelName && entry.relationshipId === recordId);
|
|
364
|
+
});
|
|
365
|
+
if (filtered.length === 0) {
|
|
366
|
+
targetIdMap.delete(targetId);
|
|
367
|
+
} else if (filtered.length < entries.length) {
|
|
368
|
+
targetIdMap.set(targetId, filtered);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
311
373
|
}
|
|
312
374
|
|
|
313
375
|
/**
|
package/src/types/orm-types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface OrmMysqlConfig {
|
|
|
18
18
|
connectionLimit?: number;
|
|
19
19
|
migrationsDir?: string;
|
|
20
20
|
migrationsTable?: string;
|
|
21
|
+
autoMigrate?: boolean;
|
|
21
22
|
[key: string]: unknown;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -30,6 +31,7 @@ export interface OrmPostgresConfig {
|
|
|
30
31
|
connectionLimit?: number;
|
|
31
32
|
migrationsDir?: string;
|
|
32
33
|
migrationsTable?: string;
|
|
34
|
+
autoMigrate?: boolean;
|
|
33
35
|
[key: string]: unknown;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -48,6 +50,13 @@ export interface OrmRestServerConfig {
|
|
|
48
50
|
metaRoute: boolean;
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
export interface OrmDynamoDBConfig {
|
|
54
|
+
region?: string;
|
|
55
|
+
endpoint?: string;
|
|
56
|
+
tablePrefix?: string;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
export interface OrmSection {
|
|
52
61
|
db: OrmDbConfig;
|
|
53
62
|
paths: OrmPaths;
|
|
@@ -55,6 +64,9 @@ export interface OrmSection {
|
|
|
55
64
|
mysql?: OrmMysqlConfig;
|
|
56
65
|
postgres?: OrmPostgresConfig;
|
|
57
66
|
timescale?: OrmPostgresConfig;
|
|
67
|
+
dynamodb?: OrmDynamoDBConfig;
|
|
68
|
+
logColor?: string;
|
|
69
|
+
logMethod?: string;
|
|
58
70
|
[key: string]: unknown;
|
|
59
71
|
}
|
|
60
72
|
|
|
@@ -77,7 +89,15 @@ export interface OrmRecord {
|
|
|
77
89
|
__model?: { __name: string };
|
|
78
90
|
__data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
|
|
79
91
|
__relationships: Record<string, unknown>;
|
|
80
|
-
|
|
92
|
+
/**
|
|
93
|
+
* `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
|
|
94
|
+
* the request (abofs/stonyx-orm#234): return `false` for a related record and
|
|
95
|
+
* its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
|
|
96
|
+
* the default, and the default is the pre-#234 document unchanged -- this
|
|
97
|
+
* method is also the `JSON.stringify` hook, so an implicit caller has no
|
|
98
|
+
* syntactic place to pass it (abofs/stonyx-orm#230).
|
|
99
|
+
*/
|
|
100
|
+
toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: LinkageFilter }): Record<string, unknown>;
|
|
81
101
|
[key: string]: unknown;
|
|
82
102
|
}
|
|
83
103
|
|
|
@@ -156,3 +176,250 @@ export interface SnapshotEntry {
|
|
|
156
176
|
source?: string;
|
|
157
177
|
viewQuery?: string;
|
|
158
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The shapes a consumer `access()` predicate may return.
|
|
182
|
+
*
|
|
183
|
+
* - `false` (or any falsy value) -- deny, 403.
|
|
184
|
+
* - `true` -- allow, with no per-record filter.
|
|
185
|
+
* - a permission string or array of them, drawn from the same four verbs as
|
|
186
|
+
* {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
|
|
187
|
+
* grant of all four.
|
|
188
|
+
* - a `(record) => boolean` predicate -- allow, and filter every record the
|
|
189
|
+
* request touches through it.
|
|
190
|
+
*
|
|
191
|
+
* Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
|
|
192
|
+
*/
|
|
193
|
+
export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The closed vocabulary `AccessContext.operation` is drawn from
|
|
197
|
+
* (abofs/stonyx-orm#202).
|
|
198
|
+
*
|
|
199
|
+
* A literal union rather than `string`, so the guarantee the prose makes is the
|
|
200
|
+
* one the compiler enforces: a consumer who writes `operation === 'GET'` or
|
|
201
|
+
* `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
|
|
202
|
+
* error instead of a comparison that never matches. A predicate that stops
|
|
203
|
+
* matching falls through to the permission array, so the misreading is
|
|
204
|
+
* fail-open shaped.
|
|
205
|
+
*
|
|
206
|
+
* In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
|
|
207
|
+
*/
|
|
208
|
+
export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The structural facts about the request being authorised, handed to a consumer
|
|
212
|
+
* `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
|
|
213
|
+
*
|
|
214
|
+
* These are the facts the framework already holds at authorisation time. Before
|
|
215
|
+
* #202 a consumer had to reconstruct both of them by string-matching a URL, and
|
|
216
|
+
* five independent fail-open variants of that reconstruction were found in one
|
|
217
|
+
* three-line documented example -- each one wrong in the direction that GRANTS
|
|
218
|
+
* access. Read these instead; there is nothing to parse and no variant to miss.
|
|
219
|
+
*
|
|
220
|
+
* `record` is deliberately NOT a member. `auth()` runs after route matching but
|
|
221
|
+
* before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
|
|
222
|
+
* so nothing has been fetched yet -- carrying a record here would force a
|
|
223
|
+
* pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
|
|
224
|
+
* return shape of {@link AccessMethod} already IS the per-record hook, applied
|
|
225
|
+
* by the handlers. Auth-time and record-time are separate decision points.
|
|
226
|
+
*/
|
|
227
|
+
export interface AccessContext {
|
|
228
|
+
/**
|
|
229
|
+
* The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
|
|
230
|
+
*
|
|
231
|
+
* Model names are kebab-case, as declared under `config.orm.paths.model` and
|
|
232
|
+
* keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
|
|
233
|
+
* read from the `OrmRequest` instance and is never derived from the request
|
|
234
|
+
* target, so a mount prefix, a case-varied path, a query string or an
|
|
235
|
+
* absolute-form request-target cannot change it.
|
|
236
|
+
*/
|
|
237
|
+
model: string;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The operation being authorised. Exactly one of the four {@link
|
|
241
|
+
* AccessOperation} verbs, or `undefined`. These are exactly the values of
|
|
242
|
+
* `methodAccessMap` in `src/orm-request.ts`, which is also what the
|
|
243
|
+
* permission-array return shape is matched against -- so the two forms cannot
|
|
244
|
+
* disagree.
|
|
245
|
+
*
|
|
246
|
+
* NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
|
|
247
|
+
* `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
|
|
248
|
+
* key of an identically-shaped context object, and the access vocabulary
|
|
249
|
+
* collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
|
|
250
|
+
* sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
|
|
251
|
+
* statement about the ACCESS path only.
|
|
252
|
+
*
|
|
253
|
+
* `undefined` when the dispatched method has no entry in that map. Express
|
|
254
|
+
* delivers `HEAD` to the `GET` handler, so this is reachable. It is left
|
|
255
|
+
* undefined rather than defaulted on purpose: a fabricated `'read'` would
|
|
256
|
+
* turn an unclassified request into an authorised one.
|
|
257
|
+
*
|
|
258
|
+
* The KEY is required even though the value may be undefined: `auth()` always
|
|
259
|
+
* sets it, and a context that simply omitted it would be indistinguishable
|
|
260
|
+
* from one that classified the request and found nothing.
|
|
261
|
+
*/
|
|
262
|
+
operation: AccessOperation | undefined;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
266
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
267
|
+
*
|
|
268
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
269
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
270
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
271
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
272
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
273
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
274
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
275
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
276
|
+
*
|
|
277
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
278
|
+
*
|
|
279
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
280
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
281
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
282
|
+
* decoded until stable would deny a record it was never asked about.
|
|
283
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
284
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
285
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
286
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
287
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
288
|
+
* allow, on that same record).
|
|
289
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
290
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
291
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
292
|
+
* `archived/x` -- was measured over-denied 403.
|
|
293
|
+
*
|
|
294
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
295
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
296
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
297
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
298
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
299
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
300
|
+
*
|
|
301
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
302
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
303
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
304
|
+
* exists to buy; it is not a defect to repair here.
|
|
305
|
+
*
|
|
306
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
307
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
308
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
309
|
+
* hand-assembled by a caller resolving the predicate through
|
|
310
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
311
|
+
* signal only because the framework never produces it.
|
|
312
|
+
*
|
|
313
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
314
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
315
|
+
* key on an identically-shaped context object, which is the exact
|
|
316
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
317
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
318
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
319
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
320
|
+
* direction, and it is corrected here rather than deleted.
|
|
321
|
+
*
|
|
322
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
323
|
+
* operations on one model:
|
|
324
|
+
*
|
|
325
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
326
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
327
|
+
* before:create key ABSENT
|
|
328
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
329
|
+
* before:delete recordId="visible3"
|
|
330
|
+
* after:delete recordId="visible3"
|
|
331
|
+
*
|
|
332
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
333
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
334
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
335
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
336
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
337
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
338
|
+
*
|
|
339
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
340
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
341
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
342
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
343
|
+
* rule --
|
|
344
|
+
*
|
|
345
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
346
|
+
*
|
|
347
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
348
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
349
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
350
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
351
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
352
|
+
*
|
|
353
|
+
* IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
|
|
354
|
+
* AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
|
|
355
|
+
*
|
|
356
|
+
* For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
|
|
357
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
|
|
358
|
+
* `recordId: 'gina'` -- `auth()` reads it off `request.params`.
|
|
359
|
+
*
|
|
360
|
+
* FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
|
|
361
|
+
* WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
|
|
362
|
+
* route families resolve the RELATED model's own predicate -- `animal` on
|
|
363
|
+
* `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
|
|
364
|
+
* carries `recordId: null` while `request.params` names a record of a
|
|
365
|
+
* DIFFERENT model. So a predicate answering about a related model gets the
|
|
366
|
+
* model name, the operation and the request, and CANNOT branch on which
|
|
367
|
+
* related record it is being asked about.
|
|
368
|
+
*
|
|
369
|
+
* The rule, so it is not re-derived wrong: `recordId` may name a record only
|
|
370
|
+
* where the route addresses exactly one record OF THE MODEL BEING ASKED
|
|
371
|
+
* ABOUT. A `hasMany` related-resource route returns many records of one type
|
|
372
|
+
* and the verdict is resolved ONCE PER TYPE, before any record is examined --
|
|
373
|
+
* seeding it from a record would let the first one decide for all of them.
|
|
374
|
+
*
|
|
375
|
+
* What still works, and what does not, is pinned as behaviour by `#232 AC10`
|
|
376
|
+
* in test/integration/orm-test.ts and stated for consumers in README.md:
|
|
377
|
+
* model-level denies work, request-level denies work, and the per-record
|
|
378
|
+
* FILTER shape works because `access()` may return a function and that
|
|
379
|
+
* function receives the whole record. Branching on identity BEFORE returning
|
|
380
|
+
* does not.
|
|
381
|
+
*
|
|
382
|
+
* `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
|
|
383
|
+
*/
|
|
384
|
+
recordId: string | number | null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A consumer `access()` predicate.
|
|
389
|
+
*
|
|
390
|
+
* The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
|
|
391
|
+
* pre-#202 single-argument predicate keeps working untouched. Changing the
|
|
392
|
+
* FIRST argument instead would have been the breaking form, and a predicate
|
|
393
|
+
* that can no longer identify its collection falls through to a full CRUD
|
|
394
|
+
* grant -- so the "safer" breaking change would have converted every unmigrated
|
|
395
|
+
* predicate into a fail-open.
|
|
396
|
+
*
|
|
397
|
+
* `context` is nonetheless REQUIRED in the type, and that costs back-compat
|
|
398
|
+
* nothing. TypeScript already lets a fewer-parameter implementation satisfy a
|
|
399
|
+
* more-parameter signature, so an arity-1 predicate assigns to this type
|
|
400
|
+
* cleanly -- measured under `--strict`. What the `?` bought was the opposite of
|
|
401
|
+
* safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
|
|
402
|
+
* site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
|
|
403
|
+
* that call gets the model-wrong answer. Required, a caller that drops the
|
|
404
|
+
* context gets `TS2554: Expected 2 arguments, but got 1`.
|
|
405
|
+
*/
|
|
406
|
+
export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* A resolved, request-scoped linkage decision: may `record` of model `type` be
|
|
410
|
+
* NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
|
|
411
|
+
*
|
|
412
|
+
* Arity is `(type, record)` and not `(type, id)` because the per-record filter
|
|
413
|
+
* a consumer returns is handed the RECORD -- this repo's own fixture reads
|
|
414
|
+
* `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
|
|
415
|
+
* key inside `createLinkageFilter`, not the input.
|
|
416
|
+
*
|
|
417
|
+
* DECLARED HERE, with the rest of the access vocabulary, and imported by every
|
|
418
|
+
* site that names it. It had three structurally-identical hand-written copies
|
|
419
|
+
* (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
|
|
420
|
+
* other by nothing, so a drift in nullability or a widening of `type` would
|
|
421
|
+
* have landed on one and not the others -- which is the same "second,
|
|
422
|
+
* unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
|
|
423
|
+
* level up in the type system.
|
|
424
|
+
*/
|
|
425
|
+
export type LinkageFilter = (type: string, record: unknown) => boolean;
|
|
@@ -5,7 +5,20 @@ declare module '@stonyx/rest-server' {
|
|
|
5
5
|
|
|
6
6
|
interface RouteOptions {
|
|
7
7
|
name: string;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `access` is the two-argument post-#202 shape. This is the THIRD place the
|
|
10
|
+
* contract is declared (`AccessInstance.access` in
|
|
11
|
+
* `src/setup-rest-server.ts` and `OrmRequest.access` in
|
|
12
|
+
* `src/orm-request.ts` are the other two) and it is the one `mountRoute` is
|
|
13
|
+
* actually called through, at `src/setup-rest-server.ts`. It kept the
|
|
14
|
+
* pre-#202 single-argument signature after the other two migrated; the
|
|
15
|
+
* union with `Record<string, unknown>` meant nothing broke, which is
|
|
16
|
+
* exactly why it would have drifted silently.
|
|
17
|
+
*
|
|
18
|
+
* Spelled structurally rather than as `AccessFunction`: an ambient
|
|
19
|
+
* `declare module` block cannot carry an `import type`.
|
|
20
|
+
*/
|
|
21
|
+
options?: { model: string; access: (request: unknown, context: { model: string; operation: string | undefined }) => unknown } | Record<string, unknown>;
|
|
9
22
|
}
|
|
10
23
|
|
|
11
24
|
export default class RestServer {
|
package/src/types/stonyx.d.ts
CHANGED
|
@@ -5,7 +5,13 @@ declare module 'stonyx/config' {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
declare module 'stonyx/log' {
|
|
8
|
-
|
|
8
|
+
interface Log {
|
|
9
|
+
db(message: string): void;
|
|
10
|
+
error(message: string, ...args: unknown[]): void;
|
|
11
|
+
defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
|
|
12
|
+
[key: string]: ((...args: unknown[]) => void) | undefined;
|
|
13
|
+
}
|
|
14
|
+
const log: Log;
|
|
9
15
|
export default log;
|
|
10
16
|
}
|
|
11
17
|
|
package/src/utils.ts
CHANGED
|
@@ -20,3 +20,53 @@ export function pluralize(word: string): string {
|
|
|
20
20
|
|
|
21
21
|
return basePluralize(word);
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The highest NUMERIC id held by a set of records, or `0` when there is none.
|
|
26
|
+
*
|
|
27
|
+
* ONE COPY, and the duplication it replaces is the reason it lives here. Three
|
|
28
|
+
* near-identical reduces existed at once: `assignRecordId` (server-assigned id
|
|
29
|
+
* selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
|
|
30
|
+
* helper. `docs/improvements.md`'s standing WET Code category prescribes
|
|
31
|
+
* exactly this remedy -- extract into the module that already acts as the
|
|
32
|
+
* shared utility -- and `assignRecordId` already imported `isOrmRecord` from
|
|
33
|
+
* here.
|
|
34
|
+
*
|
|
35
|
+
* NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
|
|
36
|
+
* `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
|
|
37
|
+
* `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
|
|
38
|
+
* `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
|
|
39
|
+
* produce IDENTICAL output on every one. In particular `typeof NaN` is
|
|
40
|
+
* `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
|
|
41
|
+
* which is `false`. An earlier revision of this code asserted that the skip was
|
|
42
|
+
* what made the `NaN` case work; it is not, the comparison is, and that claim
|
|
43
|
+
* has been removed rather than left standing.
|
|
44
|
+
*
|
|
45
|
+
* WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
|
|
46
|
+
* returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
|
|
47
|
+
* `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
|
|
48
|
+
* it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
|
|
49
|
+
* test/unit/assign-record-id-test.ts AC2; before that file existed the whole
|
|
50
|
+
* suite scored 951/0 under exactly that fix.
|
|
51
|
+
*/
|
|
52
|
+
export function maxNumericId(records: { id?: unknown }[]): number {
|
|
53
|
+
return records.reduce((max: number, record) => {
|
|
54
|
+
const { id } = record;
|
|
55
|
+
|
|
56
|
+
return typeof id === 'number' && id > max ? id : max;
|
|
57
|
+
}, 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The message prefix `assignRecordId` throws with when no free id can be
|
|
62
|
+
* derived for a model, and the ONE string `createHandler` matches on to answer
|
|
63
|
+
* `409` instead of letting the rejection reach express's default handler.
|
|
64
|
+
*
|
|
65
|
+
* It lives here rather than in either file because both need it and neither
|
|
66
|
+
* should own a copy: a literal in two places is how the two id coercions in
|
|
67
|
+
* orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
|
|
68
|
+
* and no custom error classes -- 24 bare `throw new Error` sites across `src/`
|
|
69
|
+
* -- so a shared prefix is the narrowest way to make ONE failure distinguishable
|
|
70
|
+
* without inventing an error taxonomy this codebase does not use.
|
|
71
|
+
*/
|
|
72
|
+
export const NO_FREE_ID_ERROR = 'Cannot assign record ID: no free id available';
|
package/config/environment.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
const {
|
|
2
|
-
ORM_ACCESS_PATH,
|
|
3
|
-
ORM_MODEL_PATH,
|
|
4
|
-
ORM_REST_ROUTE,
|
|
5
|
-
ORM_SERIALIZER_PATH,
|
|
6
|
-
ORM_TRANSFORM_PATH,
|
|
7
|
-
ORM_VIEW_PATH,
|
|
8
|
-
ORM_USE_REST_SERVER,
|
|
9
|
-
DB_AUTO_SAVE,
|
|
10
|
-
DB_FILE,
|
|
11
|
-
DB_MODE,
|
|
12
|
-
DB_DIRECTORY,
|
|
13
|
-
DB_SCHEMA_PATH,
|
|
14
|
-
DB_SAVE_INTERVAL,
|
|
15
|
-
MYSQL_HOST,
|
|
16
|
-
MYSQL_PORT,
|
|
17
|
-
MYSQL_USER,
|
|
18
|
-
MYSQL_PASSWORD,
|
|
19
|
-
MYSQL_DATABASE,
|
|
20
|
-
MYSQL_CONNECTION_LIMIT,
|
|
21
|
-
MYSQL_MIGRATIONS_DIR,
|
|
22
|
-
PG_HOST,
|
|
23
|
-
PG_PORT,
|
|
24
|
-
PG_USER,
|
|
25
|
-
PG_PASSWORD,
|
|
26
|
-
PG_DATABASE,
|
|
27
|
-
PG_CONNECTION_LIMIT,
|
|
28
|
-
PG_MIGRATIONS_DIR,
|
|
29
|
-
TIMESCALE_HOST,
|
|
30
|
-
TIMESCALE_PORT,
|
|
31
|
-
TIMESCALE_USER,
|
|
32
|
-
TIMESCALE_PASSWORD,
|
|
33
|
-
TIMESCALE_DATABASE,
|
|
34
|
-
TIMESCALE_CONNECTION_LIMIT,
|
|
35
|
-
TIMESCALE_MIGRATIONS_DIR,
|
|
36
|
-
} = process.env;
|
|
37
|
-
|
|
38
|
-
export default {
|
|
39
|
-
logColor: 'white',
|
|
40
|
-
logMethod: 'db',
|
|
41
|
-
|
|
42
|
-
db: {
|
|
43
|
-
autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
|
|
44
|
-
file: DB_FILE ?? 'db.json',
|
|
45
|
-
mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
|
|
46
|
-
directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
|
|
47
|
-
saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
|
|
48
|
-
schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
|
|
49
|
-
},
|
|
50
|
-
paths: {
|
|
51
|
-
access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
|
|
52
|
-
model: ORM_MODEL_PATH ?? './models',
|
|
53
|
-
serializer: ORM_SERIALIZER_PATH ?? './serializers',
|
|
54
|
-
transform: ORM_TRANSFORM_PATH ?? './transforms',
|
|
55
|
-
view: ORM_VIEW_PATH ?? './views'
|
|
56
|
-
},
|
|
57
|
-
mysql: MYSQL_HOST ? {
|
|
58
|
-
host: MYSQL_HOST ?? 'localhost',
|
|
59
|
-
port: parseInt(MYSQL_PORT ?? '3306'),
|
|
60
|
-
user: MYSQL_USER ?? 'root',
|
|
61
|
-
password: MYSQL_PASSWORD ?? '',
|
|
62
|
-
database: MYSQL_DATABASE ?? 'stonyx',
|
|
63
|
-
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
64
|
-
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
65
|
-
migrationsTable: '__migrations',
|
|
66
|
-
} : undefined,
|
|
67
|
-
postgres: PG_HOST ? {
|
|
68
|
-
host: PG_HOST ?? 'localhost',
|
|
69
|
-
port: parseInt(PG_PORT ?? '5432'),
|
|
70
|
-
user: PG_USER ?? 'postgres',
|
|
71
|
-
password: PG_PASSWORD ?? '',
|
|
72
|
-
database: PG_DATABASE ?? 'stonyx',
|
|
73
|
-
connectionLimit: parseInt(PG_CONNECTION_LIMIT ?? '10'),
|
|
74
|
-
migrationsDir: PG_MIGRATIONS_DIR ?? 'migrations',
|
|
75
|
-
migrationsTable: '__migrations',
|
|
76
|
-
} : undefined,
|
|
77
|
-
timescale: TIMESCALE_HOST ? {
|
|
78
|
-
host: TIMESCALE_HOST ?? 'localhost',
|
|
79
|
-
port: parseInt(TIMESCALE_PORT ?? '5432'),
|
|
80
|
-
user: TIMESCALE_USER ?? 'postgres',
|
|
81
|
-
password: TIMESCALE_PASSWORD ?? '',
|
|
82
|
-
database: TIMESCALE_DATABASE ?? 'stonyx',
|
|
83
|
-
connectionLimit: parseInt(TIMESCALE_CONNECTION_LIMIT ?? '10'),
|
|
84
|
-
migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
|
|
85
|
-
migrationsTable: '__migrations',
|
|
86
|
-
} : undefined,
|
|
87
|
-
restServer: {
|
|
88
|
-
enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
|
|
89
|
-
route: ORM_REST_ROUTE ?? '/',
|
|
90
|
-
}
|
|
91
|
-
}
|