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

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 (63) hide show
  1. package/README.md +968 -11
  2. package/config/environment.js +8 -0
  3. package/dist/access-verdict.d.ts +59 -0
  4. package/dist/access-verdict.js +222 -0
  5. package/dist/commands.js +34 -0
  6. package/dist/dynamodb/connection.d.ts +31 -0
  7. package/dist/dynamodb/connection.js +28 -0
  8. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  9. package/dist/dynamodb/dynamodb-db.js +596 -0
  10. package/dist/dynamodb/operation-builder.d.ts +76 -0
  11. package/dist/dynamodb/operation-builder.js +116 -0
  12. package/dist/dynamodb/type-map.d.ts +31 -0
  13. package/dist/dynamodb/type-map.js +48 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +8 -0
  16. package/dist/main.d.ts +116 -0
  17. package/dist/main.js +129 -0
  18. package/dist/manage-record.js +268 -12
  19. package/dist/mysql/connection.d.ts +1 -0
  20. package/dist/mysql/mysql-db.d.ts +8 -0
  21. package/dist/mysql/mysql-db.js +44 -10
  22. package/dist/orm-request.d.ts +216 -3
  23. package/dist/orm-request.js +924 -55
  24. package/dist/postgres/connection.d.ts +1 -0
  25. package/dist/postgres/connection.js +8 -6
  26. package/dist/postgres/postgres-db.d.ts +8 -0
  27. package/dist/postgres/postgres-db.js +44 -10
  28. package/dist/record.d.ts +16 -0
  29. package/dist/record.js +62 -6
  30. package/dist/relationships.js +1 -1
  31. package/dist/serializer.js +38 -2
  32. package/dist/setup-rest-server.js +51 -5
  33. package/dist/standalone-db.js +17 -5
  34. package/dist/store.d.ts +13 -1
  35. package/dist/store.js +65 -6
  36. package/dist/types/orm-types.d.ts +139 -0
  37. package/dist/utils.d.ts +44 -0
  38. package/dist/utils.js +47 -0
  39. package/package.json +16 -7
  40. package/src/access-verdict.ts +248 -0
  41. package/src/commands.ts +43 -0
  42. package/src/dynamodb/connection.ts +50 -0
  43. package/src/dynamodb/dynamodb-db.ts +811 -0
  44. package/src/dynamodb/operation-builder.ts +202 -0
  45. package/src/dynamodb/type-map.ts +54 -0
  46. package/src/index.ts +10 -0
  47. package/src/main.ts +133 -0
  48. package/src/manage-record.ts +294 -18
  49. package/src/mysql/connection.ts +1 -0
  50. package/src/mysql/mysql-db.ts +44 -12
  51. package/src/orm-request.ts +944 -56
  52. package/src/postgres/connection.ts +10 -6
  53. package/src/postgres/postgres-db.ts +44 -12
  54. package/src/record.ts +82 -6
  55. package/src/relationships.ts +1 -1
  56. package/src/serializer.ts +39 -2
  57. package/src/setup-rest-server.ts +59 -6
  58. package/src/standalone-db.ts +17 -6
  59. package/src/store.ts +68 -6
  60. package/src/types/orm-types.ts +146 -1
  61. package/src/types/stonyx-rest-server.d.ts +14 -1
  62. package/src/types/stonyx.d.ts +7 -1
  63. 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 {
@@ -76,9 +87,18 @@ export interface OrmRecord {
76
87
  __pendingSqlId?: boolean;
77
88
  };
78
89
  __relationships: Record<string, unknown>;
90
+ /**
91
+ * `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
92
+ * the request (abofs/stonyx-orm#234): return `false` for a related record and
93
+ * its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
94
+ * the default, and the default is the pre-#234 document unchanged -- this
95
+ * method is also the `JSON.stringify` hook, so an implicit caller has no
96
+ * syntactic place to pass it (abofs/stonyx-orm#230).
97
+ */
79
98
  toJSON?(options?: {
80
99
  fields?: Set<string>;
81
100
  baseUrl?: string;
101
+ linkage?: LinkageFilter;
82
102
  }): Record<string, unknown>;
83
103
  [key: string]: unknown;
84
104
  }
