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

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 (66) hide show
  1. package/README.md +1409 -11
  2. package/config/environment.js +99 -12
  3. package/dist/access-verdict.d.ts +85 -0
  4. package/dist/access-verdict.js +284 -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/hooks.d.ts +15 -1
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +8 -0
  17. package/dist/main.d.ts +116 -0
  18. package/dist/main.js +129 -0
  19. package/dist/manage-record.js +268 -12
  20. package/dist/mysql/connection.d.ts +1 -0
  21. package/dist/mysql/mysql-db.d.ts +8 -0
  22. package/dist/mysql/mysql-db.js +44 -10
  23. package/dist/orm-request.d.ts +274 -3
  24. package/dist/orm-request.js +1259 -65
  25. package/dist/postgres/connection.d.ts +1 -0
  26. package/dist/postgres/connection.js +8 -6
  27. package/dist/postgres/postgres-db.d.ts +8 -0
  28. package/dist/postgres/postgres-db.js +44 -10
  29. package/dist/record.d.ts +16 -0
  30. package/dist/record.js +154 -6
  31. package/dist/relationships.js +1 -1
  32. package/dist/serializer.js +38 -2
  33. package/dist/setup-rest-server.js +51 -5
  34. package/dist/standalone-db.js +17 -5
  35. package/dist/store.d.ts +13 -1
  36. package/dist/store.js +65 -6
  37. package/dist/types/orm-types.d.ts +260 -0
  38. package/dist/utils.d.ts +44 -0
  39. package/dist/utils.js +47 -0
  40. package/package.json +16 -7
  41. package/src/access-verdict.ts +312 -0
  42. package/src/commands.ts +43 -0
  43. package/src/dynamodb/connection.ts +50 -0
  44. package/src/dynamodb/dynamodb-db.ts +811 -0
  45. package/src/dynamodb/operation-builder.ts +202 -0
  46. package/src/dynamodb/type-map.ts +54 -0
  47. package/src/hooks.ts +15 -1
  48. package/src/index.ts +10 -0
  49. package/src/main.ts +133 -0
  50. package/src/manage-record.ts +294 -18
  51. package/src/mysql/connection.ts +1 -0
  52. package/src/mysql/mysql-db.ts +44 -12
  53. package/src/orm-request.ts +1281 -67
  54. package/src/postgres/connection.ts +10 -6
  55. package/src/postgres/postgres-db.ts +44 -12
  56. package/src/record.ts +182 -6
  57. package/src/relationships.ts +1 -1
  58. package/src/serializer.ts +39 -2
  59. package/src/setup-rest-server.ts +59 -6
  60. package/src/standalone-db.ts +17 -6
  61. package/src/store.ts +68 -6
  62. package/src/types/orm-types.ts +268 -1
  63. package/src/types/stonyx-rest-server.d.ts +14 -1
  64. package/src/types/stonyx.d.ts +7 -1
  65. package/src/utils.ts +50 -0
  66. package/config/environment.ts +0 -91
@@ -6,6 +6,7 @@ interface PgConfig {
6
6
  password: string;
7
7
  database: string;
8
8
  connectionLimit: number;
9
+ [key: string]: unknown;
9
10
  }
10
11
  /**
11
12
  * Create or return the singleton pg Pool.
@@ -7,15 +7,17 @@ export async function getPool(pgConfig, extensions = ['vector']) {
7
7
  if (pool)
8
8
  return pool;
9
9
  const { default: pg } = await import('pg');
10
+ const { host, port, user, password, database, connectionLimit, migrationsDir, migrationsTable, autoMigrate, ...poolOpts } = pgConfig;
10
11
  pool = new pg.Pool({
11
- host: pgConfig.host,
12
- port: pgConfig.port,
13
- user: pgConfig.user,
14
- password: pgConfig.password,
15
- database: pgConfig.database,
16
- max: pgConfig.connectionLimit,
12
+ host,
13
+ port,
14
+ user,
15
+ password,
16
+ database,
17
+ max: connectionLimit,
17
18
  idleTimeoutMillis: 30000,
18
19
  connectionTimeoutMillis: 10000,
20
+ ...poolOpts,
19
21
  });
20
22
  // Enable requested PostgreSQL extensions
21
23
  for (const ext of extensions) {
@@ -72,6 +72,14 @@ export default class PostgresDB {
72
72
  deps: PostgresDeps;
73
73
  pool: Pool | null;
74
74
  pgConfig: Record<string, unknown>;
75
+ /**
76
+ * Promise-chain mutex for write serialization (#156).
77
+ * All persist() calls chain through this single queue so concurrent
78
+ * fire-and-forget writes never produce parallel transactions
79
+ * on FK-linked rows (which cause deadlocks).
80
+ * Reads are NOT affected — only persist() serializes.
81
+ */
82
+ private _writeQueue;
75
83
  constructor(deps?: Partial<PostgresDeps>);
