@stonyx/orm 0.3.2-beta.166 → 0.3.2-beta.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,6 +113,12 @@ export default {
113
113
  };
114
114
  ```
115
115
 
116
+ > **`route` and JSON:API `links`.** Generated endpoints are mounted under `route`, and every
117
+ > `links.self` / `links.related` the ORM emits is an absolute URL that includes it. With
118
+ > `ORM_REST_ROUTE='/api'` the animal collection is served at `/api/animals` and advertises
119
+ > `http://host/api/animals`, so published links are followable as-is — do not prepend the
120
+ > mount yourself.
121
+
116
122
  Then run the application via the Stonyx CLI, which auto-initializes all modules including the ORM:
117
123
 
118
124
  ```bash
@@ -322,6 +328,36 @@ export default class GlobalAccess {
322
328
  }
323
329
  ```
324
330
 
331
+ ### Upgrading: behaviour changes
332
+
333
+ **Advertised `links.self` / `links.related` now carry the REST mount route.**
334
+ Consumer-visible for any deployment where `orm.restServer.route` is not the
335
+ default `'/'`.
336
+
337
+ Measured on this repo's mounted-route harness at `ORM_REST_ROUTE='/api'`, resource
338
+ `links.self` in the response to `GET /api/animals/1`:
339
+
340
+ | | published `links.self` | fetching that URL |
341
+ |---|---|---|
342
+ | before | `http://host/animals/1` | **404** |
343
+ | after | `http://host/api/animals/1` | **200** |
344
+
345
+ The ORM previously built links from the request origin alone, so at any non-default
346
+ mount every URL it advertised pointed at a route that did not exist
347
+ (abofs/stonyx-orm#254). Links are now built from the path the routes are actually
348
+ mounted at, and are followable verbatim.
349
+
350
+ **⚠️ Breaking if you carry a prepending workaround.** The usual workaround for #254
351
+ was for the client to prepend the mount to every link the ORM published. That now
352
+ double-prefixes: prepending `/api` to the new `http://host/api/animals/1` yields
353
+ `http://host/api/api/animals/1`, measured **404**. Remove the prepending. There is no
354
+ configuration flag that restores the old link shape.
355
+
356
+ **Unaffected.** Deployments on the default `ORM_REST_ROUTE='/'` see byte-identical
357
+ output — the prefix is empty, and this is pinned by a byte-identity test against a
358
+ golden fixture captured *before* the fix. Response structure, field names and the
359
+ public API are unchanged; only the URL value inside `links` changes.
360
+
325
361
  ### Include Parameter (Sideloading Relationships)
326
362
 
327
363
  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,6 +1,7 @@
1
1
  import { Request } from '@stonyx/rest-server';
2
2
  interface OrmRequest$ extends Request {
3
3
  protocol?: string;
4
+ baseUrl?: string;
4
5
  method: string;
5
6
  params: {
6
7
  [key: string]: string;
@@ -42,11 +42,28 @@ function getModelRelationships(modelName) {
42
42
  }
43
43
  return relationships;
44
44
  }
45
- // Helper to build base URL from request
46
- function getBaseUrl(request) {
45
+ /**
46
+ * Build the absolute base URL that every advertised link hangs off — origin
47
+ * plus the prefix the ORM's routes are actually mounted at.
48
+ *
49
+ * The prefix is derived from the *request*, not from
50
+ * `config.orm.restServer.route`. Express sets `request.baseUrl` to the real
51
+ * mountpath registered by `RestServer.mountRoute`, which for this module is
52
+ * always `<prefix>/<pluralizedModel>` (see setup-rest-server.ts). Stripping the
53
+ * trailing model segment therefore yields the prefix by construction, and the
54
+ * link builder cannot drift from the mount registrar the way a second,
55
+ * independent normalisation of `route` would.
56
+ *
57
+ * When `request.baseUrl` is absent or does not end in the model segment the
58
+ * prefix is empty, which reproduces the previous origin-only behaviour.
59
+ */
60
+ function getBaseUrl(request, pluralizedModel) {
47
61
  const protocol = request.protocol || 'http';
48
62
  const host = request.get('host');
49
- return `${protocol}://${host}`;
63
+ const modelSegment = `/${pluralizedModel}`;
64
+ const mountPath = request.baseUrl ?? '';
65
+ const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
66
+ return `${protocol}://${host}${prefix}`;
50
67
  }
51
68
  function getId(params) {
52
69
  const id = params.id;
@@ -209,7 +226,7 @@ export default class OrmRequest extends Request {
209
226
  recordsToReturn = recordsToReturn.filter(accessFilter);
210
227
  if (queryFilterPredicate)
211
228
  recordsToReturn = recordsToReturn.filter(queryFilterPredicate);
212
- const baseUrl = getBaseUrl(request);
229
+ const baseUrl = getBaseUrl(request, pluralizedModel);
213
230
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
214
231
  return buildResponse(data, request.query?.include, recordsToReturn, {
215
232
  links: { self: `${baseUrl}/${pluralizedModel}` },
@@ -222,7 +239,7 @@ export default class OrmRequest extends Request {
222
239
  return 404;
223
240
  const fieldsMap = parseFields(request.query);
224
241
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
225
- const baseUrl = getBaseUrl(request);
242
+ const baseUrl = getBaseUrl(request, pluralizedModel);
226
243
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
227
244
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
228
245
  baseUrl
@@ -397,7 +414,7 @@ export default class OrmRequest extends Request {
397
414
  if (!record)
398
415
  return 404;
399
416
  const relatedData = record.__relationships[relationshipName];
400
- const baseUrl = getBaseUrl(request);
417
+ const baseUrl = getBaseUrl(request, pluralizedModel);
401
418
  let data;
402
419
  if (info.isArray) {
403
420
  // hasMany - return array
@@ -419,7 +436,7 @@ export default class OrmRequest extends Request {
419
436
  if (!record)
420
437
  return 404;
421
438
  const relatedData = record.__relationships[relationshipName];
422
- const baseUrl = getBaseUrl(request);
439
+ const baseUrl = getBaseUrl(request, pluralizedModel);
423
440
  let data;
424
441
  if (info.isArray) {
425
442
  // hasMany - return array of linkage objects
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.166",
7
+ "version": "0.3.2-beta.168",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -103,7 +103,8 @@
103
103
  "scripts": {
104
104
  "build": "tsc",
105
105
  "build:test": "tsc -p tsconfig.test.json",
106
- "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' && ORM_TEST_ROUTE=/ pnpm test:mounted && ORM_TEST_ROUTE=/api pnpm test:mounted && ORM_TEST_ROUTE=api pnpm test:mounted && ORM_TEST_ROUTE=/api/v1 pnpm test:mounted && ORM_TEST_ROUTE=/api/ pnpm test:mounted",
107
+ "test:mounted": "node --import tsx/esm --import ./test/integration/mounted-route/setup.ts node_modules/qunit/bin/qunit.js 'test/integration/mounted-route/links-mounted.ts' 'test/zz-exit-test.ts'",
107
108
  "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'"
108
109
  }
109
110
  }
@@ -10,6 +10,10 @@ import { isOrmRecord } from './utils.js';
10
10
 
11
11
  interface OrmRequest$ extends Request {
12
12
  protocol?: string;
13
+ // Express sets this to the path the router was mounted at, e.g. '/api/animals'
14
+ // when orm.restServer.route is '/api'. Optional because non-Express callers
15
+ // (unit tests, programmatic handler invocation) do not supply it.
16
+ baseUrl?: string;
13
17
  method: string;
14
18
  params: { [key: string]: string };
15
19
  body?: { [key: string]: unknown };
@@ -77,11 +81,30 @@ function getModelRelationships(modelName: string): { [key: string]: Relationship
77
81
  return relationships;
78
82
  }
79
83
 
80
- // Helper to build base URL from request
81
- function getBaseUrl(request: OrmRequest$): string {
84
+ /**
85
+ * Build the absolute base URL that every advertised link hangs off — origin
86
+ * plus the prefix the ORM's routes are actually mounted at.
87
+ *
88
+ * The prefix is derived from the *request*, not from
89
+ * `config.orm.restServer.route`. Express sets `request.baseUrl` to the real
90
+ * mountpath registered by `RestServer.mountRoute`, which for this module is
91
+ * always `<prefix>/<pluralizedModel>` (see setup-rest-server.ts). Stripping the
92
+ * trailing model segment therefore yields the prefix by construction, and the
93
+ * link builder cannot drift from the mount registrar the way a second,
94
+ * independent normalisation of `route` would.
95
+ *
96
+ * When `request.baseUrl` is absent or does not end in the model segment the
97
+ * prefix is empty, which reproduces the previous origin-only behaviour.
98
+ */
99
+ function getBaseUrl(request: OrmRequest$, pluralizedModel: string): string {
82
100
  const protocol = request.protocol || 'http';
83
101
  const host = request.get('host');
84
- return `${protocol}://${host}`;
102
+
103
+ const modelSegment = `/${pluralizedModel}`;
104
+ const mountPath = request.baseUrl ?? '';
105
+ const prefix = mountPath.endsWith(modelSegment) ? mountPath.slice(0, -modelSegment.length) : '';
106
+
107
+ return `${protocol}://${host}${prefix}`;
85
108
  }
86
109
 
87
110
  function getId(params: { id?: string; [key: string]: unknown }): string | number {
@@ -278,7 +301,7 @@ export default class OrmRequest extends Request {
278
301
  if (accessFilter) recordsToReturn = recordsToReturn.filter(accessFilter as (record: OrmRecord) => boolean);
279
302
  if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
280
303
 
281
- const baseUrl = getBaseUrl(request);
304
+ const baseUrl = getBaseUrl(request, pluralizedModel);
282
305
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl }));
283
306
 
284
307
  return buildResponse(data, request.query?.include, recordsToReturn, {
@@ -294,7 +317,7 @@ export default class OrmRequest extends Request {
294
317
  const fieldsMap = parseFields(request.query);
295
318
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
296
319
 
297
- const baseUrl = getBaseUrl(request);
320
+ const baseUrl = getBaseUrl(request, pluralizedModel);
298
321
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl }), request.query?.include, record, {
299
322
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
300
323
  baseUrl
@@ -502,7 +525,7 @@ export default class OrmRequest extends Request {
502
525
  if (!record) return 404;
503
526
 
504
527
  const relatedData = record.__relationships[relationshipName];
505
- const baseUrl = getBaseUrl(request);
528
+ const baseUrl = getBaseUrl(request, pluralizedModel);
506
529
 
507
530
  let data: unknown;
508
531
  if (info.isArray) {
@@ -526,7 +549,7 @@ export default class OrmRequest extends Request {
526
549
  if (!record) return 404;
527
550
 
528
551
  const relatedData = record.__relationships[relationshipName];
529
- const baseUrl = getBaseUrl(request);
552
+ const baseUrl = getBaseUrl(request, pluralizedModel);
530
553
 
531
554
  let data: unknown;
532
555
  if (info.isArray) {