@@ -151,3 +171,122 @@ export interface SnapshotEntry {
151
171
  source?: string;
152
172
  viewQuery?: string;
153
173
  }
174
+ /**
175
+ * The shapes a consumer `access()` predicate may return.
176
+ *
177
+ * - `false` (or any falsy value) -- deny, 403.
178
+ * - `true` -- allow, with no per-record filter.
179
+ * - a permission string or array of them, drawn from the same four verbs as
180
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
181
+ * grant of all four.
182
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
183
+ * request touches through it.
184
+ *
185
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
186
+ */
187
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
188
+ /**
189
+ * The closed vocabulary `AccessContext.operation` is drawn from
190
+ * (abofs/stonyx-orm#202).
191
+ *
192
+ * A literal union rather than `string`, so the guarantee the prose makes is the
193
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
194
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
195
+ * error instead of a comparison that never matches. A predicate that stops
196
+ * matching falls through to the permission array, so the misreading is
197
+ * fail-open shaped.
198
+ *
199
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
200
+ */
201
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
202
+ /**
203
+ * The structural facts about the request being authorised, handed to a consumer
204
+ * `access()` predicate as its SECOND argument (abofs/stonyx-orm#202).
205
+ *
206
+ * These are the facts the framework already holds at authorisation time. Before
207
+ * #202 a consumer had to reconstruct both of them by string-matching a URL, and
208
+ * five independent fail-open variants of that reconstruction were found in one
209
+ * three-line documented example -- each one wrong in the direction that GRANTS
210
+ * access. Read these instead; there is nothing to parse and no variant to miss.
211
+ *
212
+ * `record` is deliberately NOT a member. `auth()` runs after route matching but
213
+ * before any handler executes (`@stonyx/rest-server` `src/request.ts:58-60`),
214
+ * so nothing has been fetched yet -- carrying a record here would force a
215
+ * pre-fetch on every request. It is also unnecessary: the `(record) => boolean`
216
+ * return shape of {@link AccessMethod} already IS the per-record hook, applied
217
+ * by the handlers. Auth-time and record-time are separate decision points.
218
+ */
219
+ export interface AccessContext {
220
+ /**
221
+ * The model this route was mounted for, e.g. `'owner'` or `'phone-number'`.
222
+ *
223
+ * Model names are kebab-case, as declared under `config.orm.paths.model` and
224
+ * keyed in the store -- NOT the pluralised, mount-prefixed route name. It is
225
+ * read from the `OrmRequest` instance and is never derived from the request
226
+ * target, so a mount prefix, a case-varied path, a query string or an
227
+ * absolute-form request-target cannot change it.
228
+ */
229
+ model: string;
230
+ /**
231
+ * The operation being authorised. Exactly one of the four {@link
232
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
233
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
234
+ * permission-array return shape is matched against -- so the two forms cannot
235
+ * disagree.
236
+ *
237
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
238
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
239
+ * key of an identically-shaped context object, and the access vocabulary
240
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
241
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
242
+ * statement about the ACCESS path only.
243
+ *
244
+ * `undefined` when the dispatched method has no entry in that map. Express
245
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
246
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
247
+ * turn an unclassified request into an authorised one.
248
+ *
249
+ * The KEY is required even though the value may be undefined: `auth()` always
250
+ * sets it, and a context that simply omitted it would be indistinguishable
251
+ * from one that classified the request and found nothing.
252
+ */
253
+ operation: AccessOperation | undefined;
254
+ }
255
+ /**
256
+ * A consumer `access()` predicate.
257
+ *
258
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
259
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
260
+ * FIRST argument instead would have been the breaking form, and a predicate
261
+ * that can no longer identify its collection falls through to a full CRUD
262
+ * grant -- so the "safer" breaking change would have converted every unmigrated
263
+ * predicate into a fail-open.
264
+ *
265
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
266
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
267
+ * more-parameter signature, so an arity-1 predicate assigns to this type
268
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
269
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
270
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
271
+ * that call gets the model-wrong answer. Required, a caller that drops the
272
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
273
+ */
274
+ export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
275
+ /**
276
+ * A resolved, request-scoped linkage decision: may `record` of model `type` be
277
+ * NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
278
+ *
279
+ * Arity is `(type, record)` and not `(type, id)` because the per-record filter
280
+ * a consumer returns is handed the RECORD -- this repo's own fixture reads
281
+ * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
282
+ * key inside `createLinkageFilter`, not the input.
283
+ *
284
+ * DECLARED HERE, with the rest of the access vocabulary, and imported by every
285
+ * site that names it. It had three structurally-identical hand-written copies
286
+ * (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
287
+ * other by nothing, so a drift in nullability or a widening of `type` would
288
+ * have landed on one and not the others -- which is the same "second,
289
+ * unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
290
+ * level up in the type system.
291
+ */
292
+ export type LinkageFilter = (type: string, record: unknown) => boolean;
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.71",
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
  }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The shared access-verdict primitive (abofs/stonyx-orm#234).
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * WHY THIS FILE EXISTS: ONE INTERPRETER, NOT TWO
6
+ * ---------------------------------------------------------------------------
7
+ * A consumer `access()` may return six differently-shaped things -- `false`, a
8
+ * bare permission string, a permission array, `true`, a per-record function, or
9
+ * something the contract does not define at all -- and the reading of each one
10
+ * is a security decision. `auth()` has held that reading inline since #190.
11
+ * Every surface that needs to ask "may this caller see model X's record?" needs
12
+ * the SAME reading, or the second copy becomes an unreviewed second
13
+ * authorization vocabulary that answers differently about the same value.
14
+ *
15
+ * So `interpretAccess` is extracted here and `auth()` now calls it. It is the
16
+ * only place a return shape is classified, and abofs/stonyx-orm#232 and #233
17
+ * rebase onto it rather than re-deriving it.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * WHAT A LINKAGE FILTER IS, AND WHY THE CALLER BUILDS IT
21
+ * ---------------------------------------------------------------------------
22
+ * `Record.toJSON()` APPLIES a verdict; it never RESOLVES one. That is not a
23
+ * style choice, it is forced, and it was measured before it was decided:
24
+ *
25
+ * INPUT: origin/dev @ c5f7907, unpatched -> 967 pass / 0 fail
26
+ * INPUT: same + fail-closed resolution INSIDE toJSON() -> 964 pass / 3 fail
27
+ *
28
+ * and all three reds were over-denial of PERMITTED records, not the leak. Two
29
+ * independent reasons:
30
+ *
31
+ * 1. `toJSON()` has no request. The shipped, documented sample reads
32
+ * `request.path` for its `/archived` sub-path rule -- the one read of
33
+ * argument one the README sanctions -- and fail-closes when it is absent.
34
+ * Measured against the live registry:
35
+ *
36
+ * getAccess('owner')(undefined, { model:'owner', operation:'read' }) -> false
37
+ * getAccess('animal')(undefined,{ model:'animal', operation:'read' }) -> [Function]
38
+ *
39
+ * Same predicate object, two models, two different degradation modes,
40
+ * chosen by the consumer. Without a request there is no trustworthy
41
+ * answer to get.
42
+ *
43
+ * 2. `toJSON` is also the `JSON.stringify` hook, so `JSON.stringify({data:
44
+ * record})` calls `record.toJSON('data')` -- a STRING in the options slot.
45
+ * An implicit caller has no syntactic place to pass anything
46
+ * (abofs/stonyx-orm#230). The no-argument document must therefore stay
47
+ * byte-identical to what shipped, which also rules out fail-closed by
48
+ * default: `Orm.instance.accessFunctions` is `{}` in any process that
49
+ * never ran `setup-rest-server` (CLI, SQL-only, unit tests), so a
50
+ * fail-closed default would empty every relationship on every document in
51
+ * processes that have no REST surface to protect.
52
+ *
53
+ * The caller -- which still holds the request -- resolves the predicate,
54
+ * interprets it here, caches the answer, and hands `toJSON()` an already-decided
55
+ * `(type, record) => boolean`.
56
+ */
57
+ import Orm from '@stonyx/orm';
58
+ import log from 'stonyx/log';
59
+ import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
60
+
61
+ /**
62
+ * The classified reading of one `access()` return value.
63
+ *
64
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
65
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
66
+ * this per-record predicate" -- the function return shape, which is the
67
+ * per-record hook `AccessContext` deliberately does not provide.
68
+ */
69
+ export interface AccessVerdict {
70
+ granted: boolean;
71
+ filter?: (record: unknown) => boolean;
72
+ }
73
+
74
+ const DENIED: AccessVerdict = Object.freeze({ granted: false });
75
+ const GRANTED: AccessVerdict = Object.freeze({ granted: true });
76
+
77
+ /**
78
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
79
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
80
+ *
81
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
82
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
83
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
84
+ * the same answer `auth()` gave before the extraction.
85
+ */
86
+ export function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict {
87
+ if (!access) return DENIED;
88
+
89
+ // The function return shape IS the per-record hook. Grant the request and
90
+ // carry the predicate; the caller applies it per record.
91
+ if (typeof access === 'function') return { granted: true, filter: access as (record: unknown) => boolean };
92
+
93
+ if (access === true) return GRANTED;
94
+
95
+ // `AccessMethod` declares `string` legal and it fell through every branch
96
+ // above. A bare string is ONE permission, not a grant of all four -- reading
97
+ // it as a full grant is what once let `return 'read'` authorise DELETE.
98
+ const permitted = typeof access === 'string' ? [access] : access;
99
+
100
+ // Anything that is not a permission array by this point -- an object, a
101
+ // number, a Symbol -- is a consumer mistake, and the only safe reading of a
102
+ // shape the contract does not define is a denial. Fail CLOSED.
103
+ if (!Array.isArray(permitted)) return DENIED;
104
+ if (!permitted.includes(operation as string)) return DENIED;
105
+
106
+ return GRANTED;
107
+ }
108
+
109
+ /**
110
+ * Resolve model `type`'s verdict for a read, against the live `request`.
111
+ *
112
+ * Fails closed on both ambiguous inputs:
113
+ *
114
+ * - `getAccess(type)` -> `undefined`. That is NOT "this model is
115
+ * unrestricted". `setup-rest-server` catches an access-class load failure,
116
+ * warns, and publishes whatever PARTIAL map it had, so `undefined` covers
117
+ * both "no access class claims this model" and "the class that claims it
118
+ * failed to load" -- and the caller cannot tell them apart. Deny.
119
+ * - the predicate THROWS. Same reading `auth()` and `isDenied` already use:
120
+ * a throw is a denial, logged, never a 500 and never a grant.
121
+ *
122
+ * NOTE ON CROSS-MODEL ASKS -- READ THIS BEFORE REBASING #232 OR #233 ONTO IT.
123
+ * The predicate is asked about `type` while the request in hand was dispatched
124
+ * to a DIFFERENT model's route. This function makes another model's class
125
+ * REACHABLE and asks it the model-correct question (`{ model: type }`); whether
126
+ * the ANSWER is model-correct is the CONSUMER's, because only a predicate that
127
+ * READS `context.model` can give one. Since #222 this repo's fixture does. A
128
+ * consumer's arity-1 predicate does not, and there is no supported way to tell
129
+ * which kind was resolved (the boot-time arity warning is
130
+ * abofs/stonyx-orm#213/#221, unshipped).
131
+ *
132
+ * BOTH DEGRADATION DIRECTIONS ARE REACHABLE, AND THE SECOND ONE GRANTS. This is
133
+ * measured, not reasoned:
134
+ *
135
+ * - CLOSED. The migrated fixture's surviving `request.path` read means asking
136
+ * the OWNER predicate on a request dispatched to `GET /animals/archived`
137
+ * returns a bare `false` -- a whole-request deny bleeding across models,
138
+ * treated here as "deny this linkage", not as an error. That over-denies a
139
+ * PERMITTED record.
140
+ * - OPEN. An arity-1 predicate -- the shape `setup-rest-server.ts:15-18`
141
+ * still declares valid and the README calls the default in every consumer
142
+ * tree -- identifies its collection from the request, so asked about
143
+ * `owner` on a request dispatched to `/animals` it answers about ANIMALS.
144
+ * Measured against this repo's own fixture with `reg.owner` replaced by an
145
+ * arity-1 predicate that hides angela on `/owners`:
146
+ *
147
+ * GET /owners -> ["gina","michael","bob"] angela hidden, correctly
148
+ * GET /animals -> owners named: [angela, ...] LEAK
149
+ * GET /animals/1 -> owner.data {"type":"owner","id":"angela"}
150
+ *
151
+ * That is byte-for-byte the abofs/stonyx-orm#234 defect, on the surface
152
+ * #234 was filed for, AFTER this fix. It is not a regression -- dev
153
+ * published the same id unconditionally -- and this file cannot close it,
154
+ * because the arity signal is #213/#221. Do NOT write, here or anywhere
155
+ * else, that the cross-model ask degrades closed. The standing rule this
156
+ * paragraph is held to is in docs/project-structure.md.
157
+ */
158
+ function resolveVerdict(request: unknown, type: string): AccessVerdict {
159
+ const predicate = Orm.instance?.getAccess?.(type);
160
+ if (typeof predicate !== 'function') return DENIED;
161
+
162
+ let access: AccessMethod;
163
+
164
+ try {
165
+ access = predicate(request, { model: type, operation: 'read' });
166
+ } catch (error) {
167
+ log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
168
+
169
+ return DENIED;
170
+ }
171
+
172
+ return interpretAccess(access, 'read');
173
+ }
174
+
175
+ /**
176
+ * Build a request-scoped linkage filter.
177
+ *
178
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
179
+ *
180
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
181
+ * which is arbitrary code with arbitrary cost and which the module has
182
+ * already had to guard for throwing.
183
+ * - one decision per `(type, id)`. `included` is deduplicated by
184
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
185
+ * per record. Measured on a bare `GET /animals` with no `include=`:
186
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
187
+ * a 6.9x reduction and 41 predicate calls saved.
188
+ *
189
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
190
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
191
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
192
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
193
+ * record with the first record's verdict.
194
+ *
195
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
196
+ * already partitioned per type by `byType`, so a composite key inside a per-type
197
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
198
+ * for an animal -- the claim that once stood here. The real exposure is narrower
199
+ * and entirely WITHIN one model: two records of the same type whose ids differ
200
+ * only by JavaScript type, which a per-record predicate may legitimately answer
201
+ * differently about (an id read off a JSON body is a string; the same id
202
+ * assigned by the server is a number). Pinned by unit assertion, because this
203
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
204
+ * `animal` ids are numbers.
205
+ *
206
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
207
+ * it -- a verdict cached across requests would answer a second caller with the
208
+ * first caller's authorization.
209
+ */
210
+ export function createLinkageFilter(request: unknown): LinkageFilter {
211
+ const byType = new Map<string, { verdict: AccessVerdict; decisions: Map<unknown, boolean> }>();
212
+
213
+ return function isLinkable(type: string, record: unknown): boolean {
214
+ let entry = byType.get(type);
215
+
216
+ if (!entry) {
217
+ entry = { verdict: resolveVerdict(request, type), decisions: new Map() };
218
+ byType.set(type, entry);
219
+ }
220
+
221
+ const { verdict, decisions } = entry;
222
+
223
+ if (!verdict.granted) return false;
224
+ if (!verdict.filter) return true;
225
+
226
+ const id = (record as { id?: unknown } | null)?.id;
227
+ const cached = decisions.get(id);
228
+ if (cached !== undefined) return cached;
229
+
230
+ let allowed: boolean;
231
+
232
+ try {
233
+ allowed = Boolean(verdict.filter(record));
234
+ } catch (error) {
235
+ // A predicate that throws is a denial -- the same reading `isDenied` uses
236
+ // one layer down. Logged, because a predicate that throws on every record
237
+ // empties every relationship and, silently, that is indistinguishable
238
+ // from a database with no relationships in it.
239
+ log.error?.(`[@stonyx/orm] access filter threw while filtering linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
240
+
241
+ allowed = false;
242
+ }
243
+
244
+ decisions.set(id, allowed);
245
+
246
+ return allowed;
247
+ };
248
+ }
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) {