@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
@@ -18,6 +18,7 @@ export interface OrmMysqlConfig {
18
18
  connectionLimit?: number;
19
19
  migrationsDir?: string;
20
20
  migrationsTable?: string;
21
+ autoMigrate?: boolean;
21
22
  [key: string]: unknown;
22
23
  }
23
24
 
@@ -30,6 +31,7 @@ export interface OrmPostgresConfig {
30
31
  connectionLimit?: number;
31
32
  migrationsDir?: string;
32
33
  migrationsTable?: string;
34
+ autoMigrate?: boolean;
33
35
  [key: string]: unknown;
34
36
  }
35
37
 
@@ -48,6 +50,13 @@ export interface OrmRestServerConfig {
48
50
  metaRoute: boolean;
49
51
  }
50
52
 
53
+ export interface OrmDynamoDBConfig {
54
+ region?: string;
55
+ endpoint?: string;
56
+ tablePrefix?: string;
57
+ [key: string]: unknown;
58
+ }
59
+
51
60
  export interface OrmSection {
52
61
  db: OrmDbConfig;
53
62
  paths: OrmPaths;
@@ -55,6 +64,9 @@ export interface OrmSection {
55
64
  mysql?: OrmMysqlConfig;
56
65
  postgres?: OrmPostgresConfig;
57
66
  timescale?: OrmPostgresConfig;
67
+ dynamodb?: OrmDynamoDBConfig;
68
+ logColor?: string;
69
+ logMethod?: string;
58
70
  [key: string]: unknown;
59
71
  }
60
72
 
@@ -156,3 +168,205 @@ export interface SnapshotEntry {
156
168
  source?: string;
157
169
  viewQuery?: string;
158
170
  }
171
+
172
+ /**
173
+ * The shapes a consumer `access()` predicate may return.
174
+ *
175
+ * - `false` (or any falsy value) -- deny, 403.
176
+ * - `true` -- allow, with no per-record filter.
177
+ * - a permission string or array of them, drawn from the same four verbs as
178
+ * {@link AccessContext.operation}. A BARE STRING IS ONE PERMISSION, not a
179
+ * grant of all four.
180
+ * - a `(record) => boolean` predicate -- allow, and filter every record the
181
+ * request touches through it.
182
+ *
183
+ * Anything else fails CLOSED. See `src/orm-request.ts` `auth()`.
184
+ */
185
+ export type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
186
+
187
+ /**
188
+ * The closed vocabulary `AccessContext.operation` is drawn from
189
+ * (abofs/stonyx-orm#202).
190
+ *
191
+ * A literal union rather than `string`, so the guarantee the prose makes is the
192
+ * one the compiler enforces: a consumer who writes `operation === 'GET'` or
193
+ * `operation === 'get'` -- the hook vocabulary, see below -- gets a compile
194
+ * error instead of a comparison that never matches. A predicate that stops
195
+ * matching falls through to the permission array, so the misreading is
196
+ * fail-open shaped.
197
+ *
198
+ * In-repo precedent: `PersistErrorDetail.operation` in `src/main.ts`.
199
+ */
200
+ export type AccessOperation = 'read' | 'create' | 'update' | 'delete';
201
+
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
+ /**
232
+ * The operation being authorised. Exactly one of the four {@link
233
+ * AccessOperation} verbs, or `undefined`. These are exactly the values of
234
+ * `methodAccessMap` in `src/orm-request.ts`, which is also what the
235
+ * permission-array return shape is matched against -- so the two forms cannot
236
+ * disagree.
237
+ *
238
+ * NOT the hook vocabulary. `HookContext.operation` (`src/hooks.ts`) carries
239
+ * `'list' | 'get' | 'create' | 'update' | 'delete'` on an identically-named
240
+ * key of an identically-shaped context object, and the access vocabulary
241
+ * collapses `list` and `get` into `'read'`. For one `GET /animals/1` a hook
242
+ * sees `'get'` and `access()` sees `'read'`. "No second vocabulary" is a
243
+ * statement about the ACCESS path only.
244
+ *
245
+ * `undefined` when the dispatched method has no entry in that map. Express
246
+ * delivers `HEAD` to the `GET` handler, so this is reachable. It is left
247
+ * undefined rather than defaulted on purpose: a fabricated `'read'` would
248
+ * turn an unclassified request into an authorised one.
249
+ *
250
+ * The KEY is required even though the value may be undefined: `auth()` always
251
+ * sets it, and a context that simply omitted it would be indistinguishable
252
+ * from one that classified the request and found nothing.
253
+ */
254
+ operation: AccessOperation | undefined;
255
+
256
+ /**
257
+ * The record this route was addressed to, as the store key -- or `null` on a
258
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
259
+ *
260
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
261
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
262
+ * `request.path` against a literal compares an undecoded string against a
263
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
264
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
265
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
266
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
267
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
268
+ *
269
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
270
+ *
271
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
272
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
273
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
274
+ * decoded until stable would deny a record it was never asked about.
275
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
276
+ * and express's `case sensitive routing` governs literal segments only.
277
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
278
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
279
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
280
+ * allow, on that same record).
281
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
282
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
283
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
284
+ * `archived/x` -- was measured over-denied 403.
285
+ *
286
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
287
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
288
+ * lookup the permission-array branch uses. The predicate and the dispatch
289
+ * therefore cannot disagree about which record a request addresses. Handing
290
+ * over the raw `request.params.id` instead would reintroduce that divergence
291
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
292
+ *
293
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
294
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
295
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
296
+ * exists to buy; it is not a defect to repair here.
297
+ *
298
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
299
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
300
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
301
+ * hand-assembled by a caller resolving the predicate through
302
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
303
+ * signal only because the framework never produces it.
304
+ *
305
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
306
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
307
+ * key on an identically-shaped context object, which is the exact
308
+ * configuration that makes `operation` fail-open shaped -- a hook sees
309
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
310
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
311
+ * and it is the absence spelling"). That was measured false, in the fail-open
312
+ * direction, and it is corrected here rather than deleted.
313
+ *
314
+ * MEASURED over the live dispatch, before-hooks registered for all five
315
+ * operations on one model:
316
+ *
317
+ * before:list key ABSENT ('recordId' in context === false)
318
+ * before:get key ABSENT params={"id":"visible1"}
319
+ * before:create key ABSENT
320
+ * before:update key ABSENT params={"id":"visible2"}
321
+ * before:delete recordId="visible3"
322
+ * after:delete recordId="visible3"
323
+ *
324
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
325
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
326
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
327
+ * a hook context the key is absent for get, list, create and update, while
328
+ * this key is present on every route `auth()` classifies. The absence
329
+ * spelling is the smaller half of the difference, not the whole of it.
330
+ *
331
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
332
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
333
+ * On a hook context it means "this is a get / list / create / update" -- an
334
+ * ordinary request. A consumer who writes the hook-side half of the same
335
+ * rule --
336
+ *
337
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
338
+ *
339
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
340
+ * with `ctx.recordId === undefined` while the addressed record sits in
341
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
342
+ * repaired here. A predicate must not read `undefined` here as "collection",
343
+ * and nothing in this contract makes it safe to read the two keys as one key.
344
+ *
345
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
346
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
347
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
348
+ * and is untouched by this key.
349
+ */
350
+ recordId: string | number | null;
351
+ }
352
+
353
+ /**
354
+ * A consumer `access()` predicate.
355
+ *
356
+ * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
357
+ * pre-#202 single-argument predicate keeps working untouched. Changing the
358
+ * FIRST argument instead would have been the breaking form, and a predicate
359
+ * that can no longer identify its collection falls through to a full CRUD
360
+ * grant -- so the "safer" breaking change would have converted every unmigrated
361
+ * predicate into a fail-open.
362
+ *
363
+ * `context` is nonetheless REQUIRED in the type, and that costs back-compat
364
+ * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
365
+ * more-parameter signature, so an arity-1 predicate assigns to this type
366
+ * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
367
+ * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
368
+ * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
369
+ * that call gets the model-wrong answer. Required, a caller that drops the
370
+ * context gets `TS2554: Expected 2 arguments, but got 1`.
371
+ */
372
+ export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
@@ -5,7 +5,20 @@ declare module '@stonyx/rest-server' {
5
5
 
6
6
  interface RouteOptions {
7
7
  name: string;
8
- options?: { model: string; access: (request: unknown) => unknown } | Record<string, unknown>;
8
+ /**
9
+ * `access` is the two-argument post-#202 shape. This is the THIRD place the
10
+ * contract is declared (`AccessInstance.access` in
11
+ * `src/setup-rest-server.ts` and `OrmRequest.access` in
12
+ * `src/orm-request.ts` are the other two) and it is the one `mountRoute` is
13
+ * actually called through, at `src/setup-rest-server.ts`. It kept the
14
+ * pre-#202 single-argument signature after the other two migrated; the
15
+ * union with `Record<string, unknown>` meant nothing broke, which is
16
+ * exactly why it would have drifted silently.
17
+ *
18
+ * Spelled structurally rather than as `AccessFunction`: an ambient
19
+ * `declare module` block cannot carry an `import type`.
20
+ */
21
+ options?: { model: string; access: (request: unknown, context: { model: string; operation: string | undefined }) => unknown } | Record<string, unknown>;
9
22
  }
10
23
 
11
24
  export default class RestServer {
@@ -5,7 +5,13 @@ declare module 'stonyx/config' {
5
5
  }
6
6
 
7
7
  declare module 'stonyx/log' {
8
- const log: Record<string, ((...args: unknown[]) => void) | undefined>;
8
+ interface Log {
9
+ db(message: string): void;
10
+ error(message: string, ...args: unknown[]): void;
11
+ defineType(type: string, setting: string, options?: Record<string, unknown> | null): void;
12
+ [key: string]: ((...args: unknown[]) => void) | undefined;
13
+ }
14
+ const log: Log;
9
15
  export default log;
10
16
  }
11
17
 
package/src/utils.ts CHANGED
@@ -20,3 +20,53 @@ export function pluralize(word: string): string {
20
20
 
21
21
  return basePluralize(word);
22
22
  }
23
+
24
+ /**
25
+ * The highest NUMERIC id held by a set of records, or `0` when there is none.
26
+ *
27
+ * ONE COPY, and the duplication it replaces is the reason it lives here. Three
28
+ * near-identical reduces existed at once: `assignRecordId` (server-assigned id
29
+ * selection), `StandaloneDB.create` (src/standalone-db.ts) and the #203 test
30
+ * helper. `docs/improvements.md`'s standing WET Code category prescribes
31
+ * exactly this remedy -- extract into the module that already acts as the
32
+ * shared utility -- and `assignRecordId` already imported `isOrmRecord` from
33
+ * here.
34
+ *
35
+ * NON-NUMBERS ARE SKIPPED RATHER THAN COERCED TO `0`, AND THAT IS STYLISTIC.
36
+ * `StandaloneDB`'s shape mapped them to `0`, which can never beat a seed of
37
+ * `0`. Measured over eleven input classes (`[]`, `1`, `NaN`, `'5'`, `'abc'`,
38
+ * `-3`, `0`, `null`, `undefined`, `Infinity`, and mixed arrays) the two shapes
39
+ * produce IDENTICAL output on every one. In particular `typeof NaN` is
40
+ * `'number'`, so NEITHER shape coerces `NaN` -- both reject it on `NaN > max`,
41
+ * which is `false`. An earlier revision of this code asserted that the skip was
42
+ * what made the `NaN` case work; it is not, the comparison is, and that claim
43
+ * has been removed rather than left standing.
44
+ *
45
+ * WHAT IS LOAD-BEARING is that this is not `Math.max(...ids)`. `Math.max`
46
+ * returns `NaN` if any operand is `NaN`, and a record CAN be held under the key
47
+ * `NaN` -- so the obvious fix assigns `NaN`, lands on that slot and overwrites
48
+ * it, which is abofs/stonyx-orm#203 in a new disguise. Pinned by
49
+ * test/unit/assign-record-id-test.ts AC2; before that file existed the whole
50
+ * suite scored 951/0 under exactly that fix.
51
+ */
52
+ export function maxNumericId(records: { id?: unknown }[]): number {
53
+ return records.reduce((max: number, record) => {
54
+ const { id } = record;
55
+
56
+ return typeof id === 'number' && id > max ? id : max;
57
+ }, 0);
58
+ }
59
+
60
+ /**
61
+ * The message prefix `assignRecordId` throws with when no free id can be
62
+ * derived for a model, and the ONE string `createHandler` matches on to answer
63
+ * `409` instead of letting the rejection reach express's default handler.
64
+ *
65
+ * It lives here rather than in either file because both need it and neither
66
+ * should own a copy: a literal in two places is how the two id coercions in
67
+ * orm-request.ts drifted apart (see `coerceId`). The repo has no error codes
68
+ * and no custom error classes -- 24 bare `throw new Error` sites across `src/`
69
+ * -- so a shared prefix is the narrowest way to make ONE failure distinguishable
70
+ * without inventing an error taxonomy this codebase does not use.
71
+ */
72
+ export const NO_FREE_ID_ERROR = 'Cannot assign record ID: no free id available';