@stonyx/orm 0.3.2-beta.15 → 0.3.2-beta.151
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 +402 -10
- 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 +60 -0
- package/dist/orm-request.js +644 -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/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 +654 -45
- 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,343 @@ 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
|
+
> **Do not reconstruct the request path inside `access()`. Read
|
|
315
|
+
> [Identifying the collection](#identifying-the-collection) before copying this.**
|
|
316
|
+
> Every attempt to identify the collection by parsing the request target has
|
|
317
|
+
> failed **open** — five distinct variants of this same example, each found only
|
|
318
|
+
> after the previous was fixed, by five different people. The sample below does
|
|
319
|
+
> not parse anything: it reads `request.baseUrl`, the mount Express actually
|
|
320
|
+
> matched.
|
|
321
|
+
>
|
|
322
|
+
> That is still a stopgap. **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 nothing to
|
|
325
|
+
> identify. Until it lands, prefer the array shape (`['read']`) or `false` where
|
|
326
|
+
> you can: the **function** shape is the one that requires any matching at all.
|
|
327
|
+
> The same warning is repeated at the top of `src/orm-request.ts`, which ships;
|
|
328
|
+
> the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
|
|
329
|
+
> README and that source header are the two copies a consumer sees.
|
|
330
|
+
>
|
|
331
|
+
> This sample is the same code as the shipped test fixture, and a test asserts
|
|
332
|
+
> the two `access()` bodies are identical line for line. For four rounds they
|
|
333
|
+
> were two independently written copies — and the fifth fail-open variant was
|
|
334
|
+
> found in the one nothing was mutating.
|
|
279
335
|
|
|
280
336
|
```js
|
|
281
337
|
export default class GlobalAccess {
|
|
282
338
|
models = ['owner', 'animal'];
|
|
283
339
|
|
|
284
340
|
access(request) {
|
|
285
|
-
|
|
341
|
+
// `request.baseUrl` is the mount Express matched — `/owners`, or
|
|
342
|
+
// `/api/owners` under ORM_REST_ROUTE=/api. Never parse `originalUrl`: it is
|
|
343
|
+
// the raw request target and can be absolute-form.
|
|
344
|
+
const mount = request.baseUrl;
|
|
345
|
+
|
|
346
|
+
// FAIL CLOSED. If Express did not tell us what it matched we are not behind
|
|
347
|
+
// the mount we think we are, and an unidentifiable request denies rather
|
|
348
|
+
// than falling through to the CRUD grant at the bottom.
|
|
349
|
+
if (typeof mount !== 'string' || mount === '') return false;
|
|
350
|
+
|
|
351
|
+
// Lower-cased because the router matched case-INSENSITIVELY, and a matcher
|
|
352
|
+
// stricter than the router that dispatched the request can be stepped
|
|
353
|
+
// around. The PATH only — record ids stay at their real case below.
|
|
354
|
+
const collection = mount.toLowerCase();
|
|
355
|
+
|
|
356
|
+
// `request.path` is mount-relative and query-free, so sub-path rules need no
|
|
357
|
+
// prefix arithmetic either. false → 403 for the whole request.
|
|
358
|
+
const path = String(request.path ?? '').toLowerCase();
|
|
359
|
+
|
|
360
|
+
if (collection.endsWith('/owners')) {
|
|
361
|
+
if (path === '/archived' || path.startsWith('/archived/')) return false;
|
|
362
|
+
|
|
363
|
+
// Returning a function plugs it in as a per-record filter, and it is
|
|
364
|
+
// enforced on every surface addressed to one of these records:
|
|
365
|
+
// /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
|
|
366
|
+
// A rejected record is 404 on record routes — the same status as a record
|
|
367
|
+
// that does not exist — so the filter is not an existence oracle.
|
|
368
|
+
return record => record.id !== 'angela' && record.id !== 'restricted';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// `record.owner` resolves to an OrmRecord, not to the owner's id string —
|
|
372
|
+
// comparing it directly against a string is the bug that made this predicate
|
|
373
|
+
// inert. Deliberately NO `?? record.owner` fallback: accepting the raw
|
|
374
|
+
// shape as well as the resolved one would absorb a resolution regression
|
|
375
|
+
// silently, which is exactly what blinded this fixture before.
|
|
376
|
+
if (collection.endsWith('/animals')) return record => record.owner?.id !== 'restricted';
|
|
377
|
+
|
|
378
|
+
// Allows full access to all calls that don't match any of the above conditions
|
|
286
379
|
return ['read', 'create', 'update', 'delete'];
|
|
287
380
|
}
|
|
288
381
|
}
|
|
289
382
|
```
|
|
290
383
|
|
|
384
|
+
### Return values
|
|
385
|
+
|
|
386
|
+
| `access()` returns | Effect |
|
|
387
|
+
|---|---|
|
|
388
|
+
| `false` (or any falsy value) | `403` for the whole request |
|
|
389
|
+
| `true` | full access, no filter |
|
|
390
|
+
| `['read', 'create', 'update', 'delete']` | the listed operations only; anything else is `403` |
|
|
391
|
+
| `'read'` (a bare string) | **one** permission, equivalent to `['read']` |
|
|
392
|
+
| a function | a per-record filter — see below |
|
|
393
|
+
| anything else | `403` — unknown shapes fail **closed** |
|
|
394
|
+
|
|
395
|
+
A `throw` inside `access()` is a **denial**, not a 500.
|
|
396
|
+
|
|
397
|
+
### Filter functions
|
|
398
|
+
|
|
399
|
+
A function return value is a **per-record predicate**, and it is enforced on
|
|
400
|
+
every endpoint that is addressed to a record — not only on the collection.
|
|
401
|
+
|
|
402
|
+
It is evaluated against the record the route is *addressed to*, **on that model
|
|
403
|
+
only**. It is not a guarantee that a hidden record cannot be reached or modified:
|
|
404
|
+
a write to a *different* collection can still re-parent one. See
|
|
405
|
+
[Known limitations](#known-limitations) and
|
|
406
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207).
|
|
407
|
+
|
|
408
|
+
| Endpoint | A record the predicate rejects |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `GET /:models` | omitted from the collection |
|
|
411
|
+
| `GET /:models/:id` | `404` |
|
|
412
|
+
| `GET /:models/:id/{relationship}` | `404` — the **addressed** record is filtered, not the related one |
|
|
413
|
+
| `GET /:models/:id/relationships/{relationship}` | `404` — same |
|
|
414
|
+
| `PATCH /:models/:id` | `404`, no attribute is applied |
|
|
415
|
+
| `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
|
|
416
|
+
| `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for the case where it did not insert one |
|
|
417
|
+
|
|
418
|
+
**Denied record-level requests return 404, not 403.** This is deliberate and it
|
|
419
|
+
is the property most easily "improved" away. 403 would confirm that the record
|
|
420
|
+
exists to a caller who is not allowed to know that, which turns the filter into
|
|
421
|
+
an existence oracle: `404` means "no such record", `403` means "there is one and
|
|
422
|
+
it is not yours". Every status on a record route must therefore be identical for
|
|
423
|
+
"filtered out" and "does not exist" — including `DELETE`, which is why deleting
|
|
424
|
+
a record that never existed also returns 404 rather than 204.
|
|
425
|
+
|
|
426
|
+
`POST` is the one exception and returns **403**, because 404 on a mounted
|
|
427
|
+
collection route is indistinguishable from "model not mounted" — a genuinely
|
|
428
|
+
different failure a developer needs to diagnose.
|
|
429
|
+
|
|
430
|
+
**A client-supplied `id` on `POST` is refused with `403` whenever a function
|
|
431
|
+
filter is in force.** This is the part that keeps `POST` from being an
|
|
432
|
+
enumeration oracle, and it is worth understanding rather than working around.
|
|
433
|
+
The duplicate-id check has to run before the filter, and it sees records the
|
|
434
|
+
filter hides, so the *status* of a `POST` otherwise leaks whether an id is
|
|
435
|
+
taken:
|
|
436
|
+
|
|
437
|
+
| `POST /animals` with a payload the caller may create | before | now |
|
|
438
|
+
|---|---|---|
|
|
439
|
+
| an id held by a record the filter **hides** | `403` | `403` |
|
|
440
|
+
| an id that is **free** | `200` | `403` |
|
|
441
|
+
| an id held by a record the caller **can see** | `409` | `403` |
|
|
442
|
+
|
|
443
|
+
Three outcomes, one request per id, the whole id space. Filtering only the
|
|
444
|
+
*collision* status narrows that to callers who cannot create a record they are
|
|
445
|
+
allowed to see; it does not close it. It cannot be closed while a caller both
|
|
446
|
+
chooses the id and learns whether the create succeeded — so under a filter the
|
|
447
|
+
caller does not choose the id. The refusal happens before any store lookup, so
|
|
448
|
+
neither the status nor the response time depends on whether the id exists.
|
|
449
|
+
|
|
450
|
+
Let the server assign the id and read it back from the response. Callers with no
|
|
451
|
+
function-style filter are unaffected: `409` on a duplicate id and `200` on a free
|
|
452
|
+
one both behave exactly as before.
|
|
453
|
+
|
|
454
|
+
### Identifying the collection
|
|
455
|
+
|
|
456
|
+
**Do not reconstruct the request path.** Every version of this sample that tried
|
|
457
|
+
to has failed **open**, and each variant was found only after the previous one
|
|
458
|
+
was fixed:
|
|
459
|
+
|
|
460
|
+
| # | Variant | Why it fails open |
|
|
461
|
+
|---|---|---|
|
|
462
|
+
| 1 | match `request.url` | `RestServer.mountRoute` mounts each model as an Express **sub-app**, so `url` is mount-relative — `GET /owners/angela` arrives as `/angela`. A `/owners` prefix match is **always false**, so the branch never fires and `access()` falls through to whatever it returns last. |
|
|
463
|
+
| 2 | anchored match on a raw `request.originalUrl` | `originalUrl` carries the query string, so `=== '/owners'` misses `/owners?filter[age]=30` and that collection comes back unfiltered. `endsWith('/owners')` is the older half of the same trap: it leaves every record route unguarded. |
|
|
464
|
+
| 3 | case-sensitive matcher | `RestServer` mounts with a bare `express()`, whose default is `caseSensitive: false`. A matcher stricter than the router that dispatched the request can simply be stepped around: `GET /owners/angela` → 404 but `GET /OwNeRs/angela` → 200 in full, and `DELETE /ANIMALS/22` destroyed a hidden record. Router-side fix: [stonyx-rest-server#47](https://github.com/abofs/stonyx-rest-server/issues/47). |
|
|
465
|
+
| 4 | hard-coded `/owners` | With `ORM_REST_ROUTE=/api` every url becomes `/api/owners/...` and the sample matches nothing — environment-specifically, which is harder to notice than failing everywhere. The remediation this document used to give was itself broken: `` `${config.orm.restServer.route}owners` `` evaluates to **`/apiowners`**, so a reader who followed the correction exactly still failed open and believed they had handled it. |
|
|
466
|
+
| 5 | any match on `originalUrl` at all | HTTP/1.1 permits an **absolute-form** request-target. Express routes on `parseurl(req).pathname`, so the request dispatches normally — but `originalUrl` is the raw target. `GET http://anything.example/owners/angela` yields `originalUrl === 'http://anything.example/owners/angela'`, which has no `/owners` prefix. Measured: the record came back in full, `DELETE` succeeded, and it walked past a hard `return false` deny the same way. |
|
|
467
|
+
|
|
468
|
+
**The fix is not a sixth rule.** It is to stop parsing:
|
|
469
|
+
|
|
470
|
+
**Use `request.baseUrl`.** It is the mount Express *actually matched* when it
|
|
471
|
+
dispatched the request. It carries no query string (variant 2), it is not
|
|
472
|
+
mount-relative (variant 1), it already contains the configured `ORM_REST_ROUTE`
|
|
473
|
+
prefix (variant 4 — there is nothing left to derive, so `/apiowners` is
|
|
474
|
+
unconstructible), and it is unaffected by an absolute-form target (variant 5).
|
|
475
|
+
|
|
476
|
+
| request | `request.url` | `request.originalUrl` | `request.baseUrl` | `request.path` |
|
|
477
|
+
|---|---|---|---|---|
|
|
478
|
+
| `GET /owners` | `/` | `/owners` | `/owners` | `/` |
|
|
479
|
+
| `GET /owners/angela` | `/angela` | `/owners/angela` | `/owners` | `/angela` |
|
|
480
|
+
| `GET /owners/angela?filter[age]=30` | `/angela?filter[age]=30` | `/owners/angela?filter[age]=30` | `/owners` | `/angela` |
|
|
481
|
+
| `GET /OwNeRs/angela` | `/angela` | `/OwNeRs/angela` | `/OwNeRs` | `/angela` |
|
|
482
|
+
| `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
|
|
483
|
+
| `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
|
|
484
|
+
|
|
485
|
+
Two rules remain, and they are the whole list:
|
|
486
|
+
|
|
487
|
+
**1. Compare lower-cased.** `baseUrl` is the text the caller sent, not the
|
|
488
|
+
registered mount — `GET /OwNeRs/angela` yields `/OwNeRs`. The router matched it
|
|
489
|
+
case-insensitively, so a case-sensitive comparison here is stricter than the
|
|
490
|
+
router and can be walked past. Lower-case the **mount and path only**; record ids
|
|
491
|
+
are case-sensitive and must be compared at their real case.
|
|
492
|
+
|
|
493
|
+
**2. Fail closed when `baseUrl` is absent.** `String(request.originalUrl ?? '')`
|
|
494
|
+
was added to stop a `TypeError`, and it traded fail-closed for fail-**open**: an
|
|
495
|
+
empty string matches no collection, so `access()` fell through to the permission
|
|
496
|
+
array and granted full CRUD. An input you cannot identify must **deny**.
|
|
497
|
+
|
|
498
|
+
Use `request.path` — mount-relative and query-free — if you need to distinguish
|
|
499
|
+
sub-paths beneath the mount, as the `/archived` deny above does.
|
|
500
|
+
|
|
501
|
+
### Known limitations
|
|
502
|
+
|
|
503
|
+
- **A function-style filter is not a guarantee that a hidden record cannot be
|
|
504
|
+
modified.** A write to a *different* collection can re-parent one and de-hide
|
|
505
|
+
it: `POST /owners` (or `PATCH /owners/{id}`) carrying
|
|
506
|
+
`relationships: { pets: { data: { id: 21 } } }` — or
|
|
507
|
+
`attributes: { pets: [21, 22] } `, which never enters the relationships loop at
|
|
508
|
+
all — re-parents animal 21 onto an owner the caller is permitted to write. The
|
|
509
|
+
animal's `owner` is the field the `/animals` predicate reads, so the record
|
|
510
|
+
stops being rejected: it becomes readable through `GET /animals/21` and
|
|
511
|
+
deletable through `DELETE /animals/21`. **Reachable unauthenticated** wherever
|
|
512
|
+
one collection is writable and another is filtered on a field the first can
|
|
513
|
+
set. Blocking it requires checking animal 21 against the **animal** model's
|
|
514
|
+
predicate while servicing an **owners** route — cross-model access resolution,
|
|
515
|
+
which the current contract cannot express: `access()` never receives the model
|
|
516
|
+
structurally ([#202](https://github.com/abofs/stonyx-orm/issues/202)) and
|
|
517
|
+
`setup-rest-server.ts` discards the model→predicate map at boot
|
|
518
|
+
([#196](https://github.com/abofs/stonyx-orm/issues/196)). Tracked as
|
|
519
|
+
[#207](https://github.com/abofs/stonyx-orm/issues/207), blocked on that chain
|
|
520
|
+
(#202 → #196 → #207). Until it lands, do not rely on a filter to keep a record
|
|
521
|
+
unmodifiable; keep the *writable* collections' predicates as tight as the
|
|
522
|
+
hidden ones.
|
|
523
|
+
- **Authorization by identifying the collection is a consumer-side
|
|
524
|
+
reconstruction of information the framework already holds.** `access()`
|
|
525
|
+
receives a transport artifact and is asked to work out which model, which
|
|
526
|
+
operation and which record the request addresses. The five variants above are
|
|
527
|
+
the five ways that has been observed to fail open so far. Tracked as
|
|
528
|
+
[#202](https://github.com/abofs/stonyx-orm/issues/202).
|
|
529
|
+
- **Related and included records are not filtered.** The predicate is evaluated
|
|
530
|
+
against the record the route is *addressed to*. `GET /animals/1/owner`,
|
|
531
|
+
`GET /animals/1/relationships/owner` and `?include=owner` all serialize the
|
|
532
|
+
related record without resolving that model's own access class, so a filter on
|
|
533
|
+
`/owners` does not hide an owner reached through `/animals`. Tracked as
|
|
534
|
+
[#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
|
|
535
|
+
`include=`, related-resource routes and relationship-linkage routes.
|
|
536
|
+
- **A before-hook that returns a value short-circuits the request.** On write
|
|
537
|
+
operations addressed to a record the filter is consulted first, so a hook
|
|
538
|
+
cannot answer for a record the caller may not see. On reads it is not, so a
|
|
539
|
+
`beforeHook('get', ...)` read-through cache can answer past the filter.
|
|
540
|
+
- **`beforeHook('create', ...)` still runs for a `POST` that is denied.** For
|
|
541
|
+
`create` there is no record to test until the handler has built one, so the
|
|
542
|
+
denial is not knowable in time. Every *after*-hook is gated, and every
|
|
543
|
+
before-hook on `update` and `delete` is gated; before-`create` is the one
|
|
544
|
+
exception. A before-`create` hook must not assume the create will succeed.
|
|
545
|
+
- **A caller can still learn that a collection *has* a per-record filter**, by
|
|
546
|
+
observing `403` rather than `409`/`200` for an id-bearing `POST`. That
|
|
547
|
+
discloses a configuration fact, not the existence of any record.
|
|
548
|
+
- **Enforcement is post-fetch.** The record is loaded and then tested, which
|
|
549
|
+
leaves a small timing difference between a hidden record and one that never
|
|
550
|
+
existed. Tracked as [#197](https://github.com/abofs/stonyx-orm/issues/197).
|
|
551
|
+
- **A `relationships` key that is not a declared relationship is still applied
|
|
552
|
+
to the record.** The key comes verbatim from the request body and is checked
|
|
553
|
+
against nothing except `id`, which is stripped. On a `POST` that makes an
|
|
554
|
+
undeclared key a mass-assigned attribute; on a `PATCH` it is passed to
|
|
555
|
+
`updateRecord`. `id` is stripped in both handlers because it defeats breaking
|
|
556
|
+
change 3 above; the general form is tracked as
|
|
557
|
+
[#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
558
|
+
- **A `POST` body `id` that the duplicate check cannot resolve can still
|
|
559
|
+
overwrite a different record on an unfiltered collection.** The lookup is
|
|
560
|
+
correct and deliberately does not coerce: `"9105h"` is rejected as a string
|
|
561
|
+
rather than truncated, and `9105.5`, `[9105]` and `true` are passed through as
|
|
562
|
+
themselves. The model's id transform then coerces anyway — a bare `parseInt`
|
|
563
|
+
with no such guard — so the create lands on `9105` (or on `NaN`) and
|
|
564
|
+
overwrites whatever is there. **This is not string-only**: any body id whose
|
|
565
|
+
transform output differs from its lookup key is the same defect. Filtered
|
|
566
|
+
collections are unaffected — breaking change 3 refuses any client-supplied id
|
|
567
|
+
— so this reaches consumers with **no** function-style filter. Tracked as
|
|
568
|
+
[#205](https://github.com/abofs/stonyx-orm/issues/205), alongside
|
|
569
|
+
[#203](https://github.com/abofs/stonyx-orm/issues/203), which is the other way
|
|
570
|
+
a create can land on an id nobody named.
|
|
571
|
+
- **`context.record` is `undefined` for an after-`create` hook when a string-id
|
|
572
|
+
model is given a numeric-looking id.** The post-create lookup uses the same id
|
|
573
|
+
coercion as every other surface, which resolves `'9107'` to the number `9107`,
|
|
574
|
+
while a model declaring `id = attr('string')` files the record under the string
|
|
575
|
+
key. The create itself succeeds and `context.response.data` is correct; only
|
|
576
|
+
the hook's view of the record is wrong, and it is wrong *silently*. Tracked as
|
|
577
|
+
[#209](https://github.com/abofs/stonyx-orm/issues/209).
|
|
578
|
+
- **A denied `POST` rolls back only a record it *inserted*.** The rollback
|
|
579
|
+
requires the store to have grown, because removing by id alone is a write
|
|
580
|
+
primitive keyed by a caller-supplied value. When `assignRecordId` lands a
|
|
581
|
+
**server-assigned** id on an occupied slot
|
|
582
|
+
([#203](https://github.com/abofs/stonyx-orm/issues/203) — it returns
|
|
583
|
+
last-*inserted* + 1, not max + 1, so a store whose insertion order is not
|
|
584
|
+
ascending collides), `createRecord` updates that record **in place**: the map
|
|
585
|
+
does not grow, the rollback correctly declines to remove a record this request
|
|
586
|
+
did not create, and the `403` leaves the caller's attributes on someone else's
|
|
587
|
+
record. Narrow — it needs a non-ascending insertion order — but it is the
|
|
588
|
+
reachability condition, so it is stated rather than implied.
|
|
589
|
+
|
|
590
|
+
### Breaking changes
|
|
591
|
+
|
|
592
|
+
These land in the next published build. There is no changelog or release-notes channel yet
|
|
593
|
+
([stonyx-workflows#17](https://github.com/abofs/stonyx-workflows/issues/17)), so
|
|
594
|
+
they are recorded here.
|
|
595
|
+
|
|
596
|
+
1. **`DELETE /{collection}/{id}` on a record that does not exist returns `404`
|
|
597
|
+
instead of `204`.** This affects **every** consumer issuing a DELETE against
|
|
598
|
+
any mounted collection, whether or not an access filter is configured, and
|
|
599
|
+
`models: '*'` mounts every model by default. It is not optional: if a denied
|
|
600
|
+
delete returned 404 while a missing one returned 204, the pair would be a
|
|
601
|
+
perfect existence oracle and the filter would be worthless.
|
|
602
|
+
2. **After-hooks no longer fire for a request that failed** — denied, missing,
|
|
603
|
+
`400` or `409`. The gate is on the handler's status, not on the operation, so
|
|
604
|
+
it covers reads as well: a `GET /:id` that answers 404 runs no after-`get`
|
|
605
|
+
hook either. Previously `afterHook('delete', ...)` ran with a populated
|
|
606
|
+
`context.recordId` on a request that deleted nothing, so a consumer cascade
|
|
607
|
+
destroyed children behind a 404.
|
|
608
|
+
3. **`POST` with a client-supplied `id` returns `403` when a function-style
|
|
609
|
+
`access` filter is in force**, whatever the payload and whether or not the id
|
|
610
|
+
exists, and *before* any store lookup — so neither the status nor the lookup
|
|
611
|
+
cost can depend on whether that id exists. Only affects function-style
|
|
612
|
+
`access` users. See [Filter functions](#filter-functions) for why, and let the
|
|
613
|
+
server assign the id instead. `409`-on-duplicate is unchanged for everyone
|
|
614
|
+
else.
|
|
615
|
+
|
|
616
|
+
"Whatever the payload" is a statement about the **`id` member of the resource
|
|
617
|
+
object**, and it holds only because that is the sole channel a caller id can
|
|
618
|
+
arrive on. It was not always: a caller id moved into
|
|
619
|
+
`relationships: {"id": {"data": {"id": 21}}}` reached `createRecord` past this
|
|
620
|
+
refusal and overwrote a hidden record in place. Both strips — `attributes.id`
|
|
621
|
+
and `relationships.id` — are part of this behaviour, not tidiness. A future
|
|
622
|
+
change that adds a third channel without stripping it re-opens the oracle;
|
|
623
|
+
see [#204](https://github.com/abofs/stonyx-orm/issues/204).
|
|
624
|
+
4. **Function-style `access` is now enforced on all seven surfaces.** Records
|
|
625
|
+
previously reachable by id despite being filtered from the collection now
|
|
626
|
+
return 404. Only affects function-style `access` users, for whom the old
|
|
627
|
+
behaviour was the bypass.
|
|
628
|
+
|
|
629
|
+
"Seven surfaces" means the seven endpoints of **the filtered model**. A write
|
|
630
|
+
to another collection can still reach one of its records through a
|
|
631
|
+
relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
|
|
632
|
+
is **not** closed here.
|
|
633
|
+
5. **A predicate that throws is treated as a denial** rather than propagating to
|
|
634
|
+
Express's default 500 handler. So is an `access()` that throws.
|
|
635
|
+
6. **`access()` returning a bare string is one permission, not full access.**
|
|
636
|
+
`AccessMethod` declares `string` legal, and it previously fell through every
|
|
637
|
+
branch and granted all four operations — `return 'read'` allowed `DELETE`.
|
|
638
|
+
It is now equivalent to `['read']`. Any other unrecognised shape (an object,
|
|
639
|
+
a number) now returns `403` rather than granting full access.
|
|
640
|
+
7. **`POST` with a client-supplied `id` normalises the body id before the
|
|
641
|
+
duplicate check**, so an id shape that previously *missed* the store's key
|
|
642
|
+
now finds it. `POST {"id":"0x2391"}` and `POST {"id":" 21 "}` answer `409`
|
|
643
|
+
where they answered `200`, and the `200` was not a success: the lookup missed,
|
|
644
|
+
the duplicate check was skipped, and `createRecord` overwrote the colliding
|
|
645
|
+
record in place. **This one reaches consumers with no filter at all** — the
|
|
646
|
+
population breaking changes 3 and 4 explicitly exempt. If you were relying on
|
|
647
|
+
a hex-shaped or whitespace-padded id creating a second record, it never did.
|
|
648
|
+
|
|
291
649
|
### Include Parameter (Sideloading Relationships)
|
|
292
650
|
|
|
293
651
|
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.
|
|
@@ -561,7 +919,19 @@ afterHook('delete', 'animal', async (context) => {
|
|
|
561
919
|
// Additional access control - halt with 403 if unauthorized
|
|
562
920
|
beforeHook('delete', 'animal', (context) => {
|
|
563
921
|
const user = context.state.currentUser;
|
|
564
|
-
|
|
922
|
+
|
|
923
|
+
// `context.oldState` — NOT a store lookup. For `update` and `delete` the ORM
|
|
924
|
+
// has already fetched the record (and already applied the access filter to
|
|
925
|
+
// it) before this hook runs, so re-fetching it here is a fourth id coercion
|
|
926
|
+
// that has to agree with three others.
|
|
927
|
+
//
|
|
928
|
+
// And it would not agree. `context.params.id` is the raw url segment, always
|
|
929
|
+
// a string, while the store keys numeric-id models by NUMBER — so
|
|
930
|
+
// `store.get('animal', '21')` misses the record held under `21`, and so does
|
|
931
|
+
// `store.find('animal', '21')`: neither coerces. The miss reads as "no such
|
|
932
|
+
// record", which in an authorization hook fails whichever way your code
|
|
933
|
+
// happens to handle a null.
|
|
934
|
+
const animal = context.oldState;
|
|
565
935
|
|
|
566
936
|
if (animal.owner !== user.id && !user.isAdmin) {
|
|
567
937
|
return 403; // Forbidden
|
|
@@ -569,6 +939,10 @@ beforeHook('delete', 'animal', (context) => {
|
|
|
569
939
|
});
|
|
570
940
|
```
|
|
571
941
|
|
|
942
|
+
> If you do need a lookup for some *other* model inside a hook, coerce the id
|
|
943
|
+
> yourself to the type that model's `id` attribute declares — the store is a
|
|
944
|
+
> `Map` and `'21'` and `21` are different keys.
|
|
945
|
+
|
|
572
946
|
#### Auditing
|
|
573
947
|
|
|
574
948
|
```javascript
|
|
@@ -702,11 +1076,29 @@ beforeHook('create', 'post', (context) => {
|
|
|
702
1076
|
|
|
703
1077
|
### Hook Execution Order
|
|
704
1078
|
|
|
705
|
-
1. **
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1079
|
+
1. **Authorization is evaluated first for `update` and `delete`.** A record the
|
|
1080
|
+
access filter rejects returns `404` **before any before-hook runs**, so a
|
|
1081
|
+
hook never sees a record — or a `context.oldState` — that the caller is not
|
|
1082
|
+
allowed to read. `create` is the exception: there is no record to test until
|
|
1083
|
+
the handler has built one, so `beforeHook('create', ...)` **does** fire for a
|
|
1084
|
+
`POST` that goes on to answer `403`.
|
|
1085
|
+
2. **Before hooks** fire next (sequentially, in registration order).
|
|
1086
|
+
3. **Main operation** executes (if no before hook halted).
|
|
1087
|
+
4. **After hooks** fire last (sequentially, in registration order) — **only if
|
|
1088
|
+
the request succeeded.**
|
|
1089
|
+
|
|
1090
|
+
Before hooks can halt the operation by returning a value, and that value becomes
|
|
1091
|
+
the response.
|
|
1092
|
+
|
|
1093
|
+
**After hooks do not run for a failed request.** Any status `>= 400` — denied,
|
|
1094
|
+
missing, `400`, `409` — skips the entire after-hook pipeline, along with SQL
|
|
1095
|
+
persistence and `onUpdate` autosave. This is a behaviour change; see
|
|
1096
|
+
[Breaking changes](#breaking-changes). It applies to the samples above: the
|
|
1097
|
+
`afterHook('delete', ...)` auditing hook writes **no** audit row for a `DELETE`
|
|
1098
|
+
that answered `404`, and the `afterHook('update', ...)` change-tracking hook
|
|
1099
|
+
writes none for a `PATCH` that answered `404` or `400`. If you need a record of
|
|
1100
|
+
refused requests, log them from a before-hook or from your own middleware —
|
|
1101
|
+
`after<operation>` fires only for an operation that actually happened.
|
|
710
1102
|
|
|
711
1103
|
### Best Practices
|
|
712
1104
|
|
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.');
|