@stonyx/orm 0.3.2-beta.160 → 0.3.2-beta.161

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/dist/record.d.ts CHANGED
@@ -1,23 +1,7 @@
1
1
  import type Serializer from './serializer.js';
2
- import type { LinkageFilter } from './types/orm-types.js';
3
2
  interface ToJSONOptions {
4
3
  fields?: Set<string>;
5
4
  baseUrl?: string;
6
- /**
7
- * An ALREADY-RESOLVED linkage decision, supplied by a caller that holds the
8
- * request (abofs/stonyx-orm#234). Returning `false` for a related record
9
- * drops that record's `{ type, id }` from `relationships.*.data`.
10
- *
11
- * This method APPLIES a verdict; it never RESOLVES one -- see
12
- * `src/access-verdict.ts` for the two measured reasons it cannot. ABSENT is
13
- * the default and the default is TODAY'S DOCUMENT, unchanged, because
14
- * `toJSON` is also the `JSON.stringify` hook and an implicit caller has no
15
- * syntactic place to pass this (abofs/stonyx-orm#230).
16
- *
17
- * ABSENT and UNUSABLE are read differently, and the difference is a security
18
- * decision -- see the three-way reading at the call site below.
19
- */
20
- linkage?: LinkageFilter;
21
5
  }
22
6
  interface SerializeOptions {
23
7
  update?: boolean;
package/dist/record.js CHANGED
@@ -1,26 +1,7 @@
1
1
  import { store } from '@stonyx/orm';
2
- import log from 'stonyx/log';
3
2
  import { getComputedProperties } from "./serializer.js";
4
3
  import { camelCaseToKebabCase } from '@stonyx/utils/string';
5
4
  import { getPluralName } from './plural-registry.js';
6
- /**
7
- * Name a non-boolean `linkage` return for the one log line that reports it.
8
- *
9
- * A thenable is called out BY NAME because it is the shape a consumer produces
10
- * by accident -- an `async` resolver, or one that returns the promise of an
11
- * authorization lookup -- and the one whose truthiness silently GRANTED every
12
- * relationship before the ANSWER was checked (abofs/stonyx-orm#234).
13
- */
14
- function describeNonVerdict(verdict) {
15
- if (verdict === null)
16
- return 'null';
17
- if (Array.isArray(verdict))
18
- return 'an array';
19
- if ((typeof verdict === 'object' || typeof verdict === 'function')
20
- && typeof verdict.then === 'function')
21
- return 'a Promise (or other thenable)';
22
- return `a value of type ${typeof verdict}`;
23
- }
24
5
  export default class Record {
25
6
  /** @private */
26
7
  __data = {};
@@ -84,13 +65,7 @@ export default class Record {
84
65
  toJSON(options = {}) {
85
66
  if (!this.__serialized)
86
67
  throw new Error('Record must be serialized before being converted to JSON');
87
- // DESTRUCTURED FROM A VALUE THAT IS NOT ALWAYS AN OBJECT. `toJSON` is the
88
- // ECMAScript serialization hook, so `JSON.stringify({ data: record })`
89
- // arrives here as `toJSON('data')` -- a STRING in the options slot.
90
- // Destructuring a string yields `undefined` for every key, which is exactly
91
- // the no-argument default, so the implicit path keeps working and keeps
92
- // emitting today's document (abofs/stonyx-orm#230).
93
- const { fields, baseUrl, linkage } = options;
68
+ const { fields, baseUrl } = options;
94
69
  const { __data: data } = this;
95
70
  const modelName = this.__model.__name;
96
71
  const pluralizedModelName = getPluralName(modelName);
@@ -109,133 +84,12 @@ export default class Record {
109
84
  continue;
110
85
  attributes[key] = getter.call(this);
111
86
  }
112
- // `linkage` is a PUBLIC option -- it is on `OrmRecord.toJSON`
113
- // (src/types/orm-types.ts) and the README tells consumers to pass one -- so
114
- // it arrives from outside this package, may be ANY value, and whatever it
115
- // is, it gets INVOKED here. That makes this the trust boundary, and it was
116
- // the LAX side of one: the internal `createLinkageFilter` coerces and
117
- // try/catches the consumer predicate it wraps, while this -- the site that
118
- // consumes the PUBLIC option -- did neither.
119
- //
120
- // THREE QUESTIONS. Every wrong answer below was measured, on a two-
121
- // relationship record, emitting the full pre-#234 document or throwing out
122
- // of `JSON.stringify`.
123
- //
124
- // 1. IS IT SUPPLIED? ABSENT (`undefined`) means no verdict was supplied:
125
- // emit today's document. Load-bearing and asserted (AC5/AC5b) --
126
- // `toJSON` is also the `JSON.stringify` hook, so the implicit caller
127
- // arrives as `toJSON('data')`, a STRING, which destructures to
128
- // `undefined` here (abofs/stonyx-orm#230).
129
- //
130
- // 2. IS ITS SHAPE USABLE? `[object Function]` only, because
131
- // `typeof x === 'function'` is NOT the question "can this answer a
132
- // synchronous boolean".
133
- //
134
- // A NON-FUNCTION denies. Reading it as absent is what `!linkage ||`
135
- // did, and a resolver returning `null` because it could not resolve a
136
- // session is the natural shape of that value and the fail-closed
137
- // INTENT -- measured, `toJSON({ linkage: null })` emitted the full
138
- // pre-#234 linkage with no signal, byte-identical to unpatched dev.
139
- //
140
- // AN `AsyncFunction`, `GeneratorFunction` or `AsyncGeneratorFunction`
141
- // denies for that SAME reason, one branch over -- and a `typeof`-only
142
- // check left the whole defect standing there. `async (type, r) =>
143
- // false` returns a PROMISE, a promise is TRUTHY, so every relationship
144
- // was emitted in full with ZERO log, again byte-identical to unpatched
145
- // dev. An awaited authorization lookup is at least as natural a
146
- // resolver as a nullish one -- the README's own Consumer Contracts
147
- // section points consumers at queue payloads and websocket frames,
148
- // where lookups are routinely awaited -- and it landed on the GRANT
149
- // side of the same branch the `null` reading closed.
150
- //
151
- // 3. IS ITS ANSWER A VERDICT? It must BE a boolean, not merely coerce to
152
- // one. `Boolean(...)` -- the coercion `createLinkageFilter` applies to
153
- // a consumer `access()` predicate, whose truthy contract predates this
154
- // option and is deliberately NOT changed -- is not enough here, and
155
- // was measured not to be: with `Boolean(...)` plus a try/catch in
156
- // place, `async () => false`, `function* () {}`,
157
- // `() => Promise.resolve(false)`, `() => ({})` and `() => 'no'` ALL
158
- // still emitted the full pre-#234 linkage with no log, because
159
- // truthiness is what they already had. A non-boolean is a resolver
160
- // that did not answer, and the only safe reading of a non-answer is a
161
- // denial.
162
- //
163
- // AND IT NEVER THROWS -- which is now true rather than only written down.
164
- // A throw here escapes the enclosing `JSON.stringify` and takes
165
- // `console.log` and `Orm.db.save()`'s neighbours with it, a far worse
166
- // failure mode than a status. `class Klass {}`, `Klass.bind(null)` and any
167
- // predicate that dereferences something undefined were all measured raising
168
- // out of the `stringify`; all three are caught and denied.
169
- //
170
- // Logged once per DOCUMENT, not once per relationship key or per related
171
- // record: an emptied relationship is deliberately indistinguishable from a
172
- // genuinely empty one on the wire, so the log is the ONLY signal a consumer
173
- // whose resolver quietly returned `null`, or a promise, will ever get.
174
- const linkageSupplied = linkage !== undefined;
175
- // Read the tag DEFENSIVELY. `Object.prototype.toString` consults
176
- // `Symbol.toStringTag`, so a Proxy with a throwing `get` trap would throw
177
- // out of the validation whose entire job is that nothing throws.
178
- let linkageShape = 'a non-function';
179
- if (typeof linkage === 'function') {
180
- try {
181
- linkageShape = Object.prototype.toString.call(linkage);
182
- }
183
- catch {
184
- linkageShape = '[object Unreadable]';
185
- }
186
- }
187
- const linkageUsable = linkageShape === '[object Function]';
188
- let linkageReported = false;
189
- const denyAllLinkage = (reason) => {
190
- if (linkageReported)
191
- return;
192
- linkageReported = true;
193
- log.error?.(`[@stonyx/orm] toJSON() received an unusable \`linkage\` option -- ${reason}, so ALL relationship linkage on this \`${modelName}\` document is denied.`);
194
- };
195
- if (linkageSupplied && !linkageUsable) {
196
- denyAllLinkage(typeof linkage !== 'function'
197
- ? `it is of type ${linkage === null ? 'null' : typeof linkage} and it must be a function`
198
- : `it is ${linkageShape} and it must be a SYNCHRONOUS function -- \`toJSON\` is the \`JSON.stringify\` hook and cannot await a verdict`);
199
- }
200
- const linkageVerdict = !linkageSupplied
201
- ? undefined
202
- : linkageUsable ? linkage : () => false;
203
- // Applied per related record, alongside the existing `__model` liveness
204
- // check, and producing exactly the shapes that check already produces: a
205
- // dropped hasMany member leaves `data: []`, a dropped belongsTo leaves
206
- // `data: null`. Both already ship -- a genuinely-empty hasMany emits
207
- // `data: []` with links, and a cleaned belongsTo emits `data: null` -- so a
208
- // filtered relationship is BYTE-IDENTICAL to an empty one and there is no
209
- // new wire shape and no oracle.
210
- const isLinkable = (r) => {
211
- if (!linkageVerdict)
212
- return true;
213
- try {
214
- const verdict = linkageVerdict(r.__model.__name, r);
215
- if (typeof verdict === 'boolean')
216
- return verdict;
217
- denyAllLinkage(`it answered with ${describeNonVerdict(verdict)} rather than a boolean`);
218
- }
219
- catch (error) {
220
- // Building the report is itself a throw site -- `throw Symbol('x')`
221
- // makes `String(error)` throw, and a getter on `.message` can throw --
222
- // and a throw from the reporter would escape the catch that exists so
223
- // that nothing escapes.
224
- let detail = 'a value that could not be described';
225
- try {
226
- detail = error instanceof Error ? error.message : String(error);
227
- }
228
- catch { /* keep the fallback -- the denial matters, the text does not */ }
229
- denyAllLinkage(`it threw (${detail})`);
230
- }
231
- return false;
232
- };
233
87
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
234
88
  if (fields && !fields.has(key))
235
89
  continue;
236
90
  const relationshipData = Array.isArray(childRecord)
237
- ? childRecord.filter((r) => r?.__model).filter(isLinkable).map((r) => ({ type: r.__model.__name, id: r.id }))
238
- : (childRecord && childRecord.__model && isLinkable(childRecord)) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
91
+ ? childRecord.filter((r) => r?.__model).map((r) => ({ type: r.__model.__name, id: r.id }))
92
+ : (childRecord && childRecord.__model) ? { type: childRecord.__model.__name, id: childRecord.id } : null;
239
93
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
240
94
  const dasherizedKey = camelCaseToKebabCase(key);
241
95
  relationships[dasherizedKey] = { data: relationshipData };
@@ -1,5 +1,5 @@
1
1
  import { waitForModule } from 'stonyx';
2
- import Orm, { store } from '@stonyx/orm';
2
+ import { store } from '@stonyx/orm';
3
3
  import OrmRequest from './orm-request.js';
4
4
  import MetaRequest from './meta-request.js';
5
5
  import RestServer from '@stonyx/rest-server';
@@ -8,7 +8,7 @@ import { dbKey } from './db.js';
8
8
  import { getPluralName } from './plural-registry.js';
9
9
  import log from 'stonyx/log';
10
10
  export default async function (route, accessPath, metaRoute) {
11
- const accessFunctions = {};
11
+ const accessFiles = {};
12
12
  try {
13
13
  await forEachFileImport(accessPath, (accessClass) => {
14
14
  const accessInstance = new accessClass();
@@ -25,9 +25,9 @@ export default async function (route, accessPath, metaRoute) {
25
25
  continue;
26
26
  if (!store.data.has(model))
27
27
  throw new Error(`Unable to define access for Invalid Model "${model}". Model does not exist`);
28
- if (accessFunctions[model])
28
+ if (accessFiles[model])
29
29
  throw new Error(`Access for model "${model}" has already been defined by another access class.`);
30
- accessFunctions[model] = accessInstance.access;
30
+ accessFiles[model] = accessInstance.access;
31
31
  }
32
32
  });
33
33
  }
@@ -35,57 +35,11 @@ export default async function (route, accessPath, metaRoute) {
35
35
  log.error?.(error instanceof Error ? error.message : String(error));
36
36
  log.warn?.('You must define a valid access configuration file in order to access ORM generated REST endpoints.');
37
37
  }
38
- // -------------------------------------------------------------------------
39
- // #202 -- the registry has to survive this function.
40
- //
41
- // `accessFunctions` used to be a function-local that was discarded at the return
42
- // below, so the only thing that ever saw it was the mount loop. Each mounted
43
- // OrmRequest then held its OWN model's predicate and nothing held the map, so
44
- // at request time there was no route from a model NAME to that model's
45
- // predicate -- which is what abofs/stonyx-orm#196 and #207 need in order to
46
- // ask model X's predicate about a request routed to model Y.
47
- //
48
- // Published BEFORE `await waitForModule('rest-server')`, deliberately: that
49
- // await is the ONLY yield point in this function, and the rest-server module
50
- // may already be listening by the time it reports ready, so an assignment
51
- // after it would leave a window in which a route is live and the registry is
52
- // not.
53
- //
54
- // It is NOT before the mount loop for that reason, and the comment here used
55
- // to say it was. `RestServer.mountRoute` is fully synchronous -- construct,
56
- // registerCalls(), api.use() -- and nothing between the loop and this
57
- // function's closing brace yields, so the event loop cannot deliver a request
58
- // in there and the window that clause described cannot open. Measured:
59
- // moving this assignment to the last statement of the function leaves the
60
- // suite at 951 pass / 0 fail. Being ahead of the mount loop is free and
61
- // harmless; it is not what makes the ordering correct.
62
- //
63
- // Assigned unconditionally, including when the try above failed and the map
64
- // is empty or partial: the mount loop below is driven by this exact object,
65
- // so at the moment of assignment whatever is reachable through
66
- // `Orm.instance` is the same set of predicates that is about to enforce.
67
- // A guard such as `if (Object.keys(accessFunctions).length)` would let the
68
- // registry go silently missing on a total load failure, and a later consumer
69
- // would read `undefined` from `getAccess` and have to distinguish "no access
70
- // class" from "the registry was never published" -- which it cannot. That is
71
- // the reasoning, and it is REASONING, not something this suite tests: the
72
- // guarded variant is also 951 pass / 0 fail, AC8 included, because every boot
73
- // in this suite loads a non-empty access map so the guard never fires. AC8
74
- // demonstrably cannot catch it. Catching it needs a boot with
75
- // `orm.paths.access` pointed at an empty directory, which this suite has no
76
- // harness for.
77
- //
78
- // One further limit on "by construction": the mount loop passes `access` BY
79
- // VALUE into each OrmRequest, so the enforcing set is a snapshot taken here,
80
- // while `getAccess` reads the map live. The two are the same set at boot and
81
- // stay the same set only for as long as nobody writes to the public field.
82
- // The equality is a boot-time fact, not an invariant.
83
- Orm.instance.accessFunctions = accessFunctions;
84
38
  await waitForModule('rest-server');
85
39
  // Remove "/" prefix and name mount point accordingly
86
40
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
87
41
  // Configure endpoints for models and views with access configuration
88
- for (const [model, access] of Object.entries(accessFunctions)) {
42
+ for (const [model, access] of Object.entries(accessFiles)) {
89
43
  const pluralizedModel = getPluralName(model);
90
44
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
91
45
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
@@ -7,10 +7,6 @@
7
7
  */
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
- // `./utils.js` pulls in `@stonyx/utils/string` and nothing else -- no ORM
11
- // bootstrap, no `@stonyx/orm` index, no side effects -- so the "no framework
12
- // dependencies" property above still holds.
13
- import { maxNumericId } from './utils.js';
14
10
  export default class StandaloneDB {
15
11
  mode;
16
12
  dbPath;
@@ -106,19 +102,11 @@ export default class StandaloneDB {
106
102
  async create(collection, data) {
107
103
  const records = await this.readCollection(collection);
108
104
  if (!data.id) {
109
- // SHARED WITH `assignRecordId` (src/manage-record.ts), which is the other
110
- // place this repo picks a server-assigned id. It was a second copy of the
111
- // reduce, and nothing here pointed at it — a maintainer editing this
112
- // method could not discover the other existed. See `maxNumericId` for why
113
- // it is not `Math.max` (abofs/stonyx-orm#203).
114
- //
115
- // THE TWO ARE NOT THE SAME FUNCTION beyond this line, deliberately.
116
- // `StandaloneDB` has no model, id-type or transform concept, so `maxId + 1`
117
- // IS its store key; `assignRecordId` has to map the candidate through the
118
- // model's declared id transform first, and then walk past occupied keys.
119
- // Transplanting this method's remaining logic into the ORM reproduces
120
- // #203's landing-key defect exactly — which is what AC4 pins.
121
- data.id = maxNumericId(records) + 1;
105
+ const maxId = records.reduce((max, r) => {
106
+ const rid = typeof r.id === 'number' ? r.id : 0;
107
+ return rid > max ? rid : max;
108
+ }, 0);
109
+ data.id = maxId + 1;
122
110
  }
123
111
  // Check for duplicate id
124
112
  const existing = records.find(r => r.id === data.id);
@@ -87,18 +87,9 @@ export interface OrmRecord {
87
87
  __pendingSqlId?: boolean;
88
88
  };
89
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
- */
98
90
  toJSON?(options?: {
99
91
  fields?: Set<string>;
100
92
  baseUrl?: string;
101
- linkage?: LinkageFilter;
102
93
  }): Record<string, unknown>;
103
94
  [key: string]: unknown;
104
95
  }
@@ -171,243 +162,3 @@ export interface SnapshotEntry {
171
162
  source?: string;
172
163
  viewQuery?: string;
173
164
  }
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
- * The record this route was addressed to, as the store key -- or `null` on a
256
- * collection route, which is addressed to no record (abofs/stonyx-orm#236).
257
- *
258
- * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
259
- * PARAMETERS while leaving `request.path` raw, so a consumer comparing
260
- * `request.path` against a literal compares an undecoded string against a
261
- * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
262
- * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
263
- * record `archived` -- 200 with the record in full, and `DELETE` destroyed
264
- * it, unauthenticated. 255 non-canonical spellings of an 8-character id
265
- * decode to the same key, so a deny-list of spellings is the wrong shape.
266
- *
267
- * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
268
- *
269
- * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
270
- * parameter means. `GET /owners/%2561rchived` is the legitimate id
271
- * `%61rchived`, not a second-order spelling of `archived`; a predicate that
272
- * decoded until stable would deny a record it was never asked about.
273
- * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
274
- * and express's `case sensitive routing` governs literal segments only.
275
- * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
276
- * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
277
- * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
278
- * allow, on that same record).
279
- * - Do NOT derive it from `request.path` or the request target. Decoding the
280
- * whole path decodes THEN splits, while the router splits THEN decodes, so
281
- * `/owners/archived%2fx` -- a genuinely distinct record whose id is
282
- * `archived/x` -- was measured over-denied 403.
283
- *
284
- * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
285
- * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
286
- * lookup the permission-array branch uses. The predicate and the dispatch
287
- * therefore cannot disagree about which record a request addresses. Handing
288
- * over the raw `request.params.id` instead would reintroduce that divergence
289
- * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
290
- *
291
- * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
292
- * declaring `id = attr('string')`, `'9107'` arrives here as the number
293
- * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
294
- * exists to buy; it is not a defect to repair here.
295
- *
296
- * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
297
- * PRESENT, the same rule `operation` states above. `auth()` always sets it,
298
- * so a context arriving WITHOUT the key did not come from `auth()`: it was
299
- * hand-assembled by a caller resolving the predicate through
300
- * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
301
- * signal only because the framework never produces it.
302
- *
303
- * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
304
- * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
305
- * key on an identically-shaped context object, which is the exact
306
- * configuration that makes `operation` fail-open shaped -- a hook sees
307
- * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
308
- * docblock asserted the opposite ("here they AGREE... they differ in ONE way
309
- * and it is the absence spelling"). That was measured false, in the fail-open
310
- * direction, and it is corrected here rather than deleted.
311
- *
312
- * MEASURED over the live dispatch, before-hooks registered for all five
313
- * operations on one model:
314
- *
315
- * before:list key ABSENT ('recordId' in context === false)
316
- * before:get key ABSENT params={"id":"visible1"}
317
- * before:create key ABSENT
318
- * before:update key ABSENT params={"id":"visible2"}
319
- * before:delete recordId="visible3"
320
- * after:delete recordId="visible3"
321
- *
322
- * `_withHooks` assigns `context.recordId` at exactly TWO sites in
323
- * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
324
- * branch. So the two keys differ in COVERAGE, on four of five operations: on
325
- * a hook context the key is absent for get, list, create and update, while
326
- * this key is present on every route `auth()` classifies. The absence
327
- * spelling is the smaller half of the difference, not the whole of it.
328
- *
329
- * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
330
- * a missing `recordId` means "did not come from `auth()`" and is deniable.
331
- * On a hook context it means "this is a get / list / create / update" -- an
332
- * ordinary request. A consumer who writes the hook-side half of the same
333
- * rule --
334
- *
335
- * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
336
- *
337
- * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
338
- * with `ctx.recordId === undefined` while the addressed record sits in
339
- * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
340
- * repaired here. A predicate must not read `undefined` here as "collection",
341
- * and nothing in this contract makes it safe to read the two keys as one key.
342
- *
343
- * IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
344
- * AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
345
- *
346
- * For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
347
- * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
348
- * `recordId: 'gina'` -- `auth()` reads it off `request.params`.
349
- *
350
- * FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
351
- * WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
352
- * route families resolve the RELATED model's own predicate -- `animal` on
353
- * `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
354
- * carries `recordId: null` while `request.params` names a record of a
355
- * DIFFERENT model. So a predicate answering about a related model gets the
356
- * model name, the operation and the request, and CANNOT branch on which
357
- * related record it is being asked about.
358
- *
359
- * The rule, so it is not re-derived wrong: `recordId` may name a record only
360
- * where the route addresses exactly one record OF THE MODEL BEING ASKED
361
- * ABOUT. A `hasMany` related-resource route returns many records of one type
362
- * and the verdict is resolved ONCE PER TYPE, before any record is examined --
363
- * seeding it from a record would let the first one decide for all of them.
364
- *
365
- * What still works, and what does not, is pinned as behaviour by `#232 AC10`
366
- * in test/integration/orm-test.ts and stated for consumers in README.md:
367
- * model-level denies work, request-level denies work, and the per-record
368
- * FILTER shape works because `access()` may return a function and that
369
- * function receives the whole record. Branching on identity BEFORE returning
370
- * does not.
371
- *
372
- * `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
373
- */
374
- recordId: string | number | null;
375
- }
376
- /**
377
- * A consumer `access()` predicate.
378
- *
379
- * The second argument is ADDITIVE: JavaScript ignores extra arguments, so every
380
- * pre-#202 single-argument predicate keeps working untouched. Changing the
381
- * FIRST argument instead would have been the breaking form, and a predicate
382
- * that can no longer identify its collection falls through to a full CRUD
383
- * grant -- so the "safer" breaking change would have converted every unmigrated
384
- * predicate into a fail-open.
385
- *
386
- * `context` is nonetheless REQUIRED in the type, and that costs back-compat
387
- * nothing. TypeScript already lets a fewer-parameter implementation satisfy a
388
- * more-parameter signature, so an arity-1 predicate assigns to this type
389
- * cleanly -- measured under `--strict`. What the `?` bought was the opposite of
390
- * safety: it silently permitted `getAccess('animal')?.(request)` at the CALL
391
- * site, i.e. exactly the omission {@link AccessContext} exists to prevent, and
392
- * that call gets the model-wrong answer. Required, a caller that drops the
393
- * context gets `TS2554: Expected 2 arguments, but got 1`.
394
- */
395
- export type AccessFunction = (request: unknown, context: AccessContext) => AccessMethod;
396
- /**
397
- * A resolved, request-scoped linkage decision: may `record` of model `type` be
398
- * NAMED, by id, inside another model's document (abofs/stonyx-orm#234)?
399
- *
400
- * Arity is `(type, record)` and not `(type, id)` because the per-record filter
401
- * a consumer returns is handed the RECORD -- this repo's own fixture reads
402
- * `record.owner?.id`, not just `record.id`. The `(type, id)` pair is the CACHE
403
- * key inside `createLinkageFilter`, not the input.
404
- *
405
- * DECLARED HERE, with the rest of the access vocabulary, and imported by every
406
- * site that names it. It had three structurally-identical hand-written copies
407
- * (`access-verdict.ts`, `record.ts`, `OrmRecord.toJSON` below) bridged to each
408
- * other by nothing, so a drift in nullability or a widening of `type` would
409
- * have landed on one and not the others -- which is the same "second,
410
- * unreviewed vocabulary" failure `src/access-verdict.ts` exists to prevent, one
411
- * level up in the type system.
412
- */
413
- export type LinkageFilter = (type: string, record: unknown) => boolean;
package/dist/utils.d.ts CHANGED
@@ -5,47 +5,3 @@ 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";