76
84
  protected requirePool(): Pool;
77
85
  init(): Promise<void>;
@@ -30,6 +30,14 @@ export default class PostgresDB {
30
30
  deps;
31
31
  pool;
32
32
  pgConfig;
33
+ /**
34
+ * Promise-chain mutex for write serialization (#156).
35
+ * All persist() calls chain through this single queue so concurrent
36
+ * fire-and-forget writes never produce parallel transactions
37
+ * on FK-linked rows (which cause deadlocks).
38
+ * Reads are NOT affected — only persist() serializes.
39
+ */
40
+ _writeQueue = Promise.resolve();
33
41
  constructor(deps = {}) {
34
42
  const Ctor = this.constructor;
35
43
  if (Ctor.instance)
@@ -57,7 +65,17 @@ export default class PostgresDB {
57
65
  const pending = files.filter(f => !applied.includes(f));
58
66
  if (pending.length > 0) {
59
67
  this.deps.log.db?.(`${pending.length} pending migration(s) found.`);
60
- const shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
68
+ let shouldApply;
69
+ if (this.pgConfig.autoMigrate === true) {
70
+ shouldApply = true;
71
+ }
72
+ else if (this.pgConfig.autoMigrate === false) {
73
+ shouldApply = false;
74
+ this.deps.log.warn?.(`autoMigrate is false — skipping ${pending.length} pending migration(s).`);
75
+ }
76
+ else {
77
+ shouldApply = await this.deps.confirm(`${pending.length} pending migration(s) found. Apply now?`);
78
+ }
61
79
  if (shouldApply) {
62
80
  for (const filename of pending) {
63
81
  const content = await this.deps.readFile(this.deps.path.join(migrationsPath, filename));
@@ -76,7 +94,17 @@ export default class PostgresDB {
76
94
  const schemas = this.deps.introspectModels();
77
95
  const modelCount = Object.keys(schemas).length;
78
96
  if (modelCount > 0) {
79
- const shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
97
+ let shouldGenerate;
98
+ if (this.pgConfig.autoMigrate === true) {
99
+ shouldGenerate = true;
100
+ }
101
+ else if (this.pgConfig.autoMigrate === false) {
102
+ shouldGenerate = false;
103
+ this.deps.log.warn?.(`autoMigrate is false — skipping initial migration generation for ${modelCount} model(s).`);
104
+ }
105
+ else {
106
+ shouldGenerate = await this.deps.confirm(`No migrations found but ${modelCount} model(s) detected. Generate and apply initial migration?`);
107
+ }
80
108
  if (shouldGenerate) {
81
109
  const { generateMigration } = await import('./migration-generator.js');
82
110
  const result = await generateMigration('initial_setup');
@@ -356,14 +384,20 @@ export default class PostgresDB {
356
384
  const Orm = (await import('@stonyx/orm')).default;
357
385
  if (Orm.instance?.isView?.(modelName))
358
386
  return;
359
- switch (operation) {
360
- case 'create':
361
- return this._persistCreate(modelName, context, response);
362
- case 'update':
363
- return this._persistUpdate(modelName, context, response);
364
- case 'delete':
365
- return this._persistDelete(modelName, context);
366
- }
387
+ const work = async () => {
388
+ switch (operation) {
389
+ case 'create':
390
+ return this._persistCreate(modelName, context, response);
391
+ case 'update':
392
+ return this._persistUpdate(modelName, context, response);
393
+ case 'delete':
394
+ return this._persistDelete(modelName, context);
395
+ }
396
+ };
397
+ // Chain through the write queue — .then(work, work) ensures the queue
398
+ // advances even when a previous persist rejects (#156).
399
+ this._writeQueue = this._writeQueue.then(work, work);
400
+ return this._writeQueue;
367
401
  }
368
402
  async _persistCreate(modelName, context, response) {
369
403
  const schemas = this.deps.introspectModels();
package/dist/record.d.ts CHANGED
@@ -1,7 +1,23 @@
1
1
  import type Serializer from './serializer.js';
2
+ import type { LinkageFilter } from './types/orm-types.js';
2
3
  interface ToJSONOptions {
3
4
  fields?: Set<string>;
4
5
  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;
5
21
  }
6
22
  interface SerializeOptions {
7
23
  update?: boolean;
package/dist/record.js CHANGED
@@ -1,7 +1,26 @@
1
1
  import { store } from '@stonyx/orm';
2
+ import log from 'stonyx/log';
2
3
  import { getComputedProperties } from "./serializer.js";
3
4
  import { camelCaseToKebabCase } from '@stonyx/utils/string';
4
5
  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
+ }
5
24
  export default class Record {
6
25
  /** @private */
7
26
  __data = {};
@@ -40,11 +59,13 @@ export default class Record {
40
59
  const records = {};
41
60
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
42
61
  if (Array.isArray(childRecord)) {
62
+ // Filter out cleaned records (those with no __model)
63
+ const live = childRecord.filter((r) => r?.__model);
43
64
  // Deduplicate by record ID — keep last occurrence (latest data wins)
44
65
  const seen = new Set();
45
66
  const unique = [];
46
- for (let i = childRecord.length - 1; i >= 0; i--) {
47
- const r = childRecord[i];
67
+ for (let i = live.length - 1; i >= 0; i--) {
68
+ const r = live[i];
48
69
  if (!seen.has(r.id)) {
49
70
  seen.add(r.id);
50
71
  unique.push(r);
@@ -54,7 +75,7 @@ export default class Record {
54
75
  records[key] = unique.map((r) => r.serialize());
55
76
  }
56
77
  else {
57
- records[key] = childRecord?.serialize() ?? null;
78
+ records[key] = childRecord?.__model ? childRecord.serialize() : null;
58
79
  }
59
80
  }
60
81
  return { ...data, ...records };
@@ -63,7 +84,13 @@ export default class Record {
63
84
  toJSON(options = {}) {
64
85
  if (!this.__serialized)
65
86
  throw new Error('Record must be serialized before being converted to JSON');
66
- const { fields, baseUrl } = options;
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;
67
94
  const { __data: data } = this;
68
95
  const modelName = this.__model.__name;
69
96
  const pluralizedModelName = getPluralName(modelName);
@@ -82,12 +109,133 @@ export default class Record {
82
109
  continue;
83
110
  attributes[key] = getter.call(this);
84
111
  }
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
+ };
85
233
  for (const [key, childRecord] of Object.entries(this.__relationships)) {
86
234
  if (fields && !fields.has(key))
87
235
  continue;
88
236
  const relationshipData = Array.isArray(childRecord)
89
- ? childRecord.map((r) => ({ type: r.__model.__name, id: r.id }))
90
- : childRecord ? { type: childRecord.__model.__name, id: childRecord.id } : null;
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
239
  // Dasherize the key for URL paths (e.g., accessLinks -> access-links)
92
240
  const dasherizedKey = camelCaseToKebabCase(key);
93
241
  relationships[dasherizedKey] = { data: relationshipData };
@@ -38,4 +38,4 @@ export function getPendingRegistry() {
38
38
  export function getPendingBelongsToRegistry() {
39
39
  return relationships.get('pendingBelongsTo');
40
40
  }
41
- export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending'];
41
+ export const TYPES = ['global', 'hasMany', 'belongsTo', 'pending', 'pendingBelongsTo'];
@@ -79,8 +79,44 @@ export default class Serializer {
79
79
  // Pass relationship key name to handler for pending fulfillment
80
80
  const handlerOptions = { ...options, _relationshipKey: key };
81
81
  const childRecord = handler(record, data, handlerOptions);
82
- rec[key] = childRecord;
83
- relatedRecords[key] = childRecord;
82
+ // hasMany relationships use a getter so format()/toJSON() always read
83
+ // the live registry array instead of a stale snapshot captured at
84
+ // serialization time. This is critical when child records are created
85
+ // in a later async frame — the belongsTo inverse wiring pushes into
86
+ // the shared registry array, and the getter ensures the parent sees it.
87
+ const isHasMany = handler.__relationshipType === 'hasMany';
88
+ if (isHasMany) {
89
+ // `childRecord` IS the shared registry array — define a getter that
90
+ // always dereferences through the same array reference.
91
+ const registryArray = childRecord;
92
+ Object.defineProperty(rec, key, {
93
+ enumerable: true,
94
+ configurable: true,
95
+ get: () => registryArray,
96
+ set(v) { relatedRecords[key] = v; }
97
+ });
98
+ Object.defineProperty(relatedRecords, key, {
99
+ enumerable: true,
100
+ configurable: true,
101
+ get: () => registryArray,
102
+ set(v) { Object.defineProperty(relatedRecords, key, { value: v, writable: true, enumerable: true, configurable: true }); }
103
+ });
104
+ }
105
+ else {
106
+ rec[key] = childRecord;
107
+ relatedRecords[key] = childRecord;
108
+ // Preserve the raw FK value in __data when the belongsTo handler
109
+ // couldn't resolve the target (e.g., memory:false model not loaded).
110
+ // This allows adapters to read the FK from __data as a fallback
111
+ // when __relationships[key] is null. Only store when `data` is a
112
+ // truthy non-object — i.e., a raw FK string/number that the handler
113
+ // attempted but failed to resolve. When `data` is null/undefined
114
+ // (optional empty relationship) we intentionally skip to preserve
115
+ // the existing behavior of not populating __data for empty FKs.
116
+ if (childRecord === null && data && typeof data !== 'object') {
117
+ parsedData[key] = data;
118
+ }
119
+ }
84
120
  continue;
85
121
  }
86
122
  // Aggregate property handling — use the rawData value, not the aggregate descriptor
@@ -1,5 +1,5 @@
1
1
  import { waitForModule } from 'stonyx';
2
- import { store } from '@stonyx/orm';
2
+ import Orm, { 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 accessFiles = {};
11
+ const accessFunctions = {};
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 (accessFiles[model])
28
+ if (accessFunctions[model])
29
29
  throw new Error(`Access for model "${model}" has already been defined by another access class.`);
30
- accessFiles[model] = accessInstance.access;
30
+ accessFunctions[model] = accessInstance.access;
31
31
  }
32
32
  });
33
33
  }
@@ -35,11 +35,57 @@ 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;
38
84
  await waitForModule('rest-server');
39
85
  // Remove "/" prefix and name mount point accordingly
40
86
  const name = route === '/' ? 'index' : (route[0] === '/' ? route.slice(1) : route);
41
87
  // Configure endpoints for models and views with access configuration
42
- for (const [model, access] of Object.entries(accessFiles)) {
88
+ for (const [model, access] of Object.entries(accessFunctions)) {
43
89
  const pluralizedModel = getPluralName(model);
44
90
  const modelName = name === 'index' ? pluralizedModel : `${name}/${pluralizedModel}`;
45
91
  RestServer.instance.mountRoute(OrmRequest, { name: modelName, options: { model, access } });
@@ -7,6 +7,10 @@
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';
10
14
  export default class StandaloneDB {
11
15
  mode;
12
16
  dbPath;
@@ -102,11 +106,19 @@ export default class StandaloneDB {
102
106
  async create(collection, data) {
103
107
  const records = await this.readCollection(collection);
104
108
  if (!data.id) {
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;
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;
110
122
  }
111
123
  // Check for duplicate id
112
124
  const existing = records.find(r => r.id === data.id);
package/dist/store.d.ts CHANGED
@@ -45,7 +45,19 @@ export default class Store {
45
45
  */
46
46
  private _isMemoryModel;
47
47
  set(key: string, value: Map<number | string, unknown>): void;
48
- remove(key: string, id?: number | string): void;
48
+ remove(key: string, id?: number | string, options?: {
49
+ _skipAutoPersist?: boolean;
50
+ }): void;
51
+ /**
52
+ * Evict a record from the store with full relationship registry cleanup.
53
+ * The caller retains its reference to the returned record, which is the
54
+ * contract memory:false post-persist eviction relies on.
55
+ *
56
+ * @param registryId - The ID used when the record's relationships were
57
+ * registered. For SQL models with pending IDs, this is the original
58
+ * negative pending ID (before the adapter re-keyed to the real DB ID).
59
+ */
60
+ evictRecord(modelName: string, id: unknown, registryId?: unknown): void;
49
61
  unloadRecord(model: string, id: unknown, options?: UnloadOptions): void;
50
62
  unloadAllRecords(model: string, options?: UnloadOptions): void;
51
63
  private _removeFromHasManyArrays;