@stonyx/orm 0.3.2-alpha.7 → 0.3.2-alpha.70

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 (60) hide show
  1. package/README.md +870 -11
  2. package/config/environment.js +8 -0
  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/hooks.d.ts +15 -1
  13. package/dist/index.d.ts +1 -0
  14. package/dist/main.d.ts +116 -0
  15. package/dist/main.js +129 -0
  16. package/dist/manage-record.js +268 -12
  17. package/dist/mysql/connection.d.ts +1 -0
  18. package/dist/mysql/mysql-db.d.ts +8 -0
  19. package/dist/mysql/mysql-db.js +44 -10
  20. package/dist/orm-request.d.ts +264 -3
  21. package/dist/orm-request.js +975 -49
  22. package/dist/postgres/connection.d.ts +1 -0
  23. package/dist/postgres/connection.js +8 -6
  24. package/dist/postgres/postgres-db.d.ts +8 -0
  25. package/dist/postgres/postgres-db.js +44 -10
  26. package/dist/record.js +7 -5
  27. package/dist/relationships.js +1 -1
  28. package/dist/serializer.js +38 -2
  29. package/dist/setup-rest-server.js +51 -5
  30. package/dist/standalone-db.js +17 -5
  31. package/dist/store.d.ts +13 -1
  32. package/dist/store.js +65 -6
  33. package/dist/types/orm-types.d.ts +207 -0
  34. package/dist/utils.d.ts +44 -0
  35. package/dist/utils.js +47 -0
  36. package/package.json +16 -7
  37. package/src/commands.ts +43 -0
  38. package/src/dynamodb/connection.ts +50 -0
  39. package/src/dynamodb/dynamodb-db.ts +811 -0
  40. package/src/dynamodb/operation-builder.ts +202 -0
  41. package/src/dynamodb/type-map.ts +54 -0
  42. package/src/hooks.ts +15 -1
  43. package/src/index.ts +1 -0
  44. package/src/main.ts +133 -0
  45. package/src/manage-record.ts +294 -18
  46. package/src/mysql/connection.ts +1 -0
  47. package/src/mysql/mysql-db.ts +44 -12
  48. package/src/orm-request.ts +992 -52
  49. package/src/postgres/connection.ts +10 -6
  50. package/src/postgres/postgres-db.ts +44 -12
  51. package/src/record.ts +8 -5
  52. package/src/relationships.ts +1 -1
  53. package/src/serializer.ts +39 -2
  54. package/src/setup-rest-server.ts +59 -6
  55. package/src/standalone-db.ts +17 -6
  56. package/src/store.ts +68 -6
  57. package/src/types/orm-types.ts +214 -0
  58. package/src/types/stonyx-rest-server.d.ts +14 -1
  59. package/src/types/stonyx.d.ts +7 -1
  60. package/src/utils.ts +50 -0
@@ -16,6 +16,7 @@ export interface OrmMysqlConfig {
16
16
  connectionLimit?: number;
17
17
  migrationsDir?: string;
18
18
  migrationsTable?: string;
19
+ autoMigrate?: boolean;
19
20
  [key: string]: unknown;
20
21
  }
