@stonyx/orm 0.3.2-alpha.66 → 0.3.2-alpha.68
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 +177 -17
- package/dist/access-verdict.d.ts +57 -0
- package/dist/access-verdict.js +185 -0
- package/dist/manage-record.js +234 -9
- package/dist/orm-request.js +99 -30
- package/dist/record.d.ts +12 -0
- package/dist/record.js +20 -3
- package/dist/standalone-db.js +17 -5
- package/dist/types/orm-types.d.ts +9 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +1 -1
- package/src/access-verdict.ts +222 -0
- package/src/manage-record.ts +253 -9
- package/src/orm-request.ts +105 -29
- package/src/record.ts +33 -3
- package/src/standalone-db.ts +17 -6
- package/src/types/orm-types.ts +9 -1
- package/src/utils.ts +50 -0
package/src/orm-request.ts
CHANGED
|
@@ -220,7 +220,8 @@ import type { HookContext } from './hooks.js';
|
|
|
220
220
|
import config from 'stonyx/config';
|
|
221
221
|
import log from 'stonyx/log';
|
|
222
222
|
import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
|
|
223
|
-
import { isOrmRecord } from './utils.js';
|
|
223
|
+
import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
|
|
224
|
+
import { interpretAccess, createLinkageFilter } from './access-verdict.js';
|
|
224
225
|
|
|
225
226
|
interface OrmRequest$ extends Request {
|
|
226
227
|
protocol?: string;
|
|
@@ -633,7 +634,14 @@ export default class OrmRequest extends Request {
|
|
|
633
634
|
if (queryFilterPredicate) recordsToReturn = recordsToReturn.filter(queryFilterPredicate as (record: OrmRecord) => boolean);
|
|
634
635
|
|
|
635
636
|
const baseUrl = getBaseUrl(request);
|
|
636
|
-
|
|
637
|
+
|
|
638
|
+
// ONE filter per REQUEST, not one per record: it carries the per-type
|
|
639
|
+
// verdict cache and the per-(type, id) decision cache, and both are
|
|
640
|
+
// worthless if it is rebuilt inside the map. Measured on this exact
|
|
641
|
+
// surface with no `include=`: 48 linkage entries collapse to 7 distinct
|
|
642
|
+
// (type, id) pairs.
|
|
643
|
+
const linkage = createLinkageFilter(request);
|
|
644
|
+
const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
|
|
637
645
|
|
|
638
646
|
return buildResponse(data, request.query?.include, recordsToReturn, {
|
|
639
647
|
links: { self: `${baseUrl}/${pluralizedModel}` },
|
|
@@ -653,7 +661,14 @@ export default class OrmRequest extends Request {
|
|
|
653
661
|
const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
|
|
654
662
|
|
|
655
663
|
const baseUrl = getBaseUrl(request);
|
|
656
|
-
|
|
664
|
+
const linkage = createLinkageFilter(request);
|
|
665
|
+
|
|
666
|
+
// `buildResponse` is deliberately NOT given the linkage filter. It builds
|
|
667
|
+
// `included`, and WHETHER A RESOURCE APPEARS THERE AT ALL is membership,
|
|
668
|
+
// which belongs to abofs/stonyx-orm#233 -- see this file's #234 note and
|
|
669
|
+
// the ownership boundary in that issue. Only the PRIMARY document's
|
|
670
|
+
// linkage is filtered here.
|
|
671
|
+
return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
|
|
657
672
|
links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
|
|
658
673
|
baseUrl
|
|
659
674
|
});
|
|
@@ -797,7 +812,41 @@ export default class OrmRequest extends Request {
|
|
|
797
812
|
// only O(1) signal that distinguishes an insert from an overwrite.
|
|
798
813
|
const slotsBefore = store.get(model)?.size ?? 0;
|
|
799
814
|
|
|
800
|
-
|
|
815
|
+
// THE ONE `createRecord` FAILURE THIS ROUTE ANSWERS RATHER THAN
|
|
816
|
+
// PROPAGATES, and it is narrow on purpose.
|
|
817
|
+
//
|
|
818
|
+
// `assignRecordId` throws when it cannot derive a free store key for a
|
|
819
|
+
// server-assigned id. Unguarded that rejection is auto-forwarded -- there
|
|
820
|
+
// is no catch here, none in @stonyx/rest-server's dispatcher
|
|
821
|
+
// (dist/request.js:41-70), and express 5 hands it to its default error
|
|
822
|
+
// handler, which serialises the STACK, with absolute install paths and the
|
|
823
|
+
// internal module graph, to an unauthenticated caller outside
|
|
824
|
+
// NODE_ENV=production. That is the hazard :553-558 already names in this
|
|
825
|
+
// file, and every sibling refusal in this handler returns an integer
|
|
826
|
+
// status instead. So this one returns 409, matching the client-duplicate
|
|
827
|
+
// refusal at :713: the caller asked for a record and the collection has no
|
|
828
|
+
// id to give it.
|
|
829
|
+
//
|
|
830
|
+
// MATCHED ON THE SHARED PREFIX, not on a literal, and NOT by catching
|
|
831
|
+
// everything: `createRecord` also throws for "ORM is not ready", a
|
|
832
|
+
// read-only view and an unregistered model store, and turning any of those
|
|
833
|
+
// into a 409 would report a configuration fault as a conflict. Anything
|
|
834
|
+
// else is re-thrown unchanged.
|
|
835
|
+
let created;
|
|
836
|
+
|
|
837
|
+
try {
|
|
838
|
+
created = createRecord(model, recordAttributes as { [key: string]: unknown }, { serialize: false, _skipAutoPersist: true });
|
|
839
|
+
} catch (error) {
|
|
840
|
+
if (!(error instanceof Error) || !error.message.startsWith(NO_FREE_ID_ERROR)) throw error;
|
|
841
|
+
|
|
842
|
+
// Not silently. A collection that can no longer assign an id is a
|
|
843
|
+
// configuration fault (a non-injective id transform), and a bare 409
|
|
844
|
+
// with no diagnostic is indistinguishable from an ordinary duplicate.
|
|
845
|
+
log.error?.(`[@stonyx/orm] ${error.message}`);
|
|
846
|
+
|
|
847
|
+
return 409; // Conflict
|
|
848
|
+
}
|
|
849
|
+
|
|
801
850
|
const record = isOrmRecord(created) ? created : null;
|
|
802
851
|
if (!record) return 500;
|
|
803
852
|
|
|
@@ -822,11 +871,32 @@ export default class OrmRequest extends Request {
|
|
|
822
871
|
//
|
|
823
872
|
// Both conditions are required and neither implies the other:
|
|
824
873
|
// createdNewSlot -- the store grew, so this request inserted rather
|
|
825
|
-
// than overwrote.
|
|
826
|
-
//
|
|
827
|
-
// last-INSERTED + 1,
|
|
828
|
-
//
|
|
829
|
-
//
|
|
874
|
+
// than overwrote. SURVIVOR AS OF #203, AND THAT IS
|
|
875
|
+
// WHAT THIS NOTE IS FOR. It used to be killable:
|
|
876
|
+
// `assignRecordId` returned last-INSERTED + 1, so a
|
|
877
|
+
// server-assigned id could land on an occupied slot,
|
|
878
|
+
// `createRecord` updated in place, and removing this
|
|
879
|
+
// half turned access-filter-enforcement-test.ts
|
|
880
|
+
// assertion 31 red. #203 closed that: the
|
|
881
|
+
// server-assigned path now walks past occupied keys,
|
|
882
|
+
// so no create reaching here can overwrite. Measured
|
|
883
|
+
// -- delete `createdNewSlot &&` below: `dev` gives
|
|
884
|
+
// 55 pass / 1 fail with assertion 31 RED, this tree
|
|
885
|
+
// gives 56 pass / 0 fail, GREEN.
|
|
886
|
+
// KEPT ANYWAY, AND NOT FOR THE OLD REASON. Without
|
|
887
|
+
// it a denied create becomes `store.remove` on a key
|
|
888
|
+
// the caller may have influenced, which :815-820
|
|
889
|
+
// records as having been an unauthenticated deletion
|
|
890
|
+
// primitive across the whole id space. BECOMES
|
|
891
|
+
// KILLABLE AGAIN the moment any caller-supplied id
|
|
892
|
+
// can reach `createRecord` from this handler --
|
|
893
|
+
// which is exactly what has-many.ts:65 and
|
|
894
|
+
// belongs-to.ts:45 already do for ANOTHER model's
|
|
895
|
+
// store (abofs/stonyx-orm#207), and what a third
|
|
896
|
+
// un-stripped id channel would do for this one
|
|
897
|
+
// (#204). Do not delete it on the strength of #203
|
|
898
|
+
// being closed; that is the reasoning :862-867 warns
|
|
899
|
+
// about, one level up.
|
|
830
900
|
// identity -- the slot still holds the object we just created,
|
|
831
901
|
// so nothing between createRecord and here replaced
|
|
832
902
|
// it. Deleting this half SURVIVES the suite, and it
|
|
@@ -1228,14 +1298,21 @@ export default class OrmRequest extends Request {
|
|
|
1228
1298
|
const relatedData = record.__relationships[relationshipName];
|
|
1229
1299
|
const baseUrl = getBaseUrl(request);
|
|
1230
1300
|
|
|
1301
|
+
// LINKAGE ONLY. This filter decides which ids the emitted documents may
|
|
1302
|
+
// NAME in their own `relationships.*.data`; it does NOT decide whether
|
|
1303
|
+
// the related records themselves are served -- that is the parent-only
|
|
1304
|
+
// filtering this route has done since #190, and widening it to the
|
|
1305
|
+
// related record is abofs/stonyx-orm#196.
|
|
1306
|
+
const linkage = createLinkageFilter(request);
|
|
1307
|
+
|
|
1231
1308
|
let data: unknown;
|
|
1232
1309
|
if (info.isArray) {
|
|
1233
1310
|
// hasMany - return array
|
|
1234
1311
|
const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
|
|
1235
|
-
data = related.map(r => r.toJSON?.({ baseUrl }));
|
|
1312
|
+
data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
|
|
1236
1313
|
} else {
|
|
1237
1314
|
// belongsTo - return single or null
|
|
1238
|
-
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl }) : null;
|
|
1315
|
+
data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
|
|
1239
1316
|
}
|
|
1240
1317
|
|
|
1241
1318
|
return {
|
|
@@ -1352,24 +1429,23 @@ export default class OrmRequest extends Request {
|
|
|
1352
1429
|
return 403; // Forbidden
|
|
1353
1430
|
}
|
|
1354
1431
|
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
if (
|
|
1372
|
-
if (!permitted.includes(methodAccessMap[request.method])) return 403;
|
|
1432
|
+
// THE READING OF THE RETURN SHAPE LIVES IN ONE PLACE (#234).
|
|
1433
|
+
//
|
|
1434
|
+
// It used to be inline here, and it was the only copy, which was fine while
|
|
1435
|
+
// `auth()` was the only thing that had to ask. It is not any more: the
|
|
1436
|
+
// linkage path has to ask model X's predicate about model X's records while
|
|
1437
|
+
// servicing a request routed to model Y, and a second inline copy of these
|
|
1438
|
+
// six branches would be a second authorization vocabulary -- one that can
|
|
1439
|
+
// drift, and that reviewers would have to notice had drifted. The branch
|
|
1440
|
+
// order in `interpretAccess` is this block, moved, not rewritten.
|
|
1441
|
+
const verdict = interpretAccess(access, methodAccessMap[request.method]);
|
|
1442
|
+
|
|
1443
|
+
if (!verdict.granted) return 403;
|
|
1444
|
+
|
|
1445
|
+
// The function return shape is the per-record hook, and `state` is the
|
|
1446
|
+
// whole transport for it: @stonyx/rest-server memoises one state object per
|
|
1447
|
+
// request and hands the same one to `auth()` and to the handler.
|
|
1448
|
+
if (verdict.filter) state.filter = verdict.filter;
|
|
1373
1449
|
|
|
1374
1450
|
return undefined;
|
|
1375
1451
|
}
|
package/src/record.ts
CHANGED
|
@@ -7,6 +7,18 @@ import type Serializer from './serializer.js';
|
|
|
7
7
|
interface ToJSONOptions {
|
|
8
8
|
fields?: Set<string>;
|
|
9
9
|
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
|
|
12
|
+
* request (abofs/stonyx-orm#234). Returning `false` for a related record
|
|
13
|
+
* drops that record's `{ type, id }` from `relationships.*.data`.
|
|
14
|
+
*
|
|
15
|
+
* This method APPLIES a verdict; it never RESOLVES one -- see
|
|
16
|
+
* `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
|
|
17
|
+
* the default and the default is TODAY'S DOCUMENT, unchanged, because
|
|
18
|
+
* `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
|
|
19
|
+
* syntactic place to pass this (abofs/stonyx-orm#230).
|
|
20
|
+
*/
|
|
21
|
+
linkage?: (type: string, record: unknown) => boolean;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
interface SerializeOptions {
|
|
@@ -116,7 +128,13 @@ export default class Record {
|
|
|
116
128
|
toJSON(options: ToJSONOptions = {}): JSONAPIResult {
|
|
117
129
|
if (!this.__serialized) throw new Error('Record must be serialized before being converted to JSON');
|
|
118
130
|
|
|
119
|
-
|
|
131
|
+
// DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
|
|
132
|
+
// ECMAScript serialization hook, so `JSON.stringify({ data: record })`
|
|
133
|
+
// arrives here as `toJSON('data')` -- a STRING in the options slot.
|
|
134
|
+
// Destructuring a string yields `undefined` for every key, which is exactly
|
|
135
|
+
// the no-argument default, so the implicit path keeps working and keeps
|
|
136
|
+
// emitting today's document (abofs/stonyx-orm#230).
|
|
137
|
+
const { fields, baseUrl, linkage } = options;
|
|
120
138
|
const { __data: data } = this;
|
|
121
139
|
const modelName = this.__model.__name;
|
|
122
140
|
const pluralizedModelName = getPluralName(modelName);
|
|
@@ -138,9 +156,21 @@ export default class Record {
|
|
|
138
156
|
for (const [key, childRecord] of Object.entries(this.__relationships)) {
|
|
139
157
|
if (fields && !fields.has(key)) continue;
|
|
140
158
|
|
|
159
|
+
// The linkage decision is applied HERE, alongside the existing
|
|
160
|
+
// `__model` liveness check, and it produces exactly the shapes that
|
|
161
|
+
// check already produces: a dropped hasMany member leaves `data: []`,
|
|
162
|
+
// a dropped belongsTo leaves `data: null`. Both already ship -- a
|
|
163
|
+
// genuinely-empty hasMany emits `data: []` with links, and a cleaned
|
|
164
|
+
// belongsTo emits `data: null` -- so a filtered relationship is
|
|
165
|
+
// BYTE-IDENTICAL to an empty one and there is no new wire shape and no
|
|
166
|
+
// oracle. It never throws: a throw here escapes the enclosing
|
|
167
|
+
// `JSON.stringify` and takes `console.log` and `Orm.db.save()`'s
|
|
168
|
+
// neighbours with it, which is a far worse failure mode than a status.
|
|
169
|
+
const isLinkable = (r: Record) => !linkage || linkage(r.__model.__name, r);
|
|
170
|
+
|
|
141
171
|
const relationshipData = Array.isArray(childRecord)
|
|
142
|
-
? childRecord.filter((r: Record) => r?.__model).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
143
|
-
: (childRecord && (childRecord as Record).__model) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
172
|
+
? childRecord.filter((r: Record) => r?.__model).filter(isLinkable).map((r: Record) => ({ type: r.__model.__name, id: r.id }))
|
|
173
|
+
: (childRecord && (childRecord as Record).__model && isLinkable(childRecord as Record)) ? { type: (childRecord as Record).__model.__name, id: (childRecord as Record).id } : null;
|
|
144
174
|
|
|
145
175
|
// Dasherize the key for URL paths (e.g., accessLinks -> access-links)
|
|
146
176
|
const dasherizedKey = camelCaseToKebabCase(key);
|
package/src/standalone-db.ts
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'fs/promises';
|
|
10
10
|
import path from 'path';
|
|
11
|
+
// `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
|
|
12
|
+
// bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
|
|
13
|
+
// dependencies" property above still holds.
|
|
14
|
+
import { maxNumericId } from './utils.js';
|
|
11
15
|
|
|
12
16
|
interface StandaloneDBOptions {
|
|
13
17
|
dbPath?: string;
|
|
@@ -131,12 +135,19 @@ export default class StandaloneDB {
|
|
|
131
135
|
const records = await this.readCollection(collection);
|
|
132
136
|
|
|
133
137
|
if (!data.id) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
// SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
|
|
139
|
+
// place this repo picks a server-assigned id. It was a second copy of the
|
|
140
|
+
// reduce, and nothing here pointed at it — a maintainer editing this
|
|
141
|
+
// method could not discover the other existed. See `maxNumericId` for why
|
|
142
|
+
// it is not `Math.max` (abofs/stonyx-orm#203).
|
|
143
|
+
//
|
|
144
|
+
// THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
|
|
145
|
+
// `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
|
|
146
|
+
// IS its store key; `assignRecordId` has to map the candidate through the
|
|
147
|
+
// model's declared id transform first, and then walk past occupied keys.
|
|
148
|
+
// Transplanting this method's remaining logic into the ORM reproduces
|
|
149
|
+
// #203's landing-key defect exactly — which is what AC4 pins.
|
|
150
|
+
data.id = maxNumericId(records) + 1;
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
// Check for duplicate id
|
package/src/types/orm-types.ts
CHANGED
|
@@ -89,7 +89,15 @@ export interface OrmRecord {
|
|
|
89
89
|
__model?: { __name: string };
|
|
90
90
|
__data: Record<string, unknown> & { id?: string | number; __pendingSqlId?: boolean };
|
|
91
91
|
__relationships: Record<string, unknown>;
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* `linkage` is an ALREADY-RESOLVED decision supplied by a caller that holds
|
|
94
|
+
* the request (abofs/stonyx-orm#234): return `false` for a related record and
|
|
95
|
+
* its `{ type, id }` is dropped from `relationships.*.data`. Omitting it is
|
|
96
|
+
* the default, and the default is the pre-#234 document unchanged -- this
|
|
97
|
+
* method is also the `JSON.stringify` hook, so an implicit caller has no
|
|
98
|
+
* syntactic place to pass it (abofs/stonyx-orm#230).
|
|
99
|
+
*/
|
|
100
|
+
toJSON?(options?: { fields?: Set<string>; baseUrl?: string; linkage?: (type: string, record: unknown) => boolean }): Record<string, unknown>;
|
|
93
101
|
[key: string]: unknown;
|
|
94
102
|
}
|
|
95
103
|
|
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';
|