@stonyx/orm 0.3.2-alpha.5 → 0.3.2-alpha.50
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 +296 -4
- package/config/environment.js +99 -12
- 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/main.js +10 -0
- 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/orm-request.d.ts +37 -0
- package/dist/orm-request.js +493 -43
- 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/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +11 -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/main.ts +10 -0
- package/src/manage-record.ts +41 -9
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +500 -41
- 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/store.ts +68 -6
- package/src/types/orm-types.ts +12 -0
- package/src/types/stonyx.d.ts +7 -1
- package/config/environment.ts +0 -91
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A lightweight ORM for Stonyx projects, featuring model definitions, serializers,
|
|
|
13
13
|
- **Models**: Define attributes with type-safe proxies (`attr`) and relationships (`hasMany`, `belongsTo`).
|
|
14
14
|
- **Serializers**: Map raw data into model-friendly structures, including nested properties.
|
|
15
15
|
- **Transforms**: Apply custom transformations on data values automatically.
|
|
16
|
-
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL for production workloads.
|
|
16
|
+
- **DB Integration**: Optional file-based persistence with auto-save support, or MySQL/PostgreSQL/TimescaleDB/DynamoDB for production workloads.
|
|
17
17
|
- **REST Server Integration**: Automatic route setup with customizable access control.
|
|
18
18
|
- **Lifecycle Hooks**: Middleware-based before/after hooks for validation, authorization, side effects, and auditing.
|
|
19
19
|
|
|
@@ -65,13 +65,16 @@ const {
|
|
|
65
65
|
MYSQL_DATABASE,
|
|
66
66
|
MYSQL_CONNECTION_LIMIT,
|
|
67
67
|
MYSQL_MIGRATIONS_DIR,
|
|
68
|
+
DYNAMODB_REGION,
|
|
69
|
+
DYNAMODB_ENDPOINT,
|
|
70
|
+
DYNAMODB_TABLE_PREFIX,
|
|
68
71
|
} = process.env;
|
|
69
72
|
|
|
70
73
|
export default {
|
|
71
74
|
orm: {
|
|
72
75
|
logColor: 'white',
|
|
73
76
|
logMethod: 'db',
|
|
74
|
-
|
|
77
|
+
|
|
75
78
|
db: {
|
|
76
79
|
autosave: DB_AUTO_SAVE ?? 'false',
|
|
77
80
|
file: DB_FILE ?? 'db.json',
|
|
@@ -95,6 +98,12 @@ export default {
|
|
|
95
98
|
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
96
99
|
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
97
100
|
migrationsTable: '__migrations',
|
|
101
|
+
autoMigrate: AUTO_MIGRATE === 'true' ? true : AUTO_MIGRATE === 'false' ? false : undefined,
|
|
102
|
+
} : undefined,
|
|
103
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
104
|
+
region: DYNAMODB_REGION,
|
|
105
|
+
endpoint: DYNAMODB_ENDPOINT, // optional, for DynamoDB Local
|
|
106
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX, // optional table name prefix
|
|
98
107
|
} : undefined,
|
|
99
108
|
restServer: {
|
|
100
109
|
enabled: ORM_USE_REST_SERVER ?? 'true',
|
|
@@ -243,6 +252,31 @@ Set the `MYSQL_HOST` environment variable to enable MySQL persistence. The ORM l
|
|
|
243
252
|
| `stonyx db:migrate` | Apply pending migrations |
|
|
244
253
|
| `stonyx db:migrate:rollback` | Rollback the most recent migration |
|
|
245
254
|
| `stonyx db:migrate:status` | Show migration status |
|
|
255
|
+
| `stonyx db:sync` | Sync DynamoDB table definitions to match current model schemas |
|
|
256
|
+
|
|
257
|
+
### DynamoDB Mode
|
|
258
|
+
|
|
259
|
+
Set the `DYNAMODB_REGION` environment variable to enable DynamoDB persistence. Tables are created with PAY_PER_REQUEST (on-demand) billing. Global Secondary Indexes (GSIs) are auto-provisioned at startup based on model `belongsTo` relationships — each FK column gets a GSI. `findAll()` with conditions routes to a GSI Query when the condition key matches a GSI partition key; non-indexed attribute conditions fall back to Scan + FilterExpression (expensive for large tables). ULID generation replaces auto-increment for numeric-ID models.
|
|
260
|
+
|
|
261
|
+
```javascript
|
|
262
|
+
dynamodb: {
|
|
263
|
+
region: 'us-east-1',
|
|
264
|
+
endpoint: 'http://localhost:8000', // optional, for DynamoDB Local
|
|
265
|
+
tablePrefix: 'myapp-', // optional table name prefix
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Environment variables:
|
|
270
|
+
|
|
271
|
+
* `DYNAMODB_REGION`: AWS region for DynamoDB (e.g., `'us-east-1'`).
|
|
272
|
+
* `DYNAMODB_ENDPOINT`: Optional custom endpoint URL, useful for DynamoDB Local during development.
|
|
273
|
+
* `DYNAMODB_TABLE_PREFIX`: Optional prefix prepended to all table names (e.g., `'myapp-'` yields `'myapp-animals'`).
|
|
274
|
+
|
|
275
|
+
**Peer dependencies:** `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` must be installed when using the DynamoDB driver. The AWS SDK is dynamically imported and only loaded when the DynamoDB driver is selected.
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb
|
|
279
|
+
```
|
|
246
280
|
|
|
247
281
|
### Running MySQL Tests
|
|
248
282
|
|
|
@@ -275,19 +309,277 @@ import setupRestServer from '@stonyx/orm/setup-rest-server';
|
|
|
275
309
|
await setupRestServer('/', './access');
|
|
276
310
|
```
|
|
277
311
|
|
|
278
|
-
Access classes define models and provide custom filtering/authorization logic
|
|
312
|
+
Access classes define models and provide custom filtering/authorization logic.
|
|
313
|
+
|
|
314
|
+
> **The URL-matching in this example is a stopgap. Read
|
|
315
|
+
> [Matching the url](#matching-the-url) before copying it.** The same three-line
|
|
316
|
+
> example has failed **open** in four distinct ways during one review, each found
|
|
317
|
+
> only after the previous was fixed, by four different people. The sample below
|
|
318
|
+
> closes all four; that is not the same as being safe — it is safe against the
|
|
319
|
+
> four variants that happen to have been found, and there is no reason to believe
|
|
320
|
+
> the list is complete.
|
|
321
|
+
>
|
|
322
|
+
> **The real fix is
|
|
323
|
+
> [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
|
|
324
|
+
> receive the model, the operation and the record, so there is no URL to parse
|
|
325
|
+
> and no variant to miss. Until it lands, prefer the array shape (`['read']`) or
|
|
326
|
+
> `false` where you can: the **function** shape is the one that requires URL
|
|
327
|
+
> matching. The same warning is repeated at the top of `src/orm-request.ts`,
|
|
328
|
+
> which ships; the longer write-up in `docs/usage-patterns.md` does **not** ship,
|
|
329
|
+
> so this README and that source header are the two copies a consumer sees.
|
|
279
330
|
|
|
280
331
|
```js
|
|
332
|
+
import config from 'stonyx/config';
|
|
333
|
+
|
|
334
|
+
// Build the mount prefix from the SAME value the ORM mounts under, and compare
|
|
335
|
+
// lower-cased. Both are load-bearing — see "Matching the url".
|
|
336
|
+
function collectionPrefix(name) {
|
|
337
|
+
const route = config.orm.restServer.route ?? '/';
|
|
338
|
+
const trimmed = String(route).replace(/^\/+|\/+$/g, '');
|
|
339
|
+
|
|
340
|
+
return `${trimmed === '' ? '' : `/${trimmed}`}/${name}`.toLowerCase();
|
|
341
|
+
}
|
|
342
|
+
|
|
281
343
|
export default class GlobalAccess {
|
|
282
344
|
models = ['owner', 'animal'];
|
|
283
345
|
|
|
284
346
|
access(request) {
|
|
285
|
-
|
|
347
|
+
// `originalUrl`, not `url` — `url` is mount-relative, so a prefix match
|
|
348
|
+
// against it is ALWAYS false. Query string stripped, because `originalUrl`
|
|
349
|
+
// carries it. Lower-cased, because the router matched case-insensitively
|
|
350
|
+
// and a matcher stricter than the router can be walked past. Every one of
|
|
351
|
+
// those three omissions fails OPEN.
|
|
352
|
+
const path = String(request.originalUrl ?? '').split('?')[0].toLowerCase();
|
|
353
|
+
const owners = collectionPrefix('owners');
|
|
354
|
+
|
|
355
|
+
// false → 403 for the whole request
|
|
356
|
+
if (path.startsWith(`${owners}/archived`)) return false;
|
|
357
|
+
|
|
358
|
+
// A function is a per-record filter. Anchored on a `/` boundary so it
|
|
359
|
+
// cannot also match `/owners-archive`. Rejected records are 404 on record
|
|
360
|
+
// routes, 403 on POST.
|
|
361
|
+
if (path === owners || path.startsWith(`${owners}/`)) {
|
|
362
|
+
return record => record.id !== 'angela';
|
|
363
|
+
}
|
|
364
|
+
|
|
286
365
|
return ['read', 'create', 'update', 'delete'];
|
|
287
366
|
}
|
|
288
367
|
}
|
|
289
368
|
```
|
|
290
369
|
|
|
370
|
+
### Return values
|
|
371
|
+
|
|
372
|
+
| `access()` returns | Effect |
|
|
373
|
+
|---|---|
|
|
374
|
+
| `false` (or any falsy value) | `403` for the whole request |
|
|
375
|
+
| `true` | full access, no filter |
|
|
376
|
+
| `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
|
|
377
|
+
| `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
|
|
378
|
+
| a function | a per-record filter — see below |
|
|
379
|
+
| anything else | `403` — unknown shapes fail **closed** |
|
|
380
|
+
|
|
381
|
+
A `throw` inside `access()` is a **denial**, not a 500.
|
|
382
|
+
|
|
383
|
+
### Filter functions
|
|
384
|
+
|
|
385
|
+
A function return value is a **per-record predicate**, and it is enforced on
|
|
386
|
+
every endpoint that is addressed to a record — not only on the collection:
|
|
387
|
+
|
|
388
|
+
| Endpoint | A record the predicate rejects |
|
|
389
|
+
|---|---|
|
|
390
|
+
| `GET /:models` | omitted from the collection |
|
|
391
|
+
| `GET /:models/:id` | `404` |
|
|
392
|
+
| `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
|
|
393
|
+
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
394
|
+
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
395
|
+
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
396
|
+
| `POST /:models` | `403`, and nothing is left in the store |
|
|
397
|
+
|
|
398
|
+
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
399
|
+
is the property most easily "improved" away. 403 would confirm that the record
|
|
400
|
+
exists to a caller who is not allowed to know that, which turns the filter into
|
|
401
|
+
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
402
|
+
it is not yours". Every status on a record route must therefore be identical for
|
|
403
|
+
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
404
|
+
a record that never existed also returns 404 rather than 204.
|
|
405
|
+
|
|
406
|
+
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
407
|
+
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
408
|
+
different failure a developer needs to diagnose.
|
|
409
|
+
|
|
410
|
+
**A client-supplied `id` on `POST` is refused with `403` whenever a function
|
|
411
|
+
filter is in force.** This is the part that keeps `POST` from being an
|
|
412
|
+
enumeration oracle, and it is worth understanding rather than working around.
|
|
413
|
+
The duplicate-id check has to run before the filter, and it sees records the
|
|
414
|
+
filter hides, so the *status* of a `POST` otherwise leaks whether an id is
|
|
415
|
+
taken:
|
|
416
|
+
|
|
417
|
+
| `POST /animals` with a payload the caller may create | before | now |
|
|
418
|
+
|---|---|---|
|
|
419
|
+
| an id held by a record the filter **hides** | `403` | `403` |
|
|
420
|
+
| an id that is **free** | `200` | `403` |
|
|
421
|
+
| an id held by a record the caller **can see** | `409` | `403` |
|
|
422
|
+
|
|
423
|
+
Three outcomes, one request per id, the whole id space. Filtering only the
|
|
424
|
+
*collision* status narrows that to callers who cannot create a record they are
|
|
425
|
+
allowed to see; it does not close it. It cannot be closed while a caller both
|
|
426
|
+
chooses the id and learns whether the create succeeded — so under a filter the
|
|
427
|
+
caller does not choose the id. The refusal happens before any store lookup, so
|
|
428
|
+
neither the status nor the response time depends on whether the id exists.
|
|
429
|
+
|
|
430
|
+
Let the server assign the id and read it back from the response. Callers with no
|
|
431
|
+
function-style filter are unaffected: `409` on a duplicate id and `200` on a free
|
|
432
|
+
one both behave exactly as before.
|
|
433
|
+
|
|
434
|
+
### Matching the url
|
|
435
|
+
|
|
436
|
+
**This section describes a pattern the framework should not be asking you to
|
|
437
|
+
implement.** It has produced four separate fail-open defects, listed below,
|
|
438
|
+
each found only after the previous was fixed, and there is no reason to believe
|
|
439
|
+
the list is complete. [#202](https://github.com/abofs/stonyx-orm/issues/202)
|
|
440
|
+
replaces it. Until then, all four rules apply and each one, omitted, fails
|
|
441
|
+
**open**.
|
|
442
|
+
|
|
443
|
+
**1. Match `request.originalUrl`, never `request.url`.**
|
|
444
|
+
`RestServer.mountRoute` mounts each model as an Express **sub-app**, so by the
|
|
445
|
+
time `access()` runs the mount path has been stripped from `request.url`:
|
|
446
|
+
|
|
447
|
+
| request | `request.url` | `request.originalUrl` |
|
|
448
|
+
|---|---|---|
|
|
449
|
+
| `GET /owners` | `/` | `/owners` |
|
|
450
|
+
| `GET /owners/angela` | `/angela` | `/owners/angela` |
|
|
451
|
+
| `GET /owners/angela/pets` | `/angela/pets` | `/owners/angela/pets` |
|
|
452
|
+
|
|
453
|
+
`request.url.startsWith('/owners')` is therefore **always false**: the branch
|
|
454
|
+
never fires, `access()` falls through to whatever it returns last, and a filter
|
|
455
|
+
that looks correct enforces nothing on any surface.
|
|
456
|
+
|
|
457
|
+
**2. Strip the query string, and match the prefix rather than the exact url.**
|
|
458
|
+
The predicate has to be returned for record routes too, so
|
|
459
|
+
`url.endsWith('/owners')` leaves `/owners/angela` unguarded — and `originalUrl`
|
|
460
|
+
carries the query string, so a bare `=== '/owners'` misses
|
|
461
|
+
`/owners?filter[age]=30` and lets a filtered collection through unfiltered.
|
|
462
|
+
Anchoring on the path portion covers both without also matching
|
|
463
|
+
`/owners-archive`.
|
|
464
|
+
|
|
465
|
+
**3. Compare lower-cased.** `RestServer` mounts with a bare `express()`, whose
|
|
466
|
+
default is `caseSensitive: false`, while `originalUrl` preserves the caller's
|
|
467
|
+
case. A case-sensitive matcher is stricter than the router that dispatched the
|
|
468
|
+
request, so it can simply be stepped around:
|
|
469
|
+
|
|
470
|
+
```
|
|
471
|
+
GET /owners/angela -> 404 GET /OwNeRs/angela -> 200, angela in full
|
|
472
|
+
GET /owners -> filtered GET /OWNERS -> unfiltered
|
|
473
|
+
DELETE /animals/22 -> 404 DELETE /ANIMALS/22 -> 204, record destroyed
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Lower-case the **path** only. Record ids are case-sensitive and must be compared
|
|
477
|
+
at their real case. The router-side fix is tracked as
|
|
478
|
+
[stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47).
|
|
479
|
+
|
|
480
|
+
**4. Build the prefix from the configured mount route.** With
|
|
481
|
+
`ORM_REST_ROUTE=/api` the urls above become `/api/owners/...`, and a sample
|
|
482
|
+
hard-coded to `/owners` matches nothing — environment-specifically, which is
|
|
483
|
+
harder to notice than failing everywhere.
|
|
484
|
+
|
|
485
|
+
Note what this must *not* be. An earlier version of this document suggested
|
|
486
|
+
`` `${config.orm.restServer.route}owners` ``. For the default route that is
|
|
487
|
+
`/owners` and looks correct; for `ORM_REST_ROUTE=/api` it evaluates to
|
|
488
|
+
**`/apiowners`**, so a reader who followed the correction exactly still failed
|
|
489
|
+
open and believed they had handled it. Join on `/` and collapse the duplicate,
|
|
490
|
+
as `collectionPrefix()` above does.
|
|
491
|
+
|
|
492
|
+
### Known limitations
|
|
493
|
+
|
|
494
|
+
- **Authorization by URL matching is a consumer-side reconstruction of
|
|
495
|
+
information the framework already holds.** `access()` receives a transport
|
|
496
|
+
artifact and is asked to re-derive, correctly and defensively, which model,
|
|
497
|
+
which operation and which record the request addresses. The four rules above
|
|
498
|
+
are the four ways that reconstruction has been observed to fail open so far.
|
|
499
|
+
Tracked as [#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
500
|
+
- **Related and included records are not filtered.** The predicate is evaluated
|
|
501
|
+
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
502
|
+
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
503
|
+
related record without resolving that model's own access class, so a filter on
|
|
504
|
+
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
505
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
506
|
+
`include=`, related-resource routes and relationship-linkage routes.
|
|
507
|
+
- **A before-hook that returns a value short-circuits the request.** On write
|
|
508
|
+
operations addressed to a record the filter is consulted first, so a hook
|
|
509
|
+
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
510
|
+
`beforeHook('get', ...)` read-through cache can answer past the filter.
|
|
511
|
+
- **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
|
|
512
|
+
`create` there is no record to test until the handler has built one, so the
|
|
513
|
+
denial is not knowable in time. Every *after*-hook is gated, and every
|
|
514
|
+
before-hook on `update` and `delete` is gated; before-`create` is the one
|
|
515
|
+
exception. A before-`create` hook must not assume the create will succeed.
|
|
516
|
+
- **A caller can still learn that a collection *has* a per-record filter**, by
|
|
517
|
+
observing `403` rather than `409`/`200` for an id-bearing `POST`. That
|
|
518
|
+
discloses a configuration fact, not the existence of any record.
|
|
519
|
+
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
520
|
+
leaves a small timing difference between a hidden record and one that never
|
|
521
|
+
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
522
|
+
- **A `relationships` key that is not a declared relationship is still applied
|
|
523
|
+
to the record.** The key comes verbatim from the request body and is checked
|
|
524
|
+
against nothing except `id`, which is stripped. On a `POST` that makes an
|
|
525
|
+
undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
|
|
526
|
+
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
527
|
+
change 3 above; the general form is tracked as
|
|
528
|
+
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
529
|
+
- **A partially numeric `id` in a `POST` body can overwrite a different record
|
|
530
|
+
on an unfiltered collection.** The duplicate check rejects `"9105h"` as a
|
|
531
|
+
string, correctly, but the model's id transform truncates it to `9105` and the
|
|
532
|
+
create lands there. Filtered collections are unaffected — breaking change 3
|
|
533
|
+
refuses any client-supplied id — so this reaches consumers with **no**
|
|
534
|
+
function-style filter. Tracked as
|
|
535
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
|
|
536
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
|
|
537
|
+
a create can land on an id nobody named.
|
|
538
|
+
|
|
539
|
+
### Breaking changes in 0.4.0
|
|
540
|
+
|
|
541
|
+
There is no changelog or release-notes channel yet
|
|
542
|
+
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
543
|
+
they are recorded here.
|
|
544
|
+
|
|
545
|
+
1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
|
|
546
|
+
instead of `204`.** This affects **every** consumer issuing a DELETE against
|
|
547
|
+
any mounted collection, whether or not an access filter is configured, and
|
|
548
|
+
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
549
|
+
delete returned 404 while a missing one returned 204, the pair would be a
|
|
550
|
+
perfect existence oracle and the filter would be worthless.
|
|
551
|
+
2. **After-hooks no longer fire for a write that failed** — denied, missing,
|
|
552
|
+
`400` or `409`. Previously `afterHook('delete', ...)` ran with a populated
|
|
553
|
+
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
554
|
+
destroyed children behind a 404.
|
|
555
|
+
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
556
|
+
`access` filter is in force**, whatever the payload and whether or not the id
|
|
557
|
+
exists, and *before* any store lookup — so neither the status nor the lookup
|
|
558
|
+
cost can depend on whether that id exists. Only affects function-style
|
|
559
|
+
`access` users. See [Filter functions](#filter-functions) for why, and let the
|
|
560
|
+
server assign the id instead. `409`-on-duplicate is unchanged for everyone
|
|
561
|
+
else.
|
|
562
|
+
|
|
563
|
+
"Whatever the payload" is a statement about the **`id` member of the resource
|
|
564
|
+
object**, and it holds only because that is the sole channel a caller id can
|
|
565
|
+
arrive on. It was not always: a caller id moved into
|
|
566
|
+
`relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
|
|
567
|
+
refusal and overwrote a hidden record in place. Both strips — `attributes.id`
|
|
568
|
+
and `relationships.id` — are part of this behaviour, not tidiness. A future
|
|
569
|
+
change that adds a third channel without stripping it re-opens the oracle;
|
|
570
|
+
see [#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
571
|
+
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
572
|
+
previously reachable by id despite being filtered from the collection now
|
|
573
|
+
return 404. Only affects function-style `access` users, for whom the old
|
|
574
|
+
behaviour was the bypass.
|
|
575
|
+
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
576
|
+
Express's default 500 handler. So is an `access()` that throws.
|
|
577
|
+
6. **`access()` returning a bare string is one permission, not full access.**
|
|
578
|
+
`AccessMethod` declares `string` legal, and it previously fell through every
|
|
579
|
+
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
580
|
+
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
581
|
+
a number) now returns `403` rather than granting full access.
|
|
582
|
+
|
|
291
583
|
### Include Parameter (Sideloading Relationships)
|
|
292
584
|
|
|
293
585
|
The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
|
package/config/environment.js
CHANGED
|
@@ -1,12 +1,99 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
+
DYNAMODB_REGION,
|
|
37
|
+
DYNAMODB_ENDPOINT,
|
|
38
|
+
DYNAMODB_TABLE_PREFIX,
|
|
39
|
+
} = process.env;
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
logColor: 'white',
|
|
43
|
+
logMethod: 'db',
|
|
44
|
+
|
|
45
|
+
db: {
|
|
46
|
+
autosave: DB_AUTO_SAVE ?? 'false', // 'true' (cron interval), 'false' (disabled), 'onUpdate' (save after each write op)
|
|
47
|
+
file: DB_FILE ?? 'db.json',
|
|
48
|
+
mode: DB_MODE ?? 'file', // 'file' (single db.json) or 'directory' (one file per collection)
|
|
49
|
+
directory: DB_DIRECTORY ?? 'db', // directory name for collection files when mode is 'directory'
|
|
50
|
+
saveInterval: DB_SAVE_INTERVAL ?? 60 * 60, // 1 hour
|
|
51
|
+
schema: DB_SCHEMA_PATH ?? './config/db-schema.js'
|
|
52
|
+
},
|
|
53
|
+
paths: {
|
|
54
|
+
access: ORM_ACCESS_PATH ?? './access', // Optional for restServer access hooks
|
|
55
|
+
model: ORM_MODEL_PATH ?? './models',
|
|
56
|
+
serializer: ORM_SERIALIZER_PATH ?? './serializers',
|
|
57
|
+
transform: ORM_TRANSFORM_PATH ?? './transforms',
|
|
58
|
+
view: ORM_VIEW_PATH ?? './views'
|
|
59
|
+
},
|
|
60
|
+
mysql: MYSQL_HOST ? {
|
|
61
|
+
host: MYSQL_HOST ?? 'localhost',
|
|
62
|
+
port: parseInt(MYSQL_PORT ?? '3306'),
|
|
63
|
+
user: MYSQL_USER ?? 'root',
|
|
64
|
+
password: MYSQL_PASSWORD ?? '',
|
|
65
|
+
database: MYSQL_DATABASE ?? 'stonyx',
|
|
66
|
+
connectionLimit: parseInt(MYSQL_CONNECTION_LIMIT ?? '10'),
|
|
67
|
+
migrationsDir: MYSQL_MIGRATIONS_DIR ?? 'migrations',
|
|
68
|
+
migrationsTable: '__migrations',
|
|
69
|
+
} : undefined,
|
|
70
|
+
postgres: PG_HOST ? {
|
|
71
|
+
host: PG_HOST ?? 'localhost',
|
|
72
|
+
port: parseInt(PG_PORT ?? '5432'),
|
|
73
|
+
user: PG_USER ?? 'postgres',
|
|
74
|
+
password: PG_PASSWORD ?? '',
|
|
75
|
+
database: PG_DATABASE ?? 'stonyx',
|
|
76
|
+
connectionLimit: parseInt(PG_CONNECTION_LIMIT ?? '10'),
|
|
77
|
+
migrationsDir: PG_MIGRATIONS_DIR ?? 'migrations',
|
|
78
|
+
migrationsTable: '__migrations',
|
|
79
|
+
} : undefined,
|
|
80
|
+
timescale: TIMESCALE_HOST ? {
|
|
81
|
+
host: TIMESCALE_HOST ?? 'localhost',
|
|
82
|
+
port: parseInt(TIMESCALE_PORT ?? '5432'),
|
|
83
|
+
user: TIMESCALE_USER ?? 'postgres',
|
|
84
|
+
password: TIMESCALE_PASSWORD ?? '',
|
|
85
|
+
database: TIMESCALE_DATABASE ?? 'stonyx',
|
|
86
|
+
connectionLimit: parseInt(TIMESCALE_CONNECTION_LIMIT ?? '10'),
|
|
87
|
+
migrationsDir: TIMESCALE_MIGRATIONS_DIR ?? 'migrations',
|
|
88
|
+
migrationsTable: '__migrations',
|
|
89
|
+
} : undefined,
|
|
90
|
+
dynamodb: DYNAMODB_REGION ? {
|
|
91
|
+
region: DYNAMODB_REGION,
|
|
92
|
+
endpoint: DYNAMODB_ENDPOINT || undefined,
|
|
93
|
+
tablePrefix: DYNAMODB_TABLE_PREFIX || '',
|
|
94
|
+
} : undefined,
|
|
95
|
+
restServer: {
|
|
96
|
+
enabled: ORM_USE_REST_SERVER ?? 'true', // Whether to load restServer for automatic route setup or
|
|
97
|
+
route: ORM_REST_ROUTE ?? '/',
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -20,6 +20,11 @@ const commands = {
|
|
|
20
20
|
description: 'Generate a MySQL migration from current model schemas',
|
|
21
21
|
bootstrap: true,
|
|
22
22
|
run: async (args) => {
|
|
23
|
+
const config = (await import('stonyx/config')).default;
|
|
24
|
+
if (config.orm.dynamodb) {
|
|
25
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
23
28
|
const description = args?.join(' ') || 'migration';
|
|
24
29
|
const { generateMigration } = await import('./mysql/migration-generator.js');
|
|
25
30
|
const result = await generateMigration(description);
|
|
@@ -31,12 +36,33 @@ const commands = {
|
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
},
|
|
39
|
+
'db:sync': {
|
|
40
|
+
description: 'Provision DynamoDB tables and GSIs from current model schemas',
|
|
41
|
+
bootstrap: true,
|
|
42
|
+
run: async () => {
|
|
43
|
+
const config = (await import('stonyx/config')).default;
|
|
44
|
+
if (!config.orm.dynamodb) {
|
|
45
|
+
console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
|
|
49
|
+
const db = new DynamoDBDB();
|
|
50
|
+
await db.init();
|
|
51
|
+
await db.startup();
|
|
52
|
+
await db.shutdown();
|
|
53
|
+
console.log('DynamoDB tables synced successfully.');
|
|
54
|
+
}
|
|
55
|
+
},
|
|
34
56
|
'db:migrate': {
|
|
35
57
|
description: 'Apply pending MySQL migrations',
|
|
36
58
|
bootstrap: true,
|
|
37
59
|
run: async () => {
|
|
38
60
|
const config = (await import('stonyx/config')).default;
|
|
39
61
|
const mysqlConfig = config.orm.mysql;
|
|
62
|
+
if (config.orm.dynamodb) {
|
|
63
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
40
66
|
if (!mysqlConfig) {
|
|
41
67
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
42
68
|
process.exit(1);
|
|
@@ -75,6 +101,10 @@ const commands = {
|
|
|
75
101
|
bootstrap: true,
|
|
76
102
|
run: async () => {
|
|
77
103
|
const config = (await import('stonyx/config')).default;
|
|
104
|
+
if (config.orm.dynamodb) {
|
|
105
|
+
console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
78
108
|
const mysqlConfig = config.orm.mysql;
|
|
79
109
|
if (!mysqlConfig) {
|
|
80
110
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -113,6 +143,10 @@ const commands = {
|
|
|
113
143
|
bootstrap: true,
|
|
114
144
|
run: async () => {
|
|
115
145
|
const config = (await import('stonyx/config')).default;
|
|
146
|
+
if (config.orm.dynamodb) {
|
|
147
|
+
console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
116
150
|
const mysqlConfig = config.orm.mysql;
|
|
117
151
|
if (!mysqlConfig) {
|
|
118
152
|
console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
export interface DynamoDBConfig {
|
|
8
|
+
region?: string;
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
tablePrefix?: string;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
export type DocumentClient = {
|
|
14
|
+
send(command: unknown): Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
export type DynamoDBClientConstructor = new (options: unknown) => {
|
|
17
|
+
config: unknown;
|
|
18
|
+
};
|
|
19
|
+
export type DocumentClientFromFn = {
|
|
20
|
+
from(client: unknown): DocumentClient;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
24
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient>;
|
|
27
|
+
/**
|
|
28
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
29
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
30
|
+
*/
|
|
31
|
+
export declare function destroyDocumentClient(_client: DocumentClient | null): null;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DynamoDB connection factory.
|
|
3
|
+
*
|
|
4
|
+
* Dynamically imports @aws-sdk/client-dynamodb and @aws-sdk/lib-dynamodb
|
|
5
|
+
* so these are optional peerDependencies (matching the pg/mysql2 pattern).
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Create a DynamoDBDocumentClient from the given config.
|
|
9
|
+
* Uses dynamic import so @aws-sdk/* are optional peer deps.
|
|
10
|
+
*/
|
|
11
|
+
export async function createDocumentClient(dbConfig) {
|
|
12
|
+
const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb');
|
|
13
|
+
const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb');
|
|
14
|
+
const clientOptions = {};
|
|
15
|
+
if (dbConfig.region)
|
|
16
|
+
clientOptions.region = dbConfig.region;
|
|
17
|
+
if (dbConfig.endpoint)
|
|
18
|
+
clientOptions.endpoint = dbConfig.endpoint;
|
|
19
|
+
const rawClient = new DynamoDBClient(clientOptions);
|
|
20
|
+
return DynamoDBDocumentClient.from(rawClient);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Nullify the document client reference (DynamoDB connections are HTTP-based
|
|
24
|
+
* and stateless — no explicit pool close needed, but we clear the reference).
|
|
25
|
+
*/
|
|
26
|
+
export function destroyDocumentClient(_client) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|