@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.
Files changed (48) hide show
  1. package/README.md +296 -4
  2. package/config/environment.js +99 -12
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/main.js +10 -0
  13. package/dist/manage-record.js +34 -3
  14. package/dist/mysql/connection.d.ts +1 -0
  15. package/dist/mysql/mysql-db.d.ts +8 -0
  16. package/dist/mysql/mysql-db.js +44 -10
  17. package/dist/orm-request.d.ts +37 -0
  18. package/dist/orm-request.js +493 -43
  19. package/dist/postgres/connection.d.ts +1 -0
  20. package/dist/postgres/connection.js +8 -6
  21. package/dist/postgres/postgres-db.d.ts +8 -0
  22. package/dist/postgres/postgres-db.js +44 -10
  23. package/dist/record.js +7 -5
  24. package/dist/relationships.js +1 -1
  25. package/dist/serializer.js +38 -2
  26. package/dist/store.d.ts +13 -1
  27. package/dist/store.js +65 -6
  28. package/dist/types/orm-types.d.ts +11 -0
  29. package/package.json +16 -7
  30. package/src/commands.ts +43 -0
  31. package/src/dynamodb/connection.ts +50 -0
  32. package/src/dynamodb/dynamodb-db.ts +811 -0
  33. package/src/dynamodb/operation-builder.ts +202 -0
  34. package/src/dynamodb/type-map.ts +54 -0
  35. package/src/main.ts +10 -0
  36. package/src/manage-record.ts +41 -9
  37. package/src/mysql/connection.ts +1 -0
  38. package/src/mysql/mysql-db.ts +44 -12
  39. package/src/orm-request.ts +500 -41
  40. package/src/postgres/connection.ts +10 -6
  41. package/src/postgres/postgres-db.ts +44 -12
  42. package/src/record.ts +8 -5
  43. package/src/relationships.ts +1 -1
  44. package/src/serializer.ts +39 -2
  45. package/src/store.ts +68 -6
  46. package/src/types/orm-types.ts +12 -0
  47. package/src/types/stonyx.d.ts +7 -1
  48. 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
- if (request.url.endsWith('/owner/angela')) return false;
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.
@@ -1,12 +1,99 @@
1
- // project configuration, override-able by listed environment variables
2
- const {
3
- DEBUG,
4
- NODE_ENV,
5
- } = process.env;
6
-
7
- const environment = NODE_ENV ?? 'development';
8
-
9
- export default {
10
- environment,
11
- debug: DEBUG ?? environment === 'development',
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
+ }