21
22
  export interface OrmPostgresConfig {
@@ -27,6 +28,7 @@ export interface OrmPostgresConfig {
27
28
  connectionLimit?: number;
28
29
  migrationsDir?: string;
29
30
  migrationsTable?: string;
31
+ autoMigrate?: boolean;
30
32
  [key: string]: unknown;
31
33
  }
32
34
  export interface OrmPaths {
@@ -42,6 +44,12 @@ export interface OrmRestServerConfig {
42
44
  route: string;
43
45
  metaRoute: boolean;
44
46
  }
47
+ export interface OrmDynamoDBConfig {
48
+ region?: string;
49
+ endpoint?: string;
50
+ tablePrefix?: string;
51
+ [key: string]: unknown;
52
+ }
45
53
  export interface OrmSection {
46
54
  db: OrmDbConfig;
47
55
  paths: OrmPaths;
@@ -49,6 +57,9 @@ export interface OrmSection {
49
57
  mysql?: OrmMysqlConfig;
50
58
  postgres?: OrmPostgresConfig;
51
59
  timescale?: OrmPostgresConfig;
60
+ dynamodb?: OrmDynamoDBConfig;
61
+ logColor?: string;
62
+ logMethod?: string;
52
63
  [key: string]: unknown;
53
64
  }
54
65
  export interface OrmConfig {
@@ -151,3 +162,199 @@ export interface SnapshotEntry {
151
162
  source?: string;
152
163
  viewQuery?: string;
153
164
  }
165
+ /**
166
+ * The shapes a consumer `access()` predicate may return.
167
+ *
168
+ * - `false` (or any falsy value) -- deny, 403.
169
+ * - `true` -- allow, with no per-record filter.
170
+ * - a permission string or array of them, drawn from the same four verbs as
171
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
172
+ * grant of all four.
173
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
174
+ * request touches through it.
175
+ *
176
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
177
+ */
178
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
179
+ /**
180
+ * The closed vocabulary `AccessContext.operation` is drawn from
181
+ * (abofs/stonyx-orm#202).
182
+ *
183
+ * A literal union rather than `string`, so the guarantee the prose makes is the
184
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
185
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
186
+ * error instead of a comparison that never matches. A predicate that stops
187
+ * matching falls through to the permission array, so the misreading is
188
+ * fail-open shaped.
189
+ *
190
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
191
+ */
192
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
193
+ /**
194
+ * The structural facts about the request being authorised, handed to a consumer
195
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
196
+ *
197
+ * These are the facts the framework already holds at authorisation time. Before
198
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
199
+ * five independent fail-open variants of that reconstruction were found in one
200
+ * three-line documented example -- each one wrong in the direction that GRANTS
201
+ * access. Read these instead; there is nothing to parse and no variant to miss.
202
+ *
203
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
204
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
205
+ * so nothing has been fetched yet -- carrying a record here would force a
206
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
207
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
208
+ * by the handlers. Auth-time and record-time are separate decision points.
209
+ */
210
+ export interface AccessContext {
211
+ /**
212
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
213
+ *
214
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
215
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
216
+ * read from the `OrmRequest` instance and is never derived from the request
217
+ * target, so a mount prefix, a case-varied path, a query string or an
218
+ * absolute-form request-target cannot change it.
219
+ */
220
+ model: string;
221
+ /**
222
+ * The operation being authorised. Exactly one of the four {@link
223
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
224
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
225
+ * permission-array return shape is matched against -- so the two forms cannot
226
+ * disagree.
227
+ *
228
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
229
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
230
+ * key of an identically-shaped context object, and the access vocabulary
231
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
232
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
233
+ * statement about the ACCESS path only.
234
+ *
235
+ * `undefined` when the dispatched method has no entry in that map. Express
236
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
237
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
238
+ * turn an unclassified request into an authorised one.
239
+ *
240
+ * The KEY is required even though the value may be undefined: `auth()` always
241
+ * sets it, and a context that simply omitted it would be indistinguishable
242
+ * from one that classified the request and found nothing.
243
+ */
244
+ operation: AccessOperation | undefined;
245
+ /**
246
+ * The record this route was addressed to, as the store key -- or `null` on a
247
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
248
+ *
249
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
250
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
251
+ * `request.path` against a literal compares an undecoded string against a
252
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
253
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
254
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
255
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
256
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
257
+ *
258
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
259
+ *
260
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
261
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
262
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
263
+ * decoded until stable would deny a record it was never asked about.
264
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
265
+ * and express's `case sensitive routing` governs literal segments only.
266
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
267
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
268
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
269
+ * allow, on that same record).
270
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
271
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
272
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
273
+ * `archived/x` -- was measured over-denied 403.
274
+ *
275
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
276
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
277
+ * lookup the permission-array branch uses. The predicate and the dispatch
278
+ * therefore cannot disagree about which record a request addresses. Handing
279
+ * over the raw `request.params.id` instead would reintroduce that divergence
280
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
281
+ *
282
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
283
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
284
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
285
+ * exists to buy; it is not a defect to repair here.
286
+ *
287
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
288
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
289
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
290
+ * hand-assembled by a caller resolving the predicate through
291
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
292
+ * signal only because the framework never produces it.
293
+ *
294
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
295
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
296
+ * key on an identically-shaped context object, which is the exact
297
+ * configuration that makes `operation` fail-open shaped -- a hook sees
298
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
299
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
300
+ * and it is the absence spelling"). That was measured false, in the fail-open
301
+ * direction, and it is corrected here rather than deleted.
302
+ *
303
+ * MEASURED over the live dispatch, before-hooks registered for all five
304
+ * operations on one model:
305
+ *
306
+ * before:list key ABSENT ('recordId' in context === false)
307
+ * before:get key ABSENT params={"id":"visible1"}
308
+ * before:create key ABSENT
309
+ * before:update key ABSENT params={"id":"visible2"}
310
+ * before:delete recordId="visible3"
311
+ * after:delete recordId="visible3"
312
+ *
313
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
314
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
315
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
316
+ * a hook context the key is absent for get, list, create and update, while
317
+ * this key is present on every route `auth()` classifies. The absence
318
+ * spelling is the smaller half of the difference, not the whole of it.
319
+ *
320
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
321
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
322
+ * On a hook context it means "this is a get / list / create / update" -- an
323
+ * ordinary request. A consumer who writes the hook-side half of the same
324
+ * rule --
325
+ *
326
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
327
+ *
328
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
329
+ * with `ctx.recordId === undefined` while the addressed record sits in
330
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
331
+ * repaired here. A predicate must not read `undefined` here as "collection",
332
+ * and nothing in this contract makes it safe to read the two keys as one key.
333
+ *
334
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
335
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
336
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
337
+ * and is untouched by this key.
338
+ */
339
+ recordId: string | number | null;
340
+ }
341
+ /**
342
+ * A consumer `access()` predicate.
343
+ *
344
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
345
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
346
+ * FIRST argument instead would have been the breaking form, and a predicate
347
+ * that can no longer identify its collection falls through to a full CRUD
348
+ * grant -- so the "safer" breaking change would have converted every unmigrated
349
+ * predicate into a fail-open.
350
+ *
351
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
352
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
353
+ * more-parameter signature, so an arity-1 predicate assigns to this type
354
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
355
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
356
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
357
+ * that call gets the model-wrong answer. Required, a caller that drops the
358
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
359
+ */
360
+ export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
package/dist/utils.d.ts CHANGED
@@ -5,3 +5,47 @@ export declare function isDbError(error: unknown): error is {
5
5
  };
6
6
  export declare function isOrmRecord(value: unknown): value is OrmRecord;
7
7
  export declare function pluralize(word: string): string;
8
+ /**
9
+ * The highest NUMERIC id held by a set of records, or `0` when there is none.
10
+ *
11
+ * ONE COPY, and the duplication it replaces is the reason it lives here. Three
12
+ * near-identical reduces existed at once: `assignRecordId` (server-assigned id
13
+ * selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
14
+ * helper. `docs/improvements.md`'s standing WET Code category prescribes
15
+ * exactly this remedy -- extract into the module that already acts as the
16
+ * shared utility -- and `assignRecordId` already imported `isOrmRecord` from
17
+ * here.
18
+ *
19
+ * NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
20
+ * `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
21
+ * `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
22
+ * `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
23
+ * produce IDENTICAL output on every one. In particular `typeof NaN` is
24
+ * `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
25
+ * which is `false`. An earlier revision of this code asserted that the skip was
26
+ * what made the `NaN` case work; it is not, the comparison is, and that claim
27
+ * has been removed rather than left standing.
28
+ *
29
+ * WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
30
+ * returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
31
+ * `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
32
+ * it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
33
+ * test/unit/assign-record-id-test.ts AC2; before that file existed the whole
34
+ * suite scored 951/0 under exactly that fix.
35
+ */
36
+ export declare function maxNumericId(records: {
37
+ id?: unknown;
38
+ }[]): number;
39
+ /**
40
+ * The message prefix `assignRecordId` throws with when no free id can be
41
+ * derived for a model, and the ONE string `createHandler` matches on to answer
42
+ * `409` instead of letting the rejection reach express's default handler.
43
+ *
44
+ * It lives here rather than in either file because both need it and neither
45
+ * should own a copy: a literal in two places is how the two id coercions in
46
+ * orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
47
+ * and no custom error classes -- 24 bare `throw new Error` sites across `src/`
48
+ * -- so a shared prefix is the narrowest way to make ONE failure distinguishable
49
+ * without inventing an error taxonomy this codebase does not use.
50
+ */
51
+ export declare const NO_FREE_ID_ERROR = "Cannot assign record ID: no free id available";
package/dist/utils.js CHANGED
@@ -15,3 +15,50 @@ export function pluralize(word) {
15
15
  }
16
16
  return basePluralize(word);
17
17
  }
18
+ /**
19
+ * The highest NUMERIC id held by a set of records, or `0` when there is none.
20
+ *
21
+ * ONE COPY, and the duplication it replaces is the reason it lives here. Three
22
+ * near-identical reduces existed at once: `assignRecordId` (server-assigned id
23
+ * selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
24
+ * helper. `docs/improvements.md`'s standing WET Code category prescribes
25
+ * exactly this remedy -- extract into the module that already acts as the
26
+ * shared utility -- and `assignRecordId` already imported `isOrmRecord` from
27
+ * here.
28
+ *
29
+ * NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
30
+ * `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
31
+ * `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
32
+ * `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
33
+ * produce IDENTICAL output on every one. In particular `typeof NaN` is
34
+ * `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
35
+ * which is `false`. An earlier revision of this code asserted that the skip was
36
+ * what made the `NaN` case work; it is not, the comparison is, and that claim
37
+ * has been removed rather than left standing.
38
+ *
39
+ * WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
40
+ * returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
41
+ * `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
42
+ * it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
43
+ * test/unit/assign-record-id-test.ts AC2; before that file existed the whole
44
+ * suite scored 951/0 under exactly that fix.
45
+ */
46
+ export function maxNumericId(records) {
47
+ return records.reduce((max, record) => {
48
+ const { id } = record;
49
+ return typeof id === 'number' && id > max ? id : max;
50
+ }, 0);
51
+ }
52
+ /**
53
+ * The message prefix `assignRecordId` throws with when no free id can be
54
+ * derived for a model, and the ONE string `createHandler` matches on to answer
55
+ * `409` instead of letting the rejection reach express's default handler.
56
+ *
57
+ * It lives here rather than in either file because both need it and neither
58
+ * should own a copy: a literal in two places is how the two id coercions in
59
+ * orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
60
+ * and no custom error classes -- 24 bare `throw new Error` sites across `src/`
61
+ * -- so a shared prefix is the narrowest way to make ONE failure distinguishable
62
+ * without inventing an error taxonomy this codebase does not use.
63
+ */
64
+ export const NO_FREE_ID_ERROR = 'Cannot assign record ID: no free id available';
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.7",
7
+ "version": "0.3.2-alpha.70",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -61,16 +61,25 @@
61
61
  },
62
62
  "homepage": "https://github.com/abofs/stonyx-orm#readme",
63
63
  "dependencies": {
64
- "@stonyx/cron": "0.2.1-beta.59",
65
- "@stonyx/events": "0.1.1-beta.47",
66
- "stonyx": "0.2.3-beta.63"
64
+ "@stonyx/cron": "0.2.1-beta.85",
65
+ "@stonyx/events": "0.1.1-beta.52",
66
+ "@stonyx/utils": "0.2.3-beta.26",
67
+ "stonyx": "0.2.3-beta.77"
67
68
  },
68
69
  "peerDependencies": {
70
+ "@aws-sdk/client-dynamodb": "^3.0.0",
71
+ "@aws-sdk/lib-dynamodb": "^3.0.0",
69
72
  "@stonyx/rest-server": ">=0.2.1-beta.14",
70
73
  "mysql2": "^3.0.0",
71
74
  "pg": "^8.0.0"
72
75
  },
73
76
  "peerDependenciesMeta": {
77
+ "@aws-sdk/client-dynamodb": {
78
+ "optional": true
79
+ },
80
+ "@aws-sdk/lib-dynamodb": {
81
+ "optional": true
82
+ },
74
83
  "mysql2": {
75
84
  "optional": true
76
85
  },
@@ -82,8 +91,7 @@
82
91
  }
83
92
  },
84
93
  "devDependencies": {
85
- "@stonyx/rest-server": "0.2.1-beta.59",
86
- "@stonyx/utils": "0.2.3-beta.23",
94
+ "@stonyx/rest-server": "0.2.1-beta.84",
87
95
  "@types/node": "^25.6.0",
88
96
  "mysql2": "^3.20.0",
89
97
  "pg": "^8.20.0",
@@ -95,6 +103,7 @@
95
103
  "scripts": {
96
104
  "build": "tsc",
97
105
  "build:test": "tsc -p tsconfig.test.json",
98
- "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
106
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'",
107
+ "test:dynamodb": "pnpm build && node --import tsx/esm --import ./test/integration/dynamodb/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/dynamodb/**/*-test.ts'"
99
108
  }
100
109
  }
package/src/commands.ts CHANGED
@@ -28,6 +28,13 @@ const commands: Record<string, Command> = {
28
28
  description: 'Generate a MySQL migration from current model schemas',
29
29
  bootstrap: true,
30
30
  run: async (args) => {
31
+ const config = (await import('stonyx/config')).default;
32
+
33
+ if (config.orm.dynamodb) {
34
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
35
+ return;
36
+ }
37
+
31
38
  const description = args?.join(' ') || 'migration';
32
39
  const { generateMigration } = await import('./mysql/migration-generator.js');
33
40
  const result = await generateMigration(description);
@@ -39,6 +46,25 @@ const commands: Record<string, Command> = {
39
46
  }
40
47
  }
41
48
  },
49
+ 'db:sync': {
50
+ description: 'Provision DynamoDB tables and GSIs from current model schemas',
51
+ bootstrap: true,
52
+ run: async () => {
53
+ const config = (await import('stonyx/config')).default;
54
+
55
+ if (!config.orm.dynamodb) {
56
+ console.error('DynamoDB is not configured. Set DYNAMODB_REGION (and optionally DYNAMODB_ENDPOINT) to enable DynamoDB mode.');
57
+ process.exit(1);
58
+ }
59
+
60
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
61
+ const db = new DynamoDBDB();
62
+ await db.init();
63
+ await db.startup();
64
+ await db.shutdown();
65
+ console.log('DynamoDB tables synced successfully.');
66
+ }
67
+ },
42
68
  'db:migrate': {
43
69
  description: 'Apply pending MySQL migrations',
44
70
  bootstrap: true,
@@ -46,6 +72,11 @@ const commands: Record<string, Command> = {
46
72
  const config = (await import('stonyx/config')).default;
47
73
  const mysqlConfig = config.orm.mysql;
48
74
 
75
+ if (config.orm.dynamodb) {
76
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
77
+ return;
78
+ }
79
+
49
80
  if (!mysqlConfig) {
50
81
  console.error('MySQL is not configured. Set MYSQL_HOST to enable MySQL mode.');
51
82
  process.exit(1);
@@ -92,6 +123,12 @@ const commands: Record<string, Command> = {
92
123
  bootstrap: true,
93
124
  run: async () => {
94
125
  const config = (await import('stonyx/config')).default;
126
+
127
+ if (config.orm.dynamodb) {
128
+ console.log('DynamoDB does not support migration rollback. Manage table changes via the AWS console or db:sync.');
129
+ return;
130
+ }
131
+
95
132
  const mysqlConfig = config.orm.mysql;
96
133
 
97
134
  if (!mysqlConfig) {
@@ -138,6 +175,12 @@ const commands: Record<string, Command> = {
138
175
  bootstrap: true,
139
176
  run: async () => {
140
177
  const config = (await import('stonyx/config')).default;
178
+
179
+ if (config.orm.dynamodb) {
180
+ console.log('DynamoDB does not use file-based migrations. Use db:sync to provision tables.');
181
+ return;
182
+ }
183
+
141
184
  const mysqlConfig = config.orm.mysql;
142
185
 
143
186
  if (!mysqlConfig) {
@@ -0,0 +1,50 @@
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
+ export interface DynamoDBConfig {
9
+ region?: string;
10
+ endpoint?: string;
11
+ tablePrefix?: string;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ // Type aliases — declared loose so we don't need to import the real SDK types
16
+ // at compile time (they're optional peer deps).
17
+ export type DocumentClient = {
18
+ send(command: unknown): Promise<unknown>;
19
+ };
20
+
21
+ export type DynamoDBClientConstructor = new (options: unknown) => { config: unknown };
22
+ export type DocumentClientFromFn = { from(client: unknown): DocumentClient };
23
+
24
+ /**
25
+ * Create a DynamoDBDocumentClient from the given config.
26
+ * Uses dynamic import so @aws-sdk/* are optional peer deps.
27
+ */
28
+ export async function createDocumentClient(dbConfig: DynamoDBConfig): Promise<DocumentClient> {
29
+ const { DynamoDBClient } = await import('@aws-sdk/client-dynamodb' as string) as {
30
+ DynamoDBClient: DynamoDBClientConstructor;
31
+ };
32
+ const { DynamoDBDocumentClient } = await import('@aws-sdk/lib-dynamodb' as string) as {
33
+ DynamoDBDocumentClient: DocumentClientFromFn;
34
+ };
35
+
36
+ const clientOptions: Record<string, unknown> = {};
37
+ if (dbConfig.region) clientOptions.region = dbConfig.region;
38
+ if (dbConfig.endpoint) clientOptions.endpoint = dbConfig.endpoint;
39
+
40
+ const rawClient = new DynamoDBClient(clientOptions);
41
+ return DynamoDBDocumentClient.from(rawClient);
42
+ }
43
+
44
+ /**
45
+ * Nullify the document client reference (DynamoDB connections are HTTP-based
46
+ * and stateless — no explicit pool close needed, but we clear the reference).
47
+ */
48
+ export function destroyDocumentClient(_client: DocumentClient | null): null {
49
+ return null;
50
